diff --git a/docs/experimental-v2.md b/docs/experimental-v2.md index 4ae26b2..ab005a8 100644 --- a/docs/experimental-v2.md +++ b/docs/experimental-v2.md @@ -3,6 +3,8 @@ > **Experimental.** Protocol v2 is a draft. Import it from `acp.experimental` and > expect its API and generated models to change with the upstream schema. +The bindings use `schema-v2.0.0-alpha.5`. + The v2 runtime is separate from the stable v1 API. Its methods accept and return generated request and response models directly. Install update handlers on the client before opening a session because updates are independent connection @@ -29,7 +31,7 @@ initialized = await connection.initialize( session = await connection.new_session( v2.schema.NewSessionRequest(cwd="/workspace") ) -await connection.prompt( +accepted = await connection.prompt( v2.schema.PromptRequest( session_id=session.session_id, prompt=[v2.schema.TextContentBlock(text="Hello")], @@ -37,10 +39,30 @@ await connection.prompt( ) ``` -`session/prompt` returns when the agent accepts the prompt. It does not define a -boundary for session updates: they may arrive before, during, or after that -request, and they do not carry a prompt identifier. Applications decide how to -buffer or present them. +`session/prompt` returns after the agent inserts the user message into the ACP +conversation, without waiting for processing to finish. The response requires a +non-null `message_id`. Agents return `v2.schema.PromptResponse(message_id=...)` +and echo the user message in a `UserMessageUpdate` or `UserMessageChunk` carrying +the same ID. That update may arrive before or after the response; use +`accepted.message_id` to match it. Other session updates are independent traffic +and do not carry a prompt identifier. + +Agents can send `v2.schema.SessionNotice(severity="warning", title="Context is nearly full")` +in an `UpdateSessionNotification`. V2 notices require no client capability and +are live advisory events, outside retained session history. Clients may ignore +them. Titles must be non-empty, and severity also accepts custom or future strings. + +For patch fields in session updates, omit a field to leave its current +value unchanged, or explicitly pass `None` to clear it. For example, +`v2.schema.SessionToolCallUpdate(tool_call_id="tool-1", name=None)` clears the +tool name, while omitting `name` leaves it unchanged. This also applies to +terminal updates and patch metadata. When applying received patches, use +`update.model_dump(by_alias=True, exclude_unset=True)` to retain that distinction. + +Setting `replay_from=v2.schema.ReplayFromStartVariant()` on a `ResumeSessionRequest` +requests all retained conversation history; agents need not retain every message. +Accepted elicitation content validates scalar values and string lists; nested +objects are not valid form values. Agents that serve both versions use `AgentProtocolRouter`: diff --git a/schema/v2/VERSION b/schema/v2/VERSION index 8f9b9d1..feb3886 100644 --- a/schema/v2/VERSION +++ b/schema/v2/VERSION @@ -1 +1 @@ -refs/tags/schema-v2.0.0-alpha.3 +refs/tags/schema-v2.0.0-alpha.5 diff --git a/schema/v2/schema.json b/schema/v2/schema.json index c8b3810..759b9d4 100644 --- a/schema/v2/schema.json +++ b/schema/v2/schema.json @@ -874,7 +874,7 @@ ] }, "name": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProgrammatic name of the tool being invoked.\n\nThis field is optional and has patch semantics. Omission means no\nchange, `null` clears the name, and a string replaces it. For a tool\ncall ID the client has not seen before, omission or `null` means that no\ntool name is available.", + "description": "Programmatic name of the tool being invoked.\n\nThis field is optional and has patch semantics. Omission means no\nchange, `null` clears the name, and a string replaces it. For a tool\ncall ID the client has not seen before, omission or `null` means that no\ntool name is available.", "type": [ "string", "null" @@ -5812,9 +5812,17 @@ "x-method": "session/set_config_option" }, "PromptResponse": { - "description": "Response acknowledging that a user prompt was accepted.\n\nThis response does not indicate that the agent has finished processing.\nProcessing and completion are reported through `state_update` session updates.\n\nSee protocol docs: [Prompt Accepted](https://agentclientprotocol.com/protocol/v2/draft/prompt-lifecycle#2-prompt-accepted)", + "description": "Response acknowledging that a user prompt was inserted into the ACP conversation.\n\nThis response does not indicate that the prompt was merely received or queued, nor that the\nagent has finished processing it.\nProcessing and completion are reported through `state_update` session updates.\n\nSee protocol docs: [Prompt Accepted](https://agentclientprotocol.com/protocol/v2/draft/prompt-lifecycle#2-prompt-accepted)", "type": "object", "properties": { + "messageId": { + "description": "Identifies the user message inserted into the ACP conversation.\n\nRequired and non-null. Omission and explicit `null` are both invalid.\n\nThe corresponding user-message session update carries this same identifier and may arrive\nbefore or after this response. Agents must echo the message during the live session, but are\nnot required to retain it. If retained and replayed, the message keeps this identifier.", + "allOf": [ + { + "$ref": "#/$defs/MessageId" + } + ] + }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", "type": [ @@ -5825,9 +5833,16 @@ "additionalProperties": true } }, + "required": [ + "messageId" + ], "x-side": "agent", "x-method": "session/prompt" }, + "MessageId": { + "description": "Unique identifier for a message within a session.", + "type": "string" + }, "StartNesResponse": { "description": "Response to `nes/start`.", "type": "object", @@ -6853,6 +6868,24 @@ } ] }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAdvisory information for the user that is not part of session history.\n\nNo Client capability is required. Clients that do not understand or\npresent notices may ignore them.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "notice" + } + }, + "required": [ + "sessionUpdate" + ], + "allOf": [ + { + "$ref": "#/$defs/Notice" + } + ] + }, { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA context compaction has been created or updated.", "type": "object", @@ -7108,6 +7141,18 @@ "sessionUpdate" ] }, + { + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "notice" + } + }, + "required": [ + "sessionUpdate" + ] + }, { "type": "object", "properties": { @@ -7138,10 +7183,6 @@ } ] }, - "MessageId": { - "description": "Unique identifier for a message within a session.", - "type": "string" - }, "ContentChunk": { "description": "A streamed item of message content.", "type": "object", @@ -8416,6 +8457,71 @@ "size" ] }, + "NoticeSeverity": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nSeverity hint for a session notice.", + "anyOf": [ + { + "description": "Informational notice.", + "type": "string", + "const": "info" + }, + { + "description": "Warning notice.", + "type": "string", + "const": "warning" + }, + { + "description": "Error notice.", + "type": "string", + "const": "error" + }, + { + "title": "other", + "description": "Custom or future notice severity.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Other unknown values are reserved for future ACP severities.", + "type": "string" + } + ] + }, + "Notice": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nFire-and-forget advisory information for the user.\n\nNotices are live events rather than session history. Agents must not rely on\na notice being received, displayed, or seen by the user.\nNo Client capability is required, and unsupported Clients may ignore notices.\n\nSee RFD: [Session Notices](https://agentclientprotocol.com/rfds/session-notices)", + "type": "object", + "properties": { + "severity": { + "description": "Presentation severity hint.", + "allOf": [ + { + "$ref": "#/$defs/NoticeSeverity" + } + ] + }, + "title": { + "description": "Required non-empty plain-text title that can stand alone.", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "Optional plain-text detail or guidance.\n\nOmitted and `null` are equivalent and mean no description was supplied.", + "type": [ + "string", + "null" + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "Metadata scoped to this notice.\n\nOmitted and `null` are equivalent and mean no metadata was supplied.", + "type": [ + "object", + "null" + ], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": [ + "severity", + "title" + ] + }, "CompactionId": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for a context compaction within a session.", "type": "string" @@ -8727,7 +8833,7 @@ }, { "title": "ResumeSessionRequest", - "description": "Resumes an existing session.\n\nThe agent should resume the session context, allowing the conversation\nto continue. If `replayFrom` is set, the agent should replay\nconversation history before responding.", + "description": "Resumes an existing session.\n\nThe agent should resume the session context, allowing the conversation\nto continue. If `replayFrom` is set, the agent should replay\nretained conversation history before responding.", "allOf": [ { "$ref": "#/$defs/ResumeSessionRequest" @@ -8754,7 +8860,7 @@ }, { "title": "PromptRequest", - "description": "Processes a user prompt within a session.\n\nThis request accepts the prompt:\n- Receives user messages with optional context (files, images, etc.)\n- Returns once the prompt is accepted\n\nAfter acceptance, the Agent reports the accepted user message,\nprocessing state, output, tool calls, and completion through\n`session/update` notifications.\n\nSee protocol docs: [Prompt Lifecycle](https://agentclientprotocol.com/protocol/v2/draft/prompt-lifecycle)", + "description": "Processes a user prompt within a session.\n\nAcceptance means insertion into the ACP conversation:\n- Receives user messages with optional context (files, images, etc.)\n- Returns the inserted user message's ID without waiting for processing to finish\n\nThe Agent reports the user message with the same ID through `session/update`;\nthis notification may arrive before or after the response. Processing state,\noutput, tool calls, and completion are also reported through session updates.\n\nSee protocol docs: [Prompt Lifecycle](https://agentclientprotocol.com/protocol/v2/draft/prompt-lifecycle)", "allOf": [ { "$ref": "#/$defs/PromptRequest" @@ -9695,7 +9801,7 @@ "x-method": "session/fork" }, "ResumeSessionRequest": { - "description": "Request parameters for resuming an existing session.\n\nResumes an existing session and optionally replays prior conversation\nhistory according to `replayFrom`.", + "description": "Request parameters for resuming an existing session.\n\nResumes an existing session and optionally replays retained conversation\nhistory according to `replayFrom`.", "type": "object", "properties": { "sessionId": { @@ -9733,7 +9839,7 @@ "x-deserialize-skip-invalid-items": true }, "replayFrom": { - "description": "Inclusive cursor describing where conversation replay should begin.\n\nOptional. Omitted or `null` both mean the Agent should resume without\nreplaying previous conversation history. Replay cursors are inclusive:\nreplay includes the position identified by the cursor. Supplying\n`{ \"type\": \"start\" }` means the Agent should replay the whole\nconversation before responding.", + "description": "Inclusive cursor describing where conversation replay should begin.\n\nOptional. Omitted or `null` both mean the Agent should resume without\nreplaying previous conversation history. Replay cursors are inclusive:\nreplay includes the position identified by the cursor. Supplying\n`{ \"type\": \"start\" }` means the Agent should replay all retained\nconversation history before responding.", "anyOf": [ { "$ref": "#/$defs/ReplayFrom" @@ -9765,7 +9871,7 @@ "description": "Inclusive cursor describing where replayed session history should begin.\n\nReplay includes the position identified by the cursor.", "anyOf": [ { - "description": "Replay the whole conversation from its first replayable entry.", + "description": "Replay all retained conversation history from its first replayable entry.", "type": "object", "properties": { "type": { @@ -9825,7 +9931,7 @@ ] }, "ReplayFromStart": { - "description": "Inclusive replay cursor requesting replay from the start of the conversation.", + "description": "Inclusive replay cursor requesting replay from the start of retained conversation history.", "type": "object", "properties": { "_meta": { diff --git a/scripts/_schema_semantics.py b/scripts/_schema_semantics.py index 33ade90..e318919 100644 --- a/scripts/_schema_semantics.py +++ b/scripts/_schema_semantics.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from importlib import import_module from pathlib import Path from typing import Protocol, cast @@ -23,6 +23,7 @@ class SchemaSemantics: base_class: str model_name_map: dict[str, str] compatibility_aliases: str = "" + field_type_overrides: dict[str, str] = field(default_factory=dict) class _SemanticsModule(Protocol): diff --git a/scripts/gen_schema.py b/scripts/gen_schema.py index e790395..dc1c4d0 100644 --- a/scripts/gen_schema.py +++ b/scripts/gen_schema.py @@ -109,6 +109,7 @@ def render_schema(semantics: SchemaSemantics) -> str: infer_union_variant_names=True, naming_strategy=NamingStrategy.PrimaryFirst, model_name_map=semantics.model_name_map, + type_overrides=semantics.field_type_overrides, strict_refs=True, schema_version="2020-12", schema_version_mode=VersionMode.Strict, diff --git a/scripts/gen_schema_v2.py b/scripts/gen_schema_v2.py index 7ed52ba..eed4bcf 100644 --- a/scripts/gen_schema_v2.py +++ b/scripts/gen_schema_v2.py @@ -127,6 +127,7 @@ "ConfigOptionUpdate", "SessionInfoUpdate", "UsageUpdate", + "SessionNotice", "SessionCompactionUpdate", "SessionCompactionSummaryChunk", ), @@ -161,7 +162,7 @@ inline_model_ref("NesSuggestion", ("anyOf", 4), ("object", None)): "OtherNesSuggestion", inline_model_ref("ElicitationPropertySchema", ("anyOf", 5), ("object", None)): ("ElicitationOtherPropertySchema"), inline_model_ref("MultiSelectItems", ("anyOf", 1), ("object", None)): "OtherMultiSelectItems", - inline_model_ref("SessionUpdate", ("anyOf", 19), ("object", None)): "OtherSessionUpdate", + inline_model_ref("SessionUpdate", ("anyOf", 20), ("object", None)): "OtherSessionUpdate", inline_model_ref("SessionUpdate", ("anyOf", 6), ("allOf", 0), ("allOf", None)): ("RunningSessionStateUpdateBase"), inline_model_ref("SessionUpdate", ("anyOf", 6), ("allOf", 1), ("allOf", None)): ("IdleSessionStateUpdateBase"), inline_model_ref("SessionUpdate", ("anyOf", 6), ("allOf", 2), ("allOf", None)): ( @@ -229,4 +230,6 @@ schema_out=ROOT / "src" / "acp" / "experimental" / "v2" / "schema.py", base_class="acp.experimental.v2._schema_base.BaseModel", model_name_map=MODEL_NAME_MAP, + # The generator loses additionalProperties types on nullable objects. + field_type_overrides={"ElicitationAcceptAction.content": "acp._schema_base.ElicitationContent"}, ) diff --git a/src/acp/_schema_base.py b/src/acp/_schema_base.py index ad979b3..7719a01 100644 --- a/src/acp/_schema_base.py +++ b/src/acp/_schema_base.py @@ -15,6 +15,8 @@ from ._deserialize import use_default_on_error +ElicitationContent = dict[str, str | int | float | bool | list[str]] + class BaseModel(pydantic.BaseModel): """Runtime behavior shared by generated ACP schema models.""" diff --git a/src/acp/experimental/v2/_methods.py b/src/acp/experimental/v2/_methods.py index 43867cd..ba72864 100644 --- a/src/acp/experimental/v2/_methods.py +++ b/src/acp/experimental/v2/_methods.py @@ -107,7 +107,6 @@ def notification(method: str, handler: str, params_type: Any) -> NotificationSpe "prompt", schema.PromptRequest, schema.PromptResponse, - empty_response=True, ), request(AGENT_METHODS["mcp_message"], "mcp_message", schema.MessageMcpRequest, Any), request(AGENT_METHODS["session_list"], "list_sessions", schema.ListSessionsRequest, schema.ListSessionsResponse), diff --git a/src/acp/experimental/v2/_schema_base.py b/src/acp/experimental/v2/_schema_base.py index 0dd0fe2..e2812ef 100644 --- a/src/acp/experimental/v2/_schema_base.py +++ b/src/acp/experimental/v2/_schema_base.py @@ -32,6 +32,7 @@ class BaseModel(_BaseModel): "compaction_summary_chunk", "compaction_update", "config_option_update", + "notice", "plan_removed", "plan_update", "session_info_update", diff --git a/src/acp/experimental/v2/agent.py b/src/acp/experimental/v2/agent.py index 3a4d2d4..ddc68c0 100644 --- a/src/acp/experimental/v2/agent.py +++ b/src/acp/experimental/v2/agent.py @@ -25,7 +25,7 @@ def _dump(model: BaseModel) -> dict[str, Any]: - return model.model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + return model.model_dump(mode="json", by_alias=True, exclude_unset=True) class _AgentRouter: diff --git a/src/acp/experimental/v2/meta.py b/src/acp/experimental/v2/meta.py index 2fdaf60..76bbadc 100644 --- a/src/acp/experimental/v2/meta.py +++ b/src/acp/experimental/v2/meta.py @@ -1,5 +1,5 @@ # Generated from schema/v2/meta.json. Do not edit by hand. -# Schema ref: refs/tags/schema-v2.0.0-alpha.3 +# Schema ref: refs/tags/schema-v2.0.0-alpha.5 AGENT_METHODS = { "initialize": "initialize", "auth_login": "auth/login", diff --git a/src/acp/experimental/v2/schema.py b/src/acp/experimental/v2/schema.py index 065a560..b8e5f51 100644 --- a/src/acp/experimental/v2/schema.py +++ b/src/acp/experimental/v2/schema.py @@ -1,5 +1,5 @@ # Generated from schema/v2/schema.json. Do not edit by hand. -# Schema ref: refs/tags/schema-v2.0.0-alpha.3 +# Schema ref: refs/tags/schema-v2.0.0-alpha.5 from __future__ import annotations @@ -7,6 +7,7 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union from acp._deserialize import coerce_protocol_version, skip_invalid_items, use_default_on_error +from acp._schema_base import ElicitationContent from acp.experimental.v2._schema_base import BaseModel from pydantic import ( AnyUrl, @@ -1163,17 +1164,6 @@ class CloseSessionResponse(BaseModel): """ -class PromptResponse(BaseModel): - field_meta: Annotated[Optional[Dict[str, Any]], Field(alias="_meta")] = None - """ - The _meta property is reserved by ACP to allow clients and agents to attach additional - metadata to their interactions. Implementations MUST NOT make assumptions about values at - these keys. - - See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) - """ - - class StartNesResponse(BaseModel): session_id: Annotated[str, Field(alias="sessionId")] """ @@ -1724,6 +1714,34 @@ class UsageUpdateBase(BaseModel): """ +class Notice(BaseModel): + severity: Union[Literal["info"], Literal["warning"], Literal["error"], str] + """ + Presentation severity hint. + """ + title: Annotated[str, Field(min_length=1)] + """ + Required non-empty plain-text title that can stand alone. + """ + description: Optional[str] = None + """ + Optional plain-text detail or guidance. + + Omitted and `null` are equivalent and mean no description was supplied. + """ + field_meta: Annotated[Optional[Dict[str, Any]], Field(alias="_meta")] = None + """ + Metadata scoped to this notice. + + Omitted and `null` are equivalent and mean no metadata was supplied. + """ + + @field_validator("description", mode="wrap") + @classmethod + def use_default_on_error_validator(cls, v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> Any: + return use_default_on_error(v, handler, info) + + class CompleteElicitationNotification(BaseModel): elicitation_id: Annotated[str, Field(alias="elicitationId")] """ @@ -2436,7 +2454,7 @@ class ElicitationContentValue(RootModel[Union[str, int, float, bool, List[str]]] class ElicitationAcceptAction(BaseModel): - content: Optional[Dict[str, Any]] = None + content: Optional[ElicitationContent] = None """ The user-provided content, if any, as an object matching the requested schema. """ @@ -3640,6 +3658,27 @@ def skip_invalid_items_validator(cls, v: Any, handler: ValidatorFunctionWrapHand return skip_invalid_items(v, handler, info) +class PromptResponse(BaseModel): + message_id: Annotated[str, Field(alias="messageId")] + """ + Identifies the user message inserted into the ACP conversation. + + Required and non-null. Omission and explicit `null` are both invalid. + + The corresponding user-message session update carries this same identifier and may arrive + before or after this response. Agents must echo the message during the live session, but are + not required to retain it. If retained and replayed, the message keeps this identifier. + """ + field_meta: Annotated[Optional[Dict[str, Any]], Field(alias="_meta")] = None + """ + The _meta property is reserved by ACP to allow clients and agents to attach additional + metadata to their interactions. Implementations MUST NOT make assumptions about values at + these keys. + + See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + """ + + class NesJumpSuggestionVariant(NesJumpSuggestion): kind: Literal["jump"] = "jump" @@ -3776,6 +3815,10 @@ def use_default_on_error_validator(cls, v: Any, handler: ValidatorFunctionWrapHa return use_default_on_error(v, handler, info) +class SessionNotice(Notice): + session_update: Annotated[Literal["notice"], Field(alias="sessionUpdate")] = "notice" + + class PlanUpdateFile(PlanFile): type: Literal["file"] = "file" @@ -4844,8 +4887,8 @@ class ResumeSessionRequest(BaseModel): Optional. Omitted or `null` both mean the Agent should resume without replaying previous conversation history. Replay cursors are inclusive: replay includes the position identified by the cursor. Supplying - `{ "type": "start" }` means the Agent should replay the whole - conversation before responding. + `{ "type": "start" }` means the Agent should replay all retained + conversation history before responding. """ field_meta: Annotated[Optional[Dict[str, Any]], Field(alias="_meta")] = None """ @@ -5417,10 +5460,6 @@ class ToolCallUpdate(BaseModel): """ name: Optional[str] = None """ - **UNSTABLE** - - This capability is not part of the spec yet, and may be removed or changed at any point. - Programmatic name of the tool being invoked. This field is optional and has patch semantics. Omission means no @@ -5786,6 +5825,7 @@ class UpdateSessionNotification(BaseModel): ConfigOptionUpdate, SessionInfoUpdate, UsageUpdate, + SessionNotice, SessionCompactionUpdate, SessionCompactionSummaryChunk, OtherSessionUpdate, diff --git a/tests/test_protocol_negotiation.py b/tests/test_protocol_negotiation.py index 8961802..93a043c 100644 --- a/tests/test_protocol_negotiation.py +++ b/tests/test_protocol_negotiation.py @@ -69,7 +69,7 @@ async def prompt(self, request: v2.schema.PromptRequest) -> v2.schema.PromptResp update=v2.schema.IdleSessionStateUpdate(), ) ) - return v2.schema.PromptResponse() + return v2.schema.PromptResponse(message_id="user-message-1") def v2_initialize() -> v2.schema.InitializeRequest: diff --git a/tests/test_v2_runtime.py b/tests/test_v2_runtime.py index ee5f350..78b3377 100644 --- a/tests/test_v2_runtime.py +++ b/tests/test_v2_runtime.py @@ -47,6 +47,10 @@ def __call__(self, *args: Any, **kwargs: Any) -> None: class SessionAgent(Agent): + def __init__(self, *, echo_before_response: bool) -> None: + super().__init__() + self.echo_before_response = echo_before_response + def on_connect(self, connection: v2.AgentSideConnection) -> None: self.connection = connection @@ -67,13 +71,17 @@ async def prompt(self, request: v2.schema.PromptRequest) -> v2.schema.PromptResp update=v2.schema.RunningSessionStateUpdate(), ) ) + if self.echo_before_response: + await self.echo_prompt(request) + return v2.schema.PromptResponse(message_id="user-message-1") + + async def echo_prompt(self, request: v2.schema.PromptRequest) -> None: await self.connection.session_update( v2.schema.UpdateSessionNotification( session_id=request.session_id, - update=v2.schema.IdleSessionStateUpdate(stop_reason="end_turn"), + update=v2.schema.UserMessageUpdate(message_id="user-message-1", content=request.prompt), ) ) - return v2.schema.PromptResponse() class ExtensionClient: @@ -196,28 +204,42 @@ async def test_v2_runtime_rejects_a_mismatched_initialize_response() -> None: @pytest.mark.asyncio -async def test_session_updates_are_delivered_independently_from_prompt() -> None: +@pytest.mark.parametrize("echo_before_response", [True, False]) +async def test_session_updates_are_delivered_independently_from_prompt(echo_before_response: bool) -> None: client_transport, agent_transport = memory_transport_pair() client = SessionClient() - agent_connection = v2.AgentSideConnection(SessionAgent(), agent_transport) + agent = SessionAgent(echo_before_response=echo_before_response) + agent_connection = v2.AgentSideConnection(agent, agent_transport) client_connection = v2.ClientSideConnection(client, client_transport) try: await client_connection.initialize(initialize_request()) session = await client_connection.new_session(v2.schema.NewSessionRequest(cwd="/workspace")) - await client_connection.prompt( - v2.schema.PromptRequest( + request = v2.schema.PromptRequest( + session_id=session.session_id, + prompt=[v2.schema.TextContentBlock(text="hello")], + ) + response = await client_connection.prompt(request) + if not echo_before_response: + await agent.echo_prompt(request) + # Completion is independent traffic, sent after prompt acceptance. + await agent_connection.session_update( + v2.schema.UpdateSessionNotification( session_id=session.session_id, - prompt=[v2.schema.TextContentBlock(text="hello")], + update=v2.schema.IdleSessionStateUpdate(stop_reason="end_turn"), ) ) ready = await asyncio.wait_for(client.updates.get(), timeout=1) running = await asyncio.wait_for(client.updates.get(), timeout=1) + echoed = await asyncio.wait_for(client.updates.get(), timeout=1) stopped = await asyncio.wait_for(client.updates.get(), timeout=1) assert isinstance(ready.update, v2.schema.IdleSessionStateUpdate) assert isinstance(running.update, v2.schema.RunningSessionStateUpdate) + assert isinstance(echoed.update, v2.schema.UserMessageUpdate) + assert echoed.update.message_id == response.message_id == "user-message-1" + assert echoed.update.content == request.prompt assert isinstance(stopped.update, v2.schema.IdleSessionStateUpdate) assert stopped.update.stop_reason == "end_turn" finally: @@ -225,6 +247,73 @@ async def test_session_updates_are_delivered_independently_from_prompt() -> None await agent_connection.close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "update", + [ + v2.schema.SessionNotice( + severity="warning", + title="Context is nearly full", + description="Start a new session soon.", + field_meta={"source": "context-monitor"}, + ), + v2.schema.SessionCompactionUpdate(compaction_id="compact-1", status="in_progress"), + v2.schema.SessionCompactionSummaryChunk( + compaction_id="compact-1", + content=v2.schema.TextContentBlock(text="Summary"), + ), + ], +) +async def test_notice_and_compaction_updates_reach_client(update) -> None: + client_transport, agent_transport = memory_transport_pair() + client = SessionClient() + async with ( + v2.AgentSideConnection(Agent(), agent_transport) as agent_connection, + v2.ClientSideConnection(client, client_transport) as client_connection, + ): + await client_connection.initialize(initialize_request()) + await agent_connection.session_update( + v2.schema.UpdateSessionNotification(session_id="session-1", update=update) + ) + received = await asyncio.wait_for(client.updates.get(), timeout=1) + assert received.session_id == "session-1" + assert received.update == update + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "updates", + [ + [ + v2.schema.SessionToolCallUpdate(tool_call_id="tool-1", name="read_file"), + v2.schema.SessionToolCallUpdate(tool_call_id="tool-1"), + v2.schema.SessionToolCallUpdate(tool_call_id="tool-1", name=None, field_meta=None), + ], + [ + v2.schema.SessionTerminalUpdate(terminal_id="term-1", command="ls"), + v2.schema.SessionTerminalUpdate(terminal_id="term-1"), + v2.schema.SessionTerminalUpdate(terminal_id="term-1", command=None, field_meta=None), + ], + ], +) +async def test_session_patches_preserve_omitted_and_cleared_fields(updates) -> None: + client_transport, agent_transport = memory_transport_pair() + client = SessionClient() + async with ( + v2.AgentSideConnection(Agent(), agent_transport) as agent_connection, + v2.ClientSideConnection(client, client_transport) as client_connection, + ): + await client_connection.initialize(initialize_request()) + for update in updates: + await agent_connection.session_update( + v2.schema.UpdateSessionNotification(session_id="session-1", update=update) + ) + received = await asyncio.wait_for(client.updates.get(), timeout=1) + assert received.update.model_dump(by_alias=True, exclude_unset=True) == update.model_dump( + by_alias=True, exclude_unset=True + ) + + def test_v2_public_entry_point_is_explicit() -> None: exported: dict[str, Any] = {name: getattr(v2, name) for name in v2.__all__}