From 45666cc54ed953523e02a2499febfd7fa4c41804 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 12:30:17 -0400 Subject: [PATCH 01/33] Update @github/copilot to 1.0.42 (#1211) * Update @github/copilot to 1.0.42 - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code * fix: increase ui_elicitation e2e test timeouts from 20s to 60s The test 'session created with onElicitationRequest reports elicitation capability' was flaky on Windows CI runners, timing out at 20s. The explicit 20s timeout was lower than the global vitest default of 30s. Increase all timeouts in this file to 60s to account for slower Windows CI runners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Stephen Toub Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/SessionEvents.cs | 61 +++++++++++++++++ go/generated_session_events.go | 36 ++++++++++ nodejs/package-lock.json | 56 +++++++-------- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/session-events.ts | 80 ++++++++++++++++++++++ nodejs/test/e2e/ui_elicitation.e2e.test.ts | 8 +-- python/copilot/generated/session_events.py | 57 ++++++++++++++- rust/src/generated/session_events.rs | 31 +++++++++ test/harness/package-lock.json | 56 +++++++-------- test/harness/package.json | 2 +- 11 files changed, 327 insertions(+), 64 deletions(-) diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index aabc6afce..efb647740 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -74,6 +74,8 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(SessionPlanChangedEvent), "session.plan_changed")] [JsonDerivedType(typeof(SessionRemoteSteerableChangedEvent), "session.remote_steerable_changed")] [JsonDerivedType(typeof(SessionResumeEvent), "session.resume")] +[JsonDerivedType(typeof(SessionScheduleCancelledEvent), "session.schedule_cancelled")] +[JsonDerivedType(typeof(SessionScheduleCreatedEvent), "session.schedule_created")] [JsonDerivedType(typeof(SessionShutdownEvent), "session.shutdown")] [JsonDerivedType(typeof(SessionSkillsLoadedEvent), "session.skills_loaded")] [JsonDerivedType(typeof(SessionSnapshotRewindEvent), "session.snapshot_rewind")] @@ -221,6 +223,32 @@ public partial class SessionTitleChangedEvent : SessionEvent public required SessionTitleChangedData Data { get; set; } } +/// Scheduled prompt registered via /every. +/// Represents the session.schedule_created event. +public partial class SessionScheduleCreatedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.schedule_created"; + + /// The session.schedule_created event payload. + [JsonPropertyName("data")] + public required SessionScheduleCreatedData Data { get; set; } +} + +/// Scheduled prompt cancelled from the schedule manager dialog. +/// Represents the session.schedule_cancelled event. +public partial class SessionScheduleCancelledEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.schedule_cancelled"; + + /// The session.schedule_cancelled event payload. + [JsonPropertyName("data")] + public required SessionScheduleCancelledData Data { get; set; } +} + /// Informational message for timeline display with categorization. /// Represents the session.info event. public partial class SessionInfoEvent : SessionEvent @@ -1314,6 +1342,30 @@ public partial class SessionTitleChangedData public required string Title { get; set; } } +/// Scheduled prompt registered via /every. +public partial class SessionScheduleCreatedData +{ + /// Sequential id assigned to the scheduled prompt within the session. + [JsonPropertyName("id")] + public required long Id { get; set; } + + /// Interval between ticks in milliseconds. + [JsonPropertyName("intervalMs")] + public required long IntervalMs { get; set; } + + /// Prompt text that gets enqueued on every tick. + [JsonPropertyName("prompt")] + public required string Prompt { get; set; } +} + +/// Scheduled prompt cancelled from the schedule manager dialog. +public partial class SessionScheduleCancelledData +{ + /// Id of the scheduled prompt that was cancelled. + [JsonPropertyName("id")] + public required long Id { get; set; } +} + /// Informational message for timeline display with categorization. public partial class SessionInfoData { @@ -3344,6 +3396,11 @@ public partial class AssistantMessageToolRequest [JsonPropertyName("mcpServerName")] public string? McpServerName { get; set; } + /// Original tool name on the MCP server, when the tool is an MCP tool. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mcpToolName")] + public string? McpToolName { get; set; } + /// Name of the tool being invoked. [JsonPropertyName("name")] public required string Name { get; set; } @@ -5300,6 +5357,10 @@ public enum ExtensionsLoadedExtensionStatus [JsonSerializable(typeof(SessionRemoteSteerableChangedEvent))] [JsonSerializable(typeof(SessionResumeData))] [JsonSerializable(typeof(SessionResumeEvent))] +[JsonSerializable(typeof(SessionScheduleCancelledData))] +[JsonSerializable(typeof(SessionScheduleCancelledEvent))] +[JsonSerializable(typeof(SessionScheduleCreatedData))] +[JsonSerializable(typeof(SessionScheduleCreatedEvent))] [JsonSerializable(typeof(SessionShutdownData))] [JsonSerializable(typeof(SessionShutdownEvent))] [JsonSerializable(typeof(SessionSkillsLoadedData))] diff --git a/go/generated_session_events.go b/go/generated_session_events.go index ce60561e9..6844b975a 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -111,6 +111,18 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeSessionScheduleCreated: + var d SessionScheduleCreatedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionScheduleCancelled: + var d SessionScheduleCancelledData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeSessionInfo: var d SessionInfoData if err := json.Unmarshal(raw.Data, &d); err != nil { @@ -580,6 +592,8 @@ const ( SessionEventTypeSessionError SessionEventType = "session.error" SessionEventTypeSessionIdle SessionEventType = "session.idle" SessionEventTypeSessionTitleChanged SessionEventType = "session.title_changed" + SessionEventTypeSessionScheduleCreated SessionEventType = "session.schedule_created" + SessionEventTypeSessionScheduleCancelled SessionEventType = "session.schedule_cancelled" SessionEventTypeSessionInfo SessionEventType = "session.info" SessionEventTypeSessionWarning SessionEventType = "session.warning" SessionEventTypeSessionModelChange SessionEventType = "session.model_change" @@ -1216,6 +1230,26 @@ type SamplingRequestedData struct { func (*SamplingRequestedData) sessionEventData() {} +// Scheduled prompt cancelled from the schedule manager dialog +type SessionScheduleCancelledData struct { + // Id of the scheduled prompt that was cancelled + ID int64 `json:"id"` +} + +func (*SessionScheduleCancelledData) sessionEventData() {} + +// Scheduled prompt registered via /every +type SessionScheduleCreatedData struct { + // Sequential id assigned to the scheduled prompt within the session + ID int64 `json:"id"` + // Interval between ticks in milliseconds + IntervalMs int64 `json:"intervalMs"` + // Prompt text that gets enqueued on every tick + Prompt string `json:"prompt"` +} + +func (*SessionScheduleCreatedData) sessionEventData() {} + // Session capability change notification type CapabilitiesChangedData struct { // UI capability changes @@ -1796,6 +1830,8 @@ type AssistantMessageToolRequest struct { IntentionSummary *string `json:"intentionSummary,omitempty"` // Name of the MCP server hosting this tool, when the tool is an MCP tool McpServerName *string `json:"mcpServerName,omitempty"` + // Original tool name on the MCP server, when the tool is an MCP tool + McpToolName *string `json:"mcpToolName,omitempty"` // Name of the tool being invoked Name string `json:"name"` // Unique identifier for this tool call diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 87b471a6c..75a532e0f 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.41-1", + "@github/copilot": "^1.0.42", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.41-1.tgz", - "integrity": "sha512-95Qxeds7SAi96b4bK91PAdB13M39ZKpZDfWf69yJg6362RTCFNa24QvflLG+3f4Vojh8GD4h8EvxAYwgq4zdMQ==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.42.tgz", + "integrity": "sha512-ODW5+aJi595Tb2WUaAlshBoUkOBuh9MegXXwXzMoar+k9fZzzDy3oNJLFg7ni4UtkUZvj/WL/y3s5O+FlsF2HA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.41-1", - "@github/copilot-darwin-x64": "1.0.41-1", - "@github/copilot-linux-arm64": "1.0.41-1", - "@github/copilot-linux-x64": "1.0.41-1", - "@github/copilot-win32-arm64": "1.0.41-1", - "@github/copilot-win32-x64": "1.0.41-1" + "@github/copilot-darwin-arm64": "1.0.42", + "@github/copilot-darwin-x64": "1.0.42", + "@github/copilot-linux-arm64": "1.0.42", + "@github/copilot-linux-x64": "1.0.42", + "@github/copilot-win32-arm64": "1.0.42", + "@github/copilot-win32-x64": "1.0.42" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.41-1.tgz", - "integrity": "sha512-9ExZaLv3/yi7Be9GnjhxJgmuklQhqT59014BsqsWt1lpTA1khJs8VyC5B+iP8TEOkFKvD/UXJNSP9PCE6n5inQ==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.42.tgz", + "integrity": "sha512-2w89QLRgMR7hWwV1KG3uXqu98WST6afJCfvtYtqvPdf6ZQC7Fj2HhPNCrMxZk/H8mZwTgYJeg30gZjvV1698EA==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.41-1.tgz", - "integrity": "sha512-6ZretUFTcCPajzcZyQZixn2unVlN+sbtC6hULBYT6FLHrqSrjK4QN52eCtTYOz/kPbBUO4lj9YjT/v1gkgMDwQ==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.42.tgz", + "integrity": "sha512-G2//tgGSKXx3ZGMqe774UnewasYMh+j0ZeQ3injtuZpSpzN+OAuNkzwXpvFHprdbgekMb0oAPN+Xm3rHuQY8Xw==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.41-1.tgz", - "integrity": "sha512-iP/VbjvGMQvo0fudLHBpmp31nAmtGvq1tZWC+YEQ43D58n2miOXkiDR61Tn9PSPGTkNbrnTecE0mgBO2oePYPw==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.42.tgz", + "integrity": "sha512-Ai6J4hUKVuE5ztsLspp/I7ByXtL2V6tF+AOn0q+hHp1MOA5JLq5/G8PE+c0VzG69x4hkt1lROQDjvXJGY7sv+g==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.41-1.tgz", - "integrity": "sha512-DAVCL7pMxeRRHcVOcbpllDBn87zVgskHNqfWrdFPEcgfslx0bw7GkErO35jx/SLnehcwpdwHquqfkyDpnfRAqg==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.42.tgz", + "integrity": "sha512-yYfuL6Hk3uLQuIgfxpEMCyoowFq2Bew1EaXmvg4lnDjj95tvEmyMCX77aIZ2AKwBOgp1nMV7L1B1QL9/mw6BTA==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.41-1.tgz", - "integrity": "sha512-m+un4+m1MQlTbiaA6d+/1Aa0SBI85O+De6P/8RdrVCEaoLE0Uy10wZbiHk6GK+YN74B/9WGwW8YANVVaBXsDDw==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.42.tgz", + "integrity": "sha512-WgnV6AxsvbvZdNW/42JFikK/SqR1JMw6juRpGKXZr70ond/cHK6trtrmt3dXYPymBO14ppJMFdm4+chJzKGKMw==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.41-1.tgz", - "integrity": "sha512-9Yl56T/4Eo7etQ+98XxsYTIzPdkuN5SAD0mZN2SHjdK5h0mBJFXpEmsminSelFgUbTsMHb+srfSmvx5nFe0m0A==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.42.tgz", + "integrity": "sha512-J5jtrcYuODuD4LPPRHjOCMJGO6+vKZ71n8PTiHPCg9lpfThXDDXxrB7nDDkhxl23zSXlUrpWwkMI+a2Ax/AxGg==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 4969ba23c..2d21a55e2 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.41-1", + "@github/copilot": "^1.0.42", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 0c86383f6..b7f4bdd8b 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.41-1", + "@github/copilot": "^1.0.42", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index df3702843..3668a3ca6 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -10,6 +10,8 @@ export type SessionEvent = | ErrorEvent | IdleEvent | TitleChangedEvent + | ScheduleCreatedEvent + | ScheduleCancelledEvent | InfoEvent | WarningEvent | ModelChangeEvent @@ -585,6 +587,80 @@ export interface TitleChangedData { */ title: string; } +export interface ScheduleCreatedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: ScheduleCreatedData; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + type: "session.schedule_created"; +} +/** + * Scheduled prompt registered via /every + */ +export interface ScheduleCreatedData { + /** + * Sequential id assigned to the scheduled prompt within the session + */ + id: number; + /** + * Interval between ticks in milliseconds + */ + intervalMs: number; + /** + * Prompt text that gets enqueued on every tick + */ + prompt: string; +} +export interface ScheduleCancelledEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: ScheduleCancelledData; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + type: "session.schedule_cancelled"; +} +/** + * Scheduled prompt cancelled from the schedule manager dialog + */ +export interface ScheduleCancelledData { + /** + * Id of the scheduled prompt that was cancelled + */ + id: number; +} export interface InfoEvent { /** * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. @@ -1952,6 +2028,10 @@ export interface AssistantMessageToolRequest { * Name of the MCP server hosting this tool, when the tool is an MCP tool */ mcpServerName?: string; + /** + * Original tool name on the MCP server, when the tool is an MCP tool + */ + mcpToolName?: string; /** * Name of the tool being invoked */ diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index 8651c5bd2..e30dbdacd 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -27,7 +27,7 @@ describe("UI Elicitation Callback", async () => { it( "session created with onElicitationRequest reports elicitation capability", - { timeout: 20_000 }, + { timeout: 60_000 }, async () => { const session = await client.createSession({ onPermissionRequest: approveAll, @@ -40,7 +40,7 @@ describe("UI Elicitation Callback", async () => { it( "session created without onElicitationRequest reports no elicitation capability", - { timeout: 20_000 }, + { timeout: 60_000 }, async () => { const session = await client.createSession({ onPermissionRequest: approveAll, @@ -73,7 +73,7 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { it( "capabilities.changed fires when second client joins with elicitation handler", - { timeout: 20_000 }, + { timeout: 60_000 }, async () => { // Client1 creates session without elicitation const session1 = await client1.createSession({ @@ -112,7 +112,7 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { it( "capabilities.changed fires when elicitation provider disconnects", - { timeout: 20_000 }, + { timeout: 60_000 }, async () => { // Client1 creates session without elicitation const session1 = await client1.createSession({ diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 55646eba8..1fe1af32b 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -110,6 +110,8 @@ class SessionEventType(Enum): SESSION_ERROR = "session.error" SESSION_IDLE = "session.idle" SESSION_TITLE_CHANGED = "session.title_changed" + SESSION_SCHEDULE_CREATED = "session.schedule_created" + SESSION_SCHEDULE_CANCELLED = "session.schedule_cancelled" SESSION_INFO = "session.info" SESSION_WARNING = "session.warning" SESSION_MODEL_CHANGE = "session.model_change" @@ -428,6 +430,7 @@ class AssistantMessageToolRequest: arguments: Any = None intention_summary: str | None = None mcp_server_name: str | None = None + mcp_tool_name: str | None = None tool_title: str | None = None type: AssistantMessageToolRequestType | None = None @@ -439,6 +442,7 @@ def from_dict(obj: Any) -> "AssistantMessageToolRequest": arguments = obj.get("arguments") intention_summary = from_union([from_none, from_str], obj.get("intentionSummary")) mcp_server_name = from_union([from_none, from_str], obj.get("mcpServerName")) + mcp_tool_name = from_union([from_none, from_str], obj.get("mcpToolName")) tool_title = from_union([from_none, from_str], obj.get("toolTitle")) type = from_union([from_none, lambda x: parse_enum(AssistantMessageToolRequestType, x)], obj.get("type")) return AssistantMessageToolRequest( @@ -447,6 +451,7 @@ def from_dict(obj: Any) -> "AssistantMessageToolRequest": arguments=arguments, intention_summary=intention_summary, mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, tool_title=tool_title, type=type, ) @@ -461,6 +466,8 @@ def to_dict(self) -> dict: result["intentionSummary"] = from_union([from_none, from_str], self.intention_summary) if self.mcp_server_name is not None: result["mcpServerName"] = from_union([from_none, from_str], self.mcp_server_name) + if self.mcp_tool_name is not None: + result["mcpToolName"] = from_union([from_none, from_str], self.mcp_tool_name) if self.tool_title is not None: result["toolTitle"] = from_union([from_none, from_str], self.tool_title) if self.type is not None: @@ -2825,6 +2832,52 @@ def to_dict(self) -> dict: return result +@dataclass +class SessionScheduleCancelledData: + "Scheduled prompt cancelled from the schedule manager dialog" + id: int + + @staticmethod + def from_dict(obj: Any) -> "SessionScheduleCancelledData": + assert isinstance(obj, dict) + id = from_int(obj.get("id")) + return SessionScheduleCancelledData( + id=id, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = to_int(self.id) + return result + + +@dataclass +class SessionScheduleCreatedData: + "Scheduled prompt registered via /every" + id: int + interval_ms: int + prompt: str + + @staticmethod + def from_dict(obj: Any) -> "SessionScheduleCreatedData": + assert isinstance(obj, dict) + id = from_int(obj.get("id")) + interval_ms = from_int(obj.get("intervalMs")) + prompt = from_str(obj.get("prompt")) + return SessionScheduleCreatedData( + id=id, + interval_ms=interval_ms, + prompt=prompt, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = to_int(self.id) + result["intervalMs"] = to_int(self.interval_ms) + result["prompt"] = from_str(self.prompt) + return result + + @dataclass class SessionShutdownData: "Session termination metrics including usage statistics, code changes, and shutdown reason" @@ -4764,7 +4817,7 @@ class WorkspaceFileChangedOperation(Enum): UPDATE = "update" -SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionPlanChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionContextChangedData | SessionUsageInfoData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantIntentData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantUsageData | ModelCallFailureData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | SkillInvokedData | SubagentStartedData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | SessionExtensionsLoadedData | RawSessionEventData | Data +SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionScheduleCreatedData | SessionScheduleCancelledData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionPlanChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionContextChangedData | SessionUsageInfoData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantIntentData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantUsageData | ModelCallFailureData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | SkillInvokedData | SubagentStartedData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | SessionExtensionsLoadedData | RawSessionEventData | Data @dataclass @@ -4796,6 +4849,8 @@ def from_dict(obj: Any) -> "SessionEvent": case SessionEventType.SESSION_ERROR: data = SessionErrorData.from_dict(data_obj) case SessionEventType.SESSION_IDLE: data = SessionIdleData.from_dict(data_obj) case SessionEventType.SESSION_TITLE_CHANGED: data = SessionTitleChangedData.from_dict(data_obj) + case SessionEventType.SESSION_SCHEDULE_CREATED: data = SessionScheduleCreatedData.from_dict(data_obj) + case SessionEventType.SESSION_SCHEDULE_CANCELLED: data = SessionScheduleCancelledData.from_dict(data_obj) case SessionEventType.SESSION_INFO: data = SessionInfoData.from_dict(data_obj) case SessionEventType.SESSION_WARNING: data = SessionWarningData.from_dict(data_obj) case SessionEventType.SESSION_MODEL_CHANGE: data = SessionModelChangeData.from_dict(data_obj) diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 85be3731e..cf7c33c68 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -21,6 +21,10 @@ pub enum SessionEventType { SessionIdle, #[serde(rename = "session.title_changed")] SessionTitleChanged, + #[serde(rename = "session.schedule_created")] + SessionScheduleCreated, + #[serde(rename = "session.schedule_cancelled")] + SessionScheduleCancelled, #[serde(rename = "session.info")] SessionInfo, #[serde(rename = "session.warning")] @@ -188,6 +192,10 @@ pub enum SessionEventData { SessionIdle(SessionIdleData), #[serde(rename = "session.title_changed")] SessionTitleChanged(SessionTitleChangedData), + #[serde(rename = "session.schedule_created")] + SessionScheduleCreated(SessionScheduleCreatedData), + #[serde(rename = "session.schedule_cancelled")] + SessionScheduleCancelled(SessionScheduleCancelledData), #[serde(rename = "session.info")] SessionInfo(SessionInfoData), #[serde(rename = "session.warning")] @@ -505,6 +513,26 @@ pub struct SessionTitleChangedData { pub title: String, } +/// Scheduled prompt registered via /every +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionScheduleCreatedData { + /// Sequential id assigned to the scheduled prompt within the session + pub id: i64, + /// Interval between ticks in milliseconds + pub interval_ms: i64, + /// Prompt text that gets enqueued on every tick + pub prompt: String, +} + +/// Scheduled prompt cancelled from the schedule manager dialog +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionScheduleCancelledData { + /// Id of the scheduled prompt that was cancelled + pub id: i64, +} + /// Informational message for timeline display with categorization #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1039,6 +1067,9 @@ pub struct AssistantMessageToolRequest { /// Name of the MCP server hosting this tool, when the tool is an MCP tool #[serde(skip_serializing_if = "Option::is_none")] pub mcp_server_name: Option, + /// Original tool name on the MCP server, when the tool is an MCP tool + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_tool_name: Option, /// Name of the tool being invoked pub name: String, /// Unique identifier for this tool call diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 7259fbacc..c5bbaabae 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.41-1", + "@github/copilot": "^1.0.42", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -464,27 +464,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.41-1.tgz", - "integrity": "sha512-95Qxeds7SAi96b4bK91PAdB13M39ZKpZDfWf69yJg6362RTCFNa24QvflLG+3f4Vojh8GD4h8EvxAYwgq4zdMQ==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.42.tgz", + "integrity": "sha512-ODW5+aJi595Tb2WUaAlshBoUkOBuh9MegXXwXzMoar+k9fZzzDy3oNJLFg7ni4UtkUZvj/WL/y3s5O+FlsF2HA==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.41-1", - "@github/copilot-darwin-x64": "1.0.41-1", - "@github/copilot-linux-arm64": "1.0.41-1", - "@github/copilot-linux-x64": "1.0.41-1", - "@github/copilot-win32-arm64": "1.0.41-1", - "@github/copilot-win32-x64": "1.0.41-1" + "@github/copilot-darwin-arm64": "1.0.42", + "@github/copilot-darwin-x64": "1.0.42", + "@github/copilot-linux-arm64": "1.0.42", + "@github/copilot-linux-x64": "1.0.42", + "@github/copilot-win32-arm64": "1.0.42", + "@github/copilot-win32-x64": "1.0.42" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.41-1.tgz", - "integrity": "sha512-9ExZaLv3/yi7Be9GnjhxJgmuklQhqT59014BsqsWt1lpTA1khJs8VyC5B+iP8TEOkFKvD/UXJNSP9PCE6n5inQ==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.42.tgz", + "integrity": "sha512-2w89QLRgMR7hWwV1KG3uXqu98WST6afJCfvtYtqvPdf6ZQC7Fj2HhPNCrMxZk/H8mZwTgYJeg30gZjvV1698EA==", "cpu": [ "arm64" ], @@ -499,9 +499,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.41-1.tgz", - "integrity": "sha512-6ZretUFTcCPajzcZyQZixn2unVlN+sbtC6hULBYT6FLHrqSrjK4QN52eCtTYOz/kPbBUO4lj9YjT/v1gkgMDwQ==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.42.tgz", + "integrity": "sha512-G2//tgGSKXx3ZGMqe774UnewasYMh+j0ZeQ3injtuZpSpzN+OAuNkzwXpvFHprdbgekMb0oAPN+Xm3rHuQY8Xw==", "cpu": [ "x64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.41-1.tgz", - "integrity": "sha512-iP/VbjvGMQvo0fudLHBpmp31nAmtGvq1tZWC+YEQ43D58n2miOXkiDR61Tn9PSPGTkNbrnTecE0mgBO2oePYPw==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.42.tgz", + "integrity": "sha512-Ai6J4hUKVuE5ztsLspp/I7ByXtL2V6tF+AOn0q+hHp1MOA5JLq5/G8PE+c0VzG69x4hkt1lROQDjvXJGY7sv+g==", "cpu": [ "arm64" ], @@ -533,9 +533,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.41-1.tgz", - "integrity": "sha512-DAVCL7pMxeRRHcVOcbpllDBn87zVgskHNqfWrdFPEcgfslx0bw7GkErO35jx/SLnehcwpdwHquqfkyDpnfRAqg==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.42.tgz", + "integrity": "sha512-yYfuL6Hk3uLQuIgfxpEMCyoowFq2Bew1EaXmvg4lnDjj95tvEmyMCX77aIZ2AKwBOgp1nMV7L1B1QL9/mw6BTA==", "cpu": [ "x64" ], @@ -550,9 +550,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.41-1.tgz", - "integrity": "sha512-m+un4+m1MQlTbiaA6d+/1Aa0SBI85O+De6P/8RdrVCEaoLE0Uy10wZbiHk6GK+YN74B/9WGwW8YANVVaBXsDDw==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.42.tgz", + "integrity": "sha512-WgnV6AxsvbvZdNW/42JFikK/SqR1JMw6juRpGKXZr70ond/cHK6trtrmt3dXYPymBO14ppJMFdm4+chJzKGKMw==", "cpu": [ "arm64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.41-1", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.41-1.tgz", - "integrity": "sha512-9Yl56T/4Eo7etQ+98XxsYTIzPdkuN5SAD0mZN2SHjdK5h0mBJFXpEmsminSelFgUbTsMHb+srfSmvx5nFe0m0A==", + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.42.tgz", + "integrity": "sha512-J5jtrcYuODuD4LPPRHjOCMJGO6+vKZ71n8PTiHPCg9lpfThXDDXxrB7nDDkhxl23zSXlUrpWwkMI+a2Ax/AxGg==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 2b483cb44..874aeca16 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.41-1", + "@github/copilot": "^1.0.42", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From f39d370ed6082501cf7bb1d8636e806df0c6e04f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 6 May 2026 14:22:25 -0400 Subject: [PATCH 02/33] Align Rust SDK public surface (#1212) * Align Rust SDK public surface Remove Rust-only auto mode switch and exit-plan-mode handler surfaces, align resume tool filtering config, add richer Rust tool result support, and update documentation/comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Rust clippy warning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/types.go | 6 +- rust/README.md | 52 ++++++----- rust/src/handler.rs | 132 +++------------------------- rust/src/lib.rs | 18 ++-- rust/src/permission.rs | 10 ++- rust/src/session.rs | 87 ++---------------- rust/src/tool.rs | 138 ++++++++++++++++++++++++++--- rust/src/types.rs | 175 +++++++++++++------------------------ rust/tests/session_test.rs | 124 +------------------------- 9 files changed, 257 insertions(+), 485 deletions(-) diff --git a/go/types.go b/go/types.go index dd3ffbbe3..0fa9a18d0 100644 --- a/go/types.go +++ b/go/types.go @@ -547,8 +547,7 @@ type SessionConfig struct { // Ignored if AvailableTools is specified. ExcludedTools []string // OnPermissionRequest is a handler for permission requests from the server. - // If nil, all permission requests are denied by default. - // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). + // This field is required; use PermissionHandler.ApproveAll to allow all permissions. OnPermissionRequest PermissionHandlerFunc // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler @@ -767,8 +766,7 @@ type ResumeSessionConfig struct { // Valid values: "low", "medium", "high", "xhigh" ReasoningEffort string // OnPermissionRequest is a handler for permission requests from the server. - // If nil, all permission requests are denied by default. - // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). + // This field is required; use PermissionHandler.ApproveAll to allow all permissions. OnPermissionRequest PermissionHandlerFunc // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler diff --git a/rust/README.md b/rust/README.md index 71c0bf26c..d6cf17a93 100644 --- a/rust/README.md +++ b/rust/README.md @@ -105,23 +105,40 @@ let messages = session.get_messages().await?; session.abort().await?; // Model management -let model = session.get_model().await?; session.set_model("claude-sonnet-4.5", None).await?; -// Mode management (interactive, plan, autopilot) -let mode = session.get_mode().await?; -session.set_mode("autopilot").await?; +// Generated typed RPCs cover lower-level session operations. +let model = session.rpc().model().get_current().await?; +let mode = session.rpc().mode().get().await?; // Workspace files -let files = session.list_workspace_files().await?; -let content = session.read_workspace_file("plan.md").await?; +let files = session.rpc().workspaces().list_files().await?; +let content = session + .rpc() + .workspaces() + .read_file(github_copilot_sdk::generated::api_types::WorkspacesReadFileRequest { + path: "plan.md".to_string(), + }) + .await?; // Plan management -let (exists, content) = session.read_plan().await?; -session.update_plan("Updated plan content").await?; +let plan = session.rpc().plan().read().await?; +session + .rpc() + .plan() + .update(github_copilot_sdk::generated::api_types::PlanUpdateRequest { + content: "Updated plan content".to_string(), + }) + .await?; // Fleet (sub-agents) -session.start_fleet(Some("Implement the auth module")).await?; +session + .rpc() + .fleet() + .start(github_copilot_sdk::generated::api_types::FleetStartRequest { + prompt: Some("Implement the auth module".to_string()), + }) + .await?; // Cleanup (preserves on-disk session state for later resume) session.disconnect().await?; @@ -129,14 +146,14 @@ session.disconnect().await?; #### Typed RPC namespace -The ergonomic helpers above are convenience wrappers over a fully-typed +High-level helpers are convenience wrappers over a fully-typed JSON-RPC namespace generated from the GitHub Copilot CLI schema. `Client::rpc()` and `Session::rpc()` give direct access to every method on the wire, including ones with no helper today, with strongly-typed request and response structs. ```rust,ignore -// Methods with helpers — wire strings live in one generated place. +// Common generated RPCs. let files = session.rpc().workspaces().list_files().await?.files; let models = client.rpc().models().list().await?.models; @@ -631,7 +648,7 @@ if err.is_transport_failure() { ## Differences From Other SDKs -The Rust SDK aligns closely with the Node, Python, and Go SDKs but diverges +The Rust SDK aligns closely with the Node, Python, Go, and .NET SDKs but diverges in a few places where Rust idiom or the type system gives a clearly better shape, and exposes a small additional surface where the language affords ergonomics the dynamically-typed SDKs don't. @@ -639,7 +656,7 @@ ergonomics the dynamically-typed SDKs don't. ### Shape divergence - **`SessionFsProvider` registration is direct, not factory-closure.** Where - Node/Python/Go accept a closure that the runtime calls on each + Node/Python/Go/.NET accept a closure that the runtime calls on each session-create to build a fresh provider, the Rust SDK takes `Arc` directly via [`SessionConfig::with_session_fs_provider`]. The factory pattern doesn't @@ -676,7 +693,7 @@ in-memory provider implementation. A handful of conveniences exist only on the Rust SDK as of 0.1.0. These are surface areas where Rust idiom (newtypes, enums, trait objects) -gives a clearly nicer shape than Node/Python/Go currently expose. Rust +gives a clearly nicer shape than Node/Python/Go/.NET currently expose. Rust gets to be Rust here — cross-SDK parity for these is a post-release conversation, not a release blocker. None of these are deprecated and none of them are scheduled for removal. @@ -701,13 +718,6 @@ none of them are scheduled for removal. arg vectors for "prepend before subcommand" vs "append after the built-in flags", giving precise control over CLI invocation order without string-splicing. -- **`SessionHandler::on_auto_mode_switch`** — typed handler for the CLI's - rate-limit-recovery prompt (CLI's `autoModeSwitch.request` callback, - added in copilot-agent-runtime PR #7024). Returns - `AutoModeSwitchResponse::{Yes, YesAlways, No}`. Default impl declines. - Cross-SDK parity is post-release follow-up — Node / Python / Go / .NET - consumers currently observe the request as a raw event and must drive - the wire response themselves. ## Layout diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 79c7d381d..69a488563 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -5,11 +5,10 @@ //! CLI events, permission requests, tool calls, and user input prompts. use async_trait::async_trait; -use serde::{Deserialize, Serialize}; use crate::types::{ - ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId, - SessionEvent, SessionId, ToolInvocation, ToolResult, + ElicitationRequest, ElicitationResult, PermissionRequestData, RequestId, SessionEvent, + SessionId, ToolInvocation, ToolResult, }; /// Events dispatched by the SDK session event loop to the handler. @@ -69,29 +68,6 @@ pub enum HandlerEvent { /// The elicitation request payload. request: ElicitationRequest, }, - - /// The CLI requests exiting plan mode. Return `HandlerResponse::ExitPlanMode(..)`. - ExitPlanMode { - /// The requesting session. - session_id: SessionId, - /// Plan mode exit payload. - data: ExitPlanModeData, - }, - - /// The CLI asks whether to switch to auto model when an eligible rate - /// limit is hit. Return [`HandlerResponse::AutoModeSwitch`]. - AutoModeSwitch { - /// The requesting session. - session_id: SessionId, - /// The specific rate-limit error code that triggered the request, - /// if known (e.g. `user_weekly_rate_limited`, `user_global_rate_limited`). - error_code: Option, - /// Seconds until the rate limit resets, when known. Per RFC 9110's - /// `Retry-After` `delta-seconds` form, this is an integer count of - /// seconds. Handlers can use it to render a humanized reset time - /// alongside the prompt. - retry_after_seconds: Option, - }, } /// Response from the handler back to the SDK, used to construct the @@ -109,10 +85,6 @@ pub enum HandlerResponse { ToolResult(ToolResult), /// Elicitation result (accept/decline/cancel with optional form data). Elicitation(ElicitationResult), - /// Exit plan mode decision. - ExitPlanMode(ExitPlanModeResult), - /// Auto-mode-switch decision. - AutoModeSwitch(AutoModeSwitchResponse), } /// Result of a permission request. @@ -165,50 +137,11 @@ pub struct UserInputResponse { pub was_freeform: bool, } -/// Result of an exit-plan-mode request. -#[derive(Debug, Clone)] -pub struct ExitPlanModeResult { - /// Whether the user approved exiting plan mode. - pub approved: bool, - /// The action the user selected (if any). - pub selected_action: Option, - /// Optional feedback text from the user. - pub feedback: Option, -} - -impl Default for ExitPlanModeResult { - fn default() -> Self { - Self { - approved: true, - selected_action: None, - feedback: None, - } - } -} - -/// Response to a [`HandlerEvent::AutoModeSwitch`] request. -/// -/// Wire serialization matches the CLI's `autoModeSwitch.request` response -/// schema: `"yes"`, `"yes_always"`, or `"no"`. -#[non_exhaustive] -#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AutoModeSwitchResponse { - /// Approve the auto-mode switch for this rate-limit cycle only. - Yes, - /// Approve and remember — auto-accept future auto-mode switches in this - /// session without prompting. - YesAlways, - /// Decline the auto-mode switch. The session stays on the current model - /// and surfaces the rate-limit error. - No, -} - /// Callback trait for session events. /// /// Implement this trait to control how a session responds to CLI events, -/// permission requests, tool calls, user input prompts, elicitations, and -/// plan-mode exits. There are two styles of implementation — pick whichever +/// permission requests, tool calls, user input prompts, and elicitations. +/// There are two styles of implementation — pick whichever /// fits your use case: /// /// 1. **Per-event methods (recommended for most handlers).** Override the @@ -233,18 +166,15 @@ pub enum AutoModeSwitchResponse { /// - User input → `None` (no answer available). /// - External tool calls → failure result with "no handler registered". /// - Elicitation → `"cancel"`. -/// - Exit plan mode → [`ExitPlanModeResult::default`]. -/// - Auto-mode-switch → [`AutoModeSwitchResponse::No`] (decline by default; the -/// session stays on its current model and surfaces the rate-limit error). /// - Session events → ignored (fire-and-forget). /// /// # Concurrency /// /// **Request-triggered events** (`UserInput`, `ExternalTool` via `tool.call`, -/// `ExitPlanMode`, `PermissionRequest` via `permission.request`) are awaited -/// inline in the event loop and therefore processed **serially** per session. -/// Blocking here pauses that session's event loop — which is correct, since -/// the CLI is also blocked waiting for the response. +/// `PermissionRequest` via `permission.request`) are awaited inline in the +/// event loop and therefore processed **serially** per session. Blocking here +/// pauses that session's event loop — which is correct, since the CLI is also +/// blocked waiting for the response. /// /// **Notification-triggered events** (`PermissionRequest` via /// `permission.requested`, `ExternalTool` via `external_tool.requested`) are @@ -321,17 +251,6 @@ pub trait SessionHandler: Send + Sync + 'static { } => HandlerResponse::Elicitation( self.on_elicitation(session_id, request_id, request).await, ), - HandlerEvent::ExitPlanMode { session_id, data } => { - HandlerResponse::ExitPlanMode(self.on_exit_plan_mode(session_id, data).await) - } - HandlerEvent::AutoModeSwitch { - session_id, - error_code, - retry_after_seconds, - } => HandlerResponse::AutoModeSwitch( - self.on_auto_mode_switch(session_id, error_code, retry_after_seconds) - .await, - ), } } @@ -385,8 +304,10 @@ pub trait SessionHandler: Send + Sync + 'static { ToolResult::Expanded(crate::types::ToolResultExpanded { text_result_for_llm: msg.clone(), result_type: "failure".to_string(), + binary_results_for_llm: None, session_log: None, error: Some(msg), + tool_telemetry: None, }) } @@ -404,35 +325,6 @@ pub trait SessionHandler: Send + Sync + 'static { content: None, } } - - /// The CLI is asking the user whether to exit plan mode. - /// - /// Default: [`ExitPlanModeResult::default`] (approved with no action). - async fn on_exit_plan_mode( - &self, - _session_id: SessionId, - _data: ExitPlanModeData, - ) -> ExitPlanModeResult { - ExitPlanModeResult::default() - } - - /// The CLI is asking whether to switch to auto model after an eligible - /// rate limit. - /// - /// `retry_after_seconds`, when present, is the number of seconds until the - /// rate limit resets (RFC 9110 `Retry-After` `delta-seconds`). Handlers - /// can use it to render a humanized reset time alongside the prompt. - /// - /// Default: [`AutoModeSwitchResponse::No`] — decline. Override only if - /// your application surfaces a UX for the rate-limit-recovery prompt. - async fn on_auto_mode_switch( - &self, - _session_id: SessionId, - _error_code: Option, - _retry_after_seconds: Option, - ) -> AutoModeSwitchResponse { - AutoModeSwitchResponse::No - } } /// A [`SessionHandler`] that auto-approves all permissions and ignores all events. @@ -456,8 +348,8 @@ impl SessionHandler for ApproveAllHandler { /// A [`SessionHandler`] that denies all permission requests and otherwise /// relies on the trait's default fallback responses for every other event -/// (e.g. tool invocations return "unhandled", elicitations cancel, plan-mode -/// prompts decline). This is the safe default used when no handler is set on +/// (e.g. tool invocations return "unhandled", elicitations cancel). This is the +/// safe default used when no handler is set on /// [`SessionConfig::handler`](crate::types::SessionConfig::handler) — sessions /// will not stall on permission prompts (they're denied immediately) but no /// privileged actions will be taken without an explicit opt-in. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 61e68f9b6..dfe6d8ba3 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -927,15 +927,15 @@ impl Client { info!(path = %resolved.display(), "resolved copilot CLI"); #[cfg(windows)] { - if let Some(ext) = resolved.extension().and_then(|e| e.to_str()) { - if ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") { - warn!( - path = %resolved.display(), - ext = %ext, - "resolved copilot CLI is a .cmd/.bat wrapper; \ - this may cause console window flashes on Windows" - ); - } + if let Some(ext) = resolved.extension().and_then(|e| e.to_str()).filter(|ext| { + ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") + }) { + warn!( + path = %resolved.display(), + ext = %ext, + "resolved copilot CLI is a .cmd/.bat wrapper; \ + this may cause console window flashes on Windows" + ); } } resolved diff --git a/rust/src/permission.rs b/rust/src/permission.rs index 02db23e06..364cb3c91 100644 --- a/rust/src/permission.rs +++ b/rust/src/permission.rs @@ -154,13 +154,15 @@ mod tests { #[tokio::test] async fn non_permission_events_forward_to_inner() { let h = deny_all(Arc::new(ApproveAllHandler)); - let event = HandlerEvent::ExitPlanMode { + let event = HandlerEvent::UserInput { session_id: SessionId::from("s1"), - data: crate::types::ExitPlanModeData::default(), + question: "continue?".to_string(), + choices: None, + allow_freeform: None, }; match h.on_event(event).await { - HandlerResponse::ExitPlanMode(_) => {} - other => panic!("expected ExitPlanMode forwarded, got {other:?}"), + HandlerResponse::UserInput(None) => {} + other => panic!("expected UserInput forwarded, got {other:?}"), } } } diff --git a/rust/src/session.rs b/rust/src/session.rs index d382e1208..1fc13c3e7 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -19,8 +19,7 @@ use crate::generated::session_events::{ SessionEventType, }; use crate::handler::{ - AutoModeSwitchResponse, ExitPlanModeResult, HandlerEvent, HandlerResponse, PermissionResult, - SessionHandler, UserInputResponse, + HandlerEvent, HandlerResponse, PermissionResult, SessionHandler, UserInputResponse, }; use crate::hooks::SessionHooks; use crate::session_fs::SessionFsProvider; @@ -28,10 +27,10 @@ use crate::trace_context::inject_trace_context; use crate::transforms::SystemMessageTransform; use crate::types::{ CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest, - ElicitationResult, ExitPlanModeData, GetMessagesResponse, InputOptions, MessageOptions, - PermissionRequestData, RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, - SessionConfig, SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, - ToolResult, ToolResultResponse, TraceContext, ensure_attachment_display_names, + ElicitationResult, GetMessagesResponse, InputOptions, MessageOptions, PermissionRequestData, + RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, SessionConfig, + SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, ToolResult, + ToolResultResponse, TraceContext, ensure_attachment_display_names, }; use crate::{Client, Error, JsonRpcResponse, SessionError, SessionEventNotification, error_codes}; @@ -1480,82 +1479,6 @@ async fn handle_request( let _ = client.send_response(&rpc_response).await; } - "exitPlanMode.request" => { - let params = request - .params - .as_ref() - .cloned() - .unwrap_or(Value::Object(serde_json::Map::new())); - let data: ExitPlanModeData = match serde_json::from_value(params) { - Ok(d) => d, - Err(e) => { - warn!(error = %e, "failed to deserialize exitPlanMode.request params, using defaults"); - ExitPlanModeData::default() - } - }; - - let response = handler - .on_event(HandlerEvent::ExitPlanMode { - session_id: sid, - data, - }) - .await; - - let rpc_result = match response { - HandlerResponse::ExitPlanMode(ExitPlanModeResult { - approved, - selected_action, - feedback, - }) => serde_json::json!({ - "approved": approved, - "selectedAction": selected_action, - "feedback": feedback, - }), - _ => serde_json::json!({ "approved": true }), - }; - let rpc_response = JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: request.id, - result: Some(rpc_result), - error: None, - }; - let _ = client.send_response(&rpc_response).await; - } - - "autoModeSwitch.request" => { - let error_code = request - .params - .as_ref() - .and_then(|p| p.get("errorCode")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let retry_after_seconds = request - .params - .as_ref() - .and_then(|p| p.get("retryAfterSeconds")) - .and_then(|v| v.as_u64()); - - let response = handler - .on_event(HandlerEvent::AutoModeSwitch { - session_id: sid, - error_code, - retry_after_seconds, - }) - .await; - - let answer = match response { - HandlerResponse::AutoModeSwitch(answer) => answer, - _ => AutoModeSwitchResponse::No, - }; - let rpc_response = JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: request.id, - result: Some(serde_json::json!({ "response": answer })), - error: None, - }; - let _ = client.send_response(&rpc_response).await; - } - "permission.request" => { let Some(request_id) = request .params diff --git a/rust/src/tool.rs b/rust/src/tool.rs index cccdad486..108bf6fa0 100644 --- a/rust/src/tool.rs +++ b/rust/src/tool.rs @@ -16,10 +16,10 @@ use async_trait::async_trait; pub use schemars::JsonSchema; use crate::Error; -use crate::handler::{ExitPlanModeResult, PermissionResult, SessionHandler, UserInputResponse}; +use crate::handler::{PermissionResult, SessionHandler, UserInputResponse}; use crate::types::{ - ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId, - SessionEvent, SessionId, Tool, ToolInvocation, ToolResult, ToolResultExpanded, + ElicitationRequest, ElicitationResult, PermissionRequestData, RequestId, SessionEvent, + SessionId, Tool, ToolBinaryResult, ToolInvocation, ToolResult, ToolResultExpanded, }; /// Generate a JSON Schema [`Value`](serde_json::Value) from a Rust type. @@ -86,6 +86,91 @@ pub fn try_tool_parameters( serde_json::from_value(schema) } +/// Convert an MCP `CallToolResult` JSON value into a Copilot tool result. +/// +/// Returns `None` when the value is not shaped like a `CallToolResult`. +pub fn convert_mcp_call_tool_result(value: &serde_json::Value) -> Option { + let content = value.get("content")?.as_array()?; + let mut text_parts = Vec::new(); + let mut binary_results = Vec::new(); + + for block in content { + match block.get("type").and_then(serde_json::Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(serde_json::Value::as_str) { + text_parts.push(text.to_string()); + } + } + Some("image") => { + let data = block + .get("data") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()); + let mime_type = block + .get("mimeType") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()); + if let (Some(data), Some(mime_type)) = (data, mime_type) { + binary_results.push(ToolBinaryResult { + data: data.to_string(), + mime_type: mime_type.to_string(), + r#type: "image".to_string(), + description: None, + }); + } + } + Some("resource") => { + let Some(resource) = block.get("resource").and_then(serde_json::Value::as_object) + else { + continue; + }; + if let Some(text) = resource + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()) + { + text_parts.push(text.to_string()); + } + if let Some(blob) = resource + .get("blob") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()) + { + let mime_type = resource + .get("mimeType") + .and_then(serde_json::Value::as_str) + .unwrap_or("application/octet-stream"); + let description = resource + .get("uri") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + binary_results.push(ToolBinaryResult { + data: blob.to_string(), + mime_type: mime_type.to_string(), + r#type: "resource".to_string(), + description, + }); + } + } + _ => {} + } + } + + Some(ToolResult::Expanded(ToolResultExpanded { + text_result_for_llm: text_parts.join("\n"), + result_type: if value.get("isError").and_then(serde_json::Value::as_bool) == Some(true) { + "failure".to_string() + } else { + "success".to_string() + }, + binary_results_for_llm: (!binary_results.is_empty()).then_some(binary_results), + session_log: None, + error: None, + tool_telemetry: None, + })) +} + /// A client-defined tool with its handler logic. /// /// Implement this trait for each tool you expose to the Copilot agent. @@ -317,8 +402,10 @@ impl SessionHandler for ToolHandlerRouter { ToolResult::Expanded(ToolResultExpanded { text_result_for_llm: msg.clone(), result_type: "failure".to_string(), + binary_results_for_llm: None, session_log: None, error: Some(msg), + tool_telemetry: None, }) } } @@ -361,14 +448,6 @@ impl SessionHandler for ToolHandlerRouter { .on_elicitation(session_id, request_id, request) .await } - - async fn on_exit_plan_mode( - &self, - session_id: SessionId, - data: ExitPlanModeData, - ) -> ExitPlanModeResult { - self.inner.on_exit_plan_mode(session_id, data).await - } } #[cfg(test)] @@ -413,6 +492,43 @@ mod tests { assert!(err.is_data()); } + #[test] + fn convert_mcp_call_tool_result_collects_text_and_binary_content() { + let result = convert_mcp_call_tool_result(&serde_json::json!({ + "isError": true, + "content": [ + { "type": "text", "text": "hello" }, + { "type": "image", "data": "aW1n", "mimeType": "image/png" }, + { + "type": "resource", + "resource": { + "uri": "file:///tmp/data.bin", + "blob": "Ymlu", + "mimeType": "application/octet-stream", + "text": "resource text" + } + } + ] + })) + .expect("valid CallToolResult should convert"); + + let ToolResult::Expanded(expanded) = result else { + panic!("expected expanded tool result"); + }; + + assert_eq!(expanded.text_result_for_llm, "hello\nresource text"); + assert_eq!(expanded.result_type, "failure"); + let binary_results = expanded + .binary_results_for_llm + .expect("binary results should be captured"); + assert_eq!(binary_results.len(), 2); + assert_eq!(binary_results[0].r#type, "image"); + assert_eq!( + binary_results[1].description.as_deref(), + Some("file:///tmp/data.bin") + ); + } + #[tokio::test] async fn tool_handler_call_returns_result() { let tool = EchoTool; diff --git a/rust/src/types.rs b/rust/src/types.rs index cd24f4321..0d4598cff 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -994,23 +994,6 @@ pub struct SessionConfig { /// requests so the wire surface is safe out-of-the-box. #[serde(skip_serializing_if = "Option::is_none")] pub request_permission: Option, - /// Enable `exitPlanMode.request` JSON-RPC calls for plan approval. - /// Defaults to `Some(true)` via [`SessionConfig::default`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_exit_plan_mode: Option, - /// Enable `autoModeSwitch.request` JSON-RPC calls. When `true`, the CLI - /// asks the handler whether to switch to auto model when an eligible - /// rate limit is hit. Defaults to `Some(true)` via - /// [`SessionConfig::default`]. Without this flag, the CLI surfaces the - /// rate-limit error directly without offering the auto-mode switch. - /// - /// Currently a Rust-only typed handler; cross-SDK parity (Node / - /// Python / Go / .NET) is post-release follow-up work — see - /// [`SessionHandler::on_auto_mode_switch`]. - /// - /// [`SessionHandler::on_auto_mode_switch`]: crate::handler::SessionHandler::on_auto_mode_switch - #[serde(skip_serializing_if = "Option::is_none")] - pub request_auto_mode_switch: Option, /// Advertise elicitation provider capability. When true, the CLI sends /// `elicitation.requested` events that the handler can respond to. /// Defaults to `Some(true)` via [`SessionConfig::default`]. @@ -1027,10 +1010,6 @@ pub struct SessionConfig { /// even if found in skill directories. #[serde(skip_serializing_if = "Option::is_none")] pub disabled_skills: Option>, - /// MCP server names to disable. Servers in this set will not be - /// started or connected. - #[serde(skip_serializing_if = "Option::is_none")] - pub disabled_mcp_servers: Option>, /// Enable session hooks. When `true`, the CLI sends `hooks.invoke` /// RPC requests at key lifecycle points (pre/post tool use, prompt /// submission, session start/end, errors). @@ -1128,13 +1107,10 @@ impl std::fmt::Debug for SessionConfig { .field("enable_config_discovery", &self.enable_config_discovery) .field("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) - .field("request_exit_plan_mode", &self.request_exit_plan_mode) - .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) - .field("disabled_mcp_servers", &self.disabled_mcp_servers) .field("hooks", &self.hooks) .field("custom_agents", &self.custom_agents) .field("default_agent", &self.default_agent) @@ -1190,13 +1166,10 @@ impl Default for SessionConfig { enable_config_discovery: None, request_user_input: Some(true), request_permission: Some(true), - request_exit_plan_mode: Some(true), - request_auto_mode_switch: Some(true), request_elicitation: Some(true), skill_directories: None, instruction_directories: None, disabled_skills: None, - disabled_mcp_servers: None, hooks: None, custom_agents: None, default_agent: None, @@ -1401,18 +1374,6 @@ impl SessionConfig { self } - /// Enable `exitPlanMode.request` JSON-RPC calls. Defaults to `Some(true)`. - pub fn with_request_exit_plan_mode(mut self, enable: bool) -> Self { - self.request_exit_plan_mode = Some(enable); - self - } - - /// Enable `autoModeSwitch.request` JSON-RPC calls. Defaults to `Some(true)`. - pub fn with_request_auto_mode_switch(mut self, enable: bool) -> Self { - self.request_auto_mode_switch = Some(enable); - self - } - /// Advertise elicitation provider capability. Defaults to `Some(true)`. pub fn with_request_elicitation(mut self, enable: bool) -> Self { self.request_elicitation = Some(enable); @@ -1451,16 +1412,6 @@ impl SessionConfig { self } - /// Set the names of MCP servers to disable. - pub fn with_disabled_mcp_servers(mut self, names: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.disabled_mcp_servers = Some(names.into_iter().map(Into::into).collect()); - self - } - /// Set the custom agents (sub-agents) configured for this session. pub fn with_custom_agents>( mut self, @@ -1559,6 +1510,9 @@ pub struct ResumeSessionConfig { /// Client-defined tools to re-supply on resume. #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, + /// Allowlist of tool names the agent may use. + #[serde(skip_serializing_if = "Option::is_none")] + pub available_tools: Option>, /// Blocklist of built-in tool names. #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, @@ -1577,14 +1531,6 @@ pub struct ResumeSessionConfig { /// Enable permission request RPCs. #[serde(skip_serializing_if = "Option::is_none")] pub request_permission: Option, - /// Enable exit-plan-mode request RPCs. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_exit_plan_mode: Option, - /// Enable auto-mode-switch request RPCs on resume. Defaults to - /// `Some(true)` via [`ResumeSessionConfig::new`]. See - /// [`SessionConfig::request_auto_mode_switch`] for details. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_auto_mode_switch: Option, /// Advertise elicitation provider capability on resume. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, @@ -1595,6 +1541,9 @@ pub struct ResumeSessionConfig { /// resume. Forwarded to the CLI; not the same as [`skill_directories`](Self::skill_directories). #[serde(skip_serializing_if = "Option::is_none")] pub instruction_directories: Option>, + /// Skill names to disable on resume. + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_skills: Option>, /// Enable session hooks on resume. #[serde(skip_serializing_if = "Option::is_none")] pub hooks: Option, @@ -1672,17 +1621,17 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("streaming", &self.streaming) .field("system_message", &self.system_message) .field("tools", &self.tools) + .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) .field("env_value_mode", &self.env_value_mode) .field("enable_config_discovery", &self.enable_config_discovery) .field("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) - .field("request_exit_plan_mode", &self.request_exit_plan_mode) - .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) + .field("disabled_skills", &self.disabled_skills) .field("hooks", &self.hooks) .field("custom_agents", &self.custom_agents) .field("default_agent", &self.default_agent) @@ -1729,17 +1678,17 @@ impl ResumeSessionConfig { streaming: None, system_message: None, tools: None, + available_tools: None, excluded_tools: None, mcp_servers: None, env_value_mode: None, enable_config_discovery: None, request_user_input: Some(true), request_permission: Some(true), - request_exit_plan_mode: Some(true), - request_auto_mode_switch: Some(true), request_elicitation: Some(true), skill_directories: None, instruction_directories: None, + disabled_skills: None, hooks: None, custom_agents: None, default_agent: None, @@ -1858,6 +1807,16 @@ impl ResumeSessionConfig { self } + /// Set the allowlist of tool names the agent may use. + pub fn with_available_tools(mut self, tools: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.available_tools = Some(tools.into_iter().map(Into::into).collect()); + self + } + /// Set the blocklist of built-in tool names the agent must not use. pub fn with_excluded_tools(mut self, tools: I) -> Self where @@ -1899,18 +1858,6 @@ impl ResumeSessionConfig { self } - /// Enable `exitPlanMode.request` JSON-RPC calls. Defaults to `Some(true)`. - pub fn with_request_exit_plan_mode(mut self, enable: bool) -> Self { - self.request_exit_plan_mode = Some(enable); - self - } - - /// Enable `autoModeSwitch.request` JSON-RPC calls. Defaults to `Some(true)`. - pub fn with_request_auto_mode_switch(mut self, enable: bool) -> Self { - self.request_auto_mode_switch = Some(enable); - self - } - /// Advertise elicitation provider capability on resume. Defaults to `Some(true)`. pub fn with_request_elicitation(mut self, enable: bool) -> Self { self.request_elicitation = Some(enable); @@ -1939,6 +1886,16 @@ impl ResumeSessionConfig { self } + /// Set the names of skills to disable on resume. + pub fn with_disabled_skills(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.disabled_skills = Some(names.into_iter().map(Into::into).collect()); + self + } + /// Re-supply custom agents on resume. pub fn with_custom_agents>( mut self, @@ -2729,6 +2686,21 @@ impl ToolInvocation { } } +/// Binary content returned by a tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolBinaryResult { + /// Base64-encoded binary data. + pub data: String, + /// MIME type for the binary data. + pub mime_type: String, + /// Type identifier for the binary result. + pub r#type: String, + /// Optional description shown alongside the binary result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + /// Expanded tool result with metadata for the LLM and session log. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2737,12 +2709,18 @@ pub struct ToolResultExpanded { pub text_result_for_llm: String, /// `"success"` or `"failure"`. pub result_type: String, + /// Binary payloads sent back to the LLM. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary_results_for_llm: Option>, /// Optional log message for the session timeline. #[serde(skip_serializing_if = "Option::is_none")] pub session_log: Option, /// Error message, if the tool failed. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// Tool-specific telemetry emitted with the result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_telemetry: Option>, } /// Result of a tool invocation — either a plain text string or an expanded result. @@ -3025,39 +3003,6 @@ pub struct PermissionRequestData { pub extra: Value, } -/// Data sent by the CLI with an `exitPlanMode.request` RPC call. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExitPlanModeData { - /// Markdown summary of the plan presented to the user. - #[serde(default)] - pub summary: String, - /// Full plan content (e.g. the plan.md body), if available. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub plan_content: Option, - /// Allowed exit actions (e.g. "interactive", "autopilot", "autopilot_fleet"). - #[serde(default)] - pub actions: Vec, - /// Which action the CLI recommends, defaults to "autopilot". - #[serde(default = "default_recommended_action")] - pub recommended_action: String, -} - -fn default_recommended_action() -> String { - "autopilot".to_string() -} - -impl Default for ExitPlanModeData { - fn default() -> Self { - Self { - summary: String::new(), - plan_content: None, - actions: Vec::new(), - recommended_action: default_recommended_action(), - } - } -} - #[cfg(test)] mod tests { use std::path::PathBuf; @@ -3105,8 +3050,6 @@ mod tests { let cfg = SessionConfig::default(); assert_eq!(cfg.request_user_input, Some(true)); assert_eq!(cfg.request_permission, Some(true)); - assert_eq!(cfg.request_exit_plan_mode, Some(true)); - assert_eq!(cfg.request_auto_mode_switch, Some(true)); assert_eq!(cfg.request_elicitation, Some(true)); } @@ -3115,8 +3058,6 @@ mod tests { let cfg = ResumeSessionConfig::new(SessionId::from("test-id")); assert_eq!(cfg.request_user_input, Some(true)); assert_eq!(cfg.request_permission, Some(true)); - assert_eq!(cfg.request_exit_plan_mode, Some(true)); - assert_eq!(cfg.request_auto_mode_switch, Some(true)); assert_eq!(cfg.request_elicitation, Some(true)); } @@ -3139,7 +3080,6 @@ mod tests { .with_request_user_input(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) - .with_disabled_mcp_servers(["broken-server"]) .with_agent("researcher") .with_config_dir(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) @@ -3188,12 +3128,14 @@ mod tests { .with_client_name("test-app") .with_streaming(true) .with_tools([Tool::new("greet")]) + .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) .with_mcp_servers(HashMap::new()) .with_env_value_mode("indirect") .with_enable_config_discovery(true) .with_request_user_input(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) + .with_disabled_skills(["broken-skill"]) .with_agent("researcher") .with_config_dir(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) @@ -3206,6 +3148,10 @@ mod tests { assert_eq!(cfg.client_name.as_deref(), Some("test-app")); assert_eq!(cfg.streaming, Some(true)); assert_eq!(cfg.tools.as_ref().map(|t| t.len()), Some(1)); + assert_eq!( + cfg.available_tools.as_deref(), + Some(&["bash".to_string(), "view".to_string()][..]) + ); assert_eq!( cfg.excluded_tools.as_deref(), Some(&["dangerous".to_string()][..]) @@ -3219,6 +3165,10 @@ mod tests { cfg.skill_directories.as_deref(), Some(&[PathBuf::from("/tmp/skills")][..]) ); + assert_eq!( + cfg.disabled_skills.as_deref(), + Some(&["broken-skill".to_string()][..]) + ); assert_eq!(cfg.agent.as_deref(), Some("researcher")); assert_eq!(cfg.config_dir, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); @@ -3245,8 +3195,7 @@ mod tests { } /// `instruction_directories` must serialize to wire as - /// `instructionDirectories` on `SessionConfig`. Cross-SDK parity field - /// (Node/Python pass it through to the CLI verbatim). + /// `instructionDirectories` on `SessionConfig`. #[test] fn session_config_serializes_instruction_directories_to_camel_case() { let cfg = diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index e64af248e..32a4e0fc4 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -8,8 +8,8 @@ use std::time::Duration; use async_trait::async_trait; use github_copilot_sdk::Client; use github_copilot_sdk::handler::{ - ApproveAllHandler, AutoModeSwitchResponse, ExitPlanModeResult, HandlerEvent, HandlerResponse, - PermissionResult, SessionHandler, UserInputResponse, + ApproveAllHandler, HandlerEvent, HandlerResponse, PermissionResult, SessionHandler, + UserInputResponse, }; use github_copilot_sdk::types::{ CommandContext, CommandDefinition, CommandHandler, DeliveryMode, MessageOptions, SessionConfig, @@ -1235,115 +1235,7 @@ async fn user_input_requested_notification_does_not_double_dispatch() { } #[tokio::test] -async fn exit_plan_mode_dispatches_to_handler() { - struct PlanHandler; - #[async_trait] - impl SessionHandler for PlanHandler { - async fn on_event(&self, event: HandlerEvent) -> HandlerResponse { - match event { - HandlerEvent::ExitPlanMode { .. } => { - HandlerResponse::ExitPlanMode(ExitPlanModeResult { - approved: true, - selected_action: Some("autopilot".to_string()), - feedback: None, - }) - } - _ => HandlerResponse::Ok, - } - } - } - - let (_session, mut server) = create_session_pair(Arc::new(PlanHandler)).await; - server - .send_request( - 400, - "exitPlanMode.request", - serde_json::json!({ "sessionId": server.session_id, "plan": "do the thing" }), - ) - .await; - - let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); - assert_eq!(response["result"]["approved"], true); - assert_eq!(response["result"]["selectedAction"], "autopilot"); -} - -#[tokio::test] -async fn auto_mode_switch_dispatches_to_handler_and_serializes_response() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - struct AutoModeHandler { - calls: Arc, - last_error_code: Arc>>, - last_retry_after: Arc>>, - } - #[async_trait] - impl SessionHandler for AutoModeHandler { - async fn on_auto_mode_switch( - &self, - _session_id: github_copilot_sdk::types::SessionId, - error_code: Option, - retry_after_seconds: Option, - ) -> AutoModeSwitchResponse { - self.calls.fetch_add(1, Ordering::SeqCst); - *self.last_error_code.lock() = error_code; - *self.last_retry_after.lock() = retry_after_seconds; - AutoModeSwitchResponse::YesAlways - } - } - - let calls = Arc::new(AtomicUsize::new(0)); - let last_error_code = Arc::new(parking_lot::Mutex::new(None)); - let last_retry_after = Arc::new(parking_lot::Mutex::new(None)); - let (_session, mut server) = create_session_pair(Arc::new(AutoModeHandler { - calls: calls.clone(), - last_error_code: last_error_code.clone(), - last_retry_after: last_retry_after.clone(), - })) - .await; - - server - .send_request( - 700, - "autoModeSwitch.request", - serde_json::json!({ - "sessionId": server.session_id, - "errorCode": "user_weekly_rate_limited", - "retryAfterSeconds": 3600, - }), - ) - .await; - - let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); - assert_eq!(response["id"], 700); - assert_eq!(response["result"]["response"], "yes_always"); - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!( - last_error_code.lock().as_deref(), - Some("user_weekly_rate_limited") - ); - assert_eq!(*last_retry_after.lock(), Some(3600)); -} - -#[tokio::test] -async fn auto_mode_switch_default_handler_replies_no() { - let (_session, mut server) = create_session_pair(Arc::new(ApproveAllHandler)).await; - - server - .send_request( - 701, - "autoModeSwitch.request", - serde_json::json!({ - "sessionId": server.session_id, - }), - ) - .await; - - let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); - assert_eq!(response["result"]["response"], "no"); -} - -#[tokio::test] -async fn approve_all_handler_approves_permission_and_plan() { +async fn approve_all_handler_approves_permission() { let (_session, mut server) = create_session_pair(Arc::new(ApproveAllHandler)).await; server @@ -1359,16 +1251,6 @@ async fn approve_all_handler_approves_permission_and_plan() { .await; let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); assert_eq!(response["result"]["kind"], "approve-once"); - - server - .send_request( - 501, - "exitPlanMode.request", - serde_json::json!({ "sessionId": server.session_id, "plan": "go" }), - ) - .await; - let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); - assert_eq!(response["result"]["approved"], true); } #[tokio::test] From 23b1bb89f684d9b032ca8d32c899bcc20a7abd03 Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Wed, 6 May 2026 12:18:38 -0700 Subject: [PATCH 03/33] Remove disabled_mcp_servers + internalize env_value_mode (cross-SDK parity) (#1215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Rust-only items on SessionConfig + ResumeSessionConfig that aren't present in the Node, Python, Go, or .NET SDKs and shouldn't be on the public Rust surface either. disabled_mcp_servers: removed entirely. The field was schema-undocumented (not in api.schema.json's SessionCreateRequest) and silently ignored by the runtime on session.create. Runtime MCP server disablement lives in the typed RPC namespace already (session.rpc().mcp().disable() and session.rpc().mcp().config().disable()). env_value_mode: internalized. Hardcode envValueMode: "direct" on every session.create / session.resume payload, matching Node and Go's wire behaviour. Subprocess MCP server env values pass through to the child literally; consumers don't have a meaningful choice at the SDK boundary. Also fix the README's Session cheat-sheet, which advertised several methods (get_model, get_mode/set_mode, list_workspace_files / read_workspace_file, read_plan / update_plan, start_fleet) as if they existed on Session directly. They don't — replace those snippets with the typed session.rpc().*() namespace they actually use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/types.rs | 54 +++++++++++++--------------------- rust/tests/session_test.rs | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 34 deletions(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index 0d4598cff..d47d9f841 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -720,11 +720,8 @@ pub struct McpStdioServerConfig { /// Arguments to pass to the subprocess. #[serde(default)] pub args: Vec, - /// Environment variables to set on the subprocess. - /// - /// Interpretation depends on the parent session's - /// `env_value_mode`: `"direct"` (default) treats values as literals; - /// `"indirect"` treats them as env-var names to look up at start time. + /// Environment variables to set on the subprocess. Values are passed + /// through literally to the child process. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub env: HashMap, /// Working directory for the subprocess. @@ -897,6 +894,14 @@ pub struct AzureProviderOptions { pub api_version: Option, } +/// Wire default for [`SessionConfig::env_value_mode`] / +/// [`ResumeSessionConfig::env_value_mode`]. The runtime understands +/// `"direct"` (literal values) or `"indirect"` (env-var lookup); the SDK +/// only ever sends `"direct"`. +fn default_env_value_mode() -> String { + "direct".into() +} + /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -977,10 +982,11 @@ pub struct SessionConfig { /// MCP server configurations passed through to the CLI. #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, - /// How the CLI interprets env values in MCP server configs. - /// `"direct"` = literal values; `"indirect"` = env var names to look up. - #[serde(skip_serializing_if = "Option::is_none")] - pub env_value_mode: Option, + /// Wire-format hint for MCP `env` map values. The runtime understands + /// `"direct"` (literal values) and `"indirect"` (env-var lookup); the + /// SDK only ever sends `"direct"` and consumers don't have a knob. + #[serde(default = "default_env_value_mode", skip_deserializing)] + pub(crate) env_value_mode: String, /// When true, the CLI runs config discovery (MCP config files, skills, plugins). #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option, @@ -1103,7 +1109,6 @@ impl std::fmt::Debug for SessionConfig { .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) - .field("env_value_mode", &self.env_value_mode) .field("enable_config_discovery", &self.enable_config_discovery) .field("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) @@ -1162,7 +1167,7 @@ impl Default for SessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, - env_value_mode: None, + env_value_mode: default_env_value_mode(), enable_config_discovery: None, request_user_input: Some(true), request_permission: Some(true), @@ -1349,13 +1354,6 @@ impl SessionConfig { self } - /// Set how the CLI interprets env values in MCP server configs - /// (`"direct"` literal vs `"indirect"` env var name lookup). - pub fn with_env_value_mode(mut self, mode: impl Into) -> Self { - self.env_value_mode = Some(mode.into()); - self - } - /// Enable or disable CLI config discovery (MCP config files, skills, plugins). pub fn with_enable_config_discovery(mut self, enable: bool) -> Self { self.enable_config_discovery = Some(enable); @@ -1519,9 +1517,9 @@ pub struct ResumeSessionConfig { /// Re-supply MCP servers so they remain available after app restart. #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, - /// How the CLI interprets env values in MCP configs. - #[serde(skip_serializing_if = "Option::is_none")] - pub env_value_mode: Option, + /// See [`SessionConfig::env_value_mode`]. Always `"direct"` on the wire. + #[serde(default = "default_env_value_mode", skip_deserializing)] + pub(crate) env_value_mode: String, /// Enable config discovery on resume. #[serde(skip_serializing_if = "Option::is_none")] pub enable_config_discovery: Option, @@ -1624,7 +1622,6 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("mcp_servers", &self.mcp_servers) - .field("env_value_mode", &self.env_value_mode) .field("enable_config_discovery", &self.enable_config_discovery) .field("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) @@ -1681,7 +1678,7 @@ impl ResumeSessionConfig { available_tools: None, excluded_tools: None, mcp_servers: None, - env_value_mode: None, + env_value_mode: default_env_value_mode(), enable_config_discovery: None, request_user_input: Some(true), request_permission: Some(true), @@ -1833,13 +1830,6 @@ impl ResumeSessionConfig { self } - /// Set how the CLI interprets env values in MCP configs (`"direct"` / - /// `"indirect"`). - pub fn with_env_value_mode(mut self, mode: impl Into) -> Self { - self.env_value_mode = Some(mode.into()); - self - } - /// Enable or disable CLI config discovery on resume. pub fn with_enable_config_discovery(mut self, enable: bool) -> Self { self.enable_config_discovery = Some(enable); @@ -3075,7 +3065,6 @@ mod tests { .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) .with_mcp_servers(HashMap::new()) - .with_env_value_mode("direct") .with_enable_config_discovery(true) .with_request_user_input(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) @@ -3101,7 +3090,6 @@ mod tests { Some(&["dangerous".to_string()][..]) ); assert!(cfg.mcp_servers.is_some()); - assert_eq!(cfg.env_value_mode.as_deref(), Some("direct")); assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!(cfg.request_user_input, Some(false)); // overrode default assert_eq!(cfg.request_permission, Some(true)); // default preserved @@ -3131,7 +3119,6 @@ mod tests { .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) .with_mcp_servers(HashMap::new()) - .with_env_value_mode("indirect") .with_enable_config_discovery(true) .with_request_user_input(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) @@ -3157,7 +3144,6 @@ mod tests { Some(&["dangerous".to_string()][..]) ); assert!(cfg.mcp_servers.is_some()); - assert_eq!(cfg.env_value_mode.as_deref(), Some("indirect")); assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!(cfg.request_user_input, Some(false)); // overrode default assert_eq!(cfg.request_permission, Some(true)); // default preserved diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 32a4e0fc4..1f9873879 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -1957,6 +1957,66 @@ async fn request_elicitation_sent_in_create_params() { timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn env_value_mode_hardcoded_direct_on_create_and_resume() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session(SessionConfig::default().with_handler(Arc::new(NoopHandler))) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["envValueMode"], "direct"); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": "s-env-create" }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + let cfg = ResumeSessionConfig::new(SessionId::from("s-env-create")) + .with_handler(Arc::new(NoopHandler)); + client.resume_session(cfg).await.unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["envValueMode"], "direct"); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": "s-env-create" }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + // resume_session also fires `session.skills.reload`; respond so resume can return. + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + #[tokio::test] async fn elicitation_methods_fail_without_capability() { let (session, _server) = create_session_pair(Arc::new(NoopHandler)).await; From 06bfc5d41d72b76527456dee0bd78fe4697bac86 Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Wed, 6 May 2026 12:58:24 -0700 Subject: [PATCH 04/33] feat: add remote session support across all SDKs (#1192) --- docs/features/index.md | 1 + docs/features/remote-sessions.md | 203 +++++++++++++++++++++++++++++++ docs/index.md | 1 + dotnet/src/Client.cs | 5 + dotnet/src/Generated/Rpc.cs | 66 ++++++++++ dotnet/src/Types.cs | 10 ++ dotnet/test/Unit/CloneTests.cs | 2 + go/client.go | 5 + go/rpc/generated_rpc.go | 45 +++++++ go/types.go | 6 + nodejs/package-lock.json | 56 ++++----- nodejs/package.json | 2 +- nodejs/src/client.ts | 5 + nodejs/src/generated/rpc.ts | 19 +++ nodejs/src/types.ts | 10 ++ python/copilot/client.py | 11 ++ python/copilot/generated/rpc.py | 42 ++++++- rust/src/generated/api_types.rs | 38 ++++++ rust/src/generated/rpc.rs | 53 ++++++++ rust/src/lib.rs | 44 ++++++- test/harness/package-lock.json | 56 ++++----- test/harness/package.json | 2 +- 22 files changed, 622 insertions(+), 60 deletions(-) create mode 100644 docs/features/remote-sessions.md diff --git a/docs/features/index.md b/docs/features/index.md index bbd005cb0..65a1f7535 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -17,6 +17,7 @@ These guides cover the capabilities you can add to your Copilot SDK application. | [Streaming Events](./streaming-events.md) | Subscribe to real-time session events (40+ event types) | | [Steering & Queueing](./steering-and-queueing.md) | Control message delivery — immediate steering vs. sequential queueing | | [Session Persistence](./session-persistence.md) | Resume sessions across restarts, manage session storage | +| [Remote Sessions](./remote-sessions.md) | Share sessions to GitHub web and mobile via Mission Control | ## Related diff --git a/docs/features/remote-sessions.md b/docs/features/remote-sessions.md new file mode 100644 index 000000000..f3e41bd8c --- /dev/null +++ b/docs/features/remote-sessions.md @@ -0,0 +1,203 @@ +# Remote Sessions + +Remote sessions let users access their Copilot session from GitHub web and mobile via [Mission Control](https://github.com). When enabled, the SDK connects each session to Mission Control, producing a URL that can be shared as a link or QR code. + +## Prerequisites + +- The user must be authenticated (GitHub token or logged-in user) +- The session's working directory must be a GitHub repository + +## Enabling Remote Sessions + +### Always-on (client-level) + +Set `remote: true` when creating the client. Every session in a GitHub repo automatically gets a remote URL. + + + +#### **TypeScript** + + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient({ remote: true }); +const session = await client.createSession({ + workingDirectory: "/path/to/github-repo", + onPermissionRequest: async () => ({ allowed: true }), +}); + +session.on("session.info", (event) => { + if (event.data.infoType === "remote") { + console.log("Remote URL:", event.data.url); + } +}); +``` + +#### **Python** + + +```python +from copilot import CopilotClient, SubprocessConfig + +client = CopilotClient(SubprocessConfig(remote=True)) +session = await client.create_session( + working_directory="/path/to/github-repo", + on_permission_request=lambda req: {"allowed": True}, +) + +def on_event(event): + if event.type == "session.info" and event.data.info_type == "remote": + print(f"Remote URL: {event.data.url}") + +session.on(on_event) +``` + +#### **Go** + + +```go +client, _ := copilot.NewClient(&copilot.ClientOptions{Remote: true}) +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ + WorkingDirectory: "/path/to/github-repo", + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil + }, +}) + +session.On(func(event copilot.SessionEvent) { + if event.Type == "session.info" { + // Check infoType and extract URL + } +}) +``` + +#### **C#** + + +```csharp +var client = new CopilotClient(new CopilotClientOptions { Remote = true }); +var session = await client.CreateSessionAsync(new SessionConfig +{ + WorkingDirectory = "/path/to/github-repo", + OnPermissionRequest = (req, inv) => + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), +}); + +session.On((SessionEvent e) => +{ + if (e is SessionInfoEvent info && info.Data.InfoType == "remote") + { + Console.WriteLine($"Remote URL: {info.Data.Url}"); + } +}); +``` + +#### **Rust** + + +```rust +use copilot_sdk::{Client, ClientOptions}; + +let client = Client::start( + ClientOptions::new().with_remote(true) +).await?; +let session = client.create_session( + SessionConfig::new("/path/to/github-repo") + .with_permission_handler(|_req, _inv| async { + Ok(PermissionRequestResult::approved()) + }), +).await?; + +let mut events = session.subscribe(); +while let Ok(event) = events.recv().await { + if event.event_type == "session.info" { + // Check info_type and extract URL + } +} +``` + + + +### On-demand (per-session toggle) + +Use `session.rpc.remote.enable()` to start remote access mid-session, and `session.rpc.remote.disable()` to stop it. This is equivalent to the CLI's `/remote on` and `/remote off` commands. + + + +#### **TypeScript** + + +```typescript +const result = await session.rpc.remote.enable(); +console.log("Remote URL:", result.url); + +// Later: stop sharing +await session.rpc.remote.disable(); +``` + +#### **Python** + + +```python +result = await session.rpc.remote.enable() +print(f"Remote URL: {result.url}") + +# Later: stop sharing +await session.rpc.remote.disable() +``` + +#### **Go** + + +```go +result, err := session.RPC.Remote.Enable(ctx) +if result.URL != nil { + fmt.Println("Remote URL:", *result.URL) +} + +// Later: stop sharing +err = session.RPC.Remote.Disable(ctx) +``` + +#### **C#** + + +```csharp +var result = await session.Rpc.Remote.EnableAsync(); +Console.WriteLine($"Remote URL: {result.Url}"); + +// Later: stop sharing +await session.Rpc.Remote.DisableAsync(); +``` + +#### **Rust** + + +```rust +let result = session.rpc().remote().enable().await?; +if let Some(url) = &result.url { + println!("Remote URL: {url}"); +} + +// Later: stop sharing +session.rpc().remote().disable().await?; +``` + + + +## QR Code Generation + +The remote URL can be rendered as a QR code for easy mobile access. The SDK provides the URL — use your preferred QR code library: + +- **TypeScript**: [qrcode](https://www.npmjs.com/package/qrcode) +- **Python**: [qrcode](https://pypi.org/project/qrcode/) +- **Go**: [go-qrcode](https://github.com/skip2/go-qrcode) +- **C#**: [QRCoder](https://www.nuget.org/packages/QRCoder) +- **Rust**: [qrcode](https://crates.io/crates/qrcode) + +## Notes + +- The `remote` client option only applies when the SDK spawns the CLI process. It is ignored when connecting to an external server via `cliUrl`. +- If the working directory is not a GitHub repository, remote setup is silently skipped (always-on mode) or returns an error (on-demand mode). +- Remote sessions require authentication. Ensure `gitHubToken` or `useLoggedInUser` is configured. diff --git a/docs/index.md b/docs/index.md index 1b89439ae..89936df73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,7 @@ Guides for building with the SDK's capabilities. - [Streaming Events](./features/streaming-events.md) — real-time event reference - [Steering & Queueing](./features/steering-and-queueing.md) — message delivery modes - [Session Persistence](./features/session-persistence.md) — resume sessions across restarts +- [Remote Sessions](./features/remote-sessions.md) — share sessions to GitHub web and mobile ### [Hooks Reference](./hooks/index.md) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 3ba14bebe..9c6b36fce 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1311,6 +1311,11 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) args.AddRange(["--session-idle-timeout", options.SessionIdleTimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)]); } + if (options.Remote) + { + args.Add("--remote"); + } + var (fileName, processArgs) = ResolveCliCommand(cliPath, args); var startInfo = new ProcessStartInfo diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 295c146b9..cee6ce945 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -2476,6 +2476,37 @@ internal sealed class SessionUsageGetMetricsRequest public string SessionId { get; set; } = string.Empty; } +/// RPC data type for RemoteEnable operations. +[Experimental(Diagnostics.Experimental)] +public sealed class RemoteEnableResult +{ + /// Whether remote steering is enabled. + [JsonPropertyName("remoteSteerable")] + public bool RemoteSteerable { get; set; } + + /// Mission Control frontend URL for this session. + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +/// RPC data type for SessionRemoteEnable operations. +[Experimental(Diagnostics.Experimental)] +internal sealed class SessionRemoteEnableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for SessionRemoteDisable operations. +[Experimental(Diagnostics.Experimental)] +internal sealed class SessionRemoteDisableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Describes a filesystem error. public sealed class SessionFsError { @@ -3419,6 +3450,7 @@ internal SessionRpc(JsonRpc rpc, string sessionId) Shell = new ShellApi(rpc, sessionId); History = new HistoryApi(rpc, sessionId); Usage = new UsageApi(rpc, sessionId); + Remote = new RemoteApi(rpc, sessionId); } /// Auth APIs. @@ -3484,6 +3516,9 @@ internal SessionRpc(JsonRpc rpc, string sessionId) /// Usage APIs. public UsageApi Usage { get; } + /// Remote APIs. + public RemoteApi Remote { get; } + /// Calls "session.suspend". public async Task SuspendAsync(CancellationToken cancellationToken = default) { @@ -4163,6 +4198,34 @@ public async Task GetMetricsAsync(CancellationToken cance } } +/// Provides session-scoped Remote APIs. +[Experimental(Diagnostics.Experimental)] +public sealed class RemoteApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal RemoteApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.remote.enable". + public async Task EnableAsync(CancellationToken cancellationToken = default) + { + var request = new SessionRemoteEnableRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.remote.enable", [request], cancellationToken); + } + + /// Calls "session.remote.disable". + public async Task DisableAsync(CancellationToken cancellationToken = default) + { + var request = new SessionRemoteDisableRequest { SessionId = _sessionId }; + await CopilotClient.InvokeRpcAsync(_rpc, "session.remote.disable", [request], cancellationToken); + } +} + /// Handles `sessionFs` client session API methods. public interface ISessionFsHandler { @@ -4355,6 +4418,7 @@ public static void RegisterClientSessionApiHandlers(JsonRpc rpc, Func @@ -195,6 +196,15 @@ public string? GithubToken /// public string? TcpConnectionToken { get; set; } + /// + /// Enable remote session support (Mission Control integration). + /// When true, sessions in a GitHub repository working directory are + /// accessible from GitHub web and mobile. + /// This option is only used when the SDK spawns the CLI process; it is ignored + /// when connecting to an external server via . + /// + public bool Remote { get; set; } + /// /// Creates a shallow clone of this instance. /// diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 10e0bbf45..d0b0d5162 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -27,6 +27,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() GitHubToken = "ghp_test", UseLoggedInUser = false, CopilotHome = "/custom/copilot/home", + Remote = true, SessionIdleTimeoutSeconds = 600, }; @@ -45,6 +46,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() Assert.Equal(original.GitHubToken, clone.GitHubToken); Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser); Assert.Equal(original.CopilotHome, clone.CopilotHome); + Assert.Equal(original.Remote, clone.Remote); Assert.Equal(original.SessionIdleTimeoutSeconds, clone.SessionIdleTimeoutSeconds); } diff --git a/go/client.go b/go/client.go index 851dcf4e2..5e2a547fc 100644 --- a/go/client.go +++ b/go/client.go @@ -235,6 +235,7 @@ func NewClient(options *ClientOptions) *Client { opts.CopilotHome = options.CopilotHome } opts.SessionIdleTimeoutSeconds = options.SessionIdleTimeoutSeconds + opts.Remote = options.Remote } // Default Env to current environment if not set @@ -1460,6 +1461,10 @@ func (c *Client) startCLIServer(ctx context.Context) error { args = append(args, "--session-idle-timeout", strconv.Itoa(c.options.SessionIdleTimeoutSeconds)) } + if c.options.Remote { + args = append(args, "--remote") + } + // If CLIPath is a .js file, run it with node // Note we can't rely on the shebang as Windows doesn't support it command := cliPath diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index dd5ff61b8..34428ada3 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -159,6 +159,8 @@ type RPCTypes struct { PlanUpdateResult PlanUpdateResult `json:"PlanUpdateResult"` Plugin PluginElement `json:"Plugin"` PluginList PluginList `json:"PluginList"` + RemoteDisableResult RemoteDisableResult `json:"RemoteDisableResult"` + RemoteEnableResult RemoteEnableResult `json:"RemoteEnableResult"` ServerSkill ServerSkill `json:"ServerSkill"` ServerSkillList ServerSkillList `json:"ServerSkillList"` SessionAuthStatus SessionAuthStatus `json:"SessionAuthStatus"` @@ -1266,6 +1268,18 @@ type PluginList struct { Plugins []PluginElement `json:"plugins"` } +// Experimental: RemoteDisableResult is part of an experimental API and may change or be removed. +type RemoteDisableResult struct { +} + +// Experimental: RemoteEnableResult is part of an experimental API and may change or be removed. +type RemoteEnableResult struct { + // Whether remote steering is enabled + RemoteSteerable bool `json:"remoteSteerable"` + // Mission Control frontend URL for this session + URL *string `json:"url,omitempty"` +} + type ServerSkill struct { // Description of what the skill does Description string `json:"description"` @@ -3663,6 +3677,35 @@ func (a *UsageApi) GetMetrics(ctx context.Context) (*UsageGetMetricsResult, erro return &result, nil } +// Experimental: RemoteApi contains experimental APIs that may change or be removed. +type RemoteApi sessionApi + +func (a *RemoteApi) Enable(ctx context.Context) (*RemoteEnableResult, error) { + req := map[string]any{"sessionId": a.sessionID} + raw, err := a.client.Request("session.remote.enable", req) + if err != nil { + return nil, err + } + var result RemoteEnableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *RemoteApi) Disable(ctx context.Context) (*RemoteDisableResult, error) { + req := map[string]any{"sessionId": a.sessionID} + raw, err := a.client.Request("session.remote.disable", req) + if err != nil { + return nil, err + } + var result RemoteDisableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // SessionRpc provides typed session-scoped RPC methods. type SessionRpc struct { common sessionApi // Reuse a single struct instead of allocating one for each service on the heap. @@ -3688,6 +3731,7 @@ type SessionRpc struct { Shell *ShellApi History *HistoryApi Usage *UsageApi + Remote *RemoteApi } func (a *SessionRpc) Suspend(ctx context.Context) (*SuspendResult, error) { @@ -3752,6 +3796,7 @@ func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { r.Shell = (*ShellApi)(&r.common) r.History = (*HistoryApi)(&r.common) r.Usage = (*UsageApi)(&r.common) + r.Remote = (*RemoteApi)(&r.common) return r } diff --git a/go/types.go b/go/types.go index 0fa9a18d0..4cce207f5 100644 --- a/go/types.go +++ b/go/types.go @@ -90,6 +90,12 @@ type ClientOptions struct { // This option is only used when the SDK spawns the CLI process; it is ignored // when connecting to an external server via CLIUrl. SessionIdleTimeoutSeconds int + // Remote enables remote session support (Mission Control integration). + // When true, sessions in a GitHub repository working directory are + // accessible from GitHub web and mobile. + // This option is only used when the SDK spawns the CLI process; it is ignored + // when connecting to an external server via CLIUrl. + Remote bool } // TelemetryConfig configures OpenTelemetry integration for the Copilot CLI process. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 75a532e0f..86940d079 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.42", + "@github/copilot": "^1.0.43-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.42.tgz", - "integrity": "sha512-ODW5+aJi595Tb2WUaAlshBoUkOBuh9MegXXwXzMoar+k9fZzzDy3oNJLFg7ni4UtkUZvj/WL/y3s5O+FlsF2HA==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43-0.tgz", + "integrity": "sha512-wsSlDIJj6tFIOchzuA+rIYJXP3OwrsMR1j/k8WB/4gxxACm5Q7XSRmH7Ul88k/vXak44sBC9EmJaEgAm6enwbg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.42", - "@github/copilot-darwin-x64": "1.0.42", - "@github/copilot-linux-arm64": "1.0.42", - "@github/copilot-linux-x64": "1.0.42", - "@github/copilot-win32-arm64": "1.0.42", - "@github/copilot-win32-x64": "1.0.42" + "@github/copilot-darwin-arm64": "1.0.43-0", + "@github/copilot-darwin-x64": "1.0.43-0", + "@github/copilot-linux-arm64": "1.0.43-0", + "@github/copilot-linux-x64": "1.0.43-0", + "@github/copilot-win32-arm64": "1.0.43-0", + "@github/copilot-win32-x64": "1.0.43-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.42.tgz", - "integrity": "sha512-2w89QLRgMR7hWwV1KG3uXqu98WST6afJCfvtYtqvPdf6ZQC7Fj2HhPNCrMxZk/H8mZwTgYJeg30gZjvV1698EA==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43-0.tgz", + "integrity": "sha512-50YRUf03lUJ110PbKaMRjeIG5pvEgxm6+X4nRuYtEQujp/RUFT2Rd3g6I2f9hfIrdMIxfSNw8KXanuPcrCPZqA==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.42.tgz", - "integrity": "sha512-G2//tgGSKXx3ZGMqe774UnewasYMh+j0ZeQ3injtuZpSpzN+OAuNkzwXpvFHprdbgekMb0oAPN+Xm3rHuQY8Xw==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43-0.tgz", + "integrity": "sha512-LvbJD23zbuGNOvYheS8PPT5l+TLOy6FWRc1qF7hf6PMSsqvDTqseO6Bq5S99A/Z7A/v3kh/xUZglJwrIxtVxvg==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.42.tgz", - "integrity": "sha512-Ai6J4hUKVuE5ztsLspp/I7ByXtL2V6tF+AOn0q+hHp1MOA5JLq5/G8PE+c0VzG69x4hkt1lROQDjvXJGY7sv+g==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43-0.tgz", + "integrity": "sha512-gBfW5f5XZIAxkQU7NXQytxImORMbzgrk9WcMfcTuTNgr1OXM1oaKWvXH2vc6ZVZbPhPnAcTymCC0bFOSXQBE2g==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.42.tgz", - "integrity": "sha512-yYfuL6Hk3uLQuIgfxpEMCyoowFq2Bew1EaXmvg4lnDjj95tvEmyMCX77aIZ2AKwBOgp1nMV7L1B1QL9/mw6BTA==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43-0.tgz", + "integrity": "sha512-S/0F05plYabV6siVHBcuGiYzamB/GaEVvt8jMo3CeuPJl6/AZtuU6WMWngWooCQg4PyM/zDfcnvH88xU5NDs7w==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.42.tgz", - "integrity": "sha512-WgnV6AxsvbvZdNW/42JFikK/SqR1JMw6juRpGKXZr70ond/cHK6trtrmt3dXYPymBO14ppJMFdm4+chJzKGKMw==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43-0.tgz", + "integrity": "sha512-obpdmbbDF1kuanKyIjb1Mc4+GrNEfLRT2QO0DV20uy0JXRf0duYe92Q40T7vRxfp7MRpfclpdT4wNEkVc2GZVA==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.42.tgz", - "integrity": "sha512-J5jtrcYuODuD4LPPRHjOCMJGO6+vKZ71n8PTiHPCg9lpfThXDDXxrB7nDDkhxl23zSXlUrpWwkMI+a2Ax/AxGg==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43-0.tgz", + "integrity": "sha512-5Kgk1wtoV7dMMf++RLTqjwZfGUkXOrEu5f/2ijMbMVEkjaBIHwxkK3xvPsun3xBU8htJy1PpCSBm2e/2rv78MA==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 2d21a55e2..0b16a73d8 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.42", + "@github/copilot": "^1.0.43-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index b1b6b4f46..9c6494198 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -397,6 +397,7 @@ export class CopilotClient { telemetry: options.telemetry, copilotHome: options.copilotHome, sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0, + remote: options.remote ?? false, }; } @@ -1502,6 +1503,10 @@ export class CopilotClient { ); } + if (this.options.remote) { + args.push("--remote"); + } + // Suppress debug/trace output that might pollute stdout const envWithoutNodeDebug = { ...this.options.env }; delete envWithoutNodeDebug.NODE_DEBUG; diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 6836324ab..7de129d14 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1477,6 +1477,18 @@ export interface PluginList { plugins: Plugin[]; } +/** @experimental */ +export interface RemoteEnableResult { + /** + * Mission Control frontend URL for this session + */ + url?: string; + /** + * Whether remote steering is enabled + */ + remoteSteerable: boolean; +} + export interface ServerSkill { /** * Unique identifier for the skill @@ -2722,6 +2734,13 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin getMetrics: async (): Promise => connection.sendRequest("session.usage.getMetrics", { sessionId }), }, + /** @experimental */ + remote: { + enable: async (): Promise => + connection.sendRequest("session.remote.enable", { sessionId }), + disable: async (): Promise => + connection.sendRequest("session.remote.disable", { sessionId }), + }, }; } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 59dff3d82..c7c6c8622 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -211,6 +211,16 @@ export interface CopilotClientOptions { * `useStdio: true` (stdio is pre-authenticated by transport). */ tcpConnectionToken?: string; + + /** + * Enable remote session support (Mission Control integration). + * When true, sessions in a GitHub repository working directory are + * accessible from GitHub web and mobile. + * This option is only used when the SDK spawns the CLI process; it is ignored + * when connecting to an external server via {@link cliUrl}. + * @default false + */ + remote?: boolean; } /** diff --git a/python/copilot/client.py b/python/copilot/client.py index 0e03dcbf7..f406c7f5b 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -176,6 +176,14 @@ class SubprocessConfig: This option is only used when the SDK spawns the CLI process. """ + remote: bool = False + """Enable remote session support (Mission Control integration). + + When ``True``, sessions in a GitHub repository working directory are + accessible from GitHub web and mobile. + This option is only used when the SDK spawns the CLI process. + """ + @dataclass class ExternalServerConfig: @@ -2375,6 +2383,9 @@ async def _start_cli_server(self) -> None: if cfg.session_idle_timeout_seconds is not None and cfg.session_idle_timeout_seconds > 0: args.extend(["--session-idle-timeout", str(cfg.session_idle_timeout_seconds)]) + if cfg.remote: + args.append("--remote") + # If cli_path is a .js file, run it with node # Note that we can't rely on the shebang as Windows doesn't support it if cli_path.endswith(".js"): diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index fc3eb7bdf..b18e9c7c3 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -1342,6 +1342,29 @@ def to_dict(self) -> dict: result["version"] = from_union([from_str, from_none], self.version) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class RemoteEnableResult: + remote_steerable: bool + """Whether remote steering is enabled""" + + url: str | None = None + """Mission Control frontend URL for this session""" + + @staticmethod + def from_dict(obj: Any) -> 'RemoteEnableResult': + assert isinstance(obj, dict) + remote_steerable = from_bool(obj.get("remoteSteerable")) + url = from_union([from_str, from_none], obj.get("url")) + return RemoteEnableResult(remote_steerable, url) + + def to_dict(self) -> dict: + result: dict = {} + result["remoteSteerable"] = from_bool(self.remote_steerable) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + @dataclass class ServerSkill: description: str @@ -5725,6 +5748,7 @@ class RPC: plan_update_request: PlanUpdateRequest plugin: Plugin plugin_list: PluginList + remote_enable_result: RemoteEnableResult server_skill: ServerSkill server_skill_list: ServerSkillList session_auth_status: SessionAuthStatus @@ -5953,6 +5977,7 @@ def from_dict(obj: Any) -> 'RPC': plan_update_request = PlanUpdateRequest.from_dict(obj.get("PlanUpdateRequest")) plugin = Plugin.from_dict(obj.get("Plugin")) plugin_list = PluginList.from_dict(obj.get("PluginList")) + remote_enable_result = RemoteEnableResult.from_dict(obj.get("RemoteEnableResult")) server_skill = ServerSkill.from_dict(obj.get("ServerSkill")) server_skill_list = ServerSkillList.from_dict(obj.get("ServerSkillList")) session_auth_status = SessionAuthStatus.from_dict(obj.get("SessionAuthStatus")) @@ -6047,7 +6072,7 @@ def from_dict(obj: Any) -> 'RPC': workspaces_list_files_result = WorkspacesListFilesResult.from_dict(obj.get("WorkspacesListFilesResult")) workspaces_read_file_request = WorkspacesReadFileRequest.from_dict(obj.get("WorkspacesReadFileRequest")) workspaces_read_file_result = WorkspacesReadFileResult.from_dict(obj.get("WorkspacesReadFileResult")) - return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) + return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, remote_enable_result, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) def to_dict(self) -> dict: result: dict = {} @@ -6181,6 +6206,7 @@ def to_dict(self) -> dict: result["PlanUpdateRequest"] = to_class(PlanUpdateRequest, self.plan_update_request) result["Plugin"] = to_class(Plugin, self.plugin) result["PluginList"] = to_class(PluginList, self.plugin_list) + result["RemoteEnableResult"] = to_class(RemoteEnableResult, self.remote_enable_result) result["ServerSkill"] = to_class(ServerSkill, self.server_skill) result["ServerSkillList"] = to_class(ServerSkillList, self.server_skill_list) result["SessionAuthStatus"] = to_class(SessionAuthStatus, self.session_auth_status) @@ -6798,6 +6824,19 @@ async def get_metrics(self, *, timeout: float | None = None) -> UsageGetMetricsR return UsageGetMetricsResult.from_dict(await self._client.request("session.usage.getMetrics", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) +# Experimental: this API group is experimental and may change or be removed. +class RemoteApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def enable(self, *, timeout: float | None = None) -> RemoteEnableResult: + return RemoteEnableResult.from_dict(await self._client.request("session.remote.enable", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + async def disable(self, *, timeout: float | None = None) -> None: + await self._client.request("session.remote.disable", {"sessionId": self._session_id}, **_timeout_kwargs(timeout)) + + class SessionRpc: """Typed session-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient", session_id: str): @@ -6824,6 +6863,7 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.shell = ShellApi(client, session_id) self.history = HistoryApi(client, session_id) self.usage = UsageApi(client, session_id) + self.remote = RemoteApi(client, session_id) async def suspend(self, *, timeout: float | None = None) -> None: await self._client.request("session.suspend", {"sessionId": self._session_id}, **_timeout_kwargs(timeout)) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 2b78fbae5..d0b7bf5b7 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -152,6 +152,10 @@ pub mod rpc_methods { pub const SESSION_HISTORY_TRUNCATE: &str = "session.history.truncate"; /// `session.usage.getMetrics` pub const SESSION_USAGE_GETMETRICS: &str = "session.usage.getMetrics"; + /// `session.remote.enable` + pub const SESSION_REMOTE_ENABLE: &str = "session.remote.enable"; + /// `session.remote.disable` + pub const SESSION_REMOTE_DISABLE: &str = "session.remote.disable"; /// `sessionFs.readFile` pub const SESSIONFS_READFILE: &str = "sessionFs.readFile"; /// `sessionFs.writeFile` @@ -1279,6 +1283,16 @@ pub struct PluginList { pub plugins: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteEnableResult { + /// Whether remote steering is enabled + pub remote_steerable: bool, + /// Mission Control frontend URL for this session + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ServerSkill { @@ -2734,6 +2748,30 @@ pub struct SessionUsageGetMetricsResult { pub total_user_requests: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRemoteEnableParams { + /// Target session identifier + pub session_id: SessionId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRemoteEnableResult { + /// Whether remote steering is enabled + pub remote_steerable: bool, + /// Mission Control frontend URL for this session + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRemoteDisableParams { + /// Target session identifier + pub session_id: SessionId, +} + /// Authentication type #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthInfoType { diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index ee38f27a5..eed4aea2a 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -437,6 +437,13 @@ impl<'a> SessionRpc<'a> { } } + /// `session.remote.*` sub-namespace. + pub fn remote(&self) -> SessionRpcRemote<'a> { + SessionRpcRemote { + session: self.session, + } + } + /// `session.shell.*` sub-namespace. pub fn shell(&self) -> SessionRpcShell<'a> { SessionRpcShell { @@ -1191,6 +1198,52 @@ impl<'a> SessionRpcPlugins<'a> { } } +/// `session.remote.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcRemote<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcRemote<'a> { + /// Wire method: `session.remote.enable`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn enable(&self) -> Result { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_REMOTE_ENABLE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Wire method: `session.remote.disable`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn disable(&self) -> Result<(), Error> { + let wire_params = serde_json::json!({ "sessionId": self.session.id() }); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_REMOTE_DISABLE, Some(wire_params)) + .await?; + Ok(()) + } +} + /// `session.shell.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcShell<'a> { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index dfe6d8ba3..7c3d2422b 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -393,6 +393,12 @@ pub struct ClientOptions { /// is safe by default. Combining with [`Transport::Stdio`] is invalid /// and surfaces as an error from [`Client::start`]. pub tcp_connection_token: Option, + /// Enable remote session support (Mission Control integration). + /// When `true`, the SDK passes `--remote` to the spawned CLI process so + /// sessions in a GitHub repository working directory are accessible from + /// GitHub web and mobile. Ignored when connecting to an external server + /// via [`Transport::External`]. + pub remote: bool, } impl std::fmt::Debug for ClientOptions { @@ -430,6 +436,7 @@ impl std::fmt::Debug for ClientOptions { "tcp_connection_token", &self.tcp_connection_token.as_ref().map(|_| ""), ) + .field("remote", &self.remote) .finish() } } @@ -636,6 +643,7 @@ impl Default for ClientOptions { telemetry: None, copilot_home: None, tcp_connection_token: None, + remote: false, } } } @@ -793,6 +801,13 @@ impl ClientOptions { self.tcp_connection_token = Some(token.into()); self } + + /// Enable remote session support (Mission Control). Passes `--remote` + /// to the spawned CLI process. + pub fn with_remote(mut self, enabled: bool) -> Self { + self.remote = enabled; + self + } } /// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`. @@ -1233,6 +1248,14 @@ impl Client { } } + fn remote_args(options: &ClientOptions) -> Vec { + if options.remote { + vec!["--remote".to_string()] + } else { + Vec::new() + } + } + fn spawn_stdio(program: &Path, options: &ClientOptions) -> Result { info!(cwd = ?options.cwd, program = %program.display(), "spawning copilot CLI (stdio)"); let mut command = Self::build_command(program, options); @@ -1247,6 +1270,7 @@ impl Client { ]) .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) + .args(Self::remote_args(options)) .args(&options.extra_args) .stdin(Stdio::piped()); Ok(command.spawn()?) @@ -1271,6 +1295,7 @@ impl Client { ]) .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) + .args(Self::remote_args(options)) .args(&options.extra_args) .stdin(Stdio::null()); let mut child = command.spawn()?; @@ -1901,7 +1926,8 @@ mod tests { .with_github_token("ghp_test") .with_use_logged_in_user(false) .with_log_level(LogLevel::Debug) - .with_session_idle_timeout_seconds(120); + .with_session_idle_timeout_seconds(120) + .with_remote(true); assert!(matches!(opts.program, CliProgram::Path(_))); assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]); assert_eq!(opts.cwd, PathBuf::from("/tmp")); @@ -1918,6 +1944,7 @@ mod tests { assert_eq!(opts.use_logged_in_user, Some(false)); assert!(matches!(opts.log_level, Some(LogLevel::Debug))); assert_eq!(opts.session_idle_timeout_seconds, Some(120)); + assert!(opts.remote); } #[test] @@ -2233,6 +2260,21 @@ mod tests { ); } + #[test] + fn remote_args_omitted_by_default() { + let opts = ClientOptions::default(); + assert!(Client::remote_args(&opts).is_empty()); + } + + #[test] + fn remote_args_emit_flag_when_enabled() { + let opts = ClientOptions { + remote: true, + ..Default::default() + }; + assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]); + } + #[test] fn log_level_str_round_trips() { for level in [ diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index c5bbaabae..9a1b7d587 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.42", + "@github/copilot": "^1.0.43-0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -464,27 +464,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.42.tgz", - "integrity": "sha512-ODW5+aJi595Tb2WUaAlshBoUkOBuh9MegXXwXzMoar+k9fZzzDy3oNJLFg7ni4UtkUZvj/WL/y3s5O+FlsF2HA==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43-0.tgz", + "integrity": "sha512-wsSlDIJj6tFIOchzuA+rIYJXP3OwrsMR1j/k8WB/4gxxACm5Q7XSRmH7Ul88k/vXak44sBC9EmJaEgAm6enwbg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.42", - "@github/copilot-darwin-x64": "1.0.42", - "@github/copilot-linux-arm64": "1.0.42", - "@github/copilot-linux-x64": "1.0.42", - "@github/copilot-win32-arm64": "1.0.42", - "@github/copilot-win32-x64": "1.0.42" + "@github/copilot-darwin-arm64": "1.0.43-0", + "@github/copilot-darwin-x64": "1.0.43-0", + "@github/copilot-linux-arm64": "1.0.43-0", + "@github/copilot-linux-x64": "1.0.43-0", + "@github/copilot-win32-arm64": "1.0.43-0", + "@github/copilot-win32-x64": "1.0.43-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.42.tgz", - "integrity": "sha512-2w89QLRgMR7hWwV1KG3uXqu98WST6afJCfvtYtqvPdf6ZQC7Fj2HhPNCrMxZk/H8mZwTgYJeg30gZjvV1698EA==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43-0.tgz", + "integrity": "sha512-50YRUf03lUJ110PbKaMRjeIG5pvEgxm6+X4nRuYtEQujp/RUFT2Rd3g6I2f9hfIrdMIxfSNw8KXanuPcrCPZqA==", "cpu": [ "arm64" ], @@ -499,9 +499,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.42.tgz", - "integrity": "sha512-G2//tgGSKXx3ZGMqe774UnewasYMh+j0ZeQ3injtuZpSpzN+OAuNkzwXpvFHprdbgekMb0oAPN+Xm3rHuQY8Xw==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43-0.tgz", + "integrity": "sha512-LvbJD23zbuGNOvYheS8PPT5l+TLOy6FWRc1qF7hf6PMSsqvDTqseO6Bq5S99A/Z7A/v3kh/xUZglJwrIxtVxvg==", "cpu": [ "x64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.42.tgz", - "integrity": "sha512-Ai6J4hUKVuE5ztsLspp/I7ByXtL2V6tF+AOn0q+hHp1MOA5JLq5/G8PE+c0VzG69x4hkt1lROQDjvXJGY7sv+g==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43-0.tgz", + "integrity": "sha512-gBfW5f5XZIAxkQU7NXQytxImORMbzgrk9WcMfcTuTNgr1OXM1oaKWvXH2vc6ZVZbPhPnAcTymCC0bFOSXQBE2g==", "cpu": [ "arm64" ], @@ -533,9 +533,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.42.tgz", - "integrity": "sha512-yYfuL6Hk3uLQuIgfxpEMCyoowFq2Bew1EaXmvg4lnDjj95tvEmyMCX77aIZ2AKwBOgp1nMV7L1B1QL9/mw6BTA==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43-0.tgz", + "integrity": "sha512-S/0F05plYabV6siVHBcuGiYzamB/GaEVvt8jMo3CeuPJl6/AZtuU6WMWngWooCQg4PyM/zDfcnvH88xU5NDs7w==", "cpu": [ "x64" ], @@ -550,9 +550,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.42.tgz", - "integrity": "sha512-WgnV6AxsvbvZdNW/42JFikK/SqR1JMw6juRpGKXZr70ond/cHK6trtrmt3dXYPymBO14ppJMFdm4+chJzKGKMw==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43-0.tgz", + "integrity": "sha512-obpdmbbDF1kuanKyIjb1Mc4+GrNEfLRT2QO0DV20uy0JXRf0duYe92Q40T7vRxfp7MRpfclpdT4wNEkVc2GZVA==", "cpu": [ "arm64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.42.tgz", - "integrity": "sha512-J5jtrcYuODuD4LPPRHjOCMJGO6+vKZ71n8PTiHPCg9lpfThXDDXxrB7nDDkhxl23zSXlUrpWwkMI+a2Ax/AxGg==", + "version": "1.0.43-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43-0.tgz", + "integrity": "sha512-5Kgk1wtoV7dMMf++RLTqjwZfGUkXOrEu5f/2ijMbMVEkjaBIHwxkK3xvPsun3xBU8htJy1PpCSBm2e/2rv78MA==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 874aeca16..29bf7a014 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.42", + "@github/copilot": "^1.0.43-0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From d0894fe5ad25a203b7ecef6d6e808d82b67c519c Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 6 May 2026 23:43:58 -0400 Subject: [PATCH 05/33] Fix .NET E2E event capture race (#1221) * Fix E2E event capture race Use a thread-safe collection for SessionE2ETests event type capture so assertions do not enumerate a List while background event delivery appends to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Avoid shell E2E workspace cleanup race Run long-lived shell edge-case commands from the system temp directory so killed or timed-out child processes cannot keep the fixture workspace directory locked on Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs | 8 ++++++-- dotnet/test/E2E/SessionE2ETests.cs | 9 +++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs b/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs index 13bea7ae4..8b9126b8b 100644 --- a/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs +++ b/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs @@ -35,7 +35,9 @@ public async Task Shell_Exec_With_Timeout_Kills_Long_Running_Command() ? $"echo started>\"{startedPath}\" & for /L %i in (1,1,2147483647) do @rem & echo should-not-exist>\"{markerPath}\"" : $"printf 'started' > '{startedPath}'; sleep 30; printf 'should-not-exist' > '{markerPath}'"; - var result = await session.Rpc.Shell.ExecAsync(command, timeout: TimeSpan.FromMilliseconds(200)); + // On Windows, terminating the shell wrapper can briefly leave children alive. + // Keep this long-running command outside the fixture workspace so cleanup is not blocked by cwd handles. + var result = await session.Rpc.Shell.ExecAsync(command, cwd: Path.GetTempPath(), timeout: TimeSpan.FromMilliseconds(200)); Assert.False(string.IsNullOrWhiteSpace(result.ProcessId)); await TestHelper.WaitForConditionAsync( @@ -120,7 +122,9 @@ public async Task Shell_Kill_Cleans_Up_After_Terminating_Signal(ShellKillSignal ? "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 60\"" : "sleep 60"; - var execResult = await session.Rpc.Shell.ExecAsync(command); + // On Windows, terminating the shell wrapper can briefly leave grandchildren alive. + // Keep this command outside the fixture workspace so cleanup is not blocked by cwd handles. + var execResult = await session.Rpc.Shell.ExecAsync(command, cwd: Path.GetTempPath()); Assert.False(string.IsNullOrWhiteSpace(execResult.ProcessId)); var killResult = await session.Rpc.Shell.KillAsync(execResult.ProcessId, signal); diff --git a/dotnet/test/E2E/SessionE2ETests.cs b/dotnet/test/E2E/SessionE2ETests.cs index 50b4dc1f5..15aa3543d 100644 --- a/dotnet/test/E2E/SessionE2ETests.cs +++ b/dotnet/test/E2E/SessionE2ETests.cs @@ -5,6 +5,7 @@ using GitHub.Copilot.SDK.Test.Harness; using GitHub.Copilot.SDK.Rpc; using Microsoft.Extensions.AI; +using System.Collections.Concurrent; using System.ComponentModel; using Xunit; using Xunit.Abstractions; @@ -401,9 +402,9 @@ public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() { OnPermissionRequest = PermissionHandler.ApproveAll, }); - var events = new List(); + var events = new ConcurrentQueue(); - session.On(evt => events.Add(evt.Type)); + session.On(evt => events.Enqueue(evt.Type)); // Use a slow command so we can verify SendAsync() returns before completion await session.SendAsync(new MessageOptions { Prompt = "Run 'sleep 2 && echo done'" }); @@ -423,9 +424,9 @@ public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assistant_Message() { var session = await CreateSessionAsync(); - var events = new List(); + var events = new ConcurrentQueue(); - session.On(evt => events.Add(evt.Type)); + session.On(evt => events.Enqueue(evt.Type)); var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); From 5600f417a6410d8f8751fa17641b2e4747eba206 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 14:33:55 -0400 Subject: [PATCH 06/33] Update @github/copilot to 1.0.43 (#1218) - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- nodejs/package-lock.json | 56 ++++++++++++++++---------------- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- test/harness/package-lock.json | 56 ++++++++++++++++---------------- test/harness/package.json | 2 +- 5 files changed, 59 insertions(+), 59 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 86940d079..3f260837c 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.43-0", + "@github/copilot": "^1.0.43", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43-0.tgz", - "integrity": "sha512-wsSlDIJj6tFIOchzuA+rIYJXP3OwrsMR1j/k8WB/4gxxACm5Q7XSRmH7Ul88k/vXak44sBC9EmJaEgAm6enwbg==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43.tgz", + "integrity": "sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.43-0", - "@github/copilot-darwin-x64": "1.0.43-0", - "@github/copilot-linux-arm64": "1.0.43-0", - "@github/copilot-linux-x64": "1.0.43-0", - "@github/copilot-win32-arm64": "1.0.43-0", - "@github/copilot-win32-x64": "1.0.43-0" + "@github/copilot-darwin-arm64": "1.0.43", + "@github/copilot-darwin-x64": "1.0.43", + "@github/copilot-linux-arm64": "1.0.43", + "@github/copilot-linux-x64": "1.0.43", + "@github/copilot-win32-arm64": "1.0.43", + "@github/copilot-win32-x64": "1.0.43" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43-0.tgz", - "integrity": "sha512-50YRUf03lUJ110PbKaMRjeIG5pvEgxm6+X4nRuYtEQujp/RUFT2Rd3g6I2f9hfIrdMIxfSNw8KXanuPcrCPZqA==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43.tgz", + "integrity": "sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43-0.tgz", - "integrity": "sha512-LvbJD23zbuGNOvYheS8PPT5l+TLOy6FWRc1qF7hf6PMSsqvDTqseO6Bq5S99A/Z7A/v3kh/xUZglJwrIxtVxvg==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43.tgz", + "integrity": "sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43-0.tgz", - "integrity": "sha512-gBfW5f5XZIAxkQU7NXQytxImORMbzgrk9WcMfcTuTNgr1OXM1oaKWvXH2vc6ZVZbPhPnAcTymCC0bFOSXQBE2g==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43.tgz", + "integrity": "sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43-0.tgz", - "integrity": "sha512-S/0F05plYabV6siVHBcuGiYzamB/GaEVvt8jMo3CeuPJl6/AZtuU6WMWngWooCQg4PyM/zDfcnvH88xU5NDs7w==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43.tgz", + "integrity": "sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43-0.tgz", - "integrity": "sha512-obpdmbbDF1kuanKyIjb1Mc4+GrNEfLRT2QO0DV20uy0JXRf0duYe92Q40T7vRxfp7MRpfclpdT4wNEkVc2GZVA==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43.tgz", + "integrity": "sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43-0.tgz", - "integrity": "sha512-5Kgk1wtoV7dMMf++RLTqjwZfGUkXOrEu5f/2ijMbMVEkjaBIHwxkK3xvPsun3xBU8htJy1PpCSBm2e/2rv78MA==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43.tgz", + "integrity": "sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 0b16a73d8..ad5eb3970 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.43-0", + "@github/copilot": "^1.0.43", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index b7f4bdd8b..ab6a78fdc 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.42", + "@github/copilot": "^1.0.43", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 9a1b7d587..24804c472 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.43-0", + "@github/copilot": "^1.0.43", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -464,27 +464,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43-0.tgz", - "integrity": "sha512-wsSlDIJj6tFIOchzuA+rIYJXP3OwrsMR1j/k8WB/4gxxACm5Q7XSRmH7Ul88k/vXak44sBC9EmJaEgAm6enwbg==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43.tgz", + "integrity": "sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.43-0", - "@github/copilot-darwin-x64": "1.0.43-0", - "@github/copilot-linux-arm64": "1.0.43-0", - "@github/copilot-linux-x64": "1.0.43-0", - "@github/copilot-win32-arm64": "1.0.43-0", - "@github/copilot-win32-x64": "1.0.43-0" + "@github/copilot-darwin-arm64": "1.0.43", + "@github/copilot-darwin-x64": "1.0.43", + "@github/copilot-linux-arm64": "1.0.43", + "@github/copilot-linux-x64": "1.0.43", + "@github/copilot-win32-arm64": "1.0.43", + "@github/copilot-win32-x64": "1.0.43" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43-0.tgz", - "integrity": "sha512-50YRUf03lUJ110PbKaMRjeIG5pvEgxm6+X4nRuYtEQujp/RUFT2Rd3g6I2f9hfIrdMIxfSNw8KXanuPcrCPZqA==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43.tgz", + "integrity": "sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==", "cpu": [ "arm64" ], @@ -499,9 +499,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43-0.tgz", - "integrity": "sha512-LvbJD23zbuGNOvYheS8PPT5l+TLOy6FWRc1qF7hf6PMSsqvDTqseO6Bq5S99A/Z7A/v3kh/xUZglJwrIxtVxvg==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43.tgz", + "integrity": "sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==", "cpu": [ "x64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43-0.tgz", - "integrity": "sha512-gBfW5f5XZIAxkQU7NXQytxImORMbzgrk9WcMfcTuTNgr1OXM1oaKWvXH2vc6ZVZbPhPnAcTymCC0bFOSXQBE2g==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43.tgz", + "integrity": "sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==", "cpu": [ "arm64" ], @@ -533,9 +533,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43-0.tgz", - "integrity": "sha512-S/0F05plYabV6siVHBcuGiYzamB/GaEVvt8jMo3CeuPJl6/AZtuU6WMWngWooCQg4PyM/zDfcnvH88xU5NDs7w==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43.tgz", + "integrity": "sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==", "cpu": [ "x64" ], @@ -550,9 +550,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43-0.tgz", - "integrity": "sha512-obpdmbbDF1kuanKyIjb1Mc4+GrNEfLRT2QO0DV20uy0JXRf0duYe92Q40T7vRxfp7MRpfclpdT4wNEkVc2GZVA==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43.tgz", + "integrity": "sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==", "cpu": [ "arm64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.43-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43-0.tgz", - "integrity": "sha512-5Kgk1wtoV7dMMf++RLTqjwZfGUkXOrEu5f/2ijMbMVEkjaBIHwxkK3xvPsun3xBU8htJy1PpCSBm2e/2rv78MA==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43.tgz", + "integrity": "sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 29bf7a014..baa88070f 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.43-0", + "@github/copilot": "^1.0.43", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From 33b7e8ff822979ff8262f52dd1926e67947165b9 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 May 2026 14:35:08 -0400 Subject: [PATCH 07/33] Add SDK tracing diagnostics (#1217) * Add SDK tracing diagnostics Add native logging and timing diagnostics across the .NET, Python, and Rust SDKs for client startup, JSON-RPC calls, session lifecycle operations, event dispatch, and callback handling. Align diagnostics so each SDK reports useful startup breadcrumbs, CLI output, structured timing fields, and failure paths while staying idiomatic for ILogger, Python logging, and tracing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix SDK tracing log analyzer warnings Use source-generated ILogger methods for the new C# startup intent logs so analyzer CA1873 does not flag argument evaluation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Centralize SDK timing helpers Move repeated timing helper logic into shared helpers for .NET, Python, and Rust so elapsed calculation and logging behavior stay consistent across modules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Rust tracing review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align SDK tracing log levels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 143 ++++++++- dotnet/src/JsonRpc.cs | 40 ++- dotnet/src/LoggingHelpers.cs | 108 +++++++ dotnet/src/Session.cs | 215 ++++++++++--- python/copilot/_diagnostics.py | 29 ++ python/copilot/_jsonrpc.py | 73 ++++- python/copilot/client.py | 202 +++++++++++- python/copilot/session.py | 216 ++++++++++++- rust/src/hooks.rs | 8 + rust/src/jsonrpc.rs | 53 +++- rust/src/lib.rs | 69 ++++- rust/src/session.rs | 540 ++++++++++++++++++++++++--------- 12 files changed, 1489 insertions(+), 207 deletions(-) create mode 100644 dotnet/src/LoggingHelpers.cs create mode 100644 python/copilot/_diagnostics.py diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 9c6b36fce..5c92adfae 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -8,7 +8,6 @@ using System.Collections.Concurrent; using System.Data; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; using System.Text; using System.Text.Json; @@ -17,6 +16,7 @@ using System.Text.RegularExpressions; using GitHub.Copilot.SDK.Rpc; using System.Globalization; +using static GitHub.Copilot.SDK.LoggingHelpers; namespace GitHub.Copilot.SDK; @@ -226,6 +226,7 @@ async Task StartCoreAsync(CancellationToken ct) _logger.LogDebug("Starting Copilot client"); _disconnected = false; + var startTimestamp = Stopwatch.GetTimestamp(); Connection? connection = null; Process? cliProcess = null; @@ -246,15 +247,39 @@ async Task StartCoreAsync(CancellationToken ct) connection = await ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct); } + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.StartAsync transport setup complete. Elapsed={Elapsed}", + startTimestamp); + // Verify protocol version compatibility await VerifyProtocolVersionAsync(connection, ct); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.StartAsync protocol verification complete. Elapsed={Elapsed}", + startTimestamp); + + var sessionFsTimestamp = Stopwatch.GetTimestamp(); await ConfigureSessionFsAsync(ct); + if (_options.SessionFs is not null) + { + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.StartAsync session filesystem setup complete. Elapsed={Elapsed}", + sessionFsTimestamp); + } - _logger.LogInformation("Copilot client connected"); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.StartAsync complete. Elapsed={Elapsed}", + startTimestamp); return connection; } - catch + catch (Exception ex) { + if (ex is not OperationCanceledException) + { + LogTiming(_logger, LogLevel.Warning, ex, + "CopilotClient.StartAsync failed. Elapsed={Elapsed}", + startTimestamp); + } + if (connection is not null) { await CleanupConnectionAsync(connection, errors: null); @@ -515,6 +540,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance } var connection = await EnsureConnectedAsync(cancellationToken); + var totalTimestamp = Stopwatch.GetTimestamp(); var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || @@ -530,6 +556,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. + var setupTimestamp = Stopwatch.GetTimestamp(); var session = new CopilotSession(sessionId, connection.Rpc, _logger); session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler(config.OnPermissionRequest); @@ -553,6 +580,13 @@ public async Task CreateSessionAsync(SessionConfig config, Cance } ConfigureSessionFsHandlers(session, config.CreateSessionFsHandler); _sessions[sessionId] = session; + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.CreateSessionAsync local setup complete. Elapsed={Elapsed}, SessionId={SessionId}, Tools={ToolsCount}, Commands={CommandsCount}, Hooks={HasHooks}", + setupTimestamp, + sessionId, + config.Tools?.Count ?? 0, + config.Commands?.Count ?? 0, + hasHooks); try { @@ -592,18 +626,34 @@ public async Task CreateSessionAsync(SessionConfig config, Cance GitHubToken: config.GitHubToken, InstructionDirectories: config.InstructionDirectories); + var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( connection.Rpc, "session.create", [request], cancellationToken); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.CreateSessionAsync session creation request completed successfully. Elapsed={Elapsed}, SessionId={SessionId}", + rpcTimestamp, + sessionId); session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); } - catch + catch (Exception ex) { _sessions.TryRemove(sessionId, out _); + if (ex is not OperationCanceledException) + { + LogTiming(_logger, LogLevel.Warning, ex, + "CopilotClient.CreateSessionAsync failed. Elapsed={Elapsed}, SessionId={SessionId}", + totalTimestamp, + sessionId); + } throw; } + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.CreateSessionAsync complete. Elapsed={Elapsed}, SessionId={SessionId}", + totalTimestamp, + sessionId); return session; } @@ -643,6 +693,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes } var connection = await EnsureConnectedAsync(cancellationToken); + var totalTimestamp = Stopwatch.GetTimestamp(); var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || @@ -656,6 +707,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. + var setupTimestamp = Stopwatch.GetTimestamp(); var session = new CopilotSession(sessionId, connection.Rpc, _logger); session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler(config.OnPermissionRequest); @@ -679,6 +731,13 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes } ConfigureSessionFsHandlers(session, config.CreateSessionFsHandler); _sessions[sessionId] = session; + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.ResumeSessionAsync local setup complete. Elapsed={Elapsed}, SessionId={SessionId}, Tools={ToolsCount}, Commands={CommandsCount}, Hooks={HasHooks}", + setupTimestamp, + sessionId, + config.Tools?.Count ?? 0, + config.Commands?.Count ?? 0, + hasHooks); try { @@ -720,18 +779,34 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes ContinuePendingWork: config.ContinuePendingWork, InstructionDirectories: config.InstructionDirectories); + var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( connection.Rpc, "session.resume", [request], cancellationToken); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.ResumeSessionAsync session resume request completed successfully. Elapsed={Elapsed}, SessionId={SessionId}", + rpcTimestamp, + sessionId); session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); } - catch + catch (Exception ex) { _sessions.TryRemove(sessionId, out _); + if (ex is not OperationCanceledException) + { + LogTiming(_logger, LogLevel.Warning, ex, + "CopilotClient.ResumeSessionAsync failed. Elapsed={Elapsed}, SessionId={SessionId}", + totalTimestamp, + sessionId); + } throw; } + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.ResumeSessionAsync complete. Elapsed={Elapsed}, SessionId={SessionId}", + totalTimestamp, + sessionId); return session; } @@ -1169,6 +1244,16 @@ private static string FormatCliExitedMessage(string message, string stderrOutput : $"{message}\nstderr: {stderrOutput}"; } + [LoggerMessage( + Level = LogLevel.Information, + Message = "CopilotClient.StartCliServerAsync starting Copilot CLI. CliPath={CliPath}, Executable={Executable}, CliPathSource={CliPathSource}, UseStdio={UseStdio}, Port={Port}")] + private static partial void LogStartingCopilotCli(ILogger logger, string cliPath, string executable, string cliPathSource, bool useStdio, int? port); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "CopilotClient.ConnectToServerAsync connecting to CLI server. Host={Host}, Port={Port}")] + private static partial void LogConnectingToCliServer(ILogger logger, string host, int port); + private static IOException CreateCliExitedException(string message, StringBuilder stderrBuffer) { string stderrOutput; @@ -1224,6 +1309,8 @@ private void ConfigureSessionFsHandlers(CopilotSession session, Func( connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken); serverVersion = pingResponse.ProtocolVersion; @@ -1258,6 +1346,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } _negotiatedProtocolVersion = serverVersion.Value; + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.VerifyProtocolVersionAsync protocol handshake complete. Elapsed={Elapsed}, ProtocolVersion={ProtocolVersion}, UsedFallbackPing={UsedFallbackPing}", + handshakeTimestamp, + serverVersion.Value, + usedFallbackPing); } private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) @@ -1275,6 +1368,7 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) ?? envCliPath ?? GetBundledCliPath(out var searchedPath) ?? throw new InvalidOperationException($"Copilot CLI not found at '{searchedPath}'. Ensure the SDK NuGet package was restored correctly or provide an explicit CliPath."); + var cliPathSource = options.CliPath is not null ? "Options" : envCliPath is not null ? "Environment" : "Bundled"; var args = new List(); if (options.CliArgs != null) @@ -1317,6 +1411,8 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) } var (fileName, processArgs) = ResolveCliCommand(cliPath, args); + var configuredPort = options.UseStdio == true ? (int?)null : options.Port; + LogStartingCopilotCli(logger, cliPath, fileName, cliPathSource, options.UseStdio == true, configuredPort); var startInfo = new ProcessStartInfo { @@ -1372,7 +1468,11 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) try { cliProcess = new Process { StartInfo = startInfo }; + var spawnTimestamp = Stopwatch.GetTimestamp(); cliProcess.Start(); + LogTiming(logger, LogLevel.Debug, null, + "CopilotClient.StartCliServerAsync subprocess spawned. Elapsed={Elapsed}", + spawnTimestamp); // Capture stderr for error messages and forward to logger var stderrBuffer = new StringBuilder(); @@ -1391,10 +1491,7 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) stderrBuffer.AppendLine(line); } - if (logger.IsEnabled(LogLevel.Debug)) - { - logger.LogDebug("[CLI] {Line}", line); - } + logger.LogWarning("[CLI] {Line}", line); } }, cancellationToken); @@ -1402,6 +1499,7 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) if (options.UseStdio != true) { // Wait for port announcement + var portWaitTimestamp = Stopwatch.GetTimestamp(); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(30)); @@ -1414,9 +1512,18 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) throw CreateCliExitedException("CLI process exited unexpectedly", stderrBuffer); } + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("[CLI] {Line}", line); + } + if (ListeningOnPortRegex().Match(line) is { Success: true } match) { detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + LogTiming(logger, LogLevel.Debug, null, + "CopilotClient.StartCliServerAsync TCP port wait complete. Elapsed={Elapsed}, Port={Port}", + portWaitTimestamp, + detectedLocalhostTcpPort.Value); break; } } @@ -1478,6 +1585,7 @@ private static (string FileName, IEnumerable Args) ResolveCliCommand(str private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { + var setupTimestamp = Stopwatch.GetTimestamp(); Stream inputStream, outputStream; NetworkStream? networkStream = null; @@ -1501,7 +1609,14 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); try { + var tcpConnectTimestamp = Stopwatch.GetTimestamp(); + LogConnectingToCliServer(_logger, tcpHost, tcpPort.Value); await socket.ConnectAsync(tcpHost, tcpPort.Value, cancellationToken); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.ConnectToServerAsync TCP connect complete. Elapsed={Elapsed}, Host={Host}, Port={Port}", + tcpConnectTimestamp, + tcpHost, + tcpPort.Value); } catch { @@ -1536,6 +1651,9 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? return session.ClientSessionApis; }); rpc.StartListening(); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotClient.ConnectToServerAsync transport setup complete. Elapsed={Elapsed}", + setupTimestamp); // Transition state to Disconnected if the JSON-RPC connection drops _ = rpc.Completion.ContinueWith(_ => _disconnected = true, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); @@ -1709,7 +1827,14 @@ public async ValueTask OnToolCallV2(string sessionId, } } + var toolTimestamp = Stopwatch.GetTimestamp(); var result = await tool.InvokeAsync(aiFunctionArgs); + LogTiming(client._logger, LogLevel.Debug, null, + "RpcHandler.OnToolCallV2 tool dispatch. Elapsed={Elapsed}, SessionId={SessionId}, ToolCallId={ToolCallId}, Tool={ToolName}", + toolTimestamp, + sessionId, + toolCallId, + toolName); var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions); return new ToolCallResponseV2(toolResultObject); diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index 9bb0312fa..c57166aee 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -77,6 +77,7 @@ public void StartListening() /// public async Task InvokeAsync(string method, object?[]? args, CancellationToken cancellationToken) { + var timingTimestamp = Stopwatch.GetTimestamp(); var id = Interlocked.Increment(ref _nextId); var pending = new PendingRequest(); _pendingRequests[id] = pending; @@ -111,10 +112,23 @@ await SendMessageAsync(new JsonRpcRequest if (responseElement.ValueKind == JsonValueKind.Null || responseElement.ValueKind == JsonValueKind.Undefined) { + LogInvokeTiming(LogLevel.Debug, null, method, id, "Succeeded", timingTimestamp); return default!; } - return (T)responseElement.Deserialize(_serializerOptions.GetTypeInfo(typeof(T)))!; + var result = (T)responseElement.Deserialize(_serializerOptions.GetTypeInfo(typeof(T)))!; + LogInvokeTiming(LogLevel.Debug, null, method, id, "Succeeded", timingTimestamp); + return result; + } + catch (OperationCanceledException ex) + { + LogInvokeTiming(LogLevel.Debug, ex, method, id, "Canceled", timingTimestamp); + throw; + } + catch (Exception ex) + { + LogInvokeTiming(LogLevel.Warning, ex, method, id, "Failed", timingTimestamp); + throw; } finally { @@ -123,6 +137,30 @@ await SendMessageAsync(new JsonRpcRequest } } + private void LogInvokeTiming( + LogLevel level, + Exception? exception, + string method, + long requestId, + string status, + long startTimestamp) + { + if (!_logger.IsEnabled(level)) + { + return; + } + + var elapsed = Stopwatch.GetElapsedTime(startTimestamp); + _logger.Log( + level, + exception, + "JsonRpc.InvokeAsync JSON-RPC request finished. Elapsed={Elapsed}, Method={Method}, RequestId={RequestId}, Status={Status}", + elapsed, + method, + requestId, + status); + } + /// /// Registers a method handler that receives positional parameters. /// If singleObjectParam is false (the default), parameter names and types are inferred from the delegate's signature. diff --git a/dotnet/src/LoggingHelpers.cs b/dotnet/src/LoggingHelpers.cs new file mode 100644 index 000000000..ca14ea3b9 --- /dev/null +++ b/dotnet/src/LoggingHelpers.cs @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace GitHub.Copilot.SDK; + +internal static class LoggingHelpers +{ + internal static void LogTiming( + ILogger logger, + LogLevel level, + Exception? exception, + string message, + long startTimestamp) + { + if (!logger.IsEnabled(level)) + { + return; + } + + LogTimingCore(logger, level, exception, message, Stopwatch.GetElapsedTime(startTimestamp)); + } + + internal static void LogTiming( + ILogger logger, + LogLevel level, + Exception? exception, + string message, + long startTimestamp, + T1 arg1) + { + if (!logger.IsEnabled(level)) + { + return; + } + + LogTimingCore(logger, level, exception, message, Stopwatch.GetElapsedTime(startTimestamp), arg1); + } + + internal static void LogTiming( + ILogger logger, + LogLevel level, + Exception? exception, + string message, + long startTimestamp, + T1 arg1, + T2 arg2) + { + if (!logger.IsEnabled(level)) + { + return; + } + + LogTimingCore(logger, level, exception, message, Stopwatch.GetElapsedTime(startTimestamp), arg1, arg2); + } + + internal static void LogTiming( + ILogger logger, + LogLevel level, + Exception? exception, + string message, + long startTimestamp, + T1 arg1, + T2 arg2, + T3 arg3) + { + if (!logger.IsEnabled(level)) + { + return; + } + + LogTimingCore(logger, level, exception, message, Stopwatch.GetElapsedTime(startTimestamp), arg1, arg2, arg3); + } + + internal static void LogTiming( + ILogger logger, + LogLevel level, + Exception? exception, + string message, + long startTimestamp, + T1 arg1, + T2 arg2, + T3 arg3, + T4 arg4) + { + if (!logger.IsEnabled(level)) + { + return; + } + + LogTimingCore(logger, level, exception, message, Stopwatch.GetElapsedTime(startTimestamp), arg1, arg2, arg3, arg4); + } + + private static void LogTimingCore( + ILogger logger, + LogLevel level, + Exception? exception, + string message, + params object?[] args) + { +#pragma warning disable CA2254 // Timing call sites pass static templates through this helper. + logger.Log(level, exception, message, args); +#pragma warning restore CA2254 + } +} diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 2d3e803e0..b5dc10849 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -6,10 +6,12 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Collections.Immutable; +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading.Channels; +using static GitHub.Copilot.SDK.LoggingHelpers; namespace GitHub.Copilot.SDK; @@ -195,8 +197,14 @@ public async Task SendAsync(MessageOptions options, CancellationToken ca RequestHeaders = options.RequestHeaders, }; + var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( "session.send", [request], cancellationToken); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.SendAsync completed successfully. Elapsed={Elapsed}, SessionId={SessionId}, MessageId={MessageId}", + rpcTimestamp, + SessionId, + response.MessageId); return response.MessageId; } @@ -233,9 +241,11 @@ public async Task SendAsync(MessageOptions options, CancellationToken ca TimeSpan? timeout = null, CancellationToken cancellationToken = default) { + var totalTimestamp = Stopwatch.GetTimestamp(); var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); AssistantMessageEvent? lastAssistantMessage = null; + var firstAssistantMessageLogged = false; void Handler(SessionEvent evt) { @@ -243,9 +253,21 @@ void Handler(SessionEvent evt) { case AssistantMessageEvent assistantMessage: lastAssistantMessage = assistantMessage; + if (!firstAssistantMessageLogged) + { + firstAssistantMessageLogged = true; + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.SendAndWaitAsync first assistant message. Elapsed={Elapsed}, SessionId={SessionId}", + totalTimestamp, + SessionId); + } break; case SessionIdleEvent: + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.SendAndWaitAsync idle received. Elapsed={Elapsed}, SessionId={SessionId}", + totalTimestamp, + SessionId); tcs.TrySetResult(lastAssistantMessage); break; @@ -270,7 +292,35 @@ void Handler(SessionEvent evt) else tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}")); }); - return await tcs.Task; + try + { + var result = await tcs.Task; + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.SendAndWaitAsync complete. Elapsed={Elapsed}, SessionId={SessionId}, CompletedBy={CompletedBy}, AssistantMessageReceived={AssistantMessageReceived}", + totalTimestamp, + SessionId, + "idle", + result is not null); + return result; + } + catch (Exception ex) when (ex is TimeoutException) + { + LogTiming(_logger, LogLevel.Warning, ex, + "CopilotSession.SendAndWaitAsync failed. Elapsed={Elapsed}, SessionId={SessionId}, CompletedBy={CompletedBy}", + totalTimestamp, + SessionId, + "timeout"); + throw; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogTiming(_logger, LogLevel.Warning, ex, + "CopilotSession.SendAndWaitAsync failed. Elapsed={Elapsed}, SessionId={SessionId}, CompletedBy={CompletedBy}", + totalTimestamp, + SessionId, + "error"); + throw; + } } /// @@ -344,6 +394,7 @@ private async Task ProcessEventsAsync() { await foreach (var sessionEvent in _eventChannel.Reader.ReadAllAsync()) { + var dispatchTimestamp = Stopwatch.GetTimestamp(); foreach (var handler in _eventHandlers) { try @@ -355,6 +406,11 @@ private async Task ProcessEventsAsync() LogEventHandlerError(ex); } } + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.ProcessEventsAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, EventType={EventType}", + dispatchTimestamp, + SessionId, + sessionEvent.Type); } } @@ -423,7 +479,13 @@ internal async Task HandlePermissionRequestAsync(JsonEl SessionId = SessionId }; - return await handler(request, invocation); + var permissionTimestamp = Stopwatch.GetTimestamp(); + var result = await handler(request, invocation); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandlePermissionRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", + permissionTimestamp, + SessionId); + return result; } /// @@ -433,6 +495,7 @@ internal async Task HandlePermissionRequestAsync(JsonEl /// private async Task HandleBroadcastEventAsync(SessionEvent sessionEvent) { + var dispatchTimestamp = Stopwatch.GetTimestamp(); try { switch (sessionEvent) @@ -528,6 +591,14 @@ await HandleElicitationRequestAsync( { LogBroadcastHandlerError(ex); } + finally + { + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandleBroadcastEventAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, EventType={EventType}", + dispatchTimestamp, + SessionId, + sessionEvent.Type); + } } /// @@ -566,11 +637,27 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, } } + var toolTimestamp = Stopwatch.GetTimestamp(); var result = await tool.InvokeAsync(aiFunctionArgs); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.ExecuteToolAndRespondAsync tool dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, ToolCallId={ToolCallId}, Tool={ToolName}", + toolTimestamp, + SessionId, + requestId, + toolCallId, + toolName); var toolResultObject = ToolResultObject.ConvertFromInvocationResult(result, tool.JsonSerializerOptions); + var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.ExecuteToolAndRespondAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, ToolCallId={ToolCallId}, Tool={ToolName}", + responseRpcTimestamp, + SessionId, + requestId, + toolCallId, + toolName); } catch (Exception ex) { @@ -601,12 +688,24 @@ private async Task ExecutePermissionAndRespondAsync(string requestId, Permission SessionId = SessionId }; + var permissionTimestamp = Stopwatch.GetTimestamp(); var result = await handler(permissionRequest, invocation); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.ExecutePermissionAndRespondAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", + permissionTimestamp, + SessionId, + requestId); if (result.Kind == new PermissionRequestResultKind("no-result")) { return; } + var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, new PermissionDecision { Kind = result.Kind.Value }); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.ExecutePermissionAndRespondAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", + responseRpcTimestamp, + SessionId, + requestId); } catch (Exception) { @@ -690,6 +789,7 @@ private async Task ExecuteCommandAndRespondAsync(string requestId, string comman try { + var commandTimestamp = Stopwatch.GetTimestamp(); await handler(new CommandContext { SessionId = SessionId, @@ -697,7 +797,20 @@ await handler(new CommandContext CommandName = commandName, Args = args }); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.ExecuteCommandAndRespondAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, Command={CommandName}", + commandTimestamp, + SessionId, + requestId, + commandName); + var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Commands.HandlePendingCommandAsync(requestId); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.ExecuteCommandAndRespondAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}, Command={CommandName}", + responseRpcTimestamp, + SessionId, + requestId, + commandName); } catch (Exception error) when (error is not OperationCanceledException) { @@ -726,12 +839,24 @@ private async Task HandleElicitationRequestAsync(ElicitationContext context, str try { + var elicitationTimestamp = Stopwatch.GetTimestamp(); var result = await handler(context); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandleElicitationRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", + elicitationTimestamp, + SessionId, + requestId); + var responseRpcTimestamp = Stopwatch.GetTimestamp(); await Rpc.Ui.HandlePendingElicitationAsync(requestId, new UIElicitationResponse { Action = result.Action, Content = result.Content }); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandleElicitationRequestAsync response sent successfully. Elapsed={Elapsed}, SessionId={SessionId}, RequestId={RequestId}", + responseRpcTimestamp, + SessionId, + requestId); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -882,7 +1007,13 @@ internal async Task HandleUserInputRequestAsync(UserInputRequ SessionId = SessionId }; - return await handler(request, invocation); + var userInputTimestamp = Stopwatch.GetTimestamp(); + var response = await handler(request, invocation); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandleUserInputRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", + userInputTimestamp, + SessionId); + return response; } /// @@ -931,40 +1062,52 @@ internal void RegisterHooks(SessionHooks hooks) SessionId = SessionId }; - return hookType switch + var hookTimestamp = Stopwatch.GetTimestamp(); + try { - "preToolUse" => hooks.OnPreToolUse != null - ? await hooks.OnPreToolUse( - JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!, - invocation) - : null, - "postToolUse" => hooks.OnPostToolUse != null - ? await hooks.OnPostToolUse( - JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!, - invocation) - : null, - "userPromptSubmitted" => hooks.OnUserPromptSubmitted != null - ? await hooks.OnUserPromptSubmitted( - JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptSubmittedHookInput)!, - invocation) - : null, - "sessionStart" => hooks.OnSessionStart != null - ? await hooks.OnSessionStart( - JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionStartHookInput)!, - invocation) - : null, - "sessionEnd" => hooks.OnSessionEnd != null - ? await hooks.OnSessionEnd( - JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionEndHookInput)!, - invocation) - : null, - "errorOccurred" => hooks.OnErrorOccurred != null - ? await hooks.OnErrorOccurred( - JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.ErrorOccurredHookInput)!, - invocation) - : null, - _ => null - }; + return hookType switch + { + "preToolUse" => hooks.OnPreToolUse != null + ? await hooks.OnPreToolUse( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!, + invocation) + : null, + "postToolUse" => hooks.OnPostToolUse != null + ? await hooks.OnPostToolUse( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!, + invocation) + : null, + "userPromptSubmitted" => hooks.OnUserPromptSubmitted != null + ? await hooks.OnUserPromptSubmitted( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptSubmittedHookInput)!, + invocation) + : null, + "sessionStart" => hooks.OnSessionStart != null + ? await hooks.OnSessionStart( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionStartHookInput)!, + invocation) + : null, + "sessionEnd" => hooks.OnSessionEnd != null + ? await hooks.OnSessionEnd( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionEndHookInput)!, + invocation) + : null, + "errorOccurred" => hooks.OnErrorOccurred != null + ? await hooks.OnErrorOccurred( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.ErrorOccurredHookInput)!, + invocation) + : null, + _ => null + }; + } + finally + { + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandleHooksInvokeAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}, Hook={HookType}", + hookTimestamp, + SessionId, + hookType); + } } /// diff --git a/python/copilot/_diagnostics.py b/python/copilot/_diagnostics.py new file mode 100644 index 000000000..dfc92a769 --- /dev/null +++ b/python/copilot/_diagnostics.py @@ -0,0 +1,29 @@ +"""Internal diagnostics helpers shared by SDK modules.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + + +def elapsed_ms(start: float) -> float: + return (time.perf_counter() - start) * 1000 + + +def log_timing( + logger: logging.Logger, + level: int, + message: str, + start: float, + *, + exc_info: bool = False, + **fields: Any, +) -> None: + if logger.isEnabledFor(level): + logger.log( + level, + message, + extra={"elapsed_ms": elapsed_ms(start), **fields}, + exc_info=exc_info, + ) diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index 8a200cc8d..61e216968 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -8,11 +8,17 @@ import asyncio import inspect import json +import logging import threading +import time import uuid from collections.abc import Awaitable, Callable from typing import Any +from ._diagnostics import elapsed_ms + +logger = logging.getLogger(__name__) + class JsonRpcError(Exception): """JSON-RPC error response""" @@ -33,6 +39,29 @@ class ProcessExitedError(Exception): RequestHandler = Callable[[dict], dict | Awaitable[dict]] +def _log_request_timing( + level: int, + start: float, + method: str, + request_id: str, + status: str, + *, + exc_info: bool = False, +) -> None: + if logger.isEnabledFor(level): + logger.log( + level, + "JsonRpcClient.request JSON-RPC request finished", + extra={ + "elapsed_ms": elapsed_ms(start), + "method": method, + "request_id": request_id, + "status": status, + }, + exc_info=exc_info, + ) + + class JsonRpcClient: """ Minimal async JSON-RPC 2.0 client for stdio transport @@ -84,12 +113,12 @@ def _stderr_loop(self): line = self.process.stderr.readline() if not line: break + stderr_line = line.decode("utf-8") if isinstance(line, bytes) else line + logger.warning("[CLI] %s", stderr_line.rstrip()) with self._stderr_lock: - self._stderr_output.append( - line.decode("utf-8") if isinstance(line, bytes) else line - ) + self._stderr_output.append(stderr_line) except Exception: - pass # Ignore errors reading stderr + logger.debug("Error reading Copilot CLI stderr", exc_info=True) def get_stderr_output(self) -> str: """Get captured stderr output""" @@ -123,6 +152,7 @@ async def request( JsonRpcError: If server returns an error asyncio.TimeoutError: If request times out (only when timeout is set) """ + request_start = time.perf_counter() request_id = str(uuid.uuid4()) # Use the stored loop to ensure consistency with the reader thread @@ -140,12 +170,28 @@ async def request( "params": params or {}, } - await self._send_message(message) - try: + await self._send_message(message) if timeout is not None: - return await asyncio.wait_for(future, timeout=timeout) - return await future + result = await asyncio.wait_for(future, timeout=timeout) + else: + result = await future + except asyncio.CancelledError: + _log_request_timing(logging.DEBUG, request_start, method, request_id, "canceled") + raise + except Exception: + _log_request_timing( + logging.WARNING, + request_start, + method, + request_id, + "failed", + exc_info=True, + ) + raise + else: + _log_request_timing(logging.DEBUG, request_start, method, request_id, "succeeded") + return result finally: with self._pending_lock: self.pending_requests.pop(request_id, None) @@ -206,11 +252,13 @@ def _read_loop(self): pass except Exception as e: if self._running: + logger.warning("Failed to parse incoming JSON-RPC message", exc_info=True) # Store error for pending requests self._process_exit_error = str(e) # Process exited or read failed - fail all pending requests if self._running: + logger.debug("JSON-RPC read loop ended") self._fail_pending_requests() if self.on_close is not None: self.on_close() @@ -358,8 +406,17 @@ async def _dispatch_request(self, message: dict, handler: RequestHandler): ) await self._send_response(message["id"], outcome) except JsonRpcError as exc: + logger.debug( + "Error handling JSON-RPC method %s: %s", message.get("method", ""), exc.message + ) await self._send_error_response(message["id"], exc.code, exc.message, exc.data) except Exception as exc: # pylint: disable=broad-except + logger.debug( + "Error handling JSON-RPC method %s: %s", + message.get("method", ""), + str(exc), + exc_info=True, + ) await self._send_error_response(message["id"], -32603, str(exc), None) async def _send_response(self, request_id: str, result: dict | None): diff --git a/python/copilot/client.py b/python/copilot/client.py index f406c7f5b..70b70bc9b 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -16,12 +16,14 @@ import asyncio import inspect +import logging import os import re import shutil import subprocess import sys import threading +import time import uuid from collections.abc import Awaitable, Callable from dataclasses import KW_ONLY, dataclass, field @@ -29,6 +31,7 @@ from types import TracebackType from typing import Any, Literal, TypedDict, cast, overload +from ._diagnostics import log_timing from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError from ._sdk_protocol_version import get_sdk_protocol_version from ._telemetry import get_trace_context, trace_context @@ -65,6 +68,8 @@ from .session_fs_provider import create_session_fs_adapter from .tools import Tool, ToolInvocation, ToolResult +logger = logging.getLogger(__name__) + # ============================================================================ # Connection Types # ============================================================================ @@ -931,14 +936,17 @@ def __init__( # Resolve CLI path: explicit > COPILOT_CLI_PATH env var > bundled binary effective_env = config.env if config.env is not None else os.environ + self._cli_path_source: str | None = "explicit" if config.cli_path is None: env_cli_path = effective_env.get("COPILOT_CLI_PATH") if env_cli_path: config.cli_path = env_cli_path + self._cli_path_source = "environment" else: bundled_path = _get_bundled_cli_path() if bundled_path: config.cli_path = bundled_path + self._cli_path_source = "bundled" else: raise RuntimeError( "Copilot CLI not found. The bundled CLI binary is not available. " @@ -1082,6 +1090,7 @@ async def start(self) -> None: if self._state == "connected": return + start_time = time.perf_counter() self._state = "connecting" try: @@ -1091,20 +1100,59 @@ async def start(self) -> None: # Connect to the server await self._connect_to_server() + log_timing( + logger, + logging.DEBUG, + "CopilotClient.start transport setup complete", + start_time, + ) # Verify protocol version compatibility await self._verify_protocol_version() + log_timing( + logger, + logging.DEBUG, + "CopilotClient.start protocol verification complete", + start_time, + ) if self._session_fs_config: + session_fs_start = time.perf_counter() await self._set_session_fs_provider() + log_timing( + logger, + logging.DEBUG, + "CopilotClient.start session filesystem setup complete", + session_fs_start, + ) self._state = "connected" + log_timing( + logger, + logging.DEBUG, + "CopilotClient.start complete", + start_time, + ) except ProcessExitedError as e: # Process exited with error - reraise as RuntimeError with stderr self._state = "error" + log_timing( + logger, + logging.WARNING, + "CopilotClient.start failed", + start_time, + exc_info=True, + ) raise RuntimeError(str(e)) from None except Exception as e: self._state = "error" + log_timing( + logger, + logging.WARNING, + "CopilotClient.start failed", + start_time, + exc_info=True, + ) # Check if process exited and capture any remaining stderr if self._process and hasattr(self._process, "poll"): return_code = self._process.poll() @@ -1151,6 +1199,11 @@ async def stop(self) -> None: try: await session.disconnect() except Exception as e: + logger.debug( + "Error while cleaning up Copilot session %s", + session.session_id, + exc_info=True, + ) errors.append( StopError(message=f"Failed to disconnect session {session.session_id}: {e}") ) @@ -1212,14 +1265,16 @@ async def force_stop(self) -> None: self._process.kill() self._process = None except Exception: - pass + logger.debug("Error while force-stopping Copilot CLI process", exc_info=True) # Then clean up the JSON-RPC client if self._client: try: await self._client.stop() except Exception: - pass # Ignore errors during force stop + logger.debug( + "Error while stopping JSON-RPC client during force stop", exc_info=True + ) self._client = None self._rpc = None @@ -1490,6 +1545,7 @@ async def create_session( if not self._client: raise RuntimeError("Client not connected") + total_start = time.perf_counter() actual_session_id = session_id or str(uuid.uuid4()) payload["sessionId"] = actual_session_id @@ -1499,6 +1555,7 @@ async def create_session( # Create and register the session before issuing the RPC so that # events emitted by the CLI (e.g. session.start) are not dropped. + setup_start = time.perf_counter() session = CopilotSession(actual_session_id, self._client, workspace_path=None) if self._session_fs_config: if create_session_fs_handler is None: @@ -1524,17 +1581,51 @@ async def create_session( session.on(on_event) with self._sessions_lock: self._sessions[actual_session_id] = session + log_timing( + logger, + logging.DEBUG, + "CopilotClient.create_session local setup complete", + setup_start, + session_id=actual_session_id, + tools_count=len(tools or []), + commands_count=len(commands or []), + has_hooks=hooks is not None, + ) try: + rpc_start = time.perf_counter() response = await self._client.request("session.create", payload) + log_timing( + logger, + logging.DEBUG, + "CopilotClient.create_session session creation request completed successfully", + rpc_start, + session_id=actual_session_id, + ) session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) - except BaseException: + except BaseException as exc: with self._sessions_lock: self._sessions.pop(actual_session_id, None) + if not isinstance(exc, asyncio.CancelledError): + log_timing( + logger, + logging.WARNING, + "CopilotClient.create_session failed", + total_start, + exc_info=True, + session_id=actual_session_id, + ) raise + log_timing( + logger, + logging.DEBUG, + "CopilotClient.create_session complete", + total_start, + session_id=actual_session_id, + ) return session async def resume_session( @@ -1782,12 +1873,14 @@ async def resume_session( if not self._client: raise RuntimeError("Client not connected") + total_start = time.perf_counter() # Propagate W3C Trace Context to CLI if OpenTelemetry is active trace_ctx = get_trace_context() payload.update(trace_ctx) # Create and register the session before issuing the RPC so that # events emitted by the CLI (e.g. session.start) are not dropped. + setup_start = time.perf_counter() session = CopilotSession(session_id, self._client, workspace_path=None) if self._session_fs_config: if create_session_fs_handler is None: @@ -1813,17 +1906,51 @@ async def resume_session( session.on(on_event) with self._sessions_lock: self._sessions[session_id] = session + log_timing( + logger, + logging.DEBUG, + "CopilotClient.resume_session local setup complete", + setup_start, + session_id=session_id, + tools_count=len(tools or []), + commands_count=len(commands or []), + has_hooks=hooks is not None, + ) try: + rpc_start = time.perf_counter() response = await self._client.request("session.resume", payload) + log_timing( + logger, + logging.DEBUG, + "CopilotClient.resume_session session resume request completed successfully", + rpc_start, + session_id=session_id, + ) session._workspace_path = response.get("workspacePath") capabilities = response.get("capabilities") session._set_capabilities(capabilities) - except BaseException: + except BaseException as exc: with self._sessions_lock: self._sessions.pop(session_id, None) + if not isinstance(exc, asyncio.CancelledError): + log_timing( + logger, + logging.WARNING, + "CopilotClient.resume_session failed", + total_start, + exc_info=True, + session_id=session_id, + ) raise + log_timing( + logger, + logging.DEBUG, + "CopilotClient.resume_session complete", + total_start, + session_id=session_id, + ) return session def get_state(self) -> ConnectionState: @@ -2225,6 +2352,8 @@ async def _verify_protocol_version(self) -> None: that don't implement ``connect``.""" if not self._client: raise RuntimeError("Client not connected") + handshake_start = time.perf_counter() + used_fallback_ping = False max_version = get_sdk_protocol_version() server_version: int | None @@ -2237,6 +2366,7 @@ async def _verify_protocol_version(self) -> None: if err.code == -32601 or err.message == "Unhandled method connect": # Legacy server without `connect`; fall back to `ping`. A token, if any, # is silently dropped — the legacy server can't enforce one. + used_fallback_ping = True ping_result = await self.ping() server_version = ping_result.protocolVersion else: @@ -2259,6 +2389,14 @@ async def _verify_protocol_version(self) -> None: ) self._negotiated_protocol_version = server_version + log_timing( + logger, + logging.DEBUG, + "CopilotClient._verify_protocol_version protocol handshake complete", + handshake_start, + protocol_version=server_version, + used_fallback_ping=used_fallback_ping, + ) def _convert_provider_to_wire_format( self, provider: ProviderConfig | dict[str, Any] @@ -2392,6 +2530,16 @@ async def _start_cli_server(self) -> None: args = ["node", cli_path] + args else: args = [cli_path] + args + logger.info( + "CopilotClient._start_cli_server starting Copilot CLI", + extra={ + "cli_path": cli_path, + "executable": args[0], + "cli_path_source": self._cli_path_source, + "use_stdio": cfg.use_stdio, + "port": None if cfg.use_stdio else cfg.port, + }, + ) # Get environment variables if cfg.env is None: @@ -2431,6 +2579,7 @@ async def _start_cli_server(self) -> None: cwd = cfg.cwd or os.getcwd() # Choose transport mode + spawn_start = time.perf_counter() if cfg.use_stdio: args.append("--stdio") # Use regular Popen with pipes (buffering=0 for unbuffered) @@ -2456,6 +2605,12 @@ async def _start_cli_server(self) -> None: env=env, creationflags=creationflags, ) + log_timing( + logger, + logging.DEBUG, + "CopilotClient._start_cli_server subprocess spawned", + spawn_start, + ) # For stdio mode, we're ready immediately if cfg.use_stdio: @@ -2474,13 +2629,22 @@ async def read_port(): raise RuntimeError("CLI process exited before announcing port") line_str = line.decode() if isinstance(line, bytes) else line + logger.debug("[CLI] %s", line_str.rstrip()) match = re.search(r"listening on port (\d+)", line_str, re.IGNORECASE) if match: self._actual_port = int(match.group(1)) return try: + port_wait_start = time.perf_counter() await asyncio.wait_for(read_port(), timeout=10.0) + log_timing( + logger, + logging.DEBUG, + "CopilotClient._start_cli_server TCP port wait complete", + port_wait_start, + port=self._actual_port, + ) except TimeoutError: raise RuntimeError("Timeout waiting for CLI server to start") @@ -2493,11 +2657,18 @@ async def _connect_to_server(self) -> None: Raises: RuntimeError: If the connection fails. """ + setup_start = time.perf_counter() use_stdio = isinstance(self._config, SubprocessConfig) and self._config.use_stdio if use_stdio: await self._connect_via_stdio() else: await self._connect_via_tcp() + log_timing( + logger, + logging.DEBUG, + "CopilotClient._connect_to_server transport setup complete", + setup_start, + ) async def _connect_via_stdio(self) -> None: """ @@ -2573,8 +2744,21 @@ async def _connect_via_tcp(self) -> None: sock.settimeout(TCP_CONNECTION_TIMEOUT) try: + tcp_connect_start = time.perf_counter() + logger.info( + "CopilotClient._connect_via_tcp connecting to CLI server", + extra={"host": self._actual_host, "port": self._actual_port}, + ) sock.connect((self._actual_host, self._actual_port)) sock.settimeout(None) # Remove timeout after connection + log_timing( + logger, + logging.DEBUG, + "CopilotClient._connect_via_tcp TCP connect complete", + tcp_connect_start, + host=self._actual_host, + port=self._actual_port, + ) except OSError as e: raise RuntimeError( f"Failed to connect to CLI server at {self._actual_host}:{self._actual_port}: {e}" @@ -2790,9 +2974,19 @@ async def _handle_tool_call_request_v2(self, params: dict) -> dict: try: with trace_context(tp, ts): + handler_start = time.perf_counter() result = handler(invocation) if inspect.isawaitable(result): result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotClient._handle_tool_call_request_v2 tool dispatch", + handler_start, + session_id=session_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + ) tool_result: ToolResult = result # type: ignore[assignment] return { diff --git a/python/copilot/session.py b/python/copilot/session.py index 97a505c25..8a8021e19 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -11,14 +11,17 @@ import asyncio import functools import inspect +import logging import os import pathlib import threading +import time from collections.abc import Awaitable, Callable from dataclasses import dataclass from types import TracebackType from typing import TYPE_CHECKING, Any, Literal, NotRequired, Required, TypedDict, cast +from ._diagnostics import log_timing from ._jsonrpc import JsonRpcError, ProcessExitedError from ._telemetry import get_trace_context, trace_context from .generated.rpc import ( @@ -58,6 +61,9 @@ ) from .tools import Tool, ToolHandler, ToolInvocation, ToolResult +logger = logging.getLogger(__name__) + + if TYPE_CHECKING: from .client import ModelCapabilitiesOverride from .session_fs_provider import SessionFsProvider @@ -1169,8 +1175,18 @@ async def send( params["requestHeaders"] = request_headers params.update(get_trace_context()) + rpc_start = time.perf_counter() response = await self._client.request("session.send", params) - return response["messageId"] + message_id = response["messageId"] + log_timing( + logger, + logging.DEBUG, + "CopilotSession.send completed successfully", + rpc_start, + session_id=self.session_id, + message_id=message_id, + ) + return message_id async def send_and_wait( self, @@ -1213,16 +1229,34 @@ async def send_and_wait( ... case AssistantMessageData() as data: ... print(data.content) """ + total_start = time.perf_counter() idle_event = asyncio.Event() error_event: Exception | None = None last_assistant_message: SessionEvent | None = None + first_assistant_message_logged = False def handler(event: SessionEventTypeAlias) -> None: - nonlocal last_assistant_message, error_event + nonlocal first_assistant_message_logged, last_assistant_message, error_event match event.data: case AssistantMessageData(): last_assistant_message = event + if not first_assistant_message_logged: + first_assistant_message_logged = True + log_timing( + logger, + logging.DEBUG, + "CopilotSession.send_and_wait first assistant message", + total_start, + session_id=self.session_id, + ) case SessionIdleData(): + log_timing( + logger, + logging.DEBUG, + "CopilotSession.send_and_wait idle received", + total_start, + session_id=self.session_id, + ) idle_event.set() case SessionErrorData() as data: error_event = Exception(f"Session error: {data.message or str(data)}") @@ -1238,9 +1272,34 @@ def handler(event: SessionEventTypeAlias) -> None: ) await asyncio.wait_for(idle_event.wait(), timeout=timeout) if error_event: + log_timing( + logger, + logging.WARNING, + "CopilotSession.send_and_wait failed", + total_start, + session_id=self.session_id, + completed_by="error", + ) raise error_event + log_timing( + logger, + logging.DEBUG, + "CopilotSession.send_and_wait complete", + total_start, + session_id=self.session_id, + completed_by="idle", + assistant_message_received=last_assistant_message is not None, + ) return last_assistant_message except TimeoutError: + log_timing( + logger, + logging.WARNING, + "CopilotSession.send_and_wait failed", + total_start, + session_id=self.session_id, + completed_by="timeout", + ) raise TimeoutError(f"Timeout after {timeout}s waiting for session.idle") finally: unsubscribe() @@ -1294,6 +1353,7 @@ def _dispatch_event(self, event: SessionEvent) -> None: Args: event: The session event to dispatch to all handlers. """ + dispatch_start = time.perf_counter() # Handle broadcast request events (protocol v3) before dispatching to user handlers. # Fire-and-forget: the response is sent asynchronously via RPC. self._handle_broadcast_event(event) @@ -1304,8 +1364,16 @@ def _dispatch_event(self, event: SessionEvent) -> None: for handler in handlers: try: handler(event) - except Exception as e: - print(f"Error in session event handler: {e}") + except Exception: + logger.error("Unhandled exception in session event handler", exc_info=True) + log_timing( + logger, + logging.DEBUG, + "CopilotSession._dispatch_event dispatch", + dispatch_start, + session_id=self.session_id, + event_type=event.type, + ) def _handle_broadcast_event(self, event: SessionEvent) -> None: """Handle broadcast request events by executing local handlers and responding via RPC. @@ -1335,6 +1403,14 @@ def _handle_broadcast_event(self, event: SessionEvent) -> None: ) case PermissionRequestedData() as data: + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "CopilotSession._dispatch_event permission request received", + extra={ + "session_id": self.session_id, + "event_type": event.type.value, + }, + ) request_id = data.request_id permission_request = data.permission_request if not request_id or not permission_request: @@ -1419,9 +1495,20 @@ async def _execute_tool_and_respond( ) with trace_context(traceparent, tracestate): + handler_start = time.perf_counter() result = handler(invocation) if inspect.isawaitable(result): result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_tool_and_respond tool dispatch", + handler_start, + session_id=self.session_id, + request_id=request_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + ) tool_result: ToolResult if result is None: @@ -1439,13 +1526,25 @@ async def _execute_tool_and_respond( # standard "Failed to execute..." message. Deliberate user-returned # failures send the full structured result to preserve metadata. if tool_result._from_exception: + rpc_start = time.perf_counter() await self.rpc.tools.handle_pending_tool_call( HandlePendingToolCallRequest( request_id=request_id, error=tool_result.error, ) ) + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_tool_and_respond response sent successfully", + rpc_start, + session_id=self.session_id, + request_id=request_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + ) else: + rpc_start = time.perf_counter() await self.rpc.tools.handle_pending_tool_call( HandlePendingToolCallRequest( request_id=request_id, @@ -1457,6 +1556,16 @@ async def _execute_tool_and_respond( ), ) ) + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_tool_and_respond response sent successfully", + rpc_start, + session_id=self.session_id, + request_id=request_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + ) except Exception as exc: try: await self.rpc.tools.handle_pending_tool_call( @@ -1476,9 +1585,18 @@ async def _execute_permission_and_respond( ) -> None: """Execute a permission handler and respond via RPC.""" try: + handler_start = time.perf_counter() result = handler(permission_request, {"session_id": self.session_id}) if inspect.isawaitable(result): result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_permission_and_respond dispatch", + handler_start, + session_id=self.session_id, + request_id=request_id, + ) result = cast(PermissionRequestResult, result) if result.kind == "no-result": @@ -1488,12 +1606,21 @@ async def _execute_permission_and_respond( kind=PermissionDecisionKind(result.kind), ) + rpc_start = time.perf_counter() await self.rpc.permissions.handle_pending_permission_request( PermissionDecisionRequest( request_id=request_id, result=perm_result, ) ) + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_permission_and_respond response sent successfully", + rpc_start, + session_id=self.session_id, + request_id=request_id, + ) except Exception: try: await self.rpc.permissions.handle_pending_permission_request( @@ -1537,12 +1664,32 @@ async def _execute_command_and_respond( command_name=command_name, args=args, ) + handler_start = time.perf_counter() result = handler(ctx) if inspect.isawaitable(result): await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_command_and_respond dispatch", + handler_start, + session_id=self.session_id, + request_id=request_id, + command_name=command_name, + ) + rpc_start = time.perf_counter() await self.rpc.commands.handle_pending_command( CommandsHandlePendingCommandRequest(request_id=request_id) ) + log_timing( + logger, + logging.DEBUG, + "CopilotSession._execute_command_and_respond response sent successfully", + rpc_start, + session_id=self.session_id, + request_id=request_id, + command_name=command_name, + ) except Exception as exc: message = str(exc) try: @@ -1570,21 +1717,39 @@ async def _handle_elicitation_request( if not handler: return try: + handler_start = time.perf_counter() result = handler(context) if inspect.isawaitable(result): result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_elicitation_request dispatch", + handler_start, + session_id=self.session_id, + request_id=request_id, + ) result = cast(ElicitationResult, result) action_val = result.get("action", "cancel") rpc_result = UIElicitationResponse( action=UIElicitationResponseAction(action_val), content=result.get("content"), ) + rpc_start = time.perf_counter() await self.rpc.ui.handle_pending_elicitation( UIHandlePendingElicitationRequest( request_id=request_id, result=rpc_result, ) ) + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_elicitation_request response sent successfully", + rpc_start, + session_id=self.session_id, + request_id=request_id, + ) except Exception: # Handler failed — attempt to cancel so the request doesn't hang try: @@ -1720,12 +1885,25 @@ async def _handle_permission_request( return PermissionRequestResult() try: + handler_start = time.perf_counter() result = handler(request, {"session_id": self.session_id}) if inspect.isawaitable(result): result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_permission_request dispatch", + handler_start, + session_id=self.session_id, + ) return cast(PermissionRequestResult, result) except Exception: # pylint: disable=broad-except # Handler failed, deny permission + logger.debug( + "Error handling permission request", + extra={"session_id": self.session_id}, + exc_info=True, + ) return PermissionRequestResult() def _register_user_input_handler(self, handler: UserInputHandler | None) -> None: @@ -1765,6 +1943,7 @@ async def _handle_user_input_request(self, request: dict) -> UserInputResponse: raise RuntimeError("User input requested but no handler registered") try: + handler_start = time.perf_counter() result = handler( UserInputRequest( question=request.get("question", ""), @@ -1775,6 +1954,13 @@ async def _handle_user_input_request(self, request: dict) -> UserInputResponse: ) if inspect.isawaitable(result): result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_user_input_request dispatch", + handler_start, + session_id=self.session_id, + ) return cast(UserInputResponse, result) except Exception: raise @@ -1807,6 +1993,7 @@ async def _handle_system_message_transform( self, sections: dict[str, dict[str, str]] ) -> dict[str, dict[str, dict[str, str]]]: """Handle a systemMessage.transform request from the runtime.""" + transform_start = time.perf_counter() with self._transform_callbacks_lock: callbacks = self._transform_callbacks @@ -1824,6 +2011,13 @@ async def _handle_system_message_transform( result[section_id] = {"content": content} else: result[section_id] = {"content": content} + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_system_message_transform dispatch", + transform_start, + session_id=self.session_id, + ) return {"sections": result} async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: @@ -1860,12 +2054,26 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: return None try: + handler_start = time.perf_counter() result = handler(input_data, {"session_id": self.session_id}) if inspect.isawaitable(result): result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_hooks_invoke dispatch", + handler_start, + session_id=self.session_id, + hook_type=hook_type, + ) return result except Exception: # pylint: disable=broad-except # Hook failed, return None + logger.warning( + "Hook handler failed", + extra={"session_id": self.session_id, "hook_type": hook_type}, + exc_info=True, + ) return None async def get_messages(self) -> list[SessionEvent]: diff --git a/rust/src/hooks.rs b/rust/src/hooks.rs index ca755c6f9..f8a92ebc1 100644 --- a/rust/src/hooks.rs +++ b/rust/src/hooks.rs @@ -6,6 +6,7 @@ //! [`Client::create_session`](crate::Client::create_session). use std::path::PathBuf; +use std::time::Instant; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -466,7 +467,14 @@ pub(crate) async fn dispatch_hook( } }; + let dispatch_start = Instant::now(); let output = hooks.on_hook(event).await; + tracing::debug!( + elapsed_ms = dispatch_start.elapsed().as_millis(), + session_id = %session_id, + hook_type = hook_type, + "SessionHooks::on_hook dispatch" + ); // Validate that the output variant matches the dispatched hook type. // A mismatched return (e.g. HookOutput::SessionEnd for a preToolUse diff --git a/rust/src/jsonrpc.rs b/rust/src/jsonrpc.rs index 5f6d95612..f0b0d6cc0 100644 --- a/rust/src/jsonrpc.rs +++ b/rust/src/jsonrpc.rs @@ -1,13 +1,14 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::sync::{broadcast, mpsc, oneshot}; -use tracing::{Instrument, error, warn}; +use tracing::{Instrument, debug, error, warn}; use crate::{Error, ProtocolError}; @@ -368,6 +369,7 @@ impl JsonRpcClient { method: &str, params: Option, ) -> Result { + let request_start = Instant::now(); let id = self.request_id.fetch_add(1, Ordering::SeqCst); let request = JsonRpcRequest::new(id, method, params); @@ -387,12 +389,53 @@ impl JsonRpcClient { // The PendingGuard's drop removes the entry on every error path // and on cancellation; disarmed below before the success return so // the read loop owns the cleanup on the happy path. - self.write(&request).await?; + if let Err(error) = self.write(&request).await { + warn!( + elapsed_ms = request_start.elapsed().as_millis(), + method = %method, + request_id = id, + status = "failed", + error = %error, + "JsonRpcClient::send_request JSON-RPC request finished" + ); + return Err(error); + } - let response = rx - .await - .map_err(|_| Error::Protocol(ProtocolError::RequestCancelled))?; + let response = match rx.await { + Ok(response) => response, + Err(_) => { + let error = Error::Protocol(ProtocolError::RequestCancelled); + warn!( + elapsed_ms = request_start.elapsed().as_millis(), + method = %method, + request_id = id, + status = "failed", + error = %error, + "JsonRpcClient::send_request JSON-RPC request finished" + ); + return Err(error); + } + }; guard.disarm(); + if let Some(error) = &response.error { + warn!( + elapsed_ms = request_start.elapsed().as_millis(), + method = %method, + request_id = id, + status = "failed", + code = error.code, + error = %error.message, + "JsonRpcClient::send_request JSON-RPC request finished" + ); + } else { + debug!( + elapsed_ms = request_start.elapsed().as_millis(), + method = %method, + request_id = id, + status = "succeeded", + "JsonRpcClient::send_request JSON-RPC request finished" + ); + } Ok(response) } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7c3d2422b..1af468182 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -38,6 +38,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, OnceLock}; +use std::time::Instant; use async_trait::async_trait; // JSON-RPC wire types are internal transport details (like Go SDK's internal/jsonrpc2/). @@ -895,6 +896,7 @@ impl Client { /// `sessionFs.setProvider` to register the SDK as the filesystem /// backend. pub async fn start(options: ClientOptions) -> Result { + let start_time = Instant::now(); if let Some(cfg) = &options.session_fs { validate_session_fs_config(cfg)?; } @@ -960,7 +962,14 @@ impl Client { let client = match options.transport { Transport::External { ref host, port } => { info!(host = %host, port = %port, "connecting to external CLI server"); + let connect_start = Instant::now(); let stream = TcpStream::connect((host.as_str(), port)).await?; + debug!( + elapsed_ms = connect_start.elapsed().as_millis(), + host = %host, + port, + "Client::start TCP connect complete" + ); let (reader, writer) = tokio::io::split(stream); Self::from_transport( reader, @@ -975,7 +984,13 @@ impl Client { } Transport::Tcp { port } => { let (mut child, actual_port) = Self::spawn_tcp(&program, &options, port).await?; + let connect_start = Instant::now(); let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?; + debug!( + elapsed_ms = connect_start.elapsed().as_millis(), + port = actual_port, + "Client::start TCP connect complete" + ); let (reader, writer) = tokio::io::split(stream); Self::drain_stderr(&mut child); Self::from_transport( @@ -1007,15 +1022,32 @@ impl Client { } }; + debug!( + elapsed_ms = start_time.elapsed().as_millis(), + "Client::start transport setup complete" + ); client.verify_protocol_version().await?; + debug!( + elapsed_ms = start_time.elapsed().as_millis(), + "Client::start protocol verification complete" + ); if let Some(cfg) = session_fs_config { + let session_fs_start = Instant::now(); let request = crate::generated::api_types::SessionFsSetProviderRequest { conventions: cfg.conventions.into_wire(), initial_cwd: cfg.initial_cwd, session_state_path: cfg.session_state_path, }; client.rpc().session_fs().set_provider(request).await?; + debug!( + elapsed_ms = session_fs_start.elapsed().as_millis(), + "Client::start session filesystem setup complete" + ); } + debug!( + elapsed_ms = start_time.elapsed().as_millis(), + "Client::start complete" + ); Ok(client) } @@ -1081,6 +1113,7 @@ impl Client { on_get_trace_context: Option>, effective_connection_token: Option, ) -> Result { + let setup_start = Instant::now(); let (request_tx, request_rx) = mpsc::unbounded_channel::(); let (notification_broadcast_tx, _) = broadcast::channel::(1024); let rpc = JsonRpcClient::new( @@ -1111,6 +1144,11 @@ impl Client { }), }; client.spawn_lifecycle_dispatcher(); + debug!( + elapsed_ms = setup_start.elapsed().as_millis(), + pid = ?pid, + "Client::from_transport setup complete" + ); Ok(client) } @@ -1273,7 +1311,13 @@ impl Client { .args(Self::remote_args(options)) .args(&options.extra_args) .stdin(Stdio::piped()); - Ok(command.spawn()?) + let spawn_start = Instant::now(); + let child = command.spawn()?; + debug!( + elapsed_ms = spawn_start.elapsed().as_millis(), + "Client::spawn_stdio subprocess spawned" + ); + Ok(child) } async fn spawn_tcp( @@ -1298,7 +1342,12 @@ impl Client { .args(Self::remote_args(options)) .args(&options.extra_args) .stdin(Stdio::null()); + let spawn_start = Instant::now(); let mut child = command.spawn()?; + debug!( + elapsed_ms = spawn_start.elapsed().as_millis(), + "Client::spawn_tcp subprocess spawned" + ); let stdout = child.stdout.take().expect("stdout is piped"); let (port_tx, port_rx) = oneshot::channel::(); @@ -1327,11 +1376,17 @@ impl Client { .instrument(span), ); + let port_wait_start = Instant::now(); let actual_port = tokio::time::timeout(std::time::Duration::from_secs(10), port_rx) .await .map_err(|_| Error::Protocol(ProtocolError::CliStartupTimeout))? .map_err(|_| Error::Protocol(ProtocolError::CliStartupFailed))?; + debug!( + elapsed_ms = port_wait_start.elapsed().as_millis(), + port = actual_port, + "Client::spawn_tcp TCP port wait complete" + ); info!(port = %actual_port, "CLI server listening"); Ok((child, actual_port)) } @@ -1492,13 +1547,15 @@ impl Client { /// `MIN_PROTOCOL_VERSION`..=[`SDK_PROTOCOL_VERSION`]. If the server /// doesn't report a version, logs a warning and succeeds. pub async fn verify_protocol_version(&self) -> Result<(), Error> { + let handshake_start = Instant::now(); + let mut used_fallback_ping = false; // Try the new `connect` handshake first (sends the connection // token, if any). Fall back to `ping` for legacy CLI servers - // that don't expose `connect` (-32601 MethodNotFound). Matches - // the Node SDK's verify-version sequence. + // that don't expose `connect` (-32601 MethodNotFound). let server_version = match self.connect_handshake().await { Ok(v) => v, Err(Error::Rpc { code, .. }) if code == error_codes::METHOD_NOT_FOUND => { + used_fallback_ping = true; self.ping(None).await?.protocol_version } Err(e) => return Err(e), @@ -1529,6 +1586,12 @@ impl Client { } } + debug!( + elapsed_ms = handshake_start.elapsed().as_millis(), + protocol_version = ?server_version, + used_fallback_ping, + "Client::verify_protocol_version protocol handshake complete" + ); Ok(()) } diff --git a/rust/src/session.rs b/rust/src/session.rs index 1fc13c3e7..8800ffb98 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use parking_lot::Mutex as ParkingLotMutex; use serde_json::Value; @@ -38,6 +38,8 @@ use crate::{Client, Error, JsonRpcResponse, SessionError, SessionEventNotificati struct IdleWaiter { tx: oneshot::Sender, Error>>, last_assistant_message: Option, + started_at: Instant, + first_assistant_message_seen: bool, } /// RAII guard that clears the [`Session::idle_waiter`] slot on drop. Used @@ -308,12 +310,19 @@ impl Session { self.client.resolve_trace_context().await }; inject_trace_context(&mut params, &trace_ctx); + let rpc_start = Instant::now(); let result = self.client.call("session.send", Some(params)).await?; let message_id = result .get("messageId") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_default(); + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %self.id, + message_id = %message_id, + "Session::send completed successfully" + ); Ok(message_id) } @@ -340,6 +349,7 @@ impl Session { &self, opts: impl Into, ) -> Result, Error> { + let total_start = Instant::now(); let opts = opts.into(); let timeout_duration = opts.wait_timeout.unwrap_or(Duration::from_secs(60)); let (tx, rx) = oneshot::channel(); @@ -352,6 +362,8 @@ impl Session { *guard = Some(IdleWaiter { tx, last_assistant_message: None, + started_at: total_start, + first_assistant_message_seen: false, }); } @@ -373,8 +385,24 @@ impl Session { .await; match result { - Ok(inner) => inner, - Err(_) => Err(Error::Session(SessionError::Timeout(timeout_duration))), + Ok(inner) => { + tracing::debug!( + elapsed_ms = total_start.elapsed().as_millis(), + session_id = %self.id, + completed_by = if inner.is_ok() { "idle" } else { "error" }, + "Session::send_and_wait complete" + ); + inner + } + Err(_) => { + tracing::warn!( + elapsed_ms = total_start.elapsed().as_millis(), + session_id = %self.id, + completed_by = "timeout", + "Session::send_and_wait failed" + ); + Err(Error::Session(SessionError::Timeout(timeout_duration))) + } } } @@ -685,12 +713,16 @@ impl Client { /// [`DenyAllHandler`](crate::handler::DenyAllHandler) — permission /// requests are denied; other events are no-ops. pub async fn create_session(&self, mut config: SessionConfig) -> Result { + let total_start = Instant::now(); let handler = config .handler .take() .unwrap_or_else(|| Arc::new(crate::handler::DenyAllHandler)); let hooks = config.hooks_handler.take(); let transforms = config.transform.take(); + let tools_count = config.tools.as_ref().map_or(0, Vec::len); + let commands_count = config.commands.as_ref().map_or(0, Vec::len); + let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(config.commands.as_deref()); let session_fs_provider = config.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { @@ -706,10 +738,16 @@ impl Client { let mut params = serde_json::to_value(&config)?; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); + let rpc_start = Instant::now(); let result = self.call("session.create", Some(params)).await?; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + "Client::create_session session creation request completed successfully" + ); let create_result: CreateSessionResult = serde_json::from_value(result)?; let session_id = create_result.session_id; + let setup_start = Instant::now(); let capabilities = Arc::new(parking_lot::RwLock::new( create_result.capabilities.unwrap_or_default(), )); @@ -732,7 +770,20 @@ impl Client { event_tx.clone(), shutdown.clone(), ); + tracing::debug!( + elapsed_ms = setup_start.elapsed().as_millis(), + session_id = %session_id, + tools_count, + commands_count, + has_hooks, + "Client::create_session local setup complete" + ); + tracing::debug!( + elapsed_ms = total_start.elapsed().as_millis(), + session_id = %session_id, + "Client::create_session complete" + ); Ok(Session { id: session_id, cwd: self.cwd().clone(), @@ -758,12 +809,16 @@ impl Client { /// See [`Self::create_session`] for the defaults applied when callback /// fields are unset. pub async fn resume_session(&self, mut config: ResumeSessionConfig) -> Result { + let total_start = Instant::now(); let handler = config .handler .take() .unwrap_or_else(|| Arc::new(crate::handler::DenyAllHandler)); let hooks = config.hooks_handler.take(); let transforms = config.transform.take(); + let tools_count = config.tools.as_ref().map_or(0, Vec::len); + let commands_count = config.commands.as_ref().map_or(0, Vec::len); + let has_hooks = hooks.is_some(); let command_handlers = build_command_handler_map(config.commands.as_deref()); let session_fs_provider = config.session_fs_provider.take(); if self.inner.session_fs_configured && session_fs_provider.is_none() { @@ -780,7 +835,13 @@ impl Client { let mut params = serde_json::to_value(&config)?; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); + let rpc_start = Instant::now(); let result = self.call("session.resume", Some(params)).await?; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %session_id, + "Client::resume_session session resume request completed successfully" + ); // The CLI may reassign the session ID on resume. let cli_session_id: SessionId = result @@ -803,6 +864,7 @@ impl Client { .map(ToString::to_string); // Reload skills after resume (best-effort). + let skills_reload_start = Instant::now(); if let Err(e) = self .call( "session.skills.reload", @@ -810,12 +872,24 @@ impl Client { ) .await { - warn!(error = %e, "failed to reload skills after resume"); + warn!( + elapsed_ms = skills_reload_start.elapsed().as_millis(), + session_id = %cli_session_id, + error = %e, + "Client::resume_session skills reload request failed" + ); + } else { + tracing::debug!( + elapsed_ms = skills_reload_start.elapsed().as_millis(), + session_id = %cli_session_id, + "Client::resume_session skills reload request completed successfully" + ); } let capabilities = Arc::new(parking_lot::RwLock::new( resume_capabilities.unwrap_or_default(), )); + let setup_start = Instant::now(); let channels = self.register_session(&cli_session_id); let idle_waiter = Arc::new(ParkingLotMutex::new(None)); @@ -835,7 +909,20 @@ impl Client { event_tx.clone(), shutdown.clone(), ); + tracing::debug!( + elapsed_ms = setup_start.elapsed().as_millis(), + session_id = %cli_session_id, + tools_count, + commands_count, + has_hooks, + "Client::resume_session local setup complete" + ); + tracing::debug!( + elapsed_ms = total_start.elapsed().as_millis(), + session_id = %cli_session_id, + "Client::resume_session complete" + ); Ok(Session { id: cli_session_id, cwd: self.cwd().clone(), @@ -1009,8 +1096,16 @@ async fn handle_notification( capabilities: &Arc>, event_tx: &tokio::sync::broadcast::Sender, ) { + let dispatch_start = Instant::now(); let event = notification.event.clone(); let event_type = event.parsed_type(); + if event_type == SessionEventType::PermissionRequested { + tracing::debug!( + session_id = %session_id, + event_type = %event.event_type, + "Session::handle_notification permission request received" + ); + } // Signal send_and_wait if active. The lock is only contended when // a send_and_wait call is in flight (idle_waiter is Some). @@ -1022,11 +1117,24 @@ async fn handle_notification( if let Some(waiter) = guard.as_mut() { match event_type { SessionEventType::AssistantMessage => { + if !waiter.first_assistant_message_seen { + waiter.first_assistant_message_seen = true; + tracing::debug!( + elapsed_ms = waiter.started_at.elapsed().as_millis(), + session_id = %session_id, + "Session::send_and_wait first assistant message" + ); + } waiter.last_assistant_message = Some(event.clone()); } SessionEventType::SessionIdle | SessionEventType::SessionError => { if let Some(waiter) = guard.take() { if event_type == SessionEventType::SessionIdle { + tracing::debug!( + elapsed_ms = waiter.started_at.elapsed().as_millis(), + session_id = %session_id, + "Session::send_and_wait idle received" + ); let _ = waiter.tx.send(Ok(waiter.last_assistant_message)); } else { let error_msg = event @@ -1076,6 +1184,13 @@ async fn handle_notification( } } + tracing::debug!( + elapsed_ms = dispatch_start.elapsed().as_millis(), + session_id = %session_id, + event_type = %notification.event.event_type, + "Session::handle_notification dispatch" + ); + // Notification-based permission/tool/elicitation requests require a // separate RPC callback. Spawn concurrently since the CLI doesn't block. match event_type { @@ -1094,30 +1209,52 @@ async fn handle_notification( extra: notification.event.data.clone(), } }); - tokio::spawn(async move { - let response = handler - .on_event(HandlerEvent::PermissionRequest { - session_id: sid.clone(), - request_id: request_id.clone(), - data, - }) - .await; - let Some(result_value) = notification_permission_payload(&response) else { - // Handler returned Deferred — it will call - // handlePendingPermissionRequest itself. - return; - }; - let _ = client - .call( - "session.permissions.handlePendingPermissionRequest", - Some(serde_json::json!({ - "sessionId": sid, - "requestId": request_id, - "result": result_value, - })), - ) - .await; - }); + let span = tracing::error_span!( + "permission_request_handler", + session_id = %sid, + request_id = %request_id + ); + tokio::spawn( + async move { + let handler_start = Instant::now(); + let response = handler + .on_event(HandlerEvent::PermissionRequest { + session_id: sid.clone(), + request_id: request_id.clone(), + data, + }) + .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "SessionHandler::on_permission_request dispatch" + ); + let Some(result_value) = notification_permission_payload(&response) else { + // Handler returned Deferred — it will call + // handlePendingPermissionRequest itself. + return; + }; + let rpc_start = Instant::now(); + let _ = client + .call( + "session.permissions.handlePendingPermissionRequest", + Some(serde_json::json!({ + "sessionId": sid, + "requestId": request_id, + "result": result_value, + })), + ) + .await; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "Session::handle_notification response sent successfully" + ); + } + .instrument(span), + ); } SessionEventType::ExternalToolRequested => { let Some(request_id) = extract_request_id(¬ification.event.data) else { @@ -1130,8 +1267,15 @@ async fn handle_notification( warn!(error = %e, "failed to deserialize external_tool.requested"); let client = client.clone(); let sid = session_id.clone(); - tokio::spawn(async move { - let _ = client + let span = tracing::error_span!( + "external_tool_deserialize_error", + session_id = %sid, + request_id = %request_id + ); + tokio::spawn( + async move { + let rpc_start = Instant::now(); + let _ = client .call( "session.tools.handlePendingToolCall", Some(serde_json::json!({ @@ -1141,61 +1285,104 @@ async fn handle_notification( })), ) .await; - }); + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "Session::handle_notification response sent successfully" + ); + } + .instrument(span), + ); return; } }; let client = client.clone(); let handler = handler.clone(); let sid = session_id.clone(); - tokio::spawn(async move { - if data.tool_call_id.is_empty() || data.tool_name.is_empty() { - let error_msg = if data.tool_call_id.is_empty() { - "Missing toolCallId" - } else { - "Missing toolName" + let span = tracing::error_span!( + "external_tool_handler", + session_id = %sid, + request_id = %request_id + ); + tokio::spawn( + async move { + if data.tool_call_id.is_empty() || data.tool_name.is_empty() { + let error_msg = if data.tool_call_id.is_empty() { + "Missing toolCallId" + } else { + "Missing toolName" + }; + let rpc_start = Instant::now(); + let _ = client + .call( + "session.tools.handlePendingToolCall", + Some(serde_json::json!({ + "sessionId": sid, + "requestId": request_id, + "error": error_msg, + })), + ) + .await; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "Session::handle_notification response sent successfully" + ); + return; + } + let tool_call_id = data.tool_call_id.clone(); + let tool_name = data.tool_name.clone(); + let invocation = ToolInvocation { + session_id: sid.clone(), + tool_call_id: data.tool_call_id, + tool_name: data.tool_name, + arguments: data + .arguments + .unwrap_or(Value::Object(serde_json::Map::new())), + traceparent: data.traceparent, + tracestate: data.tracestate, + }; + let handler_start = Instant::now(); + let response = handler + .on_event(HandlerEvent::ExternalTool { invocation }) + .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + tool_call_id = %tool_call_id, + tool_name = %tool_name, + "ToolHandler::call dispatch" + ); + let tool_result = match response { + HandlerResponse::ToolResult(r) => r, + _ => ToolResult::Text("Unexpected handler response".to_string()), }; + let result_value = serde_json::to_value(&tool_result).unwrap_or(Value::Null); + let rpc_start = Instant::now(); let _ = client .call( "session.tools.handlePendingToolCall", Some(serde_json::json!({ "sessionId": sid, "requestId": request_id, - "error": error_msg, + "result": result_value, })), ) .await; - return; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + tool_call_id = %tool_call_id, + tool_name = %tool_name, + "Session::handle_notification response sent successfully" + ); } - let invocation = ToolInvocation { - session_id: sid.clone(), - tool_call_id: data.tool_call_id, - tool_name: data.tool_name, - arguments: data - .arguments - .unwrap_or(Value::Object(serde_json::Map::new())), - traceparent: data.traceparent, - tracestate: data.tracestate, - }; - let response = handler - .on_event(HandlerEvent::ExternalTool { invocation }) - .await; - let tool_result = match response { - HandlerResponse::ToolResult(r) => r, - _ => ToolResult::Text("Unexpected handler response".to_string()), - }; - let result_value = serde_json::to_value(&tool_result).unwrap_or(Value::Null); - let _ = client - .call( - "session.tools.handlePendingToolCall", - Some(serde_json::json!({ - "sessionId": sid, - "requestId": request_id, - "result": result_value, - })), - ) - .await; - }); + .instrument(span), + ); } SessionEventType::UserInputRequested => { // Notification-only signal for observers (UI, telemetry). @@ -1237,55 +1424,84 @@ async fn handle_notification( let client = client.clone(); let handler = handler.clone(); let sid = session_id.clone(); - tokio::spawn(async move { - let cancel = ElicitationResult { - action: "cancel".to_string(), - content: None, - }; - // Dispatch to handler inside a nested task so panics are - // caught as JoinErrors (matches Node SDK's try/catch pattern). - let handler_task = tokio::spawn({ - let sid = sid.clone(); - let request_id = request_id.clone(); - async move { - handler - .on_event(HandlerEvent::ElicitationRequest { - session_id: sid, - request_id, - request, - }) - .await - } - }); - let result = match handler_task.await { - Ok(HandlerResponse::Elicitation(r)) => r, - _ => cancel.clone(), - }; - if let Err(e) = client - .call( - "session.ui.handlePendingElicitation", - Some(serde_json::json!({ - "sessionId": sid, - "requestId": request_id, - "result": result, - })), - ) - .await - { - // RPC failed — attempt cancel as last resort - warn!(error = %e, "handlePendingElicitation failed, sending cancel"); - let _ = client + let span = tracing::error_span!( + "elicitation_request_handler", + session_id = %sid, + request_id = %request_id + ); + tokio::spawn( + async move { + let cancel = ElicitationResult { + action: "cancel".to_string(), + content: None, + }; + // Dispatch to a nested task so panics are caught as JoinErrors. + let handler_task = tokio::spawn({ + let sid = sid.clone(); + let request_id = request_id.clone(); + let span = tracing::error_span!( + "elicitation_callback", + session_id = %sid, + request_id = %request_id + ); + async move { + let handler_start = Instant::now(); + let response = handler + .on_event(HandlerEvent::ElicitationRequest { + session_id: sid.clone(), + request_id: request_id.clone(), + request, + }) + .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "SessionHandler::on_elicitation dispatch" + ); + response + } + .instrument(span) + }); + let result = match handler_task.await { + Ok(HandlerResponse::Elicitation(r)) => r, + _ => cancel.clone(), + }; + let rpc_start = Instant::now(); + if let Err(e) = client .call( "session.ui.handlePendingElicitation", Some(serde_json::json!({ "sessionId": sid, "requestId": request_id, - "result": cancel, + "result": result, })), ) - .await; + .await + { + // RPC failed — attempt cancel as last resort + warn!(error = %e, "handlePendingElicitation failed, sending cancel"); + let _ = client + .call( + "session.ui.handlePendingElicitation", + Some(serde_json::json!({ + "sessionId": sid, + "requestId": request_id, + "result": cancel, + })), + ) + .await; + } else { + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "Session::handle_notification response sent successfully" + ); + } } - }); + .instrument(span), + ); } SessionEventType::CommandExecute => { let data: CommandExecuteData = @@ -1299,34 +1515,55 @@ async fn handle_notification( let client = client.clone(); let command_handlers = command_handlers.clone(); let sid = session_id.clone(); - tokio::spawn(async move { - let request_id = data.request_id; - let ack_error = match command_handlers.get(&data.command_name).cloned() { - None => Some(format!("Unknown command: {}", data.command_name)), - Some(handler) => { - let ctx = CommandContext { - session_id: sid.clone(), - command: data.command, - command_name: data.command_name, - args: data.args, - }; - match handler.on_command(ctx).await { - Ok(()) => None, - Err(e) => Some(e.to_string()), + let span = tracing::error_span!("command_handler", session_id = %sid); + tokio::spawn( + async move { + let request_id = data.request_id; + let ack_error = match command_handlers.get(&data.command_name).cloned() { + None => Some(format!("Unknown command: {}", data.command_name)), + Some(handler) => { + let command_name = data.command_name.clone(); + let ctx = CommandContext { + session_id: sid.clone(), + command: data.command, + command_name: data.command_name, + args: data.args, + }; + let handler_start = Instant::now(); + let result = handler.on_command(ctx).await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + command_name = %command_name, + "CommandHandler::call dispatch" + ); + match result { + Ok(()) => None, + Err(e) => Some(e.to_string()), + } } + }; + let mut params = serde_json::json!({ + "sessionId": sid, + "requestId": request_id, + }); + if let Some(error_msg) = ack_error { + params["error"] = serde_json::Value::String(error_msg); } - }; - let mut params = serde_json::json!({ - "sessionId": sid, - "requestId": request_id, - }); - if let Some(error_msg) = ack_error { - params["error"] = serde_json::Value::String(error_msg); + let rpc_start = Instant::now(); + let _ = client + .call("session.commands.handlePendingCommand", Some(params)) + .await; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "Session::handle_notification response sent successfully" + ); } - let _ = client - .call("session.commands.handlePendingCommand", Some(params)) - .await; - }); + .instrument(span), + ); } _ => {} } @@ -1400,9 +1637,19 @@ async fn handle_request( return; } }; + let tool_call_id = invocation.tool_call_id.clone(); + let tool_name = invocation.tool_name.clone(); + let handler_start = Instant::now(); let response = handler .on_event(HandlerEvent::ExternalTool { invocation }) .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + tool_call_id = %tool_call_id, + tool_name = %tool_name, + "ToolHandler::call dispatch" + ); let tool_result = match response { HandlerResponse::ToolResult(r) => r, _ => ToolResult::Text("Unexpected handler response".to_string()), @@ -1451,14 +1698,20 @@ async fn handle_request( .and_then(|p| p.get("allowFreeform")) .and_then(|v| v.as_bool()); + let handler_start = Instant::now(); let response = handler .on_event(HandlerEvent::UserInput { - session_id: sid, + session_id: sid.clone(), question, choices, allow_freeform, }) .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + "SessionHandler::on_user_input dispatch" + ); let rpc_result = match response { HandlerResponse::UserInput(Some(UserInputResponse { @@ -1514,13 +1767,20 @@ async fn handle_request( extra: raw_params, }); + let handler_start = Instant::now(); let response = handler .on_event(HandlerEvent::PermissionRequest { - session_id: sid, - request_id, + session_id: sid.clone(), + request_id: request_id.clone(), data, }) .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + request_id = %request_id, + "SessionHandler::on_permission_request dispatch" + ); let rpc_response = JsonRpcResponse { jsonrpc: "2.0".to_string(), id: request.id, @@ -1560,8 +1820,14 @@ async fn handle_request( }; let rpc_result = if let Some(transforms) = transforms { + let transform_start = Instant::now(); let response = crate::transforms::dispatch_transform(transforms, &sid, sections).await; + tracing::debug!( + elapsed_ms = transform_start.elapsed().as_millis(), + session_id = %sid, + "SystemMessageTransform::transform_section dispatch" + ); match serde_json::to_value(response) { Ok(v) => v, Err(e) => { From 2f9601a75223f20ff357ba1059cb8f19d43214c7 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 May 2026 16:20:05 -0400 Subject: [PATCH 08/33] Add enableSessionTelemetry session option across SDKs (#1224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add enableSessionTelemetry option to all SDKs Adds an optional enableSessionTelemetry field to SessionConfig and ResumeSessionConfig across Node.js, Python, Go, and .NET SDKs. When omitted (default), session telemetry remains enabled — no extra parameter is needed. When set to false, internal session telemetry (Hydro/AppInsights) is disabled for that session. Includes unit tests for serialization, cloning, and wire forwarding in all four languages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Improve enableSessionTelemetry docs, add E2E tests, fix gofmt - Enrich XML/JSDoc/comment docs across all SDKs: explain default behavior, clarify independence from OpenTelemetry config - Add two .NET E2E tests validating wire serialization of enableSessionTelemetry - Fix gofmt alignment in Go test (CI failure) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify EnableSessionTelemetry docs: GitHub-auth only, BYOK always disables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add BYOK clarification to enableSessionTelemetry docs across all SDKs Clarify that when a custom provider (BYOK) is configured, session telemetry is always disabled regardless of the enableSessionTelemetry setting. Updated Node.js, Go, and Python to match .NET docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Rust enableSessionTelemetry support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align enableSessionTelemetry docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Python rejected tool result idle race Register the idle event listener before sending the rejected tool result turn so fast runtimes cannot emit session.idle before the test starts waiting for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: David Sterling (VS) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Sterling Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 4 ++ dotnet/src/Types.cs | 24 ++++++++++ dotnet/test/E2E/ClientOptionsE2ETests.cs | 57 +++++++++++++++++++++++ dotnet/test/Unit/CloneTests.cs | 35 ++++++++++++++ dotnet/test/Unit/SerializationTests.cs | 32 +++++++++++++ go/client.go | 2 + go/client_test.go | 59 ++++++++++++++++++++++++ go/types.go | 16 +++++++ nodejs/src/client.ts | 2 + nodejs/src/types.ts | 11 +++++ nodejs/test/client.test.ts | 41 ++++++++++++++++ python/copilot/client.py | 19 ++++++++ python/copilot/session.py | 12 +++++ python/e2e/test_tool_results_e2e.py | 21 +++++---- python/test_client.py | 51 ++++++++++++++++++++ rust/src/types.rs | 42 +++++++++++++++++ rust/tests/session_test.rs | 5 ++ 17 files changed, 423 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5c92adfae..372df4d4b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -602,6 +602,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.AvailableTools, config.ExcludedTools, config.Provider, + config.EnableSessionTelemetry, (bool?)true, config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, @@ -753,6 +754,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.AvailableTools, config.ExcludedTools, config.Provider, + config.EnableSessionTelemetry, (bool?)true, config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, @@ -1911,6 +1913,7 @@ internal record CreateSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, bool? Hooks, @@ -1967,6 +1970,7 @@ internal record ResumeSessionRequest( IList? AvailableTools, IList? ExcludedTools, ProviderConfig? Provider, + bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, bool? Hooks, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index d536f57fc..adf629eef 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1859,6 +1859,7 @@ protected SessionConfig(SessionConfig? other) OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; + EnableSessionTelemetry = other.EnableSessionTelemetry; ReasoningEffort = other.ReasoningEffort; CreateSessionFsHandler = other.CreateSessionFsHandler; GitHubToken = other.GitHubToken; @@ -1940,6 +1941,17 @@ protected SessionConfig(SessionConfig? other) /// public ProviderConfig? Provider { get; set; } + /// + /// Enables or disables internal session telemetry for this session. + /// When false, disables session telemetry. When null (the default) or true, + /// telemetry is enabled for GitHub-authenticated sessions. + /// When a custom (BYOK) is configured, session telemetry is + /// always disabled regardless of this setting. + /// This is independent of , which configures + /// OpenTelemetry export for observability. + /// + public bool? EnableSessionTelemetry { get; set; } + /// /// Handler for permission requests from the server. /// When provided, the server will call this handler to request permission for operations. @@ -2124,6 +2136,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; + EnableSessionTelemetry = other.EnableSessionTelemetry; ReasoningEffort = other.ReasoningEffort; CreateSessionFsHandler = other.CreateSessionFsHandler; GitHubToken = other.GitHubToken; @@ -2174,6 +2187,17 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public ProviderConfig? Provider { get; set; } + /// + /// Enables or disables internal session telemetry for this session. + /// When false, disables session telemetry. When null (the default) or true, + /// telemetry is enabled for GitHub-authenticated sessions. + /// When a custom (BYOK) is configured, session telemetry is + /// always disabled regardless of this setting. + /// This is independent of , which configures + /// OpenTelemetry export for observability. + /// + public bool? EnableSessionTelemetry { get; set; } + /// /// Reasoning effort level for models that support it. /// Valid values: "low", "medium", "high", "xhigh". diff --git a/dotnet/test/E2E/ClientOptionsE2ETests.cs b/dotnet/test/E2E/ClientOptionsE2ETests.cs index 31627f5a3..14263de79 100644 --- a/dotnet/test/E2E/ClientOptionsE2ETests.cs +++ b/dotnet/test/E2E/ClientOptionsE2ETests.cs @@ -159,6 +159,63 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli() await session.DisposeAsync(); } + [Fact] + public async Task Should_Forward_EnableSessionTelemetry_In_Wire_Request() + { + var (cliPath, capturePath) = await CreateFakeCliCaptureAsync(); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + AutoStart = false, + CliPath = cliPath, + CliArgs = ["--capture-file", capturePath], + UseLoggedInUser = false, + }); + + await client.StartAsync(); + + // When explicitly set to false, it should appear in the wire request + var session = await client.CreateSessionAsync(new SessionConfig + { + EnableSessionTelemetry = false, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath)); + var createRequest = GetCapturedRequestParams(capture.RootElement, "session.create"); + Assert.False(createRequest.GetProperty("enableSessionTelemetry").GetBoolean()); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Omit_EnableSessionTelemetry_When_Not_Set() + { + var (cliPath, capturePath) = await CreateFakeCliCaptureAsync(); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + AutoStart = false, + CliPath = cliPath, + CliArgs = ["--capture-file", capturePath], + UseLoggedInUser = false, + }); + + await client.StartAsync(); + + // When omitted (null/default), the field should not be present in the wire request + var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath)); + var createRequest = GetCapturedRequestParams(capture.RootElement, "session.create"); + Assert.False(createRequest.TryGetProperty("enableSessionTelemetry", out _)); + + await session.DisposeAsync(); + } + [Fact] public async Task Should_Propagate_Activity_TraceContext_To_Session_Create_And_Send() { diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index d0b0d5162..ed2070b50 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -92,6 +92,7 @@ public void SessionConfig_Clone_CopiesAllProperties() ExcludedTools = ["tool3"], WorkingDirectory = "/workspace", Streaming = true, + EnableSessionTelemetry = false, IncludeSubAgentStreamingEvents = false, McpServers = new Dictionary { ["server1"] = new McpStdioServerConfig { Command = "echo" } }, CustomAgents = [new CustomAgentConfig { Name = "agent1" }], @@ -113,6 +114,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.ExcludedTools, clone.ExcludedTools); Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); Assert.Equal(original.Streaming, clone.Streaming); + Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry); Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents); Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count); @@ -317,6 +319,19 @@ public void ResumeSessionConfig_Clone_PreservesIncludeSubAgentStreamingEventsDef Assert.True(clone.IncludeSubAgentStreamingEvents); } + [Fact] + public void ResumeSessionConfig_Clone_CopiesEnableSessionTelemetry() + { + var original = new ResumeSessionConfig + { + EnableSessionTelemetry = false, + }; + + var clone = original.Clone(); + + Assert.False(clone.EnableSessionTelemetry); + } + [Fact] public void ResumeSessionConfig_Clone_CopiesContinuePendingWork() { @@ -339,4 +354,24 @@ public void ResumeSessionConfig_Clone_PreservesContinuePendingWorkDefault() Assert.Null(clone.ContinuePendingWork); } + + [Fact] + public void SessionConfig_Clone_PreservesEnableSessionTelemetryDefault() + { + var original = new SessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableSessionTelemetry); + } + + [Fact] + public void ResumeSessionConfig_Clone_PreservesEnableSessionTelemetryDefault() + { + var original = new ResumeSessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableSessionTelemetry); + } } diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index e58b256f4..713c46abd 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -126,6 +126,38 @@ public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptio Assert.Equal("C:\\resume-instructions", root.GetProperty("instructionDirectories")[0].GetString()); } + [Fact] + public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableSessionTelemetry", false)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); + } + + [Fact] + public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableSessionTelemetry", false)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); + } + [Fact] public void McpHttpServerConfig_CanSerializeOauthOptions_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index 5e2a547fc..05b0696ff 100644 --- a/go/client.go +++ b/go/client.go @@ -631,6 +631,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools req.Provider = config.Provider + req.EnableSessionTelemetry = config.EnableSessionTelemetry req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers @@ -790,6 +791,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider + req.EnableSessionTelemetry = config.EnableSessionTelemetry req.ModelCapabilities = config.ModelCapabilities req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools diff --git a/go/client_test.go b/go/client_test.go index a2dccab33..28b44086e 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -990,6 +990,35 @@ func TestResumeSessionRequest_ContinuePendingWork(t *testing.T) { }) } +func TestCreateSessionRequest_EnableSessionTelemetry(t *testing.T) { + t.Run("forwards enableSessionTelemetry when false", func(t *testing.T) { + req := createSessionRequest{ + EnableSessionTelemetry: Bool(false), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableSessionTelemetry"] != false { + t.Errorf("Expected enableSessionTelemetry to be false, got %v", m["enableSessionTelemetry"]) + } + }) + + t.Run("omits enableSessionTelemetry when not set", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableSessionTelemetry"]; ok { + t.Error("Expected enableSessionTelemetry to be omitted when not set") + } + }) +} + func TestCreateSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { t.Run("defaults to true when nil", func(t *testing.T) { req := createSessionRequest{ @@ -1026,6 +1055,36 @@ func TestCreateSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { }) } +func TestResumeSessionRequest_EnableSessionTelemetry(t *testing.T) { + t.Run("forwards enableSessionTelemetry when false", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + EnableSessionTelemetry: Bool(false), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableSessionTelemetry"] != false { + t.Errorf("Expected enableSessionTelemetry to be false, got %v", m["enableSessionTelemetry"]) + } + }) + + t.Run("omits enableSessionTelemetry when not set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableSessionTelemetry"]; ok { + t.Error("Expected enableSessionTelemetry to be omitted when not set") + } + }) +} + func TestResumeSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { t.Run("defaults to true when nil", func(t *testing.T) { req := resumeSessionRequest{ diff --git a/go/types.go b/go/types.go index 4cce207f5..161b798d6 100644 --- a/go/types.go +++ b/go/types.go @@ -575,6 +575,13 @@ type SessionConfig struct { IncludeSubAgentStreamingEvents *bool // Provider configures a custom model provider (BYOK) Provider *ProviderConfig + // EnableSessionTelemetry enables or disables internal session telemetry for this session. + // When false, disables session telemetry. When nil (the default) or true, + // telemetry is enabled for GitHub-authenticated sessions. When a custom + // Provider (BYOK) is configured, session telemetry is always disabled + // regardless of this setting. This is independent of the OpenTelemetry + // configuration in ClientOptions.Telemetry. + EnableSessionTelemetry *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -765,6 +772,13 @@ type ResumeSessionConfig struct { ExcludedTools []string // Provider configures a custom model provider Provider *ProviderConfig + // EnableSessionTelemetry enables or disables internal session telemetry for this session. + // When false, disables session telemetry. When nil (the default) or true, + // telemetry is enabled for GitHub-authenticated sessions. When a custom + // Provider (BYOK) is configured, session telemetry is always disabled + // regardless of this setting. This is independent of the OpenTelemetry + // configuration in ClientOptions.Telemetry. + EnableSessionTelemetry *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -1043,6 +1057,7 @@ type createSessionRequest struct { AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` + EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` @@ -1092,6 +1107,7 @@ type resumeSessionRequest struct { AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` + EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9c6494198..bcbb07064 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -802,6 +802,7 @@ export class CopilotClient { availableTools: config.availableTools, excludedTools: config.excludedTools, provider: config.provider ? toWireProviderConfig(config.provider) : undefined, + enableSessionTelemetry: config.enableSessionTelemetry, modelCapabilities: config.modelCapabilities, requestPermission: true, requestUserInput: !!config.onUserInputRequest, @@ -933,6 +934,7 @@ export class CopilotClient { systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, + enableSessionTelemetry: config.enableSessionTelemetry, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c7c6c8622..a5a621c73 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1283,6 +1283,16 @@ export interface SessionConfig { */ provider?: ProviderConfig; + /** + * Enables or disables internal session telemetry for this session. + * When `false`, disables session telemetry. When omitted (the default) or `true`, + * telemetry is enabled for GitHub-authenticated sessions. + * When a custom {@link provider} (BYOK) is configured, session telemetry is always + * disabled regardless of this setting. + * This is independent of the OpenTelemetry configuration in {@link CopilotClientOptions.telemetry}. + */ + enableSessionTelemetry?: boolean; + /** * Handler for permission requests from the server. * When provided, the server will call this handler to request permission for operations. @@ -1425,6 +1435,7 @@ export type ResumeSessionConfig = Pick< | "availableTools" | "excludedTools" | "provider" + | "enableSessionTelemetry" | "modelCapabilities" | "streaming" | "includeSubAgentStreamingEvents" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b2fe998ee..7328ebc1e 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -98,6 +98,47 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("forwards enableSessionTelemetry in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + enableSessionTelemetry: false, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ enableSessionTelemetry: false }) + ); + }); + + it("forwards enableSessionTelemetry in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + enableSessionTelemetry: false, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ enableSessionTelemetry: false, sessionId: session.sessionId }) + ); + spy.mockRestore(); + }); + it("defaults includeSubAgentStreamingEvents to true in session.create when not specified", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 70b70bc9b..f0098b58d 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1302,6 +1302,7 @@ async def create_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + enable_session_telemetry: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -1350,6 +1351,12 @@ async def create_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. + enable_session_telemetry: Enables or disables internal session telemetry + for this session. When False, disables session telemetry. When omitted + or True, telemetry is enabled for GitHub-authenticated sessions. When + a custom provider (BYOK) is configured, session telemetry is always + disabled regardless of this setting. This is independent of the client + OpenTelemetry configuration. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -1484,6 +1491,9 @@ async def create_session( if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) + if enable_session_telemetry is not None: + payload["enableSessionTelemetry"] = enable_session_telemetry + # Add model capabilities override if provided if model_capabilities: payload["modelCapabilities"] = _capabilities_to_dict(model_capabilities) @@ -1644,6 +1654,7 @@ async def resume_session( hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, + enable_session_telemetry: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -1693,6 +1704,12 @@ async def resume_session( hooks: Lifecycle hooks for the session. working_directory: Working directory for the session. provider: Provider configuration for Azure or custom endpoints. + enable_session_telemetry: Enables or disables internal session telemetry + for this session. When False, disables session telemetry. When omitted + or True, telemetry is enabled for GitHub-authenticated sessions. When + a custom provider (BYOK) is configured, session telemetry is always + disabled regardless of this setting. This is independent of the client + OpenTelemetry configuration. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -1789,6 +1806,8 @@ async def resume_session( payload["excludedTools"] = excluded_tools if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) + if enable_session_telemetry is not None: + payload["enableSessionTelemetry"] = enable_session_telemetry if model_capabilities: payload["modelCapabilities"] = _capabilities_to_dict(model_capabilities) if streaming is not None: diff --git a/python/copilot/session.py b/python/copilot/session.py index 8a8021e19..86c5b8443 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -888,6 +888,12 @@ class SessionConfig(TypedDict, total=False): working_directory: str # Custom provider configuration (BYOK - Bring Your Own Key) provider: ProviderConfig + # Enables or disables internal session telemetry for this session. When False, + # disables session telemetry. When omitted (the default) or True, telemetry is enabled for + # GitHub-authenticated sessions. When a custom provider (BYOK) is configured, + # session telemetry is always disabled regardless of this setting. + # This is independent of the client OpenTelemetry configuration. + enable_session_telemetry: bool # Enable streaming of assistant message and reasoning chunks # When True, assistant.message_delta and assistant.reasoning_delta events # with delta_content are sent as the response is generated @@ -956,6 +962,12 @@ class ResumeSessionConfig(TypedDict, total=False): # registered via tools=. Ignored if available_tools is set. excluded_tools: list[str] provider: ProviderConfig + # Enables or disables internal session telemetry for this session. When False, + # disables session telemetry. When omitted (the default) or True, telemetry is enabled for + # GitHub-authenticated sessions. When a custom provider (BYOK) is configured, + # session telemetry is always disabled regardless of this setting. + # This is independent of the client OpenTelemetry configuration. + enable_session_telemetry: bool # Reasoning effort level for models that support it. reasoning_effort: ReasoningEffort on_permission_request: _PermissionHandlerFn diff --git a/python/e2e/test_tool_results_e2e.py b/python/e2e/test_tool_results_e2e.py index 3e54a3abf..b7b05b7af 100644 --- a/python/e2e/test_tool_results_e2e.py +++ b/python/e2e/test_tool_results_e2e.py @@ -106,6 +106,8 @@ def analyze_code(params: AnalyzeParams, invocation: ToolInvocation) -> ToolResul async def test_should_handle_tool_result_with_rejected_resulttype(self, ctx: E2ETestContext): tool_handler_called = False tool_complete_future: asyncio.Future = asyncio.get_event_loop().create_future() + idle_future: asyncio.Future = asyncio.get_event_loop().create_future() + tool_complete_seen = False @define_tool("deploy_service", description="Deploys a service") def deploy_service(invocation: ToolInvocation) -> ToolResult: @@ -124,8 +126,15 @@ def deploy_service(invocation: ToolInvocation) -> ToolResult: ) def on_event(event): - if event.type.value == "tool.execution_complete" and not tool_complete_future.done(): - tool_complete_future.set_result(event) + nonlocal tool_complete_seen + if event.type.value == "tool.execution_complete": + tool_complete_seen = True + if not tool_complete_future.done(): + tool_complete_future.set_result(event) + elif ( + event.type.value == "session.idle" and tool_complete_seen and not idle_future.done() + ): + idle_future.set_result(event) unsubscribe = session.on(on_event) try: @@ -147,14 +156,6 @@ def on_event(event): assert "Deployment rejected" in (error_msg or "") # Session should reach idle - idle_future: asyncio.Future = asyncio.get_event_loop().create_future() - session.on( - lambda e: ( - idle_future.set_result(e) - if e.type.value == "session.idle" and not idle_future.done() - else None - ) - ) await asyncio.wait_for(idle_future, timeout=30.0) finally: unsubscribe() diff --git a/python/test_client.py b/python/test_client.py index a890ca12e..26de29287 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -543,6 +543,57 @@ async def mock_request(method, params): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_session_forwards_enable_session_telemetry(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + enable_session_telemetry=False, + ) + assert captured["session.create"]["enableSessionTelemetry"] is False + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_forwards_enable_session_telemetry(self): + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + enable_session_telemetry=False, + ) + assert captured["session.resume"]["enableSessionTelemetry"] is False + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_session_forwards_provider_headers(self): client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index d47d9f841..3831e02d6 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1042,6 +1042,15 @@ pub struct SessionConfig { /// routing. #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + /// Enables or disables internal session telemetry for this session. + /// + /// When `Some(false)`, disables session telemetry. When `None` or + /// `Some(true)`, telemetry is enabled for GitHub-authenticated sessions. + /// When a custom [`provider`](Self::provider) is configured, session + /// telemetry is always disabled regardless of this setting. This is + /// independent of [`ClientOptions::telemetry`](crate::ClientOptions::telemetry). + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_session_telemetry: Option, /// Per-property overrides for model capabilities, deep-merged over /// runtime defaults. #[serde(skip_serializing_if = "Option::is_none")] @@ -1122,6 +1131,7 @@ impl std::fmt::Debug for SessionConfig { .field("agent", &self.agent) .field("infinite_sessions", &self.infinite_sessions) .field("provider", &self.provider) + .field("enable_session_telemetry", &self.enable_session_telemetry) .field("model_capabilities", &self.model_capabilities) .field("config_dir", &self.config_dir) .field("working_directory", &self.working_directory) @@ -1181,6 +1191,7 @@ impl Default for SessionConfig { agent: None, infinite_sessions: None, provider: None, + enable_session_telemetry: None, model_capabilities: None, config_dir: None, working_directory: None, @@ -1445,6 +1456,14 @@ impl SessionConfig { self } + /// Enable or disable internal session telemetry. + /// + /// See [`Self::enable_session_telemetry`] for default and BYOK behavior. + pub fn with_enable_session_telemetry(mut self, enable: bool) -> Self { + self.enable_session_telemetry = Some(enable); + self + } + /// Set per-property overrides for model capabilities. pub fn with_model_capabilities( mut self, @@ -1560,6 +1579,15 @@ pub struct ResumeSessionConfig { /// Re-supply BYOK provider configuration on resume. #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + /// Enables or disables internal session telemetry for this session. + /// + /// When `Some(false)`, disables session telemetry. When `None` or + /// `Some(true)`, telemetry is enabled for GitHub-authenticated sessions. + /// When a custom [`provider`](Self::provider) is configured, session + /// telemetry is always disabled regardless of this setting. This is + /// independent of [`ClientOptions::telemetry`](crate::ClientOptions::telemetry). + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_session_telemetry: Option, /// Per-property model capability overrides on resume. #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, @@ -1635,6 +1663,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("agent", &self.agent) .field("infinite_sessions", &self.infinite_sessions) .field("provider", &self.provider) + .field("enable_session_telemetry", &self.enable_session_telemetry) .field("model_capabilities", &self.model_capabilities) .field("config_dir", &self.config_dir) .field("working_directory", &self.working_directory) @@ -1692,6 +1721,7 @@ impl ResumeSessionConfig { agent: None, infinite_sessions: None, provider: None, + enable_session_telemetry: None, model_capabilities: None, config_dir: None, working_directory: None, @@ -1919,6 +1949,14 @@ impl ResumeSessionConfig { self } + /// Enable or disable internal session telemetry on resume. + /// + /// See [`Self::enable_session_telemetry`] for default and BYOK behavior. + pub fn with_enable_session_telemetry(mut self, enable: bool) -> Self { + self.enable_session_telemetry = Some(enable); + self + } + /// Set per-property model capability overrides on resume. pub fn with_model_capabilities( mut self, @@ -3073,6 +3111,7 @@ mod tests { .with_config_dir(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") + .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(false); assert_eq!(cfg.session_id.as_ref().map(|s| s.as_str()), Some("sess-1")); @@ -3105,6 +3144,7 @@ mod tests { assert_eq!(cfg.config_dir, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); + assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); } @@ -3127,6 +3167,7 @@ mod tests { .with_config_dir(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) .with_github_token("ghp_test") + .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(true) .with_disable_resume(true) .with_continue_pending_work(true); @@ -3159,6 +3200,7 @@ mod tests { assert_eq!(cfg.config_dir, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); assert_eq!(cfg.github_token.as_deref(), Some("ghp_test")); + assert_eq!(cfg.enable_session_telemetry, Some(false)); assert_eq!(cfg.include_sub_agent_streaming_events, Some(true)); assert_eq!(cfg.disable_resume, Some(true)); assert_eq!(cfg.continue_pending_work, Some(true)); diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 1f9873879..4e05960e7 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -2477,6 +2477,7 @@ fn session_config_serializes_bucket_b_fields() { cfg.working_directory = Some(PathBuf::from("/tmp/work")); cfg.github_token = Some("ghs_secret".to_string()); cfg.include_sub_agent_streaming_events = Some(false); + cfg.enable_session_telemetry = Some(false); cfg }; let json = serde_json::to_value(&cfg).unwrap(); @@ -2485,6 +2486,7 @@ fn session_config_serializes_bucket_b_fields() { assert_eq!(json["workingDirectory"], "/tmp/work"); assert_eq!(json["gitHubToken"], "ghs_secret"); assert_eq!(json["includeSubAgentStreamingEvents"], false); + assert_eq!(json["enableSessionTelemetry"], false); // Debug never leaks the token. let debug = format!("{cfg:?}"); @@ -2495,6 +2497,7 @@ fn session_config_serializes_bucket_b_fields() { let empty = serde_json::to_value(SessionConfig::default()).unwrap(); assert!(empty.get("sessionId").is_none()); assert!(empty.get("gitHubToken").is_none()); + assert!(empty.get("enableSessionTelemetry").is_none()); } #[test] @@ -2508,12 +2511,14 @@ fn resume_session_config_serializes_bucket_b_fields() { cfg.config_dir = Some(PathBuf::from("/tmp/cfg")); cfg.github_token = Some("ghs_secret".to_string()); cfg.include_sub_agent_streaming_events = Some(true); + cfg.enable_session_telemetry = Some(false); let json = serde_json::to_value(&cfg).unwrap(); assert_eq!(json["sessionId"], "sess-1"); assert_eq!(json["workingDirectory"], "/tmp/work"); assert_eq!(json["configDir"], "/tmp/cfg"); assert_eq!(json["gitHubToken"], "ghs_secret"); assert_eq!(json["includeSubAgentStreamingEvents"], true); + assert_eq!(json["enableSessionTelemetry"], false); let debug = format!("{cfg:?}"); assert!(!debug.contains("ghs_secret"), "leaked token: {debug}"); From bf3bdea9e318d47fdcf98d360cf56df37799c2ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 17:39:41 -0400 Subject: [PATCH 09/33] Update @github/copilot to 1.0.44-2 (#1225) - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- dotnet/src/Generated/SessionEvents.cs | 24 +++++++++- go/generated_session_events.go | 15 +++++- nodejs/package-lock.json | 56 +++++++++++----------- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/session-events.ts | 13 +++-- python/copilot/generated/session_events.py | 18 +++++-- rust/src/generated/session_events.rs | 21 +++++++- test/harness/package-lock.json | 56 +++++++++++----------- test/harness/package.json | 2 +- 10 files changed, 137 insertions(+), 72 deletions(-) diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index efb647740..b428e39f5 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -2155,9 +2155,9 @@ public partial class ModelCallFailureData /// Turn abort information including the reason for termination. public partial class AbortData { - /// Reason the current turn was aborted (e.g., "user initiated"). + /// Finite reason code describing why the current turn was aborted. [JsonPropertyName("reason")] - public required string Reason { get; set; } + public required AbortReason Reason { get; set; } } /// User-initiated tool invocation request with tool name and arguments. @@ -2343,6 +2343,11 @@ public partial class SubagentStartedData [JsonPropertyName("agentName")] public required string AgentName { get; set; } + /// Model the sub-agent will run with, when known at start. Surfaced in the timeline for auto-selected sub-agents (e.g. rubber-duck). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("model")] + public string? Model { get; set; } + /// Tool call ID of the parent tool invocation that spawned this sub-agent. [JsonPropertyName("toolCallId")] public required string ToolCallId { get; set; } @@ -4997,6 +5002,21 @@ public enum ModelCallFailureSource McpSampling, } +/// Finite reason code describing why the current turn was aborted. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AbortReason +{ + /// The user_initiated variant. + [JsonStringEnumMemberName("user_initiated")] + UserInitiated, + /// The remote_command variant. + [JsonStringEnumMemberName("remote_command")] + RemoteCommand, + /// The user_abort variant. + [JsonStringEnumMemberName("user_abort")] + UserAbort, +} + /// Theme variant this icon is intended for. [JsonConverter(typeof(JsonStringEnumConverter))] public enum ToolExecutionCompleteContentResourceLinkIconTheme diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 6844b975a..5026f465e 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -1560,6 +1560,8 @@ type SubagentStartedData struct { AgentDisplayName string `json:"agentDisplayName"` // Internal name of the sub-agent AgentName string `json:"agentName"` + // Model the sub-agent will run with, when known at start. Surfaced in the timeline for auto-selected sub-agents (e.g. rubber-duck). + Model *string `json:"model,omitempty"` // Tool call ID of the parent tool invocation that spawned this sub-agent ToolCallID string `json:"toolCallId"` } @@ -1660,8 +1662,8 @@ func (*ToolExecutionStartData) sessionEventData() {} // Turn abort information including the reason for termination type AbortData struct { - // Reason the current turn was aborted (e.g., "user initiated") - Reason string `json:"reason"` + // Finite reason code describing why the current turn was aborted + Reason AbortReason `json:"reason"` } func (*AbortData) sessionEventData() {} @@ -2446,6 +2448,15 @@ const ( ElicitationRequestedModeURL ElicitationRequestedMode = "url" ) +// Finite reason code describing why the current turn was aborted +type AbortReason string + +const ( + AbortReasonUserInitiated AbortReason = "user_initiated" + AbortReasonRemoteCommand AbortReason = "remote_command" + AbortReasonUserAbort AbortReason = "user_abort" +) + // Hosting platform type of the repository (github or ado) type WorkingDirectoryContextHostType string diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3f260837c..7a6b44f0d 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.43", + "@github/copilot": "^1.0.44-2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43.tgz", - "integrity": "sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-2.tgz", + "integrity": "sha512-MUIR4w+oXjbg1jwUS8B86eMd/bV2gVKZ61a/aEUE4gUrFFpGXO0tNk9OkfLSH5cmlhJY6lzMzb+kKQWoeAbbNQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.43", - "@github/copilot-darwin-x64": "1.0.43", - "@github/copilot-linux-arm64": "1.0.43", - "@github/copilot-linux-x64": "1.0.43", - "@github/copilot-win32-arm64": "1.0.43", - "@github/copilot-win32-x64": "1.0.43" + "@github/copilot-darwin-arm64": "1.0.44-2", + "@github/copilot-darwin-x64": "1.0.44-2", + "@github/copilot-linux-arm64": "1.0.44-2", + "@github/copilot-linux-x64": "1.0.44-2", + "@github/copilot-win32-arm64": "1.0.44-2", + "@github/copilot-win32-x64": "1.0.44-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43.tgz", - "integrity": "sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-2.tgz", + "integrity": "sha512-6o/pvew0FZJG+8saG1K/L1pUIvpz4AWkZitiqH36tDfXdXKx/PUQ+zaFg/KPeHNnxtal5OdE/7iyrJwIqm2gPg==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43.tgz", - "integrity": "sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-2.tgz", + "integrity": "sha512-OMNoLNFYUynB4wiplSh4gtD5zVlvfWMKc0jKQ0oItJLGO8GRL9X0ZB2ONB+7JpVvPidz0Yy4+jU0zWNXEjMM5g==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43.tgz", - "integrity": "sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-2.tgz", + "integrity": "sha512-5WGRADU08hqBTWmQ6JVOYMximzsXGuOdFF4GFRQqfsCR8k4RE8fdPWQJa92BpqMgGWwEVPemq0wB3D4hDM5eWw==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43.tgz", - "integrity": "sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-2.tgz", + "integrity": "sha512-4ZnA2QxEwgrdCePdS5OjuksEGFpJrXgofuELANCpDSHwR3eTV7PynVyqhG6Et7ktN2KzHk7zf8kvtiWVCOxvFg==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43.tgz", - "integrity": "sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-2.tgz", + "integrity": "sha512-klgSdBZblz9O8BRnTh9uk9uO/INQwVeTBagXuJO7MrZ7JCfBVJyFUYky2tKIjFxlwefyhrRZuniqYeOI9fQc+A==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43.tgz", - "integrity": "sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-2.tgz", + "integrity": "sha512-ziq3abdbMCqtAqdiEWWf6cn0whlWss7rC9VMsO/Vx2gjSEVCeJkmIiRiQO45WikheyXyxEmCTAvOwZLQvs+I9g==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index ad5eb3970..69f476b73 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.43", + "@github/copilot": "^1.0.44-2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index ab6a78fdc..6f2d1ac53 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.43", + "@github/copilot": "^1.0.44-2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 3668a3ca6..c6a222d05 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -129,6 +129,10 @@ export type AssistantMessageToolRequestType = "function" | "custom"; * Where the failed model call originated */ export type ModelCallFailureSource = "top_level" | "subagent" | "mcp_sampling"; +/** + * Finite reason code describing why the current turn was aborted + */ +export type AbortReason = "user_initiated" | "remote_command" | "user_abort"; /** * A content block within a tool result, which may be text, terminal output, image, audio, or a resource */ @@ -2397,10 +2401,7 @@ export interface AbortEvent { * Turn abort information including the reason for termination */ export interface AbortData { - /** - * Reason the current turn was aborted (e.g., "user initiated") - */ - reason: string; + reason: AbortReason; } export interface ToolUserRequestedEvent { /** @@ -2927,6 +2928,10 @@ export interface SubagentStartedData { * Internal name of the sub-agent */ agentName: string; + /** + * Model the sub-agent will run with, when known at start. Surfaced in the timeline for auto-selected sub-agents (e.g. rubber-duck). + */ + model?: string; /** * Tool call ID of the parent tool invocation that spawned this sub-agent */ diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 1fe1af32b..c4dbb8158 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -260,19 +260,19 @@ def to_dict(self) -> dict: @dataclass class AbortData: "Turn abort information including the reason for termination" - reason: str + reason: AbortReason @staticmethod def from_dict(obj: Any) -> "AbortData": assert isinstance(obj, dict) - reason = from_str(obj.get("reason")) + reason = parse_enum(AbortReason, obj.get("reason")) return AbortData( reason=reason, ) def to_dict(self) -> dict: result: dict = {} - result["reason"] = from_str(self.reason) + result["reason"] = to_enum(AbortReason, self.reason) return result @@ -3649,6 +3649,7 @@ class SubagentStartedData: agent_display_name: str agent_name: str tool_call_id: str + model: str | None = None @staticmethod def from_dict(obj: Any) -> "SubagentStartedData": @@ -3657,11 +3658,13 @@ def from_dict(obj: Any) -> "SubagentStartedData": agent_display_name = from_str(obj.get("agentDisplayName")) agent_name = from_str(obj.get("agentName")) tool_call_id = from_str(obj.get("toolCallId")) + model = from_union([from_none, from_str], obj.get("model")) return SubagentStartedData( agent_description=agent_description, agent_display_name=agent_display_name, agent_name=agent_name, tool_call_id=tool_call_id, + model=model, ) def to_dict(self) -> dict: @@ -3670,6 +3673,8 @@ def to_dict(self) -> dict: result["agentDisplayName"] = from_str(self.agent_display_name) result["agentName"] = from_str(self.agent_name) result["toolCallId"] = from_str(self.tool_call_id) + if self.model is not None: + result["model"] = from_union([from_none, from_str], self.model) return result @@ -4585,6 +4590,13 @@ def to_dict(self) -> dict: return result +class AbortReason(Enum): + "Finite reason code describing why the current turn was aborted" + USER_INITIATED = "user_initiated" + REMOTE_COMMAND = "remote_command" + USER_ABORT = "user_abort" + + class AssistantMessageToolRequestType(Enum): "Tool call type: \"function\" for standard tool calls, \"custom\" for grammar-based tool calls. Defaults to \"function\" when absent." FUNCTION = "function" diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index cf7c33c68..fdcf7a6b4 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -1292,8 +1292,8 @@ pub struct ModelCallFailureData { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AbortData { - /// Reason the current turn was aborted (e.g., "user initiated") - pub reason: String, + /// Finite reason code describing why the current turn was aborted + pub reason: AbortReason, } /// User-initiated tool invocation request with tool name and arguments @@ -1449,6 +1449,9 @@ pub struct SubagentStartedData { pub agent_display_name: String, /// Internal name of the sub-agent pub agent_name: String, + /// Model the sub-agent will run with, when known at start. Surfaced in the timeline for auto-selected sub-agents (e.g. rubber-duck). + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, /// Tool call ID of the parent tool invocation that spawned this sub-agent pub tool_call_id: String, } @@ -2657,6 +2660,20 @@ pub enum ModelCallFailureSource { Unknown, } +/// Finite reason code describing why the current turn was aborted +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AbortReason { + #[serde(rename = "user_initiated")] + UserInitiated, + #[serde(rename = "remote_command")] + RemoteCommand, + #[serde(rename = "user_abort")] + UserAbort, + /// Unknown variant for forward compatibility. + #[serde(other)] + Unknown, +} + /// Message role: "system" for system prompts, "developer" for developer-injected instructions #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum SystemMessageRole { diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 24804c472..d5f77fef7 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.43", + "@github/copilot": "^1.0.44-2", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -464,27 +464,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.43.tgz", - "integrity": "sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-2.tgz", + "integrity": "sha512-MUIR4w+oXjbg1jwUS8B86eMd/bV2gVKZ61a/aEUE4gUrFFpGXO0tNk9OkfLSH5cmlhJY6lzMzb+kKQWoeAbbNQ==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.43", - "@github/copilot-darwin-x64": "1.0.43", - "@github/copilot-linux-arm64": "1.0.43", - "@github/copilot-linux-x64": "1.0.43", - "@github/copilot-win32-arm64": "1.0.43", - "@github/copilot-win32-x64": "1.0.43" + "@github/copilot-darwin-arm64": "1.0.44-2", + "@github/copilot-darwin-x64": "1.0.44-2", + "@github/copilot-linux-arm64": "1.0.44-2", + "@github/copilot-linux-x64": "1.0.44-2", + "@github/copilot-win32-arm64": "1.0.44-2", + "@github/copilot-win32-x64": "1.0.44-2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.43.tgz", - "integrity": "sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-2.tgz", + "integrity": "sha512-6o/pvew0FZJG+8saG1K/L1pUIvpz4AWkZitiqH36tDfXdXKx/PUQ+zaFg/KPeHNnxtal5OdE/7iyrJwIqm2gPg==", "cpu": [ "arm64" ], @@ -499,9 +499,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.43.tgz", - "integrity": "sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-2.tgz", + "integrity": "sha512-OMNoLNFYUynB4wiplSh4gtD5zVlvfWMKc0jKQ0oItJLGO8GRL9X0ZB2ONB+7JpVvPidz0Yy4+jU0zWNXEjMM5g==", "cpu": [ "x64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.43.tgz", - "integrity": "sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-2.tgz", + "integrity": "sha512-5WGRADU08hqBTWmQ6JVOYMximzsXGuOdFF4GFRQqfsCR8k4RE8fdPWQJa92BpqMgGWwEVPemq0wB3D4hDM5eWw==", "cpu": [ "arm64" ], @@ -533,9 +533,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.43.tgz", - "integrity": "sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-2.tgz", + "integrity": "sha512-4ZnA2QxEwgrdCePdS5OjuksEGFpJrXgofuELANCpDSHwR3eTV7PynVyqhG6Et7ktN2KzHk7zf8kvtiWVCOxvFg==", "cpu": [ "x64" ], @@ -550,9 +550,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.43.tgz", - "integrity": "sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-2.tgz", + "integrity": "sha512-klgSdBZblz9O8BRnTh9uk9uO/INQwVeTBagXuJO7MrZ7JCfBVJyFUYky2tKIjFxlwefyhrRZuniqYeOI9fQc+A==", "cpu": [ "arm64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.43", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.43.tgz", - "integrity": "sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==", + "version": "1.0.44-2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-2.tgz", + "integrity": "sha512-ziq3abdbMCqtAqdiEWWf6cn0whlWss7rC9VMsO/Vx2gjSEVCeJkmIiRiQO45WikheyXyxEmCTAvOwZLQvs+I9g==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index baa88070f..f4e117606 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.43", + "@github/copilot": "^1.0.44-2", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From 066a69c1e849adf1bd98564ab1b52316ec471182 Mon Sep 17 00:00:00 2001 From: Sunbrye Ly <56200261+sunbrye@users.noreply.github.com> Date: Thu, 7 May 2026 14:58:23 -0700 Subject: [PATCH 10/33] Docs normalization for the SDK -> Docs pipeline (#1208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Convert headings to sentence case across all SDK docs Convert 402 headings from title case to sentence case across 31 markdown files to align with docs.github.com style conventions. Proper nouns (GitHub, Copilot, OAuth, Azure, MCP, etc.) and code identifiers (SessionConfig, TelemetryConfig, etc.) are preserved. Ampersands replaced with 'and'. No code snippet contents modified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restructure index.md files for docs pipeline compatibility Move rich content out of index.md files into standalone articles: - setup/index.md → setup/choosing-a-setup-path.md - hooks/index.md → hooks/hooks-overview.md Replace all index.md files with minimal nav pages matching the docs-internal pattern (YAML-only category pages on that side). Add missing index.md files for directories that lacked them: - integrations/index.md - observability/index.md - troubleshooting/index.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Convert callout syntax to GitHub-flavored alerts Convert 27 callouts across 15 files from bold-text style (> **Note:**) to GitHub alert syntax (> [!NOTE]). Language-qualified tips (e.g., > **Tip (Python / Go):**) preserve the qualifier as bold text in the alert body. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update internal links for restructured index files Point 7 content links to their new standalone files: - auth/index.md → auth/authenticate.md - hooks/index.md → hooks/hooks-overview.md Links in docs/index.md (root nav) intentionally still point to index.md since those are category navigation links. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Convert list markers from hyphens to asterisks Convert 371 unordered list markers across 37 files from `-` to `*` to match the GitHub Docs style guide convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Normalize ordered list numbering to all 1. Convert 116 sequentially numbered list items across 20 files to use 1. for every item, matching the GitHub Docs style guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove horizontal rules used as section dividers Remove 37 horizontal rules (---) across 7 files. GitHub Docs style uses headings to separate sections, not horizontal rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Normalize em dash usage across all docs Convert 79 list item em dashes to colons (label: description pattern) and remove spaces around 114 mid-sentence em dashes to match the GitHub Docs style guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Remove bold from headings in remote-sessions.md Fix 10 headings that used bold markers (**) in heading titles and apply proper noun casing for language names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/auth/authenticate.md | 402 ++++++++++++++++++ docs/auth/byok.md | 88 ++-- docs/auth/index.md | 402 +----------------- docs/features/agent-loop.md | 376 ++++++++-------- docs/features/custom-agents.md | 81 ++-- docs/features/hooks.md | 85 ++-- docs/features/image-input.md | 36 +- docs/features/index.md | 14 +- docs/features/mcp.md | 78 ++-- docs/features/remote-sessions.md | 48 +-- docs/features/session-persistence.md | 83 ++-- docs/features/skills.md | 71 ++-- docs/features/steering-and-queueing.md | 70 +-- docs/features/streaming-events.md | 55 +-- docs/getting-started.md | 131 +++--- docs/hooks/error-handling.md | 48 +-- docs/hooks/hooks-overview.md | 271 ++++++++++++ docs/hooks/index.md | 277 +----------- docs/hooks/post-tool-use.md | 44 +- docs/hooks/pre-tool-use.md | 44 +- docs/hooks/session-lifecycle.md | 54 ++- docs/hooks/user-prompt-submitted.md | 46 +- docs/index.md | 70 +-- docs/integrations/index.md | 5 + .../integrations/microsoft-agent-framework.md | 48 ++- docs/observability/index.md | 5 + docs/observability/opentelemetry.md | 32 +- docs/setup/azure-managed-identity.md | 40 +- docs/setup/backend-services.md | 55 +-- docs/setup/bundled-cli.md | 46 +- docs/setup/choosing-a-setup-path.md | 142 +++++++ docs/setup/github-oauth.md | 60 +-- docs/setup/index.md | 149 +------ docs/setup/local-cli.md | 35 +- docs/setup/scaling.md | 70 +-- docs/troubleshooting/compatibility.md | 43 +- docs/troubleshooting/debugging.md | 123 +++--- docs/troubleshooting/index.md | 7 + docs/troubleshooting/mcp-debugging.md | 174 ++++---- 39 files changed, 1952 insertions(+), 1956 deletions(-) create mode 100644 docs/auth/authenticate.md create mode 100644 docs/hooks/hooks-overview.md create mode 100644 docs/integrations/index.md create mode 100644 docs/observability/index.md create mode 100644 docs/setup/choosing-a-setup-path.md create mode 100644 docs/troubleshooting/index.md diff --git a/docs/auth/authenticate.md b/docs/auth/authenticate.md new file mode 100644 index 000000000..740e5f3c1 --- /dev/null +++ b/docs/auth/authenticate.md @@ -0,0 +1,402 @@ +# Authentication + +The GitHub Copilot SDK supports multiple authentication methods to fit different use cases. Choose the method that best matches your deployment scenario. + +## Authentication methods + +| Method | Use Case | Copilot Subscription Required | +|--------|----------|-------------------------------| +| [GitHub Signed-in User](#github-signed-in-user) | Interactive apps where users sign in with GitHub | Yes | +| [OAuth GitHub App](#oauth-github-app) | Apps acting on behalf of users via OAuth | Yes | +| [Environment Variables](#environment-variables) | CI/CD, automation, server-to-server | Yes | +| [BYOK (Bring Your Own Key)](./byok.md) | Using your own API keys (Azure AI Foundry, OpenAI, etc.) | No | + +## GitHub signed-in user + +This is the default authentication method when running the Copilot CLI interactively. Users authenticate via GitHub OAuth device flow, and the SDK uses their stored credentials. + +**How it works:** +1. User runs `copilot` CLI and signs in via GitHub OAuth +1. Credentials are stored securely in the system keychain +1. SDK automatically uses stored credentials + +**SDK Configuration:** + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +// Default: uses logged-in user credentials +const client = new CopilotClient(); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient + +# Default: uses logged-in user credentials +client = CopilotClient() +await client.start() +``` + +
+ +
+Go + + +```go +package main + +import copilot "github.com/github/copilot-sdk/go" + +func main() { + // Default: uses logged-in user credentials + client := copilot.NewClient(nil) + _ = client +} +``` + + +```go +import copilot "github.com/github/copilot-sdk/go" + +// Default: uses logged-in user credentials +client := copilot.NewClient(nil) +``` + +
+ +
+.NET + +```csharp +using GitHub.Copilot.SDK; + +// Default: uses logged-in user credentials +await using var client = new CopilotClient(); +``` + +
+ +
+Java + +```java +import com.github.copilot.sdk.CopilotClient; + +// Default: uses logged-in user credentials +var client = new CopilotClient(); +client.start().get(); +``` + +
+ +**When to use:** +* Desktop applications where users interact directly +* Development and testing environments +* Any scenario where a user can sign in interactively + +## OAuth GitHub App + +Use an OAuth GitHub App to authenticate users through your application and pass their credentials to the SDK. This enables your application to make Copilot API requests on behalf of users who authorize your app. + +**How it works:** +1. User authorizes your OAuth GitHub App +1. Your app receives a user access token (`gho_` or `ghu_` prefix) +1. Pass the token to the SDK via `gitHubToken` option + +**SDK Configuration:** + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient({ + gitHubToken: userAccessToken, // Token from OAuth flow + useLoggedInUser: false, // Don't use stored CLI credentials +}); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient + +client = CopilotClient({ + "github_token": user_access_token, # Token from OAuth flow + "use_logged_in_user": False, # Don't use stored CLI credentials +}) +await client.start() +``` + +
+ +
+Go + + +```go +package main + +import copilot "github.com/github/copilot-sdk/go" + +func main() { + userAccessToken := "token" + client := copilot.NewClient(&copilot.ClientOptions{ + GitHubToken: userAccessToken, + UseLoggedInUser: copilot.Bool(false), + }) + _ = client +} +``` + + +```go +import copilot "github.com/github/copilot-sdk/go" + +client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: userAccessToken, // Token from OAuth flow + UseLoggedInUser: copilot.Bool(false), // Don't use stored CLI credentials +}) +``` + +
+ +
+.NET + + +```csharp +using GitHub.Copilot.SDK; + +var userAccessToken = "token"; +await using var client = new CopilotClient(new CopilotClientOptions +{ + GithubToken = userAccessToken, + UseLoggedInUser = false, +}); +``` + + +```csharp +using GitHub.Copilot.SDK; + +await using var client = new CopilotClient(new CopilotClientOptions +{ + GithubToken = userAccessToken, // Token from OAuth flow + UseLoggedInUser = false, // Don't use stored CLI credentials +}); +``` + +
+ +
+Java + +```java +import com.github.copilot.sdk.CopilotClient; +import com.github.copilot.sdk.json.*; + +var client = new CopilotClient(new CopilotClientOptions() + .setGitHubToken(userAccessToken) // Token from OAuth flow + .setUseLoggedInUser(false) // Don't use stored CLI credentials +); +client.start().get(); +``` + +
+ +**Supported token types:** +* `gho_` - OAuth user access tokens +* `ghu_` - GitHub App user access tokens +* `github_pat_` - Fine-grained personal access tokens + +**Not supported:** +* `ghp_` - Classic personal access tokens (deprecated) + +**When to use:** +* Web applications where users sign in via GitHub +* SaaS applications building on top of Copilot +* Any multi-user application where you need to make requests on behalf of different users + +## Environment variables + +For automation, CI/CD pipelines, and server-to-server scenarios, you can authenticate using environment variables. + +**Supported environment variables (in priority order):** +1. `COPILOT_GITHUB_TOKEN` - Recommended for explicit Copilot usage +1. `GH_TOKEN` - GitHub CLI compatible +1. `GITHUB_TOKEN` - GitHub Actions compatible + +**How it works:** +1. Set one of the supported environment variables with a valid token +1. The SDK automatically detects and uses the token + +**SDK Configuration:** + +No code changes needed—the SDK automatically detects environment variables: + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +// Token is read from environment variable automatically +const client = new CopilotClient(); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient + +# Token is read from environment variable automatically +client = CopilotClient() +await client.start() +``` + +
+ +**When to use:** +* CI/CD pipelines (GitHub Actions, Jenkins, etc.) +* Automated testing +* Server-side applications with service accounts +* Development when you don't want to use interactive login + +## BYOK (bring your own key) + +BYOK allows you to use your own API keys from model providers like Azure AI Foundry, OpenAI, or Anthropic. This bypasses GitHub Copilot authentication entirely. + +**Key benefits:** +* No GitHub Copilot subscription required +* Use enterprise model deployments +* Direct billing with your model provider +* Support for Azure AI Foundry, OpenAI, Anthropic, and OpenAI-compatible endpoints + +**See the [BYOK documentation](./byok.md) for complete details**, including: +* Azure AI Foundry setup +* Provider configuration options +* Limitations and considerations +* Complete code examples + +## Authentication priority + +When multiple authentication methods are available, the SDK uses them in this priority order: + +1. **Explicit `gitHubToken`** - Token passed directly to SDK constructor +1. **HMAC key** - `CAPI_HMAC_KEY` or `COPILOT_HMAC_KEY` environment variables +1. **Direct API token** - `GITHUB_COPILOT_API_TOKEN` with `COPILOT_API_URL` +1. **Environment variable tokens** - `COPILOT_GITHUB_TOKEN` → `GH_TOKEN` → `GITHUB_TOKEN` +1. **Stored OAuth credentials** - From previous `copilot` CLI login +1. **GitHub CLI** - `gh auth` credentials + +## Disabling auto-login + +To prevent the SDK from automatically using stored credentials or `gh` CLI auth, use the `useLoggedInUser: false` option: + +
+Node.js / TypeScript + +```typescript +const client = new CopilotClient({ + useLoggedInUser: false, // Only use explicit tokens +}); +``` + +
+ +
+Python + + +```python +from copilot import CopilotClient + +client = CopilotClient({ + "use_logged_in_user": False, +}) +``` + + +```python +client = CopilotClient({ + "use_logged_in_user": False, # Only use explicit tokens +}) +``` + +
+ +
+Go + + +```go +package main + +import copilot "github.com/github/copilot-sdk/go" + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + UseLoggedInUser: copilot.Bool(false), + }) + _ = client +} +``` + + +```go +client := copilot.NewClient(&copilot.ClientOptions{ + UseLoggedInUser: copilot.Bool(false), // Only use explicit tokens +}) +``` + +
+ +
+.NET + +```csharp +await using var client = new CopilotClient(new CopilotClientOptions +{ + UseLoggedInUser = false, // Only use explicit tokens +}); +``` + +
+ +
+Java + +```java +import com.github.copilot.sdk.CopilotClient; +import com.github.copilot.sdk.json.*; + +var client = new CopilotClient(new CopilotClientOptions() + .setUseLoggedInUser(false) // Only use explicit tokens +); +client.start().get(); +``` + +
+ +## Next steps + +* [BYOK Documentation](./byok.md) - Learn how to use your own API keys +* [Getting Started Guide](../getting-started.md) - Build your first Copilot-powered app +* [MCP Servers](../features/mcp.md) - Connect to external tools diff --git a/docs/auth/byok.md b/docs/auth/byok.md index f08ee450c..89398ea65 100644 --- a/docs/auth/byok.md +++ b/docs/auth/byok.md @@ -1,8 +1,8 @@ -# BYOK (Bring Your Own Key) +# BYOK (bring your own key) BYOK allows you to use the Copilot SDK with your own API keys from model providers, bypassing GitHub Copilot authentication. This is useful for enterprise deployments, custom model hosting, or when you want direct billing with your model provider. -## Supported Providers +## Supported providers | Provider | Type Value | Notes | |----------|------------|-------| @@ -13,7 +13,7 @@ BYOK allows you to use the Copilot SDK with your own API keys from model provide | Microsoft Foundry Local | `"openai"` | Run AI models locally on your device via OpenAI-compatible API | | Other OpenAI-compatible | `"openai"` | vLLM, LiteLLM, etc. | -## Quick Start: Azure AI Foundry +## Quick start: Azure AI Foundry Azure AI Foundry (formerly Azure OpenAI) is a common BYOK deployment target for enterprises. Here's a complete example: @@ -196,9 +196,9 @@ client.stop().get(); -## Provider Configuration Reference +## Provider configuration reference -### ProviderConfig Fields +### ProviderConfig fields | Field | Type | Description | |-------|------|-------------| @@ -209,31 +209,31 @@ client.stop().get(); | `wireApi` / `wire_api` | `"completions"` \| `"responses"` | API format (default: `"completions"`) | | `azure.apiVersion` / `azure.api_version` | string | Azure API version (default: `"2024-10-21"`) | -### Wire API Format +### Wire API format The `wireApi` setting determines which OpenAI API format to use: -- **`"completions"`** (default) - Chat Completions API (`/chat/completions`). Use for most models. -- **`"responses"`** - Responses API. Use for GPT-5 series models that support the newer responses format. +* **`"completions"`** (default) - Chat Completions API (`/chat/completions`). Use for most models. +* **`"responses"`** - Responses API. Use for GPT-5 series models that support the newer responses format. -### Type-Specific Notes +### Type-specific notes **OpenAI (`type: "openai"`)** -- Works with OpenAI API and any OpenAI-compatible endpoint -- `baseUrl` should include the full path (e.g., `https://api.openai.com/v1`) +* Works with OpenAI API and any OpenAI-compatible endpoint +* `baseUrl` should include the full path (e.g., `https://api.openai.com/v1`) **Azure (`type: "azure"`)** -- Use for native Azure OpenAI endpoints -- `baseUrl` should be just the host (e.g., `https://my-resource.openai.azure.com`) -- Do NOT include `/openai/v1` in the URL—the SDK handles path construction +* Use for native Azure OpenAI endpoints +* `baseUrl` should be just the host (e.g., `https://my-resource.openai.azure.com`) +* Do NOT include `/openai/v1` in the URL—the SDK handles path construction **Anthropic (`type: "anthropic"`)** -- For direct Anthropic API access -- Uses Claude-specific API format +* For direct Anthropic API access +* Uses Claude-specific API format -## Example Configurations +## Example configurations -### OpenAI Direct +### OpenAI direct ```typescript provider: { @@ -243,7 +243,7 @@ provider: { } ``` -### Azure OpenAI (Native Azure Endpoint) +### Azure OpenAI (native Azure endpoint) Use `type: "azure"` for endpoints at `*.openai.azure.com`: @@ -258,7 +258,7 @@ provider: { } ``` -### Azure AI Foundry (OpenAI-Compatible Endpoint) +### Azure AI Foundry (OpenAI-compatible endpoint) For Azure AI Foundry deployments with `/openai/v1/` endpoints, use `type: "openai"`: @@ -271,7 +271,7 @@ provider: { } ``` -### Ollama (Local) +### Ollama (local) ```typescript provider: { @@ -293,7 +293,8 @@ provider: { } ``` -> **Note:** Foundry Local starts on a **dynamic port** — the port is not fixed. Use `foundry service status` to confirm the port the service is currently listening on, then use that port in your `baseUrl`. +> [!NOTE] +> Foundry Local starts on a **dynamic port**—the port is not fixed. Use `foundry service status` to confirm the port the service is currently listening on, then use that port in your `baseUrl`. To get started with Foundry Local: @@ -322,7 +323,7 @@ provider: { } ``` -### Bearer Token Authentication +### Bearer token authentication Some providers require bearer token authentication instead of API keys: @@ -334,9 +335,10 @@ provider: { } ``` -> **Note:** The `bearerToken` option accepts a **static token string** only. The SDK does not refresh this token automatically. If your token expires, requests will fail and you'll need to create a new session with a fresh token. +> [!NOTE] +> The `bearerToken` option accepts a **static token string** only. The SDK does not refresh this token automatically. If your token expires, requests will fail and you'll need to create a new session with a fresh token. -## Custom Model Listing +## Custom model listing When using BYOK, the CLI server may not know which models your provider supports. You can supply a custom `onListModels` handler at the client level so that `client.listModels()` returns your provider's models in the standard `ModelInfo` format. This lets downstream consumers discover available models without querying the CLI. @@ -467,28 +469,28 @@ var client = new CopilotClient(new CopilotClientOptions() -Results are cached after the first call, just like the default behavior. The handler completely replaces the CLI's `models.list` RPC — no fallback to the server occurs. +Results are cached after the first call, just like the default behavior. The handler completely replaces the CLI's `models.list` RPC—no fallback to the server occurs. ## Limitations When using BYOK, be aware of these limitations: -### Identity Limitations +### Identity limitations BYOK authentication uses **static credentials only**. You must use an API key or static bearer token that you manage yourself. -### Feature Limitations +### Feature limitations Some Copilot features may behave differently with BYOK: -- **Model availability** - Only models supported by your provider are available -- **Rate limiting** - Subject to your provider's rate limits, not Copilot's -- **Usage tracking** - Usage is tracked by your provider, not GitHub Copilot -- **Premium requests** - Do not count against Copilot premium request quotas +* **Model availability** - Only models supported by your provider are available +* **Rate limiting** - Subject to your provider's rate limits, not Copilot's +* **Usage tracking** - Usage is tracked by your provider, not GitHub Copilot +* **Premium requests** - Do not count against Copilot premium request quotas -### Provider-Specific Limitations +### Provider-specific limitations | Provider | Limitations | |----------|-------------| @@ -499,7 +501,7 @@ Some Copilot features may behave differently with BYOK: ## Troubleshooting -### "Model not specified" Error +### "Model not specified" error When using BYOK, the `model` parameter is **required**: @@ -516,7 +518,7 @@ const session = await client.createSession({ }); ``` -### Azure Endpoint Type Confusion +### Azure endpoint type confusion For Azure OpenAI endpoints (`*.openai.azure.com`), use the correct type: @@ -574,7 +576,7 @@ provider: { } ``` -### Connection Refused (Ollama) +### Connection refused (Ollama) Ensure Ollama is running and accessible: @@ -586,7 +588,7 @@ curl http://localhost:11434/v1/models ollama serve ``` -### Connection Refused (Foundry Local) +### Connection refused (Foundry Local) Foundry Local uses a dynamic port that may change between restarts. Confirm the active port: @@ -601,13 +603,13 @@ Update your `baseUrl` to match the port shown in the output. If the service is n foundry model run phi-4-mini ``` -### Authentication Failed +### Authentication failed 1. Verify your API key is correct and not expired -2. Check the `baseUrl` matches your provider's expected format -3. For bearer tokens, ensure the full token is provided (not just a prefix) +1. Check the `baseUrl` matches your provider's expected format +1. For bearer tokens, ensure the full token is provided (not just a prefix) -## Next Steps +## Next steps -- [Authentication Overview](./index.md) - Learn about all authentication methods -- [Getting Started Guide](../getting-started.md) - Build your first Copilot-powered app +* [Authentication Overview](./index.md) - Learn about all authentication methods +* [Getting Started Guide](../getting-started.md) - Build your first Copilot-powered app diff --git a/docs/auth/index.md b/docs/auth/index.md index 5b2f667da..2d5a3914a 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -1,402 +1,6 @@ # Authentication -The GitHub Copilot SDK supports multiple authentication methods to fit different use cases. Choose the method that best matches your deployment scenario. +Choose the authentication method that best fits your deployment scenario for the GitHub Copilot SDK. -## Authentication Methods - -| Method | Use Case | Copilot Subscription Required | -|--------|----------|-------------------------------| -| [GitHub Signed-in User](#github-signed-in-user) | Interactive apps where users sign in with GitHub | Yes | -| [OAuth GitHub App](#oauth-github-app) | Apps acting on behalf of users via OAuth | Yes | -| [Environment Variables](#environment-variables) | CI/CD, automation, server-to-server | Yes | -| [BYOK (Bring Your Own Key)](./byok.md) | Using your own API keys (Azure AI Foundry, OpenAI, etc.) | No | - -## GitHub Signed-in User - -This is the default authentication method when running the Copilot CLI interactively. Users authenticate via GitHub OAuth device flow, and the SDK uses their stored credentials. - -**How it works:** -1. User runs `copilot` CLI and signs in via GitHub OAuth -2. Credentials are stored securely in the system keychain -3. SDK automatically uses stored credentials - -**SDK Configuration:** - -
-Node.js / TypeScript - -```typescript -import { CopilotClient } from "@github/copilot-sdk"; - -// Default: uses logged-in user credentials -const client = new CopilotClient(); -``` - -
- -
-Python - -```python -from copilot import CopilotClient - -# Default: uses logged-in user credentials -client = CopilotClient() -await client.start() -``` - -
- -
-Go - - -```go -package main - -import copilot "github.com/github/copilot-sdk/go" - -func main() { - // Default: uses logged-in user credentials - client := copilot.NewClient(nil) - _ = client -} -``` - - -```go -import copilot "github.com/github/copilot-sdk/go" - -// Default: uses logged-in user credentials -client := copilot.NewClient(nil) -``` - -
- -
-.NET - -```csharp -using GitHub.Copilot.SDK; - -// Default: uses logged-in user credentials -await using var client = new CopilotClient(); -``` - -
- -
-Java - -```java -import com.github.copilot.sdk.CopilotClient; - -// Default: uses logged-in user credentials -var client = new CopilotClient(); -client.start().get(); -``` - -
- -**When to use:** -- Desktop applications where users interact directly -- Development and testing environments -- Any scenario where a user can sign in interactively - -## OAuth GitHub App - -Use an OAuth GitHub App to authenticate users through your application and pass their credentials to the SDK. This enables your application to make Copilot API requests on behalf of users who authorize your app. - -**How it works:** -1. User authorizes your OAuth GitHub App -2. Your app receives a user access token (`gho_` or `ghu_` prefix) -3. Pass the token to the SDK via `gitHubToken` option - -**SDK Configuration:** - -
-Node.js / TypeScript - -```typescript -import { CopilotClient } from "@github/copilot-sdk"; - -const client = new CopilotClient({ - gitHubToken: userAccessToken, // Token from OAuth flow - useLoggedInUser: false, // Don't use stored CLI credentials -}); -``` - -
- -
-Python - -```python -from copilot import CopilotClient - -client = CopilotClient({ - "github_token": user_access_token, # Token from OAuth flow - "use_logged_in_user": False, # Don't use stored CLI credentials -}) -await client.start() -``` - -
- -
-Go - - -```go -package main - -import copilot "github.com/github/copilot-sdk/go" - -func main() { - userAccessToken := "token" - client := copilot.NewClient(&copilot.ClientOptions{ - GitHubToken: userAccessToken, - UseLoggedInUser: copilot.Bool(false), - }) - _ = client -} -``` - - -```go -import copilot "github.com/github/copilot-sdk/go" - -client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: userAccessToken, // Token from OAuth flow - UseLoggedInUser: copilot.Bool(false), // Don't use stored CLI credentials -}) -``` - -
- -
-.NET - - -```csharp -using GitHub.Copilot.SDK; - -var userAccessToken = "token"; -await using var client = new CopilotClient(new CopilotClientOptions -{ - GithubToken = userAccessToken, - UseLoggedInUser = false, -}); -``` - - -```csharp -using GitHub.Copilot.SDK; - -await using var client = new CopilotClient(new CopilotClientOptions -{ - GithubToken = userAccessToken, // Token from OAuth flow - UseLoggedInUser = false, // Don't use stored CLI credentials -}); -``` - -
- -
-Java - -```java -import com.github.copilot.sdk.CopilotClient; -import com.github.copilot.sdk.json.*; - -var client = new CopilotClient(new CopilotClientOptions() - .setGitHubToken(userAccessToken) // Token from OAuth flow - .setUseLoggedInUser(false) // Don't use stored CLI credentials -); -client.start().get(); -``` - -
- -**Supported token types:** -- `gho_` - OAuth user access tokens -- `ghu_` - GitHub App user access tokens -- `github_pat_` - Fine-grained personal access tokens - -**Not supported:** -- `ghp_` - Classic personal access tokens (deprecated) - -**When to use:** -- Web applications where users sign in via GitHub -- SaaS applications building on top of Copilot -- Any multi-user application where you need to make requests on behalf of different users - -## Environment Variables - -For automation, CI/CD pipelines, and server-to-server scenarios, you can authenticate using environment variables. - -**Supported environment variables (in priority order):** -1. `COPILOT_GITHUB_TOKEN` - Recommended for explicit Copilot usage -2. `GH_TOKEN` - GitHub CLI compatible -3. `GITHUB_TOKEN` - GitHub Actions compatible - -**How it works:** -1. Set one of the supported environment variables with a valid token -2. The SDK automatically detects and uses the token - -**SDK Configuration:** - -No code changes needed—the SDK automatically detects environment variables: - -
-Node.js / TypeScript - -```typescript -import { CopilotClient } from "@github/copilot-sdk"; - -// Token is read from environment variable automatically -const client = new CopilotClient(); -``` - -
- -
-Python - -```python -from copilot import CopilotClient - -# Token is read from environment variable automatically -client = CopilotClient() -await client.start() -``` - -
- -**When to use:** -- CI/CD pipelines (GitHub Actions, Jenkins, etc.) -- Automated testing -- Server-side applications with service accounts -- Development when you don't want to use interactive login - -## BYOK (Bring Your Own Key) - -BYOK allows you to use your own API keys from model providers like Azure AI Foundry, OpenAI, or Anthropic. This bypasses GitHub Copilot authentication entirely. - -**Key benefits:** -- No GitHub Copilot subscription required -- Use enterprise model deployments -- Direct billing with your model provider -- Support for Azure AI Foundry, OpenAI, Anthropic, and OpenAI-compatible endpoints - -**See the [BYOK documentation](./byok.md) for complete details**, including: -- Azure AI Foundry setup -- Provider configuration options -- Limitations and considerations -- Complete code examples - -## Authentication Priority - -When multiple authentication methods are available, the SDK uses them in this priority order: - -1. **Explicit `gitHubToken`** - Token passed directly to SDK constructor -2. **HMAC key** - `CAPI_HMAC_KEY` or `COPILOT_HMAC_KEY` environment variables -3. **Direct API token** - `GITHUB_COPILOT_API_TOKEN` with `COPILOT_API_URL` -4. **Environment variable tokens** - `COPILOT_GITHUB_TOKEN` → `GH_TOKEN` → `GITHUB_TOKEN` -5. **Stored OAuth credentials** - From previous `copilot` CLI login -6. **GitHub CLI** - `gh auth` credentials - -## Disabling Auto-Login - -To prevent the SDK from automatically using stored credentials or `gh` CLI auth, use the `useLoggedInUser: false` option: - -
-Node.js / TypeScript - -```typescript -const client = new CopilotClient({ - useLoggedInUser: false, // Only use explicit tokens -}); -``` - -
- -
-Python - - -```python -from copilot import CopilotClient - -client = CopilotClient({ - "use_logged_in_user": False, -}) -``` - - -```python -client = CopilotClient({ - "use_logged_in_user": False, # Only use explicit tokens -}) -``` - -
- -
-Go - - -```go -package main - -import copilot "github.com/github/copilot-sdk/go" - -func main() { - client := copilot.NewClient(&copilot.ClientOptions{ - UseLoggedInUser: copilot.Bool(false), - }) - _ = client -} -``` - - -```go -client := copilot.NewClient(&copilot.ClientOptions{ - UseLoggedInUser: copilot.Bool(false), // Only use explicit tokens -}) -``` - -
- -
-.NET - -```csharp -await using var client = new CopilotClient(new CopilotClientOptions -{ - UseLoggedInUser = false, // Only use explicit tokens -}); -``` - -
- -
-Java - -```java -import com.github.copilot.sdk.CopilotClient; -import com.github.copilot.sdk.json.*; - -var client = new CopilotClient(new CopilotClientOptions() - .setUseLoggedInUser(false) // Only use explicit tokens -); -client.start().get(); -``` - -
- -## Next Steps - -- [BYOK Documentation](./byok.md) - Learn how to use your own API keys -- [Getting Started Guide](../getting-started.md) - Build your first Copilot-powered app -- [MCP Servers](../features/mcp.md) - Connect to external tools +* [Authenticate Copilot SDK](authenticate.md): methods, priority order, and examples +* [Bring your own key (BYOK)](./byok.md): use your own API keys from OpenAI, Azure, Anthropic, and more diff --git a/docs/features/agent-loop.md b/docs/features/agent-loop.md index 0f0c2bbd0..ec276f61f 100644 --- a/docs/features/agent-loop.md +++ b/docs/features/agent-loop.md @@ -1,188 +1,188 @@ -# The Agent Loop - -How the Copilot CLI processes a user message end-to-end: from prompt to `session.idle`. - -## Architecture - -```mermaid -graph LR - App["Your App"] -->|send prompt| SDK["SDK Session"] - SDK -->|JSON-RPC| CLI["Copilot CLI"] - CLI -->|API calls| LLM["LLM"] - LLM -->|response| CLI - CLI -->|events| SDK - SDK -->|events| App -``` - -The **SDK** is a transport layer — it sends your prompt to the **Copilot CLI** over JSON-RPC and surfaces events back to your app. The **CLI** is the orchestrator that runs the agentic tool-use loop, making one or more LLM API calls until the task is done. - -## The Tool-Use Loop - -When you call `session.send({ prompt })`, the CLI enters a loop: - -```mermaid -flowchart TD - A["User prompt"] --> B["LLM API call\n(= one turn)"] - B --> C{"toolRequests\nin response?"} - C -->|Yes| D["Execute tools\nCollect results"] - D -->|"Results fed back\nas next turn input"| B - C -->|No| E["Final text\nresponse"] - E --> F(["session.idle"]) - - style B fill:#1a1a2e,stroke:#58a6ff,color:#c9d1d9 - style D fill:#1a1a2e,stroke:#3fb950,color:#c9d1d9 - style F fill:#0d1117,stroke:#f0883e,color:#f0883e -``` - -The model sees the **full conversation history** on each call — system prompt, user message, and all prior tool calls and results. - -**Key insight:** Each iteration of this loop is exactly one LLM API call, visible as one `assistant.turn_start` / `assistant.turn_end` pair in the event log. There are no hidden calls. - -## Turns — What They Are - -A **turn** is a single LLM API call and its consequences: - -1. The CLI sends the conversation history to the LLM -2. The LLM responds (possibly with tool requests) -3. If tools were requested, the CLI executes them -4. `assistant.turn_end` is emitted - -A single user message typically results in **multiple turns**. For example, a question like "how does X work in this codebase?" might produce: - -| Turn | What the model does | toolRequests? | -|------|-------------------|---------------| -| 1 | Calls `grep` and `glob` to search the codebase | ✅ Yes | -| 2 | Reads specific files based on search results | ✅ Yes | -| 3 | Reads more files for deeper context | ✅ Yes | -| 4 | Produces the final text answer | ❌ No → loop ends | - -The model decides on each turn whether to request more tools or produce a final answer. Each call sees the **full accumulated context** (all prior tool calls and results), so it can make an informed decision about whether it has enough information. - -## Event Flow for a Multi-Turn Interaction - -```mermaid -flowchart TD - send["session.send({ prompt: "Fix the bug in auth.ts" })"] - - subgraph Turn1 ["Turn 1"] - t1s["assistant.turn_start"] - t1m["assistant.message (toolRequests)"] - t1ts["tool.execution_start (read_file)"] - t1tc["tool.execution_complete"] - t1e["assistant.turn_end"] - t1s --> t1m --> t1ts --> t1tc --> t1e - end - - subgraph Turn2 ["Turn 2 — auto-triggered by CLI"] - t2s["assistant.turn_start"] - t2m["assistant.message (toolRequests)"] - t2ts["tool.execution_start (edit_file)"] - t2tc["tool.execution_complete"] - t2e["assistant.turn_end"] - t2s --> t2m --> t2ts --> t2tc --> t2e - end - - subgraph Turn3 ["Turn 3"] - t3s["assistant.turn_start"] - t3m["assistant.message (no toolRequests)\n"Done, here's what I changed""] - t3e["assistant.turn_end"] - t3s --> t3m --> t3e - end - - idle(["session.idle — ready for next message"]) - - send --> Turn1 --> Turn2 --> Turn3 --> idle -``` - -## Who Triggers Each Turn? - -| Actor | Responsibility | -|-------|---------------| -| **Your app** | Sends the initial prompt via `session.send()` | -| **Copilot CLI** | Runs the tool-use loop — executes tools and feeds results back to the LLM for the next turn | -| **LLM** | Decides whether to request tools (continue looping) or produce a final response (stop) | -| **SDK** | Passes events through; does not control the loop | - -The CLI is purely mechanical: "model asked for tools → execute → call model again." The **model** is the decision-maker for when to stop. - -## `session.idle` vs `session.task_complete` - -These are two different completion signals with very different guarantees: - -### `session.idle` - -- **Always emitted** when the tool-use loop ends -- **Ephemeral** — not persisted to disk, not replayed on session resume -- Means: "the agent has stopped processing and is ready for the next message" -- **Use this** as your reliable "done" signal - -The SDK's `sendAndWait()` method waits for this event: - -```typescript -// Blocks until session.idle fires -const response = await session.sendAndWait({ prompt: "Fix the bug" }); -``` - -### `session.task_complete` - -- **Optionally emitted** — requires the model to explicitly signal it -- **Persisted** — saved to the session event log on disk -- Means: "the agent considers the overall task fulfilled" -- Carries an optional `summary` field - -```typescript -session.on("session.task_complete", (event) => { - console.log("Task done:", event.data.summary); -}); -``` - -### Autopilot mode: the CLI nudges for `task_complete` - -In **autopilot mode** (headless/autonomous operation), the CLI actively tracks whether the model has called `task_complete`. If the tool-use loop ends without it, the CLI injects a synthetic user message nudging the model: - -> *"You have not yet marked the task as complete using the task_complete tool. If you were planning, stop planning and start implementing. You aren't done until you have fully completed the task."* - -This effectively restarts the tool-use loop — the model sees the nudge as a new user message and continues working. The nudge also instructs the model **not** to call `task_complete` prematurely: - -- Don't call it if you have open questions — make decisions and keep working -- Don't call it if you hit an error — try to resolve it -- Don't call it if there are remaining steps — complete them first - -This creates a **two-level completion mechanism** in autopilot: -1. The model calls `task_complete` with a summary → CLI emits `session.task_complete` → done -2. The model stops without calling it → CLI nudges → model continues or calls `task_complete` - -### Why `task_complete` might not appear - -In **interactive mode** (normal chat), the CLI does not nudge for `task_complete`. The model may skip it entirely. Common reasons: - -- **Conversational Q&A**: The model answers a question and simply stops — there's no discrete "task" to complete -- **Model discretion**: The model produces a final text response without calling the task-complete signal -- **Interrupted sessions**: The session ends before the model reaches a completion point - -The CLI emits `session.idle` regardless, because it's a mechanical signal (the loop ended), not a semantic one (the model thinks it's done). - -### Which should you use? - -| Use case | Signal | -|----------|--------| -| "Wait for the agent to finish processing" | `session.idle` ✅ | -| "Know when a coding task is done" | `session.task_complete` (best-effort) | -| "Timeout/error handling" | `session.idle` + `session.error` ✅ | - -## Counting LLM Calls - -The number of `assistant.turn_start` / `assistant.turn_end` pairs in the event log equals the total number of LLM API calls made. There are no hidden calls for planning, evaluation, or completion checking. - -To inspect turn count for a session: - -```bash -# Count turns in a session's event log -grep -c "assistant.turn_start" ~/.copilot/session-state//events.jsonl -``` - -## Further Reading - -- [Streaming Events Reference](./streaming-events.md) — Full field-level reference for every event type -- [Session Persistence](./session-persistence.md) — How sessions are saved and resumed -- [Hooks](./hooks.md) — Intercepting events in the loop (permissions, tools) +# The agent loop + +How the Copilot CLI processes a user message end-to-end: from prompt to `session.idle`. + +## Architecture + +```mermaid +graph LR + App["Your App"] -->|send prompt| SDK["SDK Session"] + SDK -->|JSON-RPC| CLI["Copilot CLI"] + CLI -->|API calls| LLM["LLM"] + LLM -->|response| CLI + CLI -->|events| SDK + SDK -->|events| App +``` + +The **SDK** is a transport layer—it sends your prompt to the **Copilot CLI** over JSON-RPC and surfaces events back to your app. The **CLI** is the orchestrator that runs the agentic tool-use loop, making one or more LLM API calls until the task is done. + +## The tool-use loop + +When you call `session.send({ prompt })`, the CLI enters a loop: + +```mermaid +flowchart TD + A["User prompt"] --> B["LLM API call\n(= one turn)"] + B --> C{"toolRequests\nin response?"} + C -->|Yes| D["Execute tools\nCollect results"] + D -->|"Results fed back\nas next turn input"| B + C -->|No| E["Final text\nresponse"] + E --> F(["session.idle"]) + + style B fill:#1a1a2e,stroke:#58a6ff,color:#c9d1d9 + style D fill:#1a1a2e,stroke:#3fb950,color:#c9d1d9 + style F fill:#0d1117,stroke:#f0883e,color:#f0883e +``` + +The model sees the **full conversation history** on each call—system prompt, user message, and all prior tool calls and results. + +**Key insight:** Each iteration of this loop is exactly one LLM API call, visible as one `assistant.turn_start` / `assistant.turn_end` pair in the event log. There are no hidden calls. + +## Turns—what they are + +A **turn** is a single LLM API call and its consequences: + +1. The CLI sends the conversation history to the LLM +1. The LLM responds (possibly with tool requests) +1. If tools were requested, the CLI executes them +1. `assistant.turn_end` is emitted + +A single user message typically results in **multiple turns**. For example, a question like "how does X work in this codebase?" might produce: + +| Turn | What the model does | toolRequests? | +|------|-------------------|---------------| +| 1 | Calls `grep` and `glob` to search the codebase | ✅ Yes | +| 2 | Reads specific files based on search results | ✅ Yes | +| 3 | Reads more files for deeper context | ✅ Yes | +| 4 | Produces the final text answer | ❌ No → loop ends | + +The model decides on each turn whether to request more tools or produce a final answer. Each call sees the **full accumulated context** (all prior tool calls and results), so it can make an informed decision about whether it has enough information. + +## Event flow for a multi-turn interaction + +```mermaid +flowchart TD + send["session.send({ prompt: "Fix the bug in auth.ts" })"] + + subgraph Turn1 ["Turn 1"] + t1s["assistant.turn_start"] + t1m["assistant.message (toolRequests)"] + t1ts["tool.execution_start (read_file)"] + t1tc["tool.execution_complete"] + t1e["assistant.turn_end"] + t1s --> t1m --> t1ts --> t1tc --> t1e + end + + subgraph Turn2 ["Turn 2 — auto-triggered by CLI"] + t2s["assistant.turn_start"] + t2m["assistant.message (toolRequests)"] + t2ts["tool.execution_start (edit_file)"] + t2tc["tool.execution_complete"] + t2e["assistant.turn_end"] + t2s --> t2m --> t2ts --> t2tc --> t2e + end + + subgraph Turn3 ["Turn 3"] + t3s["assistant.turn_start"] + t3m["assistant.message (no toolRequests)\n"Done, here's what I changed""] + t3e["assistant.turn_end"] + t3s --> t3m --> t3e + end + + idle(["session.idle — ready for next message"]) + + send --> Turn1 --> Turn2 --> Turn3 --> idle +``` + +## Who triggers each turn? + +| Actor | Responsibility | +|-------|---------------| +| **Your app** | Sends the initial prompt via `session.send()` | +| **Copilot CLI** | Runs the tool-use loop—executes tools and feeds results back to the LLM for the next turn | +| **LLM** | Decides whether to request tools (continue looping) or produce a final response (stop) | +| **SDK** | Passes events through; does not control the loop | + +The CLI is purely mechanical: "model asked for tools → execute → call model again." The **model** is the decision-maker for when to stop. + +## `session.idle` vs `session.task_complete` + +These are two different completion signals with very different guarantees: + +### `session.idle` + +* **Always emitted** when the tool-use loop ends +* **Ephemeral**: not persisted to disk, not replayed on session resume +* Means: "the agent has stopped processing and is ready for the next message" +* **Use this** as your reliable "done" signal + +The SDK's `sendAndWait()` method waits for this event: + +```typescript +// Blocks until session.idle fires +const response = await session.sendAndWait({ prompt: "Fix the bug" }); +``` + +### `session.task_complete` + +* **Optionally emitted**: requires the model to explicitly signal it +* **Persisted**: saved to the session event log on disk +* Means: "the agent considers the overall task fulfilled" +* Carries an optional `summary` field + +```typescript +session.on("session.task_complete", (event) => { + console.log("Task done:", event.data.summary); +}); +``` + +### Autopilot mode: the CLI nudges for `task_complete` + +In **autopilot mode** (headless/autonomous operation), the CLI actively tracks whether the model has called `task_complete`. If the tool-use loop ends without it, the CLI injects a synthetic user message nudging the model: + +> *"You have not yet marked the task as complete using the task_complete tool. If you were planning, stop planning and start implementing. You aren't done until you have fully completed the task."* + +This effectively restarts the tool-use loop—the model sees the nudge as a new user message and continues working. The nudge also instructs the model **not** to call `task_complete` prematurely: + +* Don't call it if you have open questions—make decisions and keep working +* Don't call it if you hit an error—try to resolve it +* Don't call it if there are remaining steps—complete them first + +This creates a **two-level completion mechanism** in autopilot: +1. The model calls `task_complete` with a summary → CLI emits `session.task_complete` → done +1. The model stops without calling it → CLI nudges → model continues or calls `task_complete` + +### Why `task_complete` might not appear + +In **interactive mode** (normal chat), the CLI does not nudge for `task_complete`. The model may skip it entirely. Common reasons: + +* **Conversational Q&A**: The model answers a question and simply stops—there's no discrete "task" to complete +* **Model discretion**: The model produces a final text response without calling the task-complete signal +* **Interrupted sessions**: The session ends before the model reaches a completion point + +The CLI emits `session.idle` regardless, because it's a mechanical signal (the loop ended), not a semantic one (the model thinks it's done). + +### Which should you use? + +| Use case | Signal | +|----------|--------| +| "Wait for the agent to finish processing" | `session.idle` ✅ | +| "Know when a coding task is done" | `session.task_complete` (best-effort) | +| "Timeout/error handling" | `session.idle` + `session.error` ✅ | + +## Counting LLM calls + +The number of `assistant.turn_start` / `assistant.turn_end` pairs in the event log equals the total number of LLM API calls made. There are no hidden calls for planning, evaluation, or completion checking. + +To inspect turn count for a session: + +```bash +# Count turns in a session's event log +grep -c "assistant.turn_start" ~/.copilot/session-state//events.jsonl +``` + +## Further reading + +* [Streaming Events Reference](./streaming-events.md): Full field-level reference for every event type +* [Session Persistence](./session-persistence.md): How sessions are saved and resumed +* [Hooks](./hooks.md): Intercepting events in the loop (permissions, tools) diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index 0d27fe873..c36b856a4 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -1,10 +1,10 @@ -# Custom Agents & Sub-Agent Orchestration +# Custom agents and sub-agent orchestration Define specialized agents with scoped tools and prompts, then let Copilot orchestrate them as sub-agents within a single session. ## Overview -Custom agents are lightweight agent definitions you attach to a session. Each agent has its own system prompt, tool restrictions, and optional MCP servers. When a user's request matches an agent's expertise, the Copilot runtime automatically delegates to that agent as a **sub-agent** — running it in an isolated context while streaming lifecycle events back to the parent session. +Custom agents are lightweight agent definitions you attach to a session. Each agent has its own system prompt, tool restrictions, and optional MCP servers. When a user's request matches an agent's expertise, the Copilot runtime automatically delegates to that agent as a **sub-agent**—running it in an isolated context while streaming lifecycle events back to the parent session. ```mermaid flowchart TD @@ -23,7 +23,7 @@ flowchart TD | **Inference** | The runtime's ability to auto-select an agent based on the user's intent | | **Parent session** | The session that spawned the sub-agent; receives all lifecycle events | -## Defining Custom Agents +## Defining custom agents Pass `customAgents` when creating a session. Each agent needs at minimum a `name` and `prompt`. @@ -241,20 +241,21 @@ try (var client = new CopilotClient()) { -## Configuration Reference +## Configuration reference | Property | Type | Required | Description | |----------|------|----------|-------------| | `name` | `string` | ✅ | Unique identifier for the agent | | `displayName` | `string` | | Human-readable name shown in events | -| `description` | `string` | | What the agent does — helps the runtime select it | +| `description` | `string` | | What the agent does—helps the runtime select it | | `tools` | `string[]` or `null` | | Tool names the agent can use. `null` or omitted = all tools | | `prompt` | `string` | ✅ | System prompt for the agent | | `mcpServers` | `object` | | MCP server configurations specific to this agent | | `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) | | `skills` | `string[]` | | Skill names to preload into the agent's context at startup | -> **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. +> [!TIP] +> A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. In addition to per-agent configuration above, you can set `agent` on the **session config** itself to pre-select which custom agent is active when the session starts. See [Selecting an Agent at Session Creation](#selecting-an-agent-at-session-creation) below. @@ -262,9 +263,9 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi |-------------------------|------|-------------| | `agent` | `string` | Name of the custom agent to pre-select at session creation. Must match a `name` in `customAgents`. | -## Per-Agent Skills +## Per-agent skills -You can preload skills into an agent's context using the `skills` property. When specified, the **full content** of each listed skill is eagerly injected into the agent's context at startup — the agent doesn't need to invoke a skill tool; the instructions are already present. Skills are **opt-in**: agents receive no skills by default, and sub-agents do not inherit skills from the parent. Skill names are resolved from the session-level `skillDirectories`. +You can preload skills into an agent's context using the `skills` property. When specified, the **full content** of each listed skill is eagerly injected into the agent's context at startup—the agent doesn't need to invoke a skill tool; the instructions are already present. Skills are **opt-in**: agents receive no skills by default, and sub-agents do not inherit skills from the parent. Skill names are resolved from the session-level `skillDirectories`. ```typescript const session = await client.createSession({ @@ -289,7 +290,7 @@ const session = await client.createSession({ In this example, `security-auditor` starts with `security-scan` and `dependency-check` already injected into its context, while `docs-writer` starts with `markdown-lint`. An agent without a `skills` field receives no skill content. -## Selecting an Agent at Session Creation +## Selecting an agent at session creation You can pass `agent` in the session config to pre-select which custom agent should be active when the session starts. The value must match the `name` of one of the agents defined in `customAgents`. @@ -405,19 +406,19 @@ var session = client.createSession( -## How Sub-Agent Delegation Works +## How sub-agent delegation works When you send a prompt to a session with custom agents, the runtime evaluates whether to delegate to a sub-agent: -1. **Intent matching** — The runtime analyzes the user's prompt against each agent's `name` and `description` -2. **Agent selection** — If a match is found and `infer` is not `false`, the runtime selects the agent -3. **Isolated execution** — The sub-agent runs with its own prompt and restricted tool set -4. **Event streaming** — Lifecycle events (`subagent.started`, `subagent.completed`, etc.) stream back to the parent session -5. **Result integration** — The sub-agent's output is incorporated into the parent agent's response +1. **Intent matching**—The runtime analyzes the user's prompt against each agent's `name` and `description` +1. **Agent selection**—If a match is found and `infer` is not `false`, the runtime selects the agent +1. **Isolated execution**—The sub-agent runs with its own prompt and restricted tool set +1. **Event streaming**—Lifecycle events (`subagent.started`, `subagent.completed`, etc.) stream back to the parent session +1. **Result integration**—The sub-agent's output is incorporated into the parent agent's response -### Controlling Inference +### Controlling inference -By default, all custom agents are available for automatic selection (`infer: true`). Set `infer: false` to prevent the runtime from auto-selecting an agent — useful for agents you only want invoked through explicit user requests: +By default, all custom agents are available for automatic selection (`infer: true`). Set `infer: false` to prevent the runtime from auto-selecting an agent—useful for agents you only want invoked through explicit user requests: ```typescript { @@ -429,11 +430,11 @@ By default, all custom agents are available for automatic selection (`infer: tru } ``` -## Listening to Sub-Agent Events +## Listening to sub-agent events When a sub-agent runs, the parent session emits lifecycle events. Subscribe to these events to build UIs that visualize agent activity. -### Event Types +### Event types | Event | Emitted when | Data | |-------|-------------|------| @@ -441,9 +442,9 @@ When a sub-agent runs, the parent session emits lifecycle events. Subscribe to t | `subagent.started` | Sub-agent begins execution | `toolCallId`, `agentName`, `agentDisplayName`, `agentDescription` | | `subagent.completed` | Sub-agent finishes successfully | `toolCallId`, `agentName`, `agentDisplayName` | | `subagent.failed` | Sub-agent encounters an error | `toolCallId`, `agentName`, `agentDisplayName`, `error` | -| `subagent.deselected` | Runtime switches away from the sub-agent | — | +| `subagent.deselected` | Runtime switches away from the sub-agent |—| -### Subscribing to Events +### Subscribing to events
Node.js / TypeScript @@ -678,7 +679,7 @@ var response = session.sendAndWait(
-## Building an Agent Tree UI +## Building an agent tree UI Sub-agent events include `toolCallId` fields that let you reconstruct the execution tree. Here's a pattern for tracking agent activity: @@ -728,7 +729,7 @@ session.on((event) => { }); ``` -## Scoping Tools per Agent +## Scoping tools per agent Use the `tools` property to restrict which tools an agent can access. This is essential for security and for keeping agents focused: @@ -757,16 +758,17 @@ const session = await client.createSession({ }); ``` -> **Note:** When `tools` is `null` or omitted, the agent inherits access to all tools configured on the session. Use explicit tool lists to enforce the principle of least privilege. +> [!NOTE] +> When `tools` is `null` or omitted, the agent inherits access to all tools configured on the session. Use explicit tool lists to enforce the principle of least privilege. -## Agent-Exclusive Tools +## Agent-exclusive tools Use the `defaultAgent` property on the session configuration to hide specific tools from the default agent (the built-in agent that handles turns when no custom agent is selected). This forces the main agent to delegate to sub-agents when those tools' capabilities are needed, keeping the main agent's context clean. This is useful when: -- Certain tools generate large amounts of context that would overwhelm the main agent -- You want the main agent to act as an orchestrator, delegating heavy work to specialized sub-agents -- You need strict separation between orchestration and execution +* Certain tools generate large amounts of context that would overwhelm the main agent +* You want the main agent to act as an orchestrator, delegating heavy work to specialized sub-agents +* You need strict separation between orchestration and execution
Node.js / TypeScript @@ -883,31 +885,32 @@ var session = await client.CreateSessionAsync(new SessionConfig
-### How It Works +### How it works Tools listed in `defaultAgent.excludedTools`: -1. **Are registered** — their handlers are available for execution -2. **Are hidden** from the main agent's tool list — the LLM won't see or call them directly -3. **Remain available** to any custom sub-agent that includes them in its `tools` array +1. **Are registered**—their handlers are available for execution +1. **Are hidden** from the main agent's tool list—the LLM won't see or call them directly +1. **Remain available** to any custom sub-agent that includes them in its `tools` array -### Interaction with Other Tool Filters +### Interaction with other tool filters `defaultAgent.excludedTools` is orthogonal to the session-level `availableTools` and `excludedTools`: | Filter | Scope | Effect | |--------|-------|--------| -| `availableTools` | Session-wide | Allowlist — only these tools exist for anyone | -| `excludedTools` | Session-wide | Blocklist — these tools are blocked for everyone | +| `availableTools` | Session-wide | Allowlist—only these tools exist for anyone | +| `excludedTools` | Session-wide | Blocklist—these tools are blocked for everyone | | `defaultAgent.excludedTools` | Main agent only | These tools are hidden from the main agent but available to sub-agents | Precedence: 1. Session-level `availableTools`/`excludedTools` are applied first (globally) -2. `defaultAgent.excludedTools` is applied on top, further restricting the main agent only +1. `defaultAgent.excludedTools` is applied on top, further restricting the main agent only -> **Note:** If a tool is in both `excludedTools` (session-level) and `defaultAgent.excludedTools`, the session-level exclusion takes precedence — the tool is unavailable to everyone. +> [!NOTE] +> If a tool is in both `excludedTools` (session-level) and `defaultAgent.excludedTools`, the session-level exclusion takes precedence—the tool is unavailable to everyone. -## Attaching MCP Servers to Agents +## Attaching MCP servers to agents Each custom agent can have its own MCP (Model Context Protocol) servers, giving it access to specialized data sources: @@ -929,7 +932,7 @@ const session = await client.createSession({ }); ``` -## Patterns & Best Practices +## Patterns and best practices ### Pair a researcher with an editor diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 826ee5efd..d8684d202 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -1,6 +1,6 @@ -# Working with Hooks +# Working with hooks -Hooks let you plug custom logic into every stage of a Copilot session — from the moment it starts, through each user prompt and tool call, to the moment it ends. This guide walks through practical use cases so you can ship permissions, auditing, notifications, and more without modifying the core agent behavior. +Hooks let you plug custom logic into every stage of a Copilot session—from the moment it starts, through each user prompt and tool call, to the moment it ends. This guide walks through practical use cases so you can ship permissions, auditing, notifications, and more without modifying the core agent behavior. ## Overview @@ -28,9 +28,9 @@ flowchart LR | [`onSessionEnd`](../hooks/session-lifecycle.md#session-end) | Session ends | Clean up, record metrics | | [`onErrorOccurred`](../hooks/error-handling.md) | An error is raised | Custom logging, retry logic, alerts | -All hooks are **optional** — register only the ones you need. Returning `null` (or the language equivalent) from any hook tells the SDK to continue with default behavior. +All hooks are **optional**—register only the ones you need. Returning `null` (or the language equivalent) from any hook tells the SDK to continue with default behavior. -## Registering Hooks +## Registering hooks Pass a `hooks` object when you create (or resume) a session. Every example below follows this pattern. @@ -223,11 +223,10 @@ try (var client = new CopilotClient()) { -> **Tip:** Every hook handler receives an `invocation` parameter containing the `sessionId`, which is useful for correlating logs and maintaining per-session state. +> [!TIP] +> Every hook handler receives an `invocation` parameter containing the `sessionId`, which is useful for correlating logs and maintaining per-session state. ---- - -## Use Case: Permission Control +## Use case: permission control Use `onPreToolUse` to build a permission layer that decides which tools the agent may run, what arguments are allowed, and whether the user should be prompted before execution. @@ -486,11 +485,9 @@ const session = await client.createSession({ }); ``` -Returning `"ask"` delegates the decision to the user at runtime — useful for destructive actions where you want a human in the loop. - ---- +Returning `"ask"` delegates the decision to the user at runtime—useful for destructive actions where you want a human in the loop. -## Use Case: Auditing & Compliance +## Use case: auditing and compliance Combine `onPreToolUse`, `onPostToolUse`, and the session lifecycle hooks to build a complete audit trail that records every action the agent takes. @@ -668,11 +665,9 @@ const session = await client.createSession({ }); ``` ---- +## Use case: notifications and sounds -## Use Case: Notifications & Sounds - -Hooks fire in your application's process, so you can trigger any side-effect — desktop notifications, sounds, Slack messages, or webhook calls. +Hooks fire in your application's process, so you can trigger any side-effect—desktop notifications, sounds, Slack messages, or webhook calls. ### Desktop notification on session events @@ -783,9 +778,7 @@ const session = await client.createSession({ }); ``` ---- - -## Use Case: Prompt Enrichment +## Use case: prompt enrichment Use `onSessionStart` and `onUserPromptSubmitted` to automatically inject context so users don't have to repeat themselves. @@ -837,11 +830,9 @@ const session = await client.createSession({ }); ``` ---- +## Use case: error handling and recovery -## Use Case: Error Handling & Recovery - -The `onErrorOccurred` hook gives you a chance to react to failures — whether that means retrying, notifying a human, or gracefully shutting down. +The `onErrorOccurred` hook gives you a chance to react to failures—whether that means retrying, notifying a human, or gracefully shutting down. ### Retry transient model errors @@ -884,11 +875,9 @@ const session = await client.createSession({ }); ``` ---- - -## Use Case: Session Metrics +## Use case: session metrics -Track how long sessions run, how many tools are invoked, and why sessions end — useful for dashboards and cost monitoring. +Track how long sessions run, how many tools are invoked, and why sessions end—useful for dashboards and cost monitoring.
Node.js / TypeScript @@ -979,11 +968,9 @@ session = await client.create_session(
---- - -## Combining Hooks +## Combining hooks -Hooks compose naturally. A single `hooks` object can handle permissions **and** auditing **and** notifications — each hook does its own job. +Hooks compose naturally. A single `hooks` object can handle permissions **and** auditing **and** notifications—each hook does its own job. ```typescript const session = await client.createSession({ @@ -1016,34 +1003,34 @@ const session = await client.createSession({ }); ``` -## Best Practices +## Best practices -1. **Keep hooks fast.** Every hook runs inline — slow hooks delay the conversation. Offload heavy work (database writes, HTTP calls) to a background queue when possible. +1. **Keep hooks fast.** Every hook runs inline—slow hooks delay the conversation. Offload heavy work (database writes, HTTP calls) to a background queue when possible. -2. **Return `null` when you have nothing to change.** This tells the SDK to proceed with defaults and avoids unnecessary object allocation. +1. **Return `null` when you have nothing to change.** This tells the SDK to proceed with defaults and avoids unnecessary object allocation. -3. **Be explicit with permission decisions.** Returning `{ permissionDecision: "allow" }` is clearer than returning `null`, even though both allow the tool. +1. **Be explicit with permission decisions.** Returning `{ permissionDecision: "allow" }` is clearer than returning `null`, even though both allow the tool. -4. **Don't swallow critical errors.** It's fine to suppress recoverable tool errors, but always log or alert on unrecoverable ones. +1. **Don't swallow critical errors.** It's fine to suppress recoverable tool errors, but always log or alert on unrecoverable ones. -5. **Use `additionalContext` instead of `modifiedPrompt` when possible.** Appending context preserves the user's original intent while still guiding the model. +1. **Use `additionalContext` instead of `modifiedPrompt` when possible.** Appending context preserves the user's original intent while still guiding the model. -6. **Scope state by session ID.** If you track per-session data, key it on `invocation.sessionId` and clean up in `onSessionEnd`. +1. **Scope state by session ID.** If you track per-session data, key it on `invocation.sessionId` and clean up in `onSessionEnd`. ## Reference For full type definitions, input/output field tables, and additional examples for every hook, see the API reference: -- [Hooks Overview](../hooks/index.md) -- [Pre-Tool Use](../hooks/pre-tool-use.md) -- [Post-Tool Use](../hooks/post-tool-use.md) -- [User Prompt Submitted](../hooks/user-prompt-submitted.md) -- [Session Lifecycle](../hooks/session-lifecycle.md) -- [Error Handling](../hooks/error-handling.md) +* [Hooks Overview](../hooks/hooks-overview.md) +* [Pre-Tool Use](../hooks/pre-tool-use.md) +* [Post-Tool Use](../hooks/post-tool-use.md) +* [User Prompt Submitted](../hooks/user-prompt-submitted.md) +* [Session Lifecycle](../hooks/session-lifecycle.md) +* [Error Handling](../hooks/error-handling.md) -## See Also +## See also -- [Getting Started](../getting-started.md) -- [Custom Agents & Sub-Agent Orchestration](./custom-agents.md) -- [Streaming Session Events](./streaming-events.md) -- [Debugging Guide](../troubleshooting/debugging.md) +* [Getting Started](../getting-started.md) +* [Custom Agents & Sub-Agent Orchestration](./custom-agents.md) +* [Streaming Session Events](./streaming-events.md) +* [Debugging Guide](../troubleshooting/debugging.md) diff --git a/docs/features/image-input.md b/docs/features/image-input.md index 409130bbd..342ad3c8c 100644 --- a/docs/features/image-input.md +++ b/docs/features/image-input.md @@ -1,9 +1,9 @@ -# Image Input +# Image input Send images to Copilot sessions as attachments. There are two ways to attach images: -- **File attachment** (`type: "file"`) — provide an absolute path; the runtime reads the file from disk, converts it to base64, and sends it to the LLM. -- **Blob attachment** (`type: "blob"`) — provide base64-encoded data directly; useful when the image is already in memory (e.g., screenshots, generated images, or data from an API). +* **File attachment** (`type: "file"`): provide an absolute path; the runtime reads the file from disk, converts it to base64, and sends it to the LLM. +* **Blob attachment** (`type: "blob"`): provide base64-encoded data directly; useful when the image is already in memory (e.g., screenshots, generated images, or data from an API). ## Overview @@ -28,12 +28,12 @@ sequenceDiagram | Concept | Description | |---------|-------------| | **File attachment** | An attachment with `type: "file"` and an absolute `path` to an image on disk | -| **Blob attachment** | An attachment with `type: "blob"`, base64-encoded `data`, and a `mimeType` — no disk I/O needed | +| **Blob attachment** | An attachment with `type: "blob"`, base64-encoded `data`, and a `mimeType`—no disk I/O needed | | **Automatic encoding** | For file attachments, the runtime reads the image and converts it to base64 automatically | | **Auto-resize** | The runtime automatically resizes or quality-reduces images that exceed model-specific limits | | **Vision capability** | The model must have `capabilities.supports.vision = true` to process images | -## Quick Start — File Attachment +## Quick start—file attachment Attach an image file to any message using the file attachment type. The path must be an absolute path to an image on disk. @@ -248,7 +248,7 @@ try (var client = new CopilotClient()) { -## Quick Start — Blob Attachment +## Quick start—blob attachment When you already have image data in memory (e.g., a screenshot captured by your app, or an image fetched from an API), use a blob attachment to send it directly without writing to disk. @@ -462,23 +462,23 @@ try (var client = new CopilotClient()) { -## Supported Formats +## Supported formats Supported image formats include JPG, PNG, GIF, and other common image types. For file attachments, the runtime reads the image from disk and converts it as needed. For blob attachments, you provide the base64 data and MIME type directly. Use PNG or JPEG for best results, as these are the most widely supported formats. The model's `capabilities.limits.vision.supported_media_types` field lists the exact MIME types it accepts. -## Automatic Processing +## Automatic processing The runtime automatically processes images to fit within the model's constraints. No manual resizing is required. -- Images that exceed the model's dimension or size limits are automatically resized (preserving aspect ratio) or quality-reduced. -- If an image cannot be brought within limits after processing, it is skipped and not sent to the LLM. -- The model's `capabilities.limits.vision.max_prompt_image_size` field indicates the maximum image size in bytes. +* Images that exceed the model's dimension or size limits are automatically resized (preserving aspect ratio) or quality-reduced. +* If an image cannot be brought within limits after processing, it is skipped and not sent to the LLM. +* The model's `capabilities.limits.vision.max_prompt_image_size` field indicates the maximum image size in bytes. You can check these limits at runtime via the model capabilities object. For the best experience, use reasonably-sized PNG or JPEG images. -## Vision Model Capabilities +## Vision model capabilities Not all models support vision. Check the model's capabilities before sending images. @@ -512,7 +512,7 @@ vision?: { }; ``` -## Receiving Image Results +## Receiving image results When tools return images (e.g., screenshots or generated charts), the result contains `"image"` content blocks with base64-encoded data. @@ -524,11 +524,11 @@ When tools return images (e.g., screenshots or generated charts), the result con These image blocks appear in `tool.execution_complete` event results. See the [Streaming Events](./streaming-events.md) guide for the full event lifecycle. -## Tips & Limitations +## Tips and limitations | Tip | Details | |-----|---------| -| **Use PNG or JPEG directly** | Avoids conversion overhead — these are sent to the LLM as-is | +| **Use PNG or JPEG directly** | Avoids conversion overhead—these are sent to the LLM as-is | | **Keep images reasonably sized** | Large images may be quality-reduced, which can lose important details | | **Use absolute paths for file attachments** | The runtime reads files from disk; relative paths may not resolve correctly | | **Use blob attachments for in-memory data** | When you already have base64 data (e.g., screenshots, API responses), blob avoids unnecessary disk I/O | @@ -536,7 +536,7 @@ These image blocks appear in `tool.execution_complete` event results. See the [S | **Multiple images are supported** | Attach several attachments in one message, up to the model's `max_prompt_images` limit | | **SVG is not supported** | SVG files are text-based and excluded from image processing | -## See Also +## See also -- [Streaming Events](./streaming-events.md) — event lifecycle including tool result content blocks -- [Steering & Queueing](./steering-and-queueing.md) — sending follow-up messages with attachments +* [Streaming Events](./streaming-events.md): event lifecycle including tool result content blocks +* [Steering & Queueing](./steering-and-queueing.md): sending follow-up messages with attachments diff --git a/docs/features/index.md b/docs/features/index.md index 65a1f7535..5c97a007b 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -8,20 +8,20 @@ These guides cover the capabilities you can add to your Copilot SDK application. | Feature | Description | |---|---| -| [The Agent Loop](./agent-loop.md) | How the CLI processes a prompt — the tool-use loop, turns, and completion signals | -| [Hooks](./hooks.md) | Intercept and customize session behavior — control tool execution, transform results, handle errors | +| [The Agent Loop](./agent-loop.md) | How the CLI processes a prompt—the tool-use loop, turns, and completion signals | +| [Hooks](./hooks.md) | Intercept and customize session behavior—control tool execution, transform results, handle errors | | [Custom Agents](./custom-agents.md) | Define specialized sub-agents with scoped tools and instructions | | [MCP Servers](./mcp.md) | Integrate Model Context Protocol servers for external tool access | | [Skills](./skills.md) | Load reusable prompt modules from directories | | [Image Input](./image-input.md) | Send images to sessions as attachments | | [Streaming Events](./streaming-events.md) | Subscribe to real-time session events (40+ event types) | -| [Steering & Queueing](./steering-and-queueing.md) | Control message delivery — immediate steering vs. sequential queueing | +| [Steering & Queueing](./steering-and-queueing.md) | Control message delivery—immediate steering vs. sequential queueing | | [Session Persistence](./session-persistence.md) | Resume sessions across restarts, manage session storage | | [Remote Sessions](./remote-sessions.md) | Share sessions to GitHub web and mobile via Mission Control | ## Related -- [Hooks Reference](../hooks/index.md) — detailed API reference for each hook type -- [Integrations](../integrations/microsoft-agent-framework.md) — use the SDK with other platforms (MAF, etc.) -- [Troubleshooting](../troubleshooting/debugging.md) — when things don't work as expected -- [Compatibility](../troubleshooting/compatibility.md) — SDK vs CLI feature matrix +* [Hooks Reference](../hooks/index.md): detailed API reference for each hook type +* [Integrations](../integrations/microsoft-agent-framework.md): use the SDK with other platforms (MAF, etc.) +* [Troubleshooting](../troubleshooting/debugging.md): when things don't work as expected +* [Compatibility](../troubleshooting/compatibility.md): SDK vs CLI feature matrix diff --git a/docs/features/mcp.md b/docs/features/mcp.md index d8af04533..f6c5a7d7e 100644 --- a/docs/features/mcp.md +++ b/docs/features/mcp.md @@ -1,20 +1,21 @@ -# Using MCP Servers with the GitHub Copilot SDK +# Using MCP servers with the GitHub Copilot SDK The Copilot SDK can integrate with **MCP servers** (Model Context Protocol) to extend the assistant's capabilities with external tools. MCP servers run as separate processes and expose tools (functions) that Copilot can invoke during conversations. -> **Note:** This is an evolving feature. See [issue #36](https://github.com/github/copilot-sdk/issues/36) for ongoing discussion. +> [!NOTE] +> This is an evolving feature. See [issue #36](https://github.com/github/copilot-sdk/issues/36) for ongoing discussion. ## What is MCP? [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is an open standard for connecting AI assistants to external tools and data sources. MCP servers can: -- Execute code or scripts -- Query databases -- Access file systems -- Call external APIs -- And much more +* Execute code or scripts +* Query databases +* Access file systems +* Call external APIs +* And much more -## Server Types +## Server types The SDK supports two types of MCP servers: @@ -153,7 +154,7 @@ await using var session = await client.CreateSessionAsync(new SessionConfig }); ``` -## Tool Configuration +## Tool configuration You can control which tools are available to an MCP server using the `tools` field. @@ -165,8 +166,6 @@ Use `"*"` to enable all tools provided by the MCP server: tools: ["*"] ``` ---- - ### Allow specific tools Provide a list of tool names to restrict access: @@ -177,8 +176,6 @@ tools: ["bash", "edit"] Only the listed tools will be available to the agent. ---- - ### Disable all tools Use an empty array to disable all tools: @@ -187,14 +184,12 @@ Use an empty array to disable all tools: tools: [] ``` ---- - ### Notes -- The `tools` field defines which tools are allowed. -- There is no separate `allow` or `disallow` configuration — tool access is controlled directly through this list. +* The `tools` field defines which tools are allowed. +* There is no separate `allow` or `disallow` configuration—tool access is controlled directly through this list. -## Quick Start: Filesystem MCP Server +## Quick start: filesystem MCP server Here's a complete working example using the official [`@modelcontextprotocol/server-filesystem`](https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem) MCP server: @@ -240,11 +235,12 @@ and subdirectories including temporary system files, log files, and directories for different applications. ``` -> **Tip:** You can use any MCP server from the [MCP Servers Directory](https://github.com/modelcontextprotocol/servers). Popular options include `@modelcontextprotocol/server-github`, `@modelcontextprotocol/server-sqlite`, and `@modelcontextprotocol/server-puppeteer`. +> [!TIP] +> You can use any MCP server from the [MCP Servers Directory](https://github.com/modelcontextprotocol/servers). Popular options include `@modelcontextprotocol/server-github`, `@modelcontextprotocol/server-sqlite`, and `@modelcontextprotocol/server-puppeteer`. -## Configuration Options +## Configuration options -### Local/Stdio Server +### Local/stdio server | Property | Type | Required | Description | |----------|------|----------|-------------| @@ -256,7 +252,7 @@ directories for different applications. | `tools` | `string[]` | No | Tools to enable (`["*"]` for all, `[]` for none) | | `timeout` | `number` | No | Timeout in milliseconds | -### Remote Server (HTTP/SSE) +### Remote server (HTTP/SSE) | Property | Type | Required | Description | |----------|------|----------|-------------| @@ -271,17 +267,17 @@ directories for different applications. ### Tools not showing up or not being invoked 1. **Verify the MCP server starts correctly** - - Check that the command and args are correct - - Ensure the server process doesn't crash on startup - - Look for error output in stderr + * Check that the command and args are correct + * Ensure the server process doesn't crash on startup + * Look for error output in stderr -2. **Check tool configuration** - - Make sure `tools` is set to `["*"]` or lists the specific tools you need - - An empty array `[]` means no tools are enabled +1. **Check tool configuration** + * Make sure `tools` is set to `["*"]` or lists the specific tools you need + * An empty array `[]` means no tools are enabled -3. **Verify connectivity for remote servers** - - Ensure the URL is accessible - - Check that authentication headers are correct +1. **Verify connectivity for remote servers** + * Ensure the URL is accessible + * Check that authentication headers are correct ### Common issues @@ -294,16 +290,16 @@ directories for different applications. For detailed debugging guidance, see the **[MCP Debugging Guide](../troubleshooting/mcp-debugging.md)**. -## Related Resources +## Related resources -- [Model Context Protocol Specification](https://modelcontextprotocol.io/) -- [MCP Servers Directory](https://github.com/modelcontextprotocol/servers) - Community MCP servers -- [GitHub MCP Server](https://github.com/github/github-mcp-server) - Official GitHub MCP server -- [Getting Started Guide](../getting-started.md) - SDK basics and custom tools -- [General Debugging Guide](.../troubleshooting/mcp-debugging.md) - SDK-wide debugging +* [Model Context Protocol Specification](https://modelcontextprotocol.io/) +* [MCP Servers Directory](https://github.com/modelcontextprotocol/servers) - Community MCP servers +* [GitHub MCP Server](https://github.com/github/github-mcp-server) - Official GitHub MCP server +* [Getting Started Guide](../getting-started.md) - SDK basics and custom tools +* [General Debugging Guide](../troubleshooting/debugging.md) - SDK-wide debugging -## See Also +## See also -- [MCP Debugging Guide](../troubleshooting/mcp-debugging.md) - Detailed MCP troubleshooting -- [Issue #9](https://github.com/github/copilot-sdk/issues/9) - Original MCP tools usage question -- [Issue #36](https://github.com/github/copilot-sdk/issues/36) - MCP documentation tracking issue +* [MCP Debugging Guide](../troubleshooting/mcp-debugging.md) - Detailed MCP troubleshooting +* [Issue #9](https://github.com/github/copilot-sdk/issues/9) - Original MCP tools usage question +* [Issue #36](https://github.com/github/copilot-sdk/issues/36) - MCP documentation tracking issue diff --git a/docs/features/remote-sessions.md b/docs/features/remote-sessions.md index f3e41bd8c..7d91d9955 100644 --- a/docs/features/remote-sessions.md +++ b/docs/features/remote-sessions.md @@ -1,13 +1,13 @@ -# Remote Sessions +# Remote sessions Remote sessions let users access their Copilot session from GitHub web and mobile via [Mission Control](https://github.com). When enabled, the SDK connects each session to Mission Control, producing a URL that can be shared as a link or QR code. ## Prerequisites -- The user must be authenticated (GitHub token or logged-in user) -- The session's working directory must be a GitHub repository +* The user must be authenticated (GitHub token or logged-in user) +* The session's working directory must be a GitHub repository -## Enabling Remote Sessions +## Enabling remote sessions ### Always-on (client-level) @@ -15,7 +15,7 @@ Set `remote: true` when creating the client. Every session in a GitHub repo auto -#### **TypeScript** +#### TypeScript ```typescript @@ -34,7 +34,7 @@ session.on("session.info", (event) => { }); ``` -#### **Python** +#### Python ```python @@ -53,7 +53,7 @@ def on_event(event): session.on(on_event) ``` -#### **Go** +#### Go ```go @@ -72,7 +72,7 @@ session.On(func(event copilot.SessionEvent) { }) ``` -#### **C#** +#### C# ```csharp @@ -93,7 +93,7 @@ session.On((SessionEvent e) => }); ``` -#### **Rust** +#### Rust ```rust @@ -125,7 +125,7 @@ Use `session.rpc.remote.enable()` to start remote access mid-session, and `sessi -#### **TypeScript** +#### TypeScript ```typescript @@ -136,7 +136,7 @@ console.log("Remote URL:", result.url); await session.rpc.remote.disable(); ``` -#### **Python** +#### Python ```python @@ -147,7 +147,7 @@ print(f"Remote URL: {result.url}") await session.rpc.remote.disable() ``` -#### **Go** +#### Go ```go @@ -160,7 +160,7 @@ if result.URL != nil { err = session.RPC.Remote.Disable(ctx) ``` -#### **C#** +#### C# ```csharp @@ -171,7 +171,7 @@ Console.WriteLine($"Remote URL: {result.Url}"); await session.Rpc.Remote.DisableAsync(); ``` -#### **Rust** +#### Rust ```rust @@ -186,18 +186,18 @@ session.rpc().remote().disable().await?; -## QR Code Generation +## QR code generation -The remote URL can be rendered as a QR code for easy mobile access. The SDK provides the URL — use your preferred QR code library: +The remote URL can be rendered as a QR code for easy mobile access. The SDK provides the URL—use your preferred QR code library: -- **TypeScript**: [qrcode](https://www.npmjs.com/package/qrcode) -- **Python**: [qrcode](https://pypi.org/project/qrcode/) -- **Go**: [go-qrcode](https://github.com/skip2/go-qrcode) -- **C#**: [QRCoder](https://www.nuget.org/packages/QRCoder) -- **Rust**: [qrcode](https://crates.io/crates/qrcode) +* **TypeScript**: [qrcode](https://www.npmjs.com/package/qrcode) +* **Python**: [qrcode](https://pypi.org/project/qrcode/) +* **Go**: [go-qrcode](https://github.com/skip2/go-qrcode) +* **C#**: [QRCoder](https://www.nuget.org/packages/QRCoder) +* **Rust**: [qrcode](https://crates.io/crates/qrcode) ## Notes -- The `remote` client option only applies when the SDK spawns the CLI process. It is ignored when connecting to an external server via `cliUrl`. -- If the working directory is not a GitHub repository, remote setup is silently skipped (always-on mode) or returns an error (on-demand mode). -- Remote sessions require authentication. Ensure `gitHubToken` or `useLoggedInUser` is configured. +* The `remote` client option only applies when the SDK spawns the CLI process. It is ignored when connecting to an external server via `cliUrl`. +* If the working directory is not a GitHub repository, remote setup is silently skipped (always-on mode) or returns an error (on-demand mode). +* Remote sessions require authentication. Ensure `gitHubToken` or `useLoggedInUser` is configured. diff --git a/docs/features/session-persistence.md b/docs/features/session-persistence.md index 53caaff11..374497711 100644 --- a/docs/features/session-persistence.md +++ b/docs/features/session-persistence.md @@ -1,8 +1,8 @@ -# Session Resume & Persistence +# Session resume and persistence This guide walks you through the SDK's session persistence capabilities—how to pause work, resume it later, and manage sessions in production environments. -## How Sessions Work +## How sessions work When you create a session, the Copilot CLI maintains conversation history, tool state, and planning context. By default, this state lives in memory and disappears when the session ends. With persistence enabled, you can resume sessions across restarts, container migrations, or even different client instances. @@ -19,7 +19,7 @@ flowchart LR | **Paused** | State saved to disk | | **Resume** | State loaded from disk | -## Quick Start: Creating a Resumable Session +## Quick start: creating a resumable session The key to resumable sessions is providing your own `session_id`. Without one, the SDK generates a random ID and the session can't be resumed later. @@ -126,7 +126,7 @@ await session.SendAndWaitAsync(new MessageOptions { Prompt = "Analyze my codebas // Session state is automatically persisted ``` -## Resuming a Session +## Resuming a session Later—minutes, hours, or even days—you can resume the session from where you left off. @@ -229,7 +229,7 @@ var session = await client.ResumeSessionAsync("user-123-task-456"); await session.SendAndWaitAsync(new MessageOptions { Prompt = "What did we discuss earlier?" }); ``` -## Resume Options +## Resume options When resuming a session, you can optionally reconfigure many settings. This is useful when you need to change the model, update tool configurations, or modify behavior. @@ -251,7 +251,7 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | -### Example: Changing Model on Resume +### Example: changing model on resume ```typescript // Resume with a different model @@ -261,7 +261,7 @@ const session = await client.resumeSession("user-123-task-456", { }); ``` -## Using BYOK (Bring Your Own Key) with Resumed Sessions +## Using BYOK (bring your own key) with resumed sessions When using your own API keys, you must re-provide the provider configuration when resuming. API keys are never persisted to disk for security reasons. @@ -289,7 +289,7 @@ const resumed = await client.resumeSession("user-123-task-456", { }); ``` -## What Gets Persisted? +## What gets persisted? Session state is saved to `~/.copilot/session-state/{sessionId}/`: @@ -315,7 +315,7 @@ Session state is saved to `~/.copilot/session-state/{sessionId}/`: | Provider/API keys | ❌ No | Security: must re-provide | | In-memory tool state | ❌ No | Tools should be stateless | -## Session ID Best Practices +## Session ID best practices Choose session IDs that encode ownership and purpose. This makes auditing and cleanup much easier. @@ -327,11 +327,11 @@ Choose session IDs that encode ownership and purpose. This makes auditing and cl | ✅ `{userId}-{taskId}-{timestamp}` | `alice-deploy-1706932800` | Time-based cleanup | **Benefits of structured IDs:** -- Easy to audit: "Show all sessions for user alice" -- Easy to clean up: "Delete all sessions older than X" -- Natural access control: Parse user ID from session ID +* Easy to audit: "Show all sessions for user alice" +* Easy to clean up: "Delete all sessions older than X" +* Natural access control: Parse user ID from session ID -### Example: Generating Session IDs +### Example: generating session IDs ```typescript function createSessionId(userId: string, taskType: string): string { @@ -354,9 +354,9 @@ session_id = create_session_id("alice", "code-review") # → "alice-code-review-1706932800" ``` -## Managing Session Lifecycle +## Managing session lifecycle -### Listing Active Sessions +### Listing active sessions ```typescript // List all sessions @@ -371,7 +371,7 @@ for (const session of sessions) { const repoSessions = await client.listSessions({ repository: "owner/repo" }); ``` -### Cleaning Up Old Sessions +### Cleaning up old sessions ```typescript async function cleanupExpiredSessions(maxAgeMs: number) { @@ -391,7 +391,7 @@ async function cleanupExpiredSessions(maxAgeMs: number) { await cleanupExpiredSessions(24 * 60 * 60 * 1000); ``` -### Disconnecting from a Session (`disconnect`) +### Disconnecting from a session (`disconnect`) When a task completes, disconnect from the session explicitly rather than waiting for timeouts. This releases in-memory resources but **preserves session data on disk**, so the session can still be resumed later: @@ -418,11 +418,12 @@ Each SDK also provides idiomatic automatic cleanup patterns: | **C#** | `IAsyncDisposable` | `await using var session = await client.CreateSessionAsync(config);` | | **Go** | `defer` | `defer session.Disconnect()` | -> **Note:** `destroy()` is deprecated in favor of `disconnect()`. Existing code using `destroy()` will continue to work but should be migrated. +> [!NOTE] +> `destroy()` is deprecated in favor of `disconnect()`. Existing code using `destroy()` will continue to work but should be migrated. -### Permanently Deleting a Session (`deleteSession`) +### Permanently deleting a session (`deleteSession`) -To permanently remove a session and all its data from disk (conversation history, planning state, artifacts), use `deleteSession`. This is irreversible — the session **cannot** be resumed after deletion: +To permanently remove a session and all its data from disk (conversation history, planning state, artifacts), use `deleteSession`. This is irreversible—the session **cannot** be resumed after deletion: ```typescript // Permanently remove session data @@ -431,7 +432,7 @@ await client.deleteSession("user-123-task-456"); > **`disconnect()` vs `deleteSession()`:** `disconnect()` releases in-memory resources but keeps session data on disk for later resumption. `deleteSession()` permanently removes everything, including files on disk. -## Automatic Cleanup: Idle Timeout +## Automatic cleanup: idle timeout By default, sessions have **no idle timeout** and live indefinitely until explicitly disconnected or deleted. You can optionally configure a server-wide idle timeout via `CopilotClientOptions.sessionIdleTimeoutSeconds`: @@ -443,7 +444,8 @@ const client = new CopilotClient({ When a timeout is configured, sessions without activity for that duration are automatically cleaned up. Set to `0` or omit to disable. -> **Note:** This option only applies when the SDK spawns the runtime process. When connecting to an existing server via `cliUrl`, the server's own timeout configuration applies. +> [!NOTE] +> This option only applies when the SDK spawns the runtime process. When connecting to an existing server via `cliUrl`, the server's own timeout configuration applies. ```mermaid flowchart LR @@ -460,9 +462,9 @@ session.on("session.idle", (event) => { }); ``` -## Deployment Patterns +## Deployment patterns -### Pattern 1: One CLI Server Per User (Recommended) +### Pattern 1: one CLI server per user (recommended) Best for: Strong isolation, multi-tenant environments, Azure Dynamic Sessions. @@ -480,7 +482,7 @@ flowchart LR **Benefits:** ✅ Complete isolation | ✅ Simple security | ✅ Easy scaling -### Pattern 2: Shared CLI Server (Resource Efficient) +### Pattern 2: shared CLI server (resource efficient) Best for: Internal tools, trusted environments, resource-constrained setups. @@ -495,9 +497,9 @@ flowchart LR ``` **Requirements:** -- ⚠️ Unique session IDs per user -- ⚠️ Application-level access control -- ⚠️ Session ID validation before operations +* ⚠️ Unique session IDs per user +* ⚠️ Application-level access control +* ⚠️ Session ID validation before operations ```typescript // Application-level access control for shared CLI @@ -517,11 +519,11 @@ async function resumeSessionWithAuth( } ``` -## Azure Dynamic Sessions +## Azure dynamic sessions For serverless/container deployments where containers can restart or migrate: -### Mount Persistent Storage +### Mount persistent storage The session state directory must be mounted to persistent storage: @@ -557,7 +559,7 @@ flowchart LR **Session survives container restarts!** -## Infinite Sessions for Long-Running Workflows +## Infinite sessions for long-running workflows For workflows that might exceed context limits, enable infinite sessions with automatic compaction: @@ -572,9 +574,10 @@ const session = await client.createSession({ }); ``` -> **Note:** Thresholds are context utilization ratios (0.0-1.0), not absolute token counts. See the [Compatibility Guide](../troubleshooting/compatibility.md) for details. +> [!NOTE] +> Thresholds are context utilization ratios (0.0-1.0), not absolute token counts. See the [Compatibility Guide](../troubleshooting/compatibility.md) for details. -## Limitations & Considerations +## Limitations and considerations | Limitation | Description | Mitigation | |------------|-------------|------------| @@ -583,7 +586,7 @@ const session = await client.createSession({ | **No session locking** | Concurrent access to same session is undefined | Implement application-level locking or queue | | **Tool state not persisted** | In-memory tool state is lost | Design tools to be stateless or persist their own state | -### Handling Concurrent Access +### Handling concurrent access The SDK doesn't provide built-in session locking. If multiple clients might access the same session: @@ -626,12 +629,12 @@ await withSessionLock("user-123-task-456", async () => { | **Resume session** | `client.resumeSession(sessionId)` | | **BYOK resume** | Re-provide `provider` config | | **List sessions** | `client.listSessions(filter?)` | -| **Disconnect from active session** | `session.disconnect()` — releases in-memory resources; session data on disk is preserved for resumption | -| **Delete session permanently** | `client.deleteSession(sessionId)` — permanently removes all session data from disk; cannot be resumed | +| **Disconnect from active session** | `session.disconnect()`—releases in-memory resources; session data on disk is preserved for resumption | +| **Delete session permanently** | `client.deleteSession(sessionId)`—permanently removes all session data from disk; cannot be resumed | | **Containerized deployment** | Mount `~/.copilot/session-state/` to persistent storage | -## Next Steps +## Next steps -- [Hooks Overview](../hooks/index.md) - Customize session behavior with hooks -- [Compatibility Guide](../troubleshooting/compatibility.md) - SDK vs CLI feature comparison -- [Debugging Guide](../troubleshooting/debugging.md) - Troubleshoot session issues +* [Hooks Overview](../hooks/hooks-overview.md) - Customize session behavior with hooks +* [Compatibility Guide](../troubleshooting/compatibility.md) - SDK vs CLI feature comparison +* [Debugging Guide](../troubleshooting/debugging.md) - Troubleshoot session issues diff --git a/docs/features/skills.md b/docs/features/skills.md index 6c3888eb8..2d89d62a8 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -1,18 +1,18 @@ -# Custom Skills +# Custom skills Skills are reusable prompt modules that extend Copilot's capabilities. Load skills from directories to give Copilot specialized abilities for specific domains or workflows. ## Overview -A skill is a named directory containing a `SKILL.md` file — a markdown document that provides instructions to Copilot. When loaded, the skill's content is injected into the session context. +A skill is a named directory containing a `SKILL.md` file—a markdown document that provides instructions to Copilot. When loaded, the skill's content is injected into the session context. Skills allow you to: -- Package domain expertise into reusable modules -- Share specialized behaviors across projects -- Organize complex agent configurations -- Enable/disable capabilities per session +* Package domain expertise into reusable modules +* Share specialized behaviors across projects +* Organize complex agent configurations +* Enable/disable capabilities per session -## Loading Skills +## Loading skills Specify directories containing skills when creating a session: @@ -171,7 +171,7 @@ try (var client = new CopilotClient()) { -## Disabling Skills +## Disabling skills Disable specific skills while keeping others active: @@ -291,7 +291,7 @@ var session = client.createSession( -## Skill Directory Structure +## Skill directory structure Each skill is a named subdirectory containing a `SKILL.md` file: @@ -305,7 +305,7 @@ skills/ The `skillDirectories` option points to the parent directory (e.g., `./skills`). The CLI discovers all `SKILL.md` files in immediate subdirectories. -### SKILL.md Format +### SKILL.md format A `SKILL.md` file is a markdown document with optional YAML frontmatter: @@ -328,14 +328,14 @@ Provide specific line-number references and suggested fixes. ``` The frontmatter fields: -- **`name`** — The skill's identifier (used with `disabledSkills` to selectively disable it). If omitted, the directory name is used. -- **`description`** — A short description of what the skill does. +* **`name`**: The skill's identifier (used with `disabledSkills` to selectively disable it). If omitted, the directory name is used. +* **`description`**: A short description of what the skill does. The markdown body contains the instructions that are injected into the session context when the skill is loaded. -## Configuration Options +## Configuration options -### SessionConfig Skill Fields +### SessionConfig skill fields | Language | Field | Type | Description | |----------|-------|------|-------------| @@ -348,23 +348,23 @@ The markdown body contains the instructions that are injected into the session c | .NET | `SkillDirectories` | `List` | Directories to load skills from | | .NET | `DisabledSkills` | `List` | Skills to disable | -## Best Practices +## Best practices 1. **Organize by domain** - Group related skills together (e.g., `skills/security/`, `skills/testing/`) -2. **Use frontmatter** - Include `name` and `description` in YAML frontmatter for clarity +1. **Use frontmatter** - Include `name` and `description` in YAML frontmatter for clarity -3. **Document dependencies** - Note any tools or MCP servers a skill requires +1. **Document dependencies** - Note any tools or MCP servers a skill requires -4. **Test skills in isolation** - Verify skills work before combining them +1. **Test skills in isolation** - Verify skills work before combining them -5. **Use relative paths** - Keep skills portable across environments +1. **Use relative paths** - Keep skills portable across environments -## Combining with Other Features +## Combining with other features -### Skills + Custom Agents +### Skills + custom agents -Skills listed in an agent's `skills` field are **eagerly preloaded** — their full content is injected into the agent's context at startup, so the agent has access to the skill instructions immediately without needing to invoke a skill tool. Skill names are resolved from the session-level `skillDirectories`. +Skills listed in an agent's `skills` field are **eagerly preloaded**—their full content is injected into the agent's context at startup, so the agent has access to the skill instructions immediately without needing to invoke a skill tool. Skill names are resolved from the session-level `skillDirectories`. ```typescript const session = await client.createSession({ @@ -378,9 +378,10 @@ const session = await client.createSession({ onPermissionRequest: async () => ({ kind: "approved" }), }); ``` -> **Note:** Skills are opt-in — when `skills` is omitted, no skill content is injected. Sub-agents do not inherit skills from the parent; you must list them explicitly per agent. +> [!NOTE] +> Skills are opt-in—when `skills` is omitted, no skill content is injected. Sub-agents do not inherit skills from the parent; you must list them explicitly per agent. -### Skills + MCP Servers +### Skills + MCP servers Skills can complement MCP server capabilities: @@ -401,21 +402,21 @@ const session = await client.createSession({ ## Troubleshooting -### Skills Not Loading +### Skills not loading 1. **Check path exists** - Verify the skill directory path is correct and contains subdirectories with `SKILL.md` files -2. **Check permissions** - Ensure the SDK can read the directory -3. **Check SKILL.md format** - Verify the markdown is well-formed and any YAML frontmatter uses valid syntax -4. **Enable debug logging** - Set `logLevel: "debug"` to see skill loading logs +1. **Check permissions** - Ensure the SDK can read the directory +1. **Check SKILL.md format** - Verify the markdown is well-formed and any YAML frontmatter uses valid syntax +1. **Enable debug logging** - Set `logLevel: "debug"` to see skill loading logs -### Skill Conflicts +### Skill conflicts If multiple skills provide conflicting instructions: -- Use `disabledSkills` to exclude conflicting skills -- Reorganize skill directories to avoid overlaps +* Use `disabledSkills` to exclude conflicting skills +* Reorganize skill directories to avoid overlaps -## See Also +## See also -- [Custom Agents](../getting-started.md#create-custom-agents) - Define specialized AI personas -- [Custom Tools](../getting-started.md#step-4-add-a-custom-tool) - Build your own tools -- [MCP Servers](./mcp.md) - Connect external tool providers +* [Custom Agents](../getting-started.md#create-custom-agents) - Define specialized AI personas +* [Custom Tools](../getting-started.md#step-4-add-a-custom-tool) - Build your own tools +* [MCP Servers](./mcp.md) - Connect external tool providers diff --git a/docs/features/steering-and-queueing.md b/docs/features/steering-and-queueing.md index f4acd0006..4c7f9a914 100644 --- a/docs/features/steering-and-queueing.md +++ b/docs/features/steering-and-queueing.md @@ -1,4 +1,4 @@ -# Steering & Queueing +# Steering and queueing Two interaction patterns let users send messages while the agent is already working: **steering** redirects the agent mid-turn, and **queueing** buffers messages for sequential processing after the current turn completes. @@ -8,7 +8,7 @@ When a session is actively processing a turn, incoming messages can be delivered | Mode | Behavior | Use case | |------|----------|----------| -| `"immediate"` (steering) | Injected into the **current** LLM turn | "Actually, don't create that file — use a different approach" | +| `"immediate"` (steering) | Injected into the **current** LLM turn | "Actually, don't create that file—use a different approach" | | `"enqueue"` (queueing) | Queued and processed **after** the current turn finishes | "After this, also fix the tests" | ```mermaid @@ -33,9 +33,9 @@ sequenceDiagram LLM->>S: Turn completes ``` -## Steering (Immediate Mode) +## Steering (immediate mode) -Steering sends a message that is injected directly into the agent's current turn. The agent sees the message in real time and adjusts its response accordingly — useful for course-correcting without aborting the turn. +Steering sends a message that is injected directly into the agent's current turn. The agent sees the message in real time and adjusts its response accordingly—useful for course-correcting without aborting the turn.
Node.js / TypeScript @@ -210,18 +210,19 @@ try (var client = new CopilotClient()) {
-### How Steering Works Internally +### How steering works internally 1. The message is added to the runtime's `ImmediatePromptProcessor` queue -2. Before the next LLM request within the current turn, the processor injects the message into the conversation -3. The agent sees the steering message as a new user message and adjusts its response -4. If the turn completes before the steering message is processed, it is automatically moved to the regular queue for the next turn +1. Before the next LLM request within the current turn, the processor injects the message into the conversation +1. The agent sees the steering message as a new user message and adjusts its response +1. If the turn completes before the steering message is processed, it is automatically moved to the regular queue for the next turn -> **Note:** Steering messages are best-effort within the current turn. If the agent has already committed to a tool call, the steering takes effect after that call completes but still within the same turn. +> [!NOTE] +> Steering messages are best-effort within the current turn. If the agent has already committed to a tool call, the steering takes effect after that call completes but still within the same turn. -## Queueing (Enqueue Mode) +## Queueing (enqueue mode) -Queueing buffers messages to be processed sequentially after the current turn finishes. Each queued message starts its own full turn. This is the default mode — if you omit `mode`, the SDK uses `"enqueue"`. +Queueing buffers messages to be processed sequentially after the current turn finishes. Each queued message starts its own full turn. This is the default mode—if you omit `mode`, the SDK uses `"enqueue"`.
Node.js / TypeScript @@ -457,15 +458,15 @@ try (var client = new CopilotClient()) {
-### How Queueing Works Internally +### How queueing works internally 1. The message is added to the session's `itemQueue` as a `QueuedItem` -2. When the current turn completes and the session becomes idle, `processQueuedItems()` runs -3. Items are dequeued in FIFO order — each message triggers a full agentic turn -4. If a steering message was pending when the turn ended, it is moved to the front of the queue -5. Processing continues until the queue is empty, then the session emits an idle event +1. When the current turn completes and the session becomes idle, `processQueuedItems()` runs +1. Items are dequeued in FIFO order—each message triggers a full agentic turn +1. If a steering message was pending when the turn ended, it is moved to the front of the queue +1. Processing continues until the queue is empty, then the session emits an idle event -## Combining Steering and Queueing +## Combining steering and queueing You can use both patterns together in a single session. Steering affects the current turn while queued messages wait for their own turns: @@ -523,7 +524,7 @@ await session.send({ -## Choosing Between Steering and Queueing +## Choosing between steering and queueing | Scenario | Pattern | Why | |----------|---------|-----| @@ -534,7 +535,7 @@ await session.send({ | You want to add context to the current task | **Steering** | Agent incorporates it into its current reasoning | | You want to batch unrelated requests | **Queueing** | Each gets its own full turn with clean context | -## Building a UI with Steering & Queueing +## Building a UI with steering and queueing Here's a pattern for building an interactive UI that supports both modes: @@ -606,7 +607,7 @@ class InteractiveChat { } ``` -## API Reference +## API reference ### MessageOptions @@ -617,32 +618,33 @@ class InteractiveChat { | Go | `Mode` | `string` | `"enqueue"` | Message delivery mode | | .NET | `Mode` | `string?` | `"enqueue"` | Message delivery mode | -### Delivery Modes +### Delivery modes | Mode | Effect | During active turn | During idle | |------|--------|-------------------|-------------| | `"enqueue"` | Queue for next turn | Waits in FIFO queue | Starts a new turn immediately | | `"immediate"` | Inject into current turn | Injected before next LLM call | Starts a new turn immediately | -> **Note:** When the session is idle (not processing), both modes behave identically — the message starts a new turn immediately. +> [!NOTE] +> When the session is idle (not processing), both modes behave identically—the message starts a new turn immediately. -## Best Practices +## Best practices -1. **Default to queueing** — Use `"enqueue"` (or omit `mode`) for most messages. It's predictable and doesn't risk disrupting in-progress work. +1. **Default to queueing**—Use `"enqueue"` (or omit `mode`) for most messages. It's predictable and doesn't risk disrupting in-progress work. -2. **Reserve steering for corrections** — Use `"immediate"` when the agent is actively doing the wrong thing and you need to redirect it before it goes further. +1. **Reserve steering for corrections**—Use `"immediate"` when the agent is actively doing the wrong thing and you need to redirect it before it goes further. -3. **Keep steering messages concise** — The agent needs to quickly understand the course correction. Long, complex steering messages may confuse the current context. +1. **Keep steering messages concise**—The agent needs to quickly understand the course correction. Long, complex steering messages may confuse the current context. -4. **Don't over-steer** — Multiple rapid steering messages can degrade turn quality. If you need to change direction significantly, consider aborting the turn and starting fresh. +1. **Don't over-steer**—Multiple rapid steering messages can degrade turn quality. If you need to change direction significantly, consider aborting the turn and starting fresh. -5. **Show queue state in your UI** — Display the number of queued messages so users know what's pending. Listen for idle events to clear the display. +1. **Show queue state in your UI**—Display the number of queued messages so users know what's pending. Listen for idle events to clear the display. -6. **Handle the steering-to-queue fallback** — If a steering message arrives after the turn completes, it's automatically moved to the queue. Design your UI to reflect this transition. +1. **Handle the steering-to-queue fallback**—If a steering message arrives after the turn completes, it's automatically moved to the queue. Design your UI to reflect this transition. -## See Also +## See also -- [Getting Started](../getting-started.md) — Set up a session and send messages -- [Custom Agents](./custom-agents.md) — Define specialized agents with scoped tools -- [Session Hooks](../hooks/index.md) — React to session lifecycle events -- [Session Persistence](./session-persistence.md) — Resume sessions across restarts +* [Getting Started](../getting-started.md): Set up a session and send messages +* [Custom Agents](./custom-agents.md): Define specialized agents with scoped tools +* [Session Hooks](../hooks/hooks-overview.md): React to session lifecycle events +* [Session Persistence](./session-persistence.md): Resume sessions across restarts diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index 9dde8f21b..a12440ee5 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -1,6 +1,6 @@ -# Streaming Session Events +# Streaming session events -Every action the Copilot agent takes — thinking, writing code, running tools — is emitted as a **session event** you can subscribe to. This guide is a field-level reference for each event type so you know exactly what data to expect without reading the SDK source. +Every action the Copilot agent takes—thinking, writing code, running tools—is emitted as a **session event** you can subscribe to. This guide is a field-level reference for each event type so you know exactly what data to expect without reading the SDK source. ## Overview @@ -47,7 +47,7 @@ sequenceDiagram | **Delta event** | An ephemeral streaming chunk (text or reasoning). Accumulate deltas to build the complete content. | | **`parentId` chain** | Each event's `parentId` points to the previous event, forming a linked list you can walk. | -## Event Envelope +## Event envelope Every session event, regardless of type, includes these fields: @@ -60,7 +60,7 @@ Every session event, regardless of type, includes these fields: | `type` | `string` | Event type discriminator (see tables below) | | `data` | `object` | Event-specific payload | -## Subscribing to Events +## Subscribing to events
Node.js / TypeScript @@ -206,17 +206,18 @@ session.on(AssistantMessageDeltaEvent.class, event ->
-> **Tip (Python / Go):** These SDKs use a single `Data` class/struct with all possible fields as optional/nullable. Only the fields listed in the tables below are populated for each event type — the rest will be `None` / `nil`. +> [!TIP] +> **(Python / Go)** These SDKs use a single `Data` class/struct with all possible fields as optional/nullable. Only the fields listed in the tables below are populated for each event type—the rest will be `None` / `nil`. > -> **Tip (.NET):** The .NET SDK uses separate, strongly-typed data classes per event (e.g., `AssistantMessageDeltaData`), so only the relevant fields exist on each type. +> [!TIP] +> **(.NET)** The .NET SDK uses separate, strongly-typed data classes per event (e.g., `AssistantMessageDeltaData`), so only the relevant fields exist on each type. > -> **Tip (TypeScript):** The TypeScript SDK uses a discriminated union — when you match on `event.type`, the `data` payload is automatically narrowed to the correct shape. +> [!TIP] +> **(TypeScript)** The TypeScript SDK uses a discriminated union—when you match on `event.type`, the `data` payload is automatically narrowed to the correct shape. ---- +## Assistant events -## Assistant Events - -These events track the agent's response lifecycle — from turn start through streaming chunks to the final message. +These events track the agent's response lifecycle—from turn start through streaming chunks to the final message. ### `assistant.turn_start` @@ -319,17 +320,15 @@ Ephemeral. Token usage and cost information for an individual API call. ### `assistant.streaming_delta` -Ephemeral. Low-level network progress indicator — total bytes received from the streaming API response. +Ephemeral. Low-level network progress indicator—total bytes received from the streaming API response. | Data Field | Type | Required | Description | |------------|------|----------|-------------| | `totalResponseSizeBytes` | `number` | ✅ | Cumulative bytes received so far | ---- - -## Tool Execution Events +## Tool execution events -These events track the full lifecycle of each tool invocation — from the model requesting a tool call through execution to completion. +These events track the full lifecycle of each tool invocation—from the model requesting a tool call through execution to completion. ### `tool.execution_start` @@ -364,7 +363,7 @@ Ephemeral. Human-readable progress status from a running tool (e.g., MCP server ### `tool.execution_complete` -Emitted when a tool finishes executing — successfully or with an error. +Emitted when a tool finishes executing—successfully or with an error. | Data Field | Type | Required | Description | |------------|------|----------|-------------| @@ -396,9 +395,7 @@ Emitted when the user explicitly requests a tool invocation (rather than the mod | `toolName` | `string` | ✅ | Name of the tool the user wants to invoke | | `arguments` | `object` | | Arguments for the invocation | ---- - -## Session Lifecycle Events +## Session lifecycle events ### `session.idle` @@ -495,9 +492,7 @@ The session has ended. | `modelMetrics` | `Record` | ✅ | Per-model usage breakdown | | `currentModel` | `string` | | Model selected at shutdown time | ---- - -## Permission & User Input Events +## Permission and user input events These events are emitted when the agent needs approval or input from the user before continuing. @@ -571,9 +566,7 @@ Ephemeral. An elicitation request was resolved. |------------|------|----------|-------------| | `requestId` | `string` | ✅ | Matches the corresponding `elicitation.requested` | ---- - -## Sub-Agent & Skill Events +## Sub-agent and skill events ### `subagent.started` @@ -634,9 +627,7 @@ A skill was activated for the current conversation. | `pluginName` | `string` | | Plugin the skill originated from | | `pluginVersion` | `string` | | Plugin version | ---- - -## Other Events +## Other events ### `abort` @@ -727,9 +718,7 @@ Ephemeral. A queued command was resolved. |------------|------|----------|-------------| | `requestId` | `string` | ✅ | Matches the corresponding `command.queued` | ---- - -## Quick Reference: Agentic Turn Flow +## Quick reference: agentic turn flow A typical agentic turn emits events in this order: @@ -756,7 +745,7 @@ assistant.turn_end → Turn complete session.idle → Ready for next message (ephemeral) ``` -## All Event Types at a Glance +## All event types at a glance | Event Type | Ephemeral | Category | Key Data Fields | |------------|-----------|----------|-----------------| diff --git a/docs/getting-started.md b/docs/getting-started.md index 4335ac61b..8238dc772 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,4 +1,4 @@ -# Build Your First Copilot-Powered App +# Build your first Copilot-powered app In this tutorial, you'll use the Copilot SDK to build a command-line assistant. You'll start with the basics, add streaming responses, then add custom tools - giving Copilot the ability to call your code. @@ -18,9 +18,9 @@ Copilot: In Tokyo it's 75°F and sunny. Great day to be outside! Before you begin, make sure you have: -- **GitHub Copilot CLI** installed and authenticated ([Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)) -- Your preferred language runtime: - - **Node.js** 18+ or **Python** 3.11+ or **Go** 1.21+ or **Java** 17+ or **.NET** 8.0+ +* **GitHub Copilot CLI** installed and authenticated ([Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)) +* Your preferred language runtime: + * **Node.js** 18+ or **Python** 3.11+ or **Go** 1.21+ or **Java** 17+ or **.NET** 8.0+ Verify the CLI is working: @@ -28,7 +28,7 @@ Verify the CLI is working: copilot --version ``` -## Step 1: Install the SDK +## Step 1: install the SDK
Node.js / TypeScript @@ -97,7 +97,7 @@ dotnet add package GitHub.Copilot.SDK First, create a new directory and initialize your project. -**Maven** — add to your `pom.xml`: +**Maven**—add to your `pom.xml`: ```xml @@ -107,7 +107,7 @@ First, create a new directory and initialize your project. ``` -**Gradle** — add to your `build.gradle`: +**Gradle**—add to your `build.gradle`: ```groovy implementation 'com.github:copilot-sdk-java:${copilotSdkVersion}' @@ -115,7 +115,7 @@ implementation 'com.github:copilot-sdk-java:${copilotSdkVersion}'
-## Step 2: Send Your First Message +## Step 2: send your first message Create a new file and add the following code. This is the simplest way to use the SDK—about 5 lines of code. @@ -302,7 +302,7 @@ javac -cp copilot-sdk.jar HelloCopilot.java && java -cp .:copilot-sdk.jar HelloC Congratulations! You just built your first Copilot-powered app. -## Step 3: Add Streaming Responses +## Step 3: add streaming responses Right now, you wait for the complete response before seeing anything. Let's make it interactive by streaming the response as it's generated. @@ -505,7 +505,7 @@ public class HelloCopilot { Run the code again. You'll see the response appear word by word. -### Event Subscription Methods +### Event subscription methods The SDK provides methods for subscribing to session events: @@ -728,7 +728,7 @@ unsubscribe.close(); -## Step 4: Add a Custom Tool +## Step 4: add a custom tool Now for the powerful part. Let's give Copilot the ability to call your code by defining a custom tool. We'll create a simple weather lookup tool. @@ -1054,7 +1054,7 @@ public class HelloCopilot { Run it and you'll see Copilot call your tool to get weather data, then respond with the results! -## Step 5: Build an Interactive Assistant +## Step 5: build an interactive assistant Let's put it all together into a useful interactive assistant: @@ -1469,7 +1469,6 @@ javac -cp copilot-sdk.jar WeatherAssistant.java && java -cp .:copilot-sdk.jar We - **Example session:** ``` @@ -1490,28 +1489,24 @@ You: exit You've built an assistant with a custom tool that Copilot can call! ---- - -## How Tools Work +## How tools work When you define a tool, you're telling Copilot: 1. **What the tool does** (description) -2. **What parameters it needs** (schema) -3. **What code to run** (handler) +1. **What parameters it needs** (schema) +1. **What code to run** (handler) Copilot decides when to call your tool based on the user's question. When it does: 1. Copilot sends a tool call request with the parameters -2. The SDK runs your handler function -3. The result is sent back to Copilot -4. Copilot incorporates the result into its response +1. The SDK runs your handler function +1. The result is sent back to Copilot +1. Copilot incorporates the result into its response ---- - -## What's Next? +## What's next? Now that you've got the basics, here are more powerful features to explore: -### Connect to MCP Servers +### Connect to MCP servers MCP (Model Context Protocol) servers provide pre-built tools. Connect to GitHub's MCP server to give Copilot access to repositories, issues, and pull requests: @@ -1528,7 +1523,7 @@ const session = await client.createSession({ 📖 **[Full MCP documentation →](./features/mcp.md)** - Learn about local vs remote servers, all configuration options, and troubleshooting. -### Create Custom Agents +### Create custom agents Define specialized AI personas for specific tasks: @@ -1543,9 +1538,10 @@ const session = await client.createSession({ }); ``` -> **Tip:** You can also set `agent: "pr-reviewer"` in the session config to pre-select this agent from the start. See the [Custom Agents guide](./features/custom-agents.md#selecting-an-agent-at-session-creation) for details. +> [!TIP] +> You can also set `agent: "pr-reviewer"` in the session config to pre-select this agent from the start. See the [Custom Agents guide](./features/custom-agents.md#selecting-an-agent-at-session-creation) for details. -### Customize the System Message +### Customize the system message Control the AI's behavior and personality by appending instructions: @@ -1575,21 +1571,19 @@ const session = await client.createSession({ Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`, `last_instructions`. -Each override supports four actions: `replace`, `remove`, `append`, and `prepend`. Unknown section IDs are handled gracefully — content is appended to additional instructions and a warning is emitted; `remove` on unknown sections is silently ignored. +Each override supports four actions: `replace`, `remove`, `append`, and `prepend`. Unknown section IDs are handled gracefully—content is appended to additional instructions and a warning is emitted; `remove` on unknown sections is silently ignored. See the language-specific SDK READMEs for examples in [TypeScript](../nodejs/README.md), [Python](../python/README.md), [Go](../go/README.md), [Java](../java/README.md), and [C#](../dotnet/README.md). ---- - -## Connecting to an External CLI Server +## Connecting to an external CLI server By default, the SDK automatically manages the Copilot CLI process lifecycle, starting and stopping the CLI as needed. However, you can also run the CLI in server mode separately and have the SDK connect to it. This can be useful for: -- **Debugging**: Keep the CLI running between SDK restarts to inspect logs -- **Resource sharing**: Multiple SDK clients can connect to the same CLI server -- **Development**: Run the CLI with custom settings or in a different environment +* **Debugging**: Keep the CLI running between SDK restarts to inspect logs +* **Resource sharing**: Multiple SDK clients can connect to the same CLI server +* **Development**: Run the CLI with custom settings or in a different environment -### Running the CLI in Server Mode +### Running the CLI in server mode Start the CLI in server mode using the `--headless` flag and optionally specify a port: @@ -1606,9 +1600,10 @@ By default the headless server only accepts connections from loopback (`127.0.0. copilot --headless --host 0.0.0.0 --port 4321 ``` -> **Warning:** Exposing the headless server on a non-loopback address makes it reachable by anyone who can route to that address. Pair it with network controls (firewall, private network, reverse proxy) and authentication appropriate for your environment. +> [!WARNING] +> Exposing the headless server on a non-loopback address makes it reachable by anyone who can route to that address. Pair it with network controls (firewall, private network, reverse proxy) and authentication appropriate for your environment. -### Connecting the SDK to the External Server +### Connecting the SDK to the external server Once the CLI is running in server mode, configure your SDK client to connect to it using the "cli url" option: @@ -1748,15 +1743,13 @@ var session = client.createSession( **Note:** When `cli_url` / `cliUrl` / `CLIUrl` is provided, the SDK will not spawn or manage a CLI process - it will only connect to the existing server at the specified URL. ---- - -## Telemetry & Observability +## Telemetry and observability The Copilot SDK supports [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. Provide a `telemetry` configuration to the client to enable trace export from the CLI process and automatic [W3C Trace Context](https://www.w3.org/TR/trace-context/) propagation between the SDK and CLI. -### Enabling Telemetry +### Enabling telemetry -Pass a `telemetry` (or `Telemetry`) config when creating the client. This is the opt-in — no separate "enabled" flag is needed. +Pass a `telemetry` (or `Telemetry`) config when creating the client. This is the opt-in—no separate "enabled" flag is needed.
Node.js / TypeScript @@ -1824,7 +1817,7 @@ var client = new CopilotClient(new CopilotClientOptions }); ``` -No extra dependencies — uses built-in `System.Diagnostics.Activity`. +No extra dependencies—uses built-in `System.Diagnostics.Activity`.
@@ -1845,7 +1838,7 @@ Dependency: `io.opentelemetry:opentelemetry-api` -### TelemetryConfig Options +### TelemetryConfig options | Option | Node.js | Python | Go | Java | .NET | Description | |---|---|---|---|---|---|---| @@ -1855,7 +1848,7 @@ Dependency: `io.opentelemetry:opentelemetry-api` | Source name | `sourceName` | `source_name` | `SourceName` | `sourceName` | `SourceName` | Instrumentation scope name | | Capture content | `captureContent` | `capture_content` | `CaptureContent` | `captureContent` | `CaptureContent` | Whether to capture message content | -### File Export +### File export To write traces to a local file instead of an OTLP endpoint: @@ -1869,37 +1862,33 @@ const client = new CopilotClient({ }); ``` -### Trace Context Propagation - -Trace context is propagated automatically — no manual instrumentation is needed: - -- **SDK → CLI**: `traceparent` and `tracestate` headers from the current span/activity are included in `session.create`, `session.resume`, and `session.send` RPC calls. -- **CLI → SDK**: When the CLI invokes tool handlers, the trace context from the CLI's span is propagated so your tool code runs under the correct parent span. +### Trace context propagation -📖 **[OpenTelemetry Instrumentation Guide →](./observability/opentelemetry.md)** — TelemetryConfig options, trace context propagation, and per-language dependencies. +Trace context is propagated automatically—no manual instrumentation is needed: ---- +* **SDK → CLI**: `traceparent` and `tracestate` headers from the current span/activity are included in `session.create`, `session.resume`, and `session.send` RPC calls. +* **CLI → SDK**: When the CLI invokes tool handlers, the trace context from the CLI's span is propagated so your tool code runs under the correct parent span. -## Learn More +📖 **[OpenTelemetry Instrumentation Guide →](./observability/opentelemetry.md)**—TelemetryConfig options, trace context propagation, and per-language dependencies. -- [Authentication Guide](./auth/index.md) - GitHub OAuth, environment variables, and BYOK -- [BYOK (Bring Your Own Key)](./auth/byok.md) - Use your own API keys from Azure AI Foundry, OpenAI, etc. -- [Node.js SDK Reference](../nodejs/README.md) -- [Python SDK Reference](../python/README.md) -- [Go SDK Reference](../go/README.md) -- [.NET SDK Reference](../dotnet/README.md) -- [Java SDK Reference](../java/README.md) -- [Using MCP Servers](./features/mcp.md) - Integrate external tools via Model Context Protocol -- [GitHub MCP Server Documentation](https://github.com/github/github-mcp-server) -- [MCP Servers Directory](https://github.com/modelcontextprotocol/servers) - Explore more MCP servers -- [OpenTelemetry Instrumentation](./observability/opentelemetry.md) - TelemetryConfig, trace context propagation, and per-language dependencies +## Learn more ---- +* [Authentication Guide](./auth/authenticate.md) - GitHub OAuth, environment variables, and BYOK +* [BYOK (Bring Your Own Key)](./auth/byok.md) - Use your own API keys from Azure AI Foundry, OpenAI, etc. +* [Node.js SDK Reference](../nodejs/README.md) +* [Python SDK Reference](../python/README.md) +* [Go SDK Reference](../go/README.md) +* [.NET SDK Reference](../dotnet/README.md) +* [Java SDK Reference](../java/README.md) +* [Using MCP Servers](./features/mcp.md) - Integrate external tools via Model Context Protocol +* [GitHub MCP Server Documentation](https://github.com/github/github-mcp-server) +* [MCP Servers Directory](https://github.com/modelcontextprotocol/servers) - Explore more MCP servers +* [OpenTelemetry Instrumentation](./observability/opentelemetry.md) - TelemetryConfig, trace context propagation, and per-language dependencies **You did it!** You've learned the core concepts of the GitHub Copilot SDK: -- ✅ Creating a client and session -- ✅ Sending messages and receiving responses -- ✅ Streaming for real-time output -- ✅ Defining custom tools that Copilot can call +* ✅ Creating a client and session +* ✅ Sending messages and receiving responses +* ✅ Streaming for real-time output +* ✅ Defining custom tools that Copilot can call Now go build something amazing! 🚀 diff --git a/docs/hooks/error-handling.md b/docs/hooks/error-handling.md index b721a3b91..803032432 100644 --- a/docs/hooks/error-handling.md +++ b/docs/hooks/error-handling.md @@ -1,13 +1,13 @@ -# Error Handling Hook +# Error handling hook The `onErrorOccurred` hook is called when errors occur during session execution. Use it to: -- Implement custom error logging -- Track error patterns -- Provide user-friendly error messages -- Trigger alerts for critical errors +* Implement custom error logging +* Track error patterns +* Provide user-friendly error messages +* Trigger alerts for critical errors -## Hook Signature +## Hook signature
Node.js / TypeScript @@ -139,7 +139,7 @@ Return `null` or `undefined` to use default error handling. Otherwise, return an ## Examples -### Basic Error Logging +### Basic error logging
Node.js / TypeScript @@ -292,7 +292,7 @@ session.setEventErrorHandler((event, ex) -> {
-### Send Errors to Monitoring Service +### Send errors to monitoring service ```typescript import { captureException } from "@sentry/node"; // or your monitoring service @@ -318,7 +318,7 @@ const session = await client.createSession({ }); ``` -### User-Friendly Error Messages +### User-friendly error messages ```typescript const ERROR_MESSAGES: Record = { @@ -345,7 +345,7 @@ const session = await client.createSession({ }); ``` -### Suppress Non-Critical Errors +### Suppress non-critical errors ```typescript const session = await client.createSession({ @@ -362,7 +362,7 @@ const session = await client.createSession({ }); ``` -### Add Recovery Context +### Add recovery context ```typescript const session = await client.createSession({ @@ -393,7 +393,7 @@ The tool failed. Here are some recovery suggestions: }); ``` -### Track Error Patterns +### Track error patterns ```typescript interface ErrorStats { @@ -432,7 +432,7 @@ const session = await client.createSession({ }); ``` -### Alert on Critical Errors +### Alert on critical errors ```typescript const CRITICAL_CONTEXTS = ["system", "model_call"]; @@ -456,7 +456,7 @@ const session = await client.createSession({ }); ``` -### Combine with Other Hooks for Context +### Combine with other hooks for context ```typescript const sessionContext = new Map(); @@ -496,22 +496,22 @@ const session = await client.createSession({ }); ``` -## Best Practices +## Best practices 1. **Always log errors** - Even if you suppress them from users, keep logs for debugging. -2. **Categorize errors** - Use `errorType` to handle different errors appropriately. +1. **Categorize errors** - Use `errorType` to handle different errors appropriately. -3. **Don't swallow critical errors** - Only suppress errors you're certain are non-critical. +1. **Don't swallow critical errors** - Only suppress errors you're certain are non-critical. -4. **Keep hooks fast** - Error handling shouldn't slow down recovery. +1. **Keep hooks fast** - Error handling shouldn't slow down recovery. -5. **Provide helpful context** - When errors occur, `additionalContext` can help the model recover. +1. **Provide helpful context** - When errors occur, `additionalContext` can help the model recover. -6. **Monitor error patterns** - Track recurring errors to identify systemic issues. +1. **Monitor error patterns** - Track recurring errors to identify systemic issues. -## See Also +## See also -- [Hooks Overview](./index.md) -- [Session Lifecycle Hooks](./session-lifecycle.md) -- [Debugging Guide](../troubleshooting/debugging.md) +* [Hooks Overview](./index.md) +* [Session Lifecycle Hooks](./session-lifecycle.md) +* [Debugging Guide](../troubleshooting/debugging.md) diff --git a/docs/hooks/hooks-overview.md b/docs/hooks/hooks-overview.md new file mode 100644 index 000000000..a5f5981f4 --- /dev/null +++ b/docs/hooks/hooks-overview.md @@ -0,0 +1,271 @@ +# Session hooks + +Hooks allow you to intercept and customize the behavior of Copilot sessions at key points in the conversation lifecycle. Use hooks to: + +* **Control tool execution** - approve, deny, or modify tool calls +* **Transform results** - modify tool outputs before they're processed +* **Add context** - inject additional information at session start +* **Handle errors** - implement custom error handling +* **Audit and log** - track all interactions for compliance + +## Available hooks + +| Hook | Trigger | Use Case | +|------|---------|----------| +| [`onPreToolUse`](./pre-tool-use.md) | Before a tool executes | Permission control, argument validation | +| [`onPostToolUse`](./post-tool-use.md) | After a tool executes | Result transformation, logging | +| [`onUserPromptSubmitted`](./user-prompt-submitted.md) | When user sends a message | Prompt modification, filtering | +| [`onSessionStart`](./session-lifecycle.md#session-start) | Session begins | Add context, configure session | +| [`onSessionEnd`](./session-lifecycle.md#session-end) | Session ends | Cleanup, analytics | +| [`onErrorOccurred`](./error-handling.md) | Error happens | Custom error handling | + +## Quick start + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient(); + +const session = await client.createSession({ + hooks: { + onPreToolUse: async (input) => { + console.log(`Tool called: ${input.toolName}`); + // Allow all tools + return { permissionDecision: "allow" }; + }, + onPostToolUse: async (input) => { + console.log(`Tool result: ${JSON.stringify(input.toolResult)}`); + return null; // No modifications + }, + onSessionStart: async (input) => { + return { additionalContext: "User prefers concise answers." }; + }, + }, +}); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient +from copilot.session import PermissionHandler + +async def main(): + client = CopilotClient() + await client.start() + + async def on_pre_tool_use(input_data, invocation): + print(f"Tool called: {input_data['toolName']}") + return {"permissionDecision": "allow"} + + async def on_post_tool_use(input_data, invocation): + print(f"Tool result: {input_data['toolResult']}") + return None + + async def on_session_start(input_data, invocation): + return {"additionalContext": "User prefers concise answers."} + + session = await client.create_session(on_permission_request=PermissionHandler.approve_all, hooks={ + "on_pre_tool_use": on_pre_tool_use, + "on_post_tool_use": on_post_tool_use, + "on_session_start": on_session_start, + }) +``` + +
+ +
+Go + +```go +package main + +import ( + "context" + "fmt" + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(nil) + + session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{ + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + fmt.Printf("Tool called: %s\n", input.ToolName) + return &copilot.PreToolUseHookOutput{ + PermissionDecision: "allow", + }, nil + }, + OnPostToolUse: func(input copilot.PostToolUseHookInput, inv copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + fmt.Printf("Tool result: %v\n", input.ToolResult) + return nil, nil + }, + OnSessionStart: func(input copilot.SessionStartHookInput, inv copilot.HookInvocation) (*copilot.SessionStartHookOutput, error) { + return &copilot.SessionStartHookOutput{ + AdditionalContext: "User prefers concise answers.", + }, nil + }, + }, + }) + _ = session +} +``` + +
+ +
+.NET + +```csharp +using GitHub.Copilot.SDK; + +var client = new CopilotClient(); + +var session = await client.CreateSessionAsync(new SessionConfig +{ + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + { + Console.WriteLine($"Tool called: {input.ToolName}"); + return Task.FromResult( + new PreToolUseHookOutput { PermissionDecision = "allow" } + ); + }, + OnPostToolUse = (input, invocation) => + { + Console.WriteLine($"Tool result: {input.ToolResult}"); + return Task.FromResult(null); + }, + OnSessionStart = (input, invocation) => + { + return Task.FromResult( + new SessionStartHookOutput { AdditionalContext = "User prefers concise answers." } + ); + }, + }, +}); +``` + +
+ +
+Java + +```java +import com.github.copilot.sdk.*; +import com.github.copilot.sdk.json.*; +import java.util.concurrent.CompletableFuture; + +try (var client = new CopilotClient()) { + client.start().get(); + + var hooks = new SessionHooks() + .setOnPreToolUse((input, invocation) -> { + System.out.println("Tool called: " + input.getToolName()); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + }) + .setOnPostToolUse((input, invocation) -> { + System.out.println("Tool result: " + input.getToolResult()); + return CompletableFuture.completedFuture(null); + }) + .setOnSessionStart((input, invocation) -> { + return CompletableFuture.completedFuture( + new SessionStartHookOutput("User prefers concise answers.", null) + ); + }); + + var session = client.createSession( + new SessionConfig() + .setHooks(hooks) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + ).get(); +} +``` + +
+ +## Hook invocation context + +Every hook receives an `invocation` parameter with context about the current session: + +| Field | Type | Description | +|-------|------|-------------| +| `sessionId` | string | The ID of the current session | + +This allows hooks to maintain state or perform session-specific logic. + +## Common patterns + +### Logging all tool calls + +```typescript +const session = await client.createSession({ + hooks: { + onPreToolUse: async (input) => { + console.log(`[${new Date().toISOString()}] Tool: ${input.toolName}, Args: ${JSON.stringify(input.toolArgs)}`); + return { permissionDecision: "allow" }; + }, + onPostToolUse: async (input) => { + console.log(`[${new Date().toISOString()}] Result: ${JSON.stringify(input.toolResult)}`); + return null; + }, + }, +}); +``` + +### Blocking dangerous tools + +```typescript +const BLOCKED_TOOLS = ["shell", "bash", "exec"]; + +const session = await client.createSession({ + hooks: { + onPreToolUse: async (input) => { + if (BLOCKED_TOOLS.includes(input.toolName)) { + return { + permissionDecision: "deny", + permissionDecisionReason: "Shell access is not permitted", + }; + } + return { permissionDecision: "allow" }; + }, + }, +}); +``` + +### Adding user context + +```typescript +const session = await client.createSession({ + hooks: { + onSessionStart: async () => { + const userPrefs = await loadUserPreferences(); + return { + additionalContext: `User preferences: ${JSON.stringify(userPrefs)}`, + }; + }, + }, +}); +``` + +## Hook guides + +* **[Pre-Tool Use Hook](./pre-tool-use.md)** - Control tool execution permissions +* **[Post-Tool Use Hook](./post-tool-use.md)** - Transform tool results +* **[User Prompt Submitted Hook](./user-prompt-submitted.md)** - Modify user prompts +* **[Session Lifecycle Hooks](./session-lifecycle.md)** - Session start and end +* **[Error Handling Hook](./error-handling.md)** - Custom error handling + +## See also + +* [Getting Started Guide](../getting-started.md) +* [Custom Tools](../getting-started.md#step-4-add-a-custom-tool) +* [Debugging Guide](../troubleshooting/debugging.md) diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 3373602c4..517be9614 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -1,271 +1,10 @@ -# Session Hooks +# Use hooks -Hooks allow you to intercept and customize the behavior of Copilot sessions at key points in the conversation lifecycle. Use hooks to: +Detailed API reference for each session hook in the GitHub Copilot SDK. -- **Control tool execution** - approve, deny, or modify tool calls -- **Transform results** - modify tool outputs before they're processed -- **Add context** - inject additional information at session start -- **Handle errors** - implement custom error handling -- **Audit and log** - track all interactions for compliance - -## Available Hooks - -| Hook | Trigger | Use Case | -|------|---------|----------| -| [`onPreToolUse`](./pre-tool-use.md) | Before a tool executes | Permission control, argument validation | -| [`onPostToolUse`](./post-tool-use.md) | After a tool executes | Result transformation, logging | -| [`onUserPromptSubmitted`](./user-prompt-submitted.md) | When user sends a message | Prompt modification, filtering | -| [`onSessionStart`](./session-lifecycle.md#session-start) | Session begins | Add context, configure session | -| [`onSessionEnd`](./session-lifecycle.md#session-end) | Session ends | Cleanup, analytics | -| [`onErrorOccurred`](./error-handling.md) | Error happens | Custom error handling | - -## Quick Start - -
-Node.js / TypeScript - -```typescript -import { CopilotClient } from "@github/copilot-sdk"; - -const client = new CopilotClient(); - -const session = await client.createSession({ - hooks: { - onPreToolUse: async (input) => { - console.log(`Tool called: ${input.toolName}`); - // Allow all tools - return { permissionDecision: "allow" }; - }, - onPostToolUse: async (input) => { - console.log(`Tool result: ${JSON.stringify(input.toolResult)}`); - return null; // No modifications - }, - onSessionStart: async (input) => { - return { additionalContext: "User prefers concise answers." }; - }, - }, -}); -``` - -
- -
-Python - -```python -from copilot import CopilotClient -from copilot.session import PermissionHandler - -async def main(): - client = CopilotClient() - await client.start() - - async def on_pre_tool_use(input_data, invocation): - print(f"Tool called: {input_data['toolName']}") - return {"permissionDecision": "allow"} - - async def on_post_tool_use(input_data, invocation): - print(f"Tool result: {input_data['toolResult']}") - return None - - async def on_session_start(input_data, invocation): - return {"additionalContext": "User prefers concise answers."} - - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, hooks={ - "on_pre_tool_use": on_pre_tool_use, - "on_post_tool_use": on_post_tool_use, - "on_session_start": on_session_start, - }) -``` - -
- -
-Go - -```go -package main - -import ( - "context" - "fmt" - copilot "github.com/github/copilot-sdk/go" -) - -func main() { - client := copilot.NewClient(nil) - - session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{ - Hooks: &copilot.SessionHooks{ - OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { - fmt.Printf("Tool called: %s\n", input.ToolName) - return &copilot.PreToolUseHookOutput{ - PermissionDecision: "allow", - }, nil - }, - OnPostToolUse: func(input copilot.PostToolUseHookInput, inv copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { - fmt.Printf("Tool result: %v\n", input.ToolResult) - return nil, nil - }, - OnSessionStart: func(input copilot.SessionStartHookInput, inv copilot.HookInvocation) (*copilot.SessionStartHookOutput, error) { - return &copilot.SessionStartHookOutput{ - AdditionalContext: "User prefers concise answers.", - }, nil - }, - }, - }) - _ = session -} -``` - -
- -
-.NET - -```csharp -using GitHub.Copilot.SDK; - -var client = new CopilotClient(); - -var session = await client.CreateSessionAsync(new SessionConfig -{ - Hooks = new SessionHooks - { - OnPreToolUse = (input, invocation) => - { - Console.WriteLine($"Tool called: {input.ToolName}"); - return Task.FromResult( - new PreToolUseHookOutput { PermissionDecision = "allow" } - ); - }, - OnPostToolUse = (input, invocation) => - { - Console.WriteLine($"Tool result: {input.ToolResult}"); - return Task.FromResult(null); - }, - OnSessionStart = (input, invocation) => - { - return Task.FromResult( - new SessionStartHookOutput { AdditionalContext = "User prefers concise answers." } - ); - }, - }, -}); -``` - -
- -
-Java - -```java -import com.github.copilot.sdk.*; -import com.github.copilot.sdk.json.*; -import java.util.concurrent.CompletableFuture; - -try (var client = new CopilotClient()) { - client.start().get(); - - var hooks = new SessionHooks() - .setOnPreToolUse((input, invocation) -> { - System.out.println("Tool called: " + input.getToolName()); - return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); - }) - .setOnPostToolUse((input, invocation) -> { - System.out.println("Tool result: " + input.getToolResult()); - return CompletableFuture.completedFuture(null); - }) - .setOnSessionStart((input, invocation) -> { - return CompletableFuture.completedFuture( - new SessionStartHookOutput("User prefers concise answers.", null) - ); - }); - - var session = client.createSession( - new SessionConfig() - .setHooks(hooks) - .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) - ).get(); -} -``` - -
- -## Hook Invocation Context - -Every hook receives an `invocation` parameter with context about the current session: - -| Field | Type | Description | -|-------|------|-------------| -| `sessionId` | string | The ID of the current session | - -This allows hooks to maintain state or perform session-specific logic. - -## Common Patterns - -### Logging All Tool Calls - -```typescript -const session = await client.createSession({ - hooks: { - onPreToolUse: async (input) => { - console.log(`[${new Date().toISOString()}] Tool: ${input.toolName}, Args: ${JSON.stringify(input.toolArgs)}`); - return { permissionDecision: "allow" }; - }, - onPostToolUse: async (input) => { - console.log(`[${new Date().toISOString()}] Result: ${JSON.stringify(input.toolResult)}`); - return null; - }, - }, -}); -``` - -### Blocking Dangerous Tools - -```typescript -const BLOCKED_TOOLS = ["shell", "bash", "exec"]; - -const session = await client.createSession({ - hooks: { - onPreToolUse: async (input) => { - if (BLOCKED_TOOLS.includes(input.toolName)) { - return { - permissionDecision: "deny", - permissionDecisionReason: "Shell access is not permitted", - }; - } - return { permissionDecision: "allow" }; - }, - }, -}); -``` - -### Adding User Context - -```typescript -const session = await client.createSession({ - hooks: { - onSessionStart: async () => { - const userPrefs = await loadUserPreferences(); - return { - additionalContext: `User preferences: ${JSON.stringify(userPrefs)}`, - }; - }, - }, -}); -``` - -## Hook Guides - -- **[Pre-Tool Use Hook](./pre-tool-use.md)** - Control tool execution permissions -- **[Post-Tool Use Hook](./post-tool-use.md)** - Transform tool results -- **[User Prompt Submitted Hook](./user-prompt-submitted.md)** - Modify user prompts -- **[Session Lifecycle Hooks](./session-lifecycle.md)** - Session start and end -- **[Error Handling Hook](./error-handling.md)** - Custom error handling - -## See Also - -- [Getting Started Guide](../getting-started.md) -- [Custom Tools](../getting-started.md#step-4-add-a-custom-tool) -- [Debugging Guide](../troubleshooting/debugging.md) +* [Hooks overview](./hooks-overview.md): quick start, common patterns, and hook invocation context +* [Pre-tool use](./pre-tool-use.md): approve, deny, or modify tool calls +* [Post-tool use](./post-tool-use.md): transform tool results +* [User prompt submitted](./user-prompt-submitted.md): modify or filter user messages +* [Session lifecycle](./session-lifecycle.md): session start and end +* [Error handling](./error-handling.md): custom error handling diff --git a/docs/hooks/post-tool-use.md b/docs/hooks/post-tool-use.md index f7c4089c9..47262415f 100644 --- a/docs/hooks/post-tool-use.md +++ b/docs/hooks/post-tool-use.md @@ -1,13 +1,13 @@ -# Post-Tool Use Hook +# Post-tool use hook The `onPostToolUse` hook is called **after** a tool executes. Use it to: -- Transform or filter tool results -- Log tool execution for auditing -- Add context based on results -- Suppress results from the conversation +* Transform or filter tool results +* Log tool execution for auditing +* Add context based on results +* Suppress results from the conversation -## Hook Signature +## Hook signature
Node.js / TypeScript @@ -132,7 +132,7 @@ Return `null` or `undefined` to pass through the result unchanged. Otherwise, re ## Examples -### Log All Tool Results +### Log all tool results
Node.js / TypeScript @@ -286,7 +286,7 @@ var session = client.createSession(
-### Redact Sensitive Data +### Redact sensitive data ```typescript const SENSITIVE_PATTERNS = [ @@ -314,7 +314,7 @@ const session = await client.createSession({ }); ``` -### Truncate Large Results +### Truncate large results ```typescript const MAX_RESULT_LENGTH = 10000; @@ -340,7 +340,7 @@ const session = await client.createSession({ }); ``` -### Add Context Based on Results +### Add context based on results ```typescript const session = await client.createSession({ @@ -366,7 +366,7 @@ const session = await client.createSession({ }); ``` -### Filter Error Stack Traces +### Filter error stack traces ```typescript const session = await client.createSession({ @@ -388,7 +388,7 @@ const session = await client.createSession({ }); ``` -### Audit Trail for Compliance +### Audit trail for compliance ```typescript interface AuditEntry { @@ -423,7 +423,7 @@ const session = await client.createSession({ }); ``` -### Suppress Noisy Results +### Suppress noisy results ```typescript const NOISY_TOOLS = ["list_directory", "search_codebase"]; @@ -450,20 +450,20 @@ const session = await client.createSession({ }); ``` -## Best Practices +## Best practices 1. **Return `null` when no changes needed** - This is more efficient than returning an empty object or the same result. -2. **Be careful with result modification** - Changing results can affect how the model interprets tool output. Only modify when necessary. +1. **Be careful with result modification** - Changing results can affect how the model interprets tool output. Only modify when necessary. -3. **Use `additionalContext` for hints** - Instead of modifying results, add context to help the model interpret them. +1. **Use `additionalContext` for hints** - Instead of modifying results, add context to help the model interpret them. -4. **Consider privacy when logging** - Tool results may contain sensitive data. Apply redaction before logging. +1. **Consider privacy when logging** - Tool results may contain sensitive data. Apply redaction before logging. -5. **Keep hooks fast** - Post-tool hooks run synchronously. Heavy processing should be done asynchronously or batched. +1. **Keep hooks fast** - Post-tool hooks run synchronously. Heavy processing should be done asynchronously or batched. -## See Also +## See also -- [Hooks Overview](./index.md) -- [Pre-Tool Use Hook](./pre-tool-use.md) -- [Error Handling Hook](./error-handling.md) +* [Hooks Overview](./index.md) +* [Pre-Tool Use Hook](./pre-tool-use.md) +* [Error Handling Hook](./error-handling.md) diff --git a/docs/hooks/pre-tool-use.md b/docs/hooks/pre-tool-use.md index c8e8504f0..e3509dd0a 100644 --- a/docs/hooks/pre-tool-use.md +++ b/docs/hooks/pre-tool-use.md @@ -1,13 +1,13 @@ -# Pre-Tool Use Hook +# Pre-tool use hook The `onPreToolUse` hook is called **before** a tool executes. Use it to: -- Approve or deny tool execution -- Modify tool arguments -- Add context for the tool -- Suppress tool output from the conversation +* Approve or deny tool execution +* Modify tool arguments +* Add context for the tool +* Suppress tool output from the conversation -## Hook Signature +## Hook signature
Node.js / TypeScript @@ -131,7 +131,7 @@ Return `null` or `undefined` to allow the tool to execute with no changes. Other | `additionalContext` | string | Extra context injected into the conversation | | `suppressOutput` | boolean | If true, tool output won't appear in conversation | -### Permission Decisions +### Permission decisions | Decision | Behavior | |----------|----------| @@ -141,7 +141,7 @@ Return `null` or `undefined` to allow the tool to execute with no changes. Other ## Examples -### Allow All Tools (Logging Only) +### Allow all tools (logging only)
Node.js / TypeScript @@ -296,7 +296,7 @@ var session = client.createSession(
-### Block Specific Tools +### Block specific tools ```typescript const BLOCKED_TOOLS = ["shell", "bash", "write_file", "delete_file"]; @@ -316,7 +316,7 @@ const session = await client.createSession({ }); ``` -### Modify Tool Arguments +### Modify tool arguments ```typescript const session = await client.createSession({ @@ -339,7 +339,7 @@ const session = await client.createSession({ }); ``` -### Restrict File Access to Specific Directories +### Restrict file access to specific directories ```typescript const ALLOWED_DIRECTORIES = ["/home/user/projects", "/tmp"]; @@ -366,7 +366,7 @@ const session = await client.createSession({ }); ``` -### Suppress Verbose Tool Output +### Suppress verbose tool output ```typescript const VERBOSE_TOOLS = ["list_directory", "search_files"]; @@ -383,7 +383,7 @@ const session = await client.createSession({ }); ``` -### Add Context Based on Tool +### Add context based on tool ```typescript const session = await client.createSession({ @@ -401,11 +401,11 @@ const session = await client.createSession({ }); ``` -## Best Practices +## Best practices 1. **Always return a decision** - Returning `null` allows the tool, but being explicit with `{ permissionDecision: "allow" }` is clearer. -2. **Provide helpful denial reasons** - When denying, explain why so users understand: +1. **Provide helpful denial reasons** - When denying, explain why so users understand: ```typescript return { permissionDecision: "deny", @@ -413,14 +413,14 @@ const session = await client.createSession({ }; ``` -3. **Be careful with argument modification** - Ensure modified args maintain the expected schema for the tool. +1. **Be careful with argument modification** - Ensure modified args maintain the expected schema for the tool. -4. **Consider performance** - Pre-tool hooks run synchronously before each tool call. Keep them fast. +1. **Consider performance** - Pre-tool hooks run synchronously before each tool call. Keep them fast. -5. **Use `suppressOutput` judiciously** - Suppressing output means the model won't see the result, which may affect conversation quality. +1. **Use `suppressOutput` judiciously** - Suppressing output means the model won't see the result, which may affect conversation quality. -## See Also +## See also -- [Hooks Overview](./index.md) -- [Post-Tool Use Hook](./post-tool-use.md) -- [Debugging Guide](../troubleshooting/debugging.md) +* [Hooks Overview](./index.md) +* [Post-Tool Use Hook](./post-tool-use.md) +* [Debugging Guide](../troubleshooting/debugging.md) diff --git a/docs/hooks/session-lifecycle.md b/docs/hooks/session-lifecycle.md index 1c8723854..b4ff502d5 100644 --- a/docs/hooks/session-lifecycle.md +++ b/docs/hooks/session-lifecycle.md @@ -1,17 +1,17 @@ -# Session Lifecycle Hooks +# Session lifecycle hooks Session lifecycle hooks let you respond to session start and end events. Use them to: -- Initialize context when sessions begin -- Clean up resources when sessions end -- Track session metrics and analytics -- Configure session behavior dynamically +* Initialize context when sessions begin +* Clean up resources when sessions end +* Track session metrics and analytics +* Configure session behavior dynamically -## Session Start Hook {#session-start} +## Session start hook {#session-start} The `onSessionStart` hook is called when a session begins (new or resumed). -### Hook Signature +### Hook signature
Node.js / TypeScript @@ -132,7 +132,7 @@ SessionStartHandler sessionStartHandler; ### Examples -#### Add Project Context at Start +#### Add project context at start
Node.js / TypeScript @@ -183,7 +183,7 @@ session = await client.create_session(on_permission_request=PermissionHandler.ap
-#### Handle Session Resume +#### Handle session resume ```typescript const session = await client.createSession({ @@ -207,7 +207,7 @@ Session resumed. Previous context: }); ``` -#### Load User Preferences +#### Load user preferences ```typescript const session = await client.createSession({ @@ -235,13 +235,11 @@ const session = await client.createSession({ }); ``` ---- - -## Session End Hook {#session-end} +## Session end hook {#session-end} The `onSessionEnd` hook is called when a session ends. -### Hook Signature +### Hook signature
Node.js / TypeScript @@ -336,7 +334,7 @@ SessionEndHandler sessionEndHandler; | `finalMessage` | string \| undefined | The last message from the session | | `error` | string \| undefined | Error message if session ended due to error | -#### End Reasons +#### End reasons | Reason | Description | |--------|-------------| @@ -356,7 +354,7 @@ SessionEndHandler sessionEndHandler; ### Examples -#### Track Session Metrics +#### Track session metrics
Node.js / TypeScript @@ -422,7 +420,7 @@ session = await client.create_session(on_permission_request=PermissionHandler.ap
-#### Clean Up Resources +#### Clean up resources ```typescript const sessionResources = new Map(); @@ -451,7 +449,7 @@ const session = await client.createSession({ }); ``` -#### Save Session State for Resume +#### Save session state for resume ```typescript const session = await client.createSession({ @@ -471,7 +469,7 @@ const session = await client.createSession({ }); ``` -#### Log Session Summary +#### Log session summary ```typescript const sessionData: Record = {}; @@ -512,20 +510,20 @@ Session Summary: }); ``` -## Best Practices +## Best practices 1. **Keep `onSessionStart` fast** - Users are waiting for the session to be ready. -2. **Handle all end reasons** - Don't assume sessions end cleanly; handle errors and aborts. +1. **Handle all end reasons** - Don't assume sessions end cleanly; handle errors and aborts. -3. **Clean up resources** - Use `onSessionEnd` to free any resources allocated during the session. +1. **Clean up resources** - Use `onSessionEnd` to free any resources allocated during the session. -4. **Store minimal state** - If tracking session data, keep it lightweight. +1. **Store minimal state** - If tracking session data, keep it lightweight. -5. **Make cleanup idempotent** - `onSessionEnd` might not be called if the process crashes. +1. **Make cleanup idempotent** - `onSessionEnd` might not be called if the process crashes. -## See Also +## See also -- [Hooks Overview](./index.md) -- [Error Handling Hook](./error-handling.md) -- [Debugging Guide](../troubleshooting/debugging.md) +* [Hooks Overview](./index.md) +* [Error Handling Hook](./error-handling.md) +* [Debugging Guide](../troubleshooting/debugging.md) diff --git a/docs/hooks/user-prompt-submitted.md b/docs/hooks/user-prompt-submitted.md index 0c0751980..d5965f4a1 100644 --- a/docs/hooks/user-prompt-submitted.md +++ b/docs/hooks/user-prompt-submitted.md @@ -1,13 +1,13 @@ -# User Prompt Submitted Hook +# User prompt submitted hook The `onUserPromptSubmitted` hook is called when a user submits a message. Use it to: -- Modify or enhance user prompts -- Add context before processing -- Filter or validate user input -- Implement prompt templates +* Modify or enhance user prompts +* Add context before processing +* Filter or validate user input +* Implement prompt templates -## Hook Signature +## Hook signature
Node.js / TypeScript @@ -130,7 +130,7 @@ Return `null` or `undefined` to use the prompt unchanged. Otherwise, return an o ## Examples -### Log All User Prompts +### Log all user prompts
Node.js / TypeScript @@ -270,7 +270,7 @@ var session = client.createSession(
-### Add Project Context +### Add project context ```typescript const session = await client.createSession({ @@ -290,7 +290,7 @@ Framework: ${projectInfo.framework} }); ``` -### Expand Shorthand Commands +### Expand shorthand commands ```typescript const SHORTCUTS: Record = { @@ -317,7 +317,7 @@ const session = await client.createSession({ }); ``` -### Content Filtering +### Content filtering ```typescript const BLOCKED_PATTERNS = [ @@ -344,7 +344,7 @@ const session = await client.createSession({ }); ``` -### Enforce Prompt Length Limits +### Enforce prompt length limits ```typescript const MAX_PROMPT_LENGTH = 10000; @@ -365,7 +365,7 @@ const session = await client.createSession({ }); ``` -### Add User Preferences +### Add user preferences ```typescript interface UserPreferences { @@ -399,7 +399,7 @@ const session = await client.createSession({ }); ``` -### Rate Limiting +### Rate limiting ```typescript const promptTimestamps: number[] = []; @@ -430,7 +430,7 @@ const session = await client.createSession({ }); ``` -### Prompt Templates +### Prompt templates ```typescript const TEMPLATES: Record string> = { @@ -466,20 +466,20 @@ const session = await client.createSession({ }); ``` -## Best Practices +## Best practices 1. **Preserve user intent** - When modifying prompts, ensure the core intent remains clear. -2. **Be transparent about modifications** - If you significantly change a prompt, consider logging or notifying the user. +1. **Be transparent about modifications** - If you significantly change a prompt, consider logging or notifying the user. -3. **Use `additionalContext` over `modifiedPrompt`** - Adding context is less intrusive than rewriting the prompt. +1. **Use `additionalContext` over `modifiedPrompt`** - Adding context is less intrusive than rewriting the prompt. -4. **Provide clear rejection reasons** - When rejecting prompts, explain why and how to fix it. +1. **Provide clear rejection reasons** - When rejecting prompts, explain why and how to fix it. -5. **Keep processing fast** - This hook runs on every user message. Avoid slow operations. +1. **Keep processing fast** - This hook runs on every user message. Avoid slow operations. -## See Also +## See also -- [Hooks Overview](./index.md) -- [Session Lifecycle Hooks](./session-lifecycle.md) -- [Pre-Tool Use Hook](./pre-tool-use.md) +* [Hooks Overview](./index.md) +* [Session Lifecycle Hooks](./session-lifecycle.md) +* [Pre-Tool Use Hook](./pre-tool-use.md) diff --git a/docs/index.md b/docs/index.md index 89936df73..0ab08b32b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,18 @@ -# GitHub Copilot SDK Documentation +# GitHub Copilot SDK documentation Welcome to the GitHub Copilot SDK docs. Whether you're building your first Copilot-powered app or deploying to production, you'll find what you need here. -## Where to Start +## Where to start | I want to... | Go to | |---|---| -| **Build my first app** | [Getting Started](./getting-started.md) — end-to-end tutorial with streaming & custom tools | -| **Set up for production** | [Setup Guides](./setup/index.md) — architecture, deployment patterns, scaling | -| **Configure authentication** | [Authentication](./auth/index.md) — GitHub OAuth, environment variables, BYOK | -| **Add features to my app** | [Features](./features/index.md) — hooks, custom agents, MCP, skills, and more | -| **Debug an issue** | [Troubleshooting](./troubleshooting/debugging.md) — common problems and solutions | +| **Build my first app** | [Getting Started](./getting-started.md)—end-to-end tutorial with streaming & custom tools | +| **Set up for production** | [Setup Guides](./setup/index.md)—architecture, deployment patterns, scaling | +| **Configure authentication** | [Authentication](./auth/index.md)—GitHub OAuth, environment variables, BYOK | +| **Add features to my app** | [Features](./features/index.md)—hooks, custom agents, MCP, skills, and more | +| **Debug an issue** | [Troubleshooting](./troubleshooting/debugging.md)—common problems and solutions | -## Documentation Map +## Documentation map ### [Getting Started](./getting-started.md) @@ -22,56 +22,56 @@ Step-by-step tutorial that takes you from zero to a working Copilot app with str How to configure and deploy the SDK for your use case. -- [Default Setup (Bundled CLI)](./setup/bundled-cli.md) — the SDK includes the CLI automatically -- [Local CLI](./setup/local-cli.md) — use your own CLI binary or running instance -- [Backend Services](./setup/backend-services.md) — server-side with headless CLI over TCP -- [GitHub OAuth](./setup/github-oauth.md) — implement the OAuth flow -- [Azure Managed Identity](./setup/azure-managed-identity.md) — BYOK with Azure AI Foundry -- [Scaling & Multi-Tenancy](./setup/scaling.md) — horizontal scaling, isolation patterns +* [Default Setup (Bundled CLI)](./setup/bundled-cli.md): the SDK includes the CLI automatically +* [Local CLI](./setup/local-cli.md): use your own CLI binary or running instance +* [Backend Services](./setup/backend-services.md): server-side with headless CLI over TCP +* [GitHub OAuth](./setup/github-oauth.md): implement the OAuth flow +* [Azure Managed Identity](./setup/azure-managed-identity.md): BYOK with Azure AI Foundry +* [Scaling & Multi-Tenancy](./setup/scaling.md): horizontal scaling, isolation patterns ### [Authentication](./auth/index.md) Configuring how users and services authenticate with Copilot. -- [Authentication Overview](./auth/index.md) — methods, priority order, and examples -- [Bring Your Own Key (BYOK)](./auth/byok.md) — use your own API keys from OpenAI, Azure, Anthropic, and more +* [Authentication Overview](./auth/index.md): methods, priority order, and examples +* [Bring Your Own Key (BYOK)](./auth/byok.md): use your own API keys from OpenAI, Azure, Anthropic, and more ### [Features](./features/index.md) Guides for building with the SDK's capabilities. -- [Hooks](./features/hooks.md) — intercept and customize session behavior -- [Custom Agents](./features/custom-agents.md) — define specialized sub-agents -- [MCP Servers](./features/mcp.md) — integrate Model Context Protocol servers -- [Skills](./features/skills.md) — load reusable prompt modules -- [Image Input](./features/image-input.md) — send images as attachments -- [Streaming Events](./features/streaming-events.md) — real-time event reference -- [Steering & Queueing](./features/steering-and-queueing.md) — message delivery modes -- [Session Persistence](./features/session-persistence.md) — resume sessions across restarts -- [Remote Sessions](./features/remote-sessions.md) — share sessions to GitHub web and mobile +* [Hooks](./features/hooks.md): intercept and customize session behavior +* [Custom Agents](./features/custom-agents.md): define specialized sub-agents +* [MCP Servers](./features/mcp.md): integrate Model Context Protocol servers +* [Skills](./features/skills.md): load reusable prompt modules +* [Image Input](./features/image-input.md): send images as attachments +* [Streaming Events](./features/streaming-events.md): real-time event reference +* [Steering & Queueing](./features/steering-and-queueing.md): message delivery modes +* [Session Persistence](./features/session-persistence.md): resume sessions across restarts +* [Remote Sessions](./features/remote-sessions.md): share sessions to GitHub web and mobile ### [Hooks Reference](./hooks/index.md) Detailed API reference for each session hook. -- [Pre-Tool Use](./hooks/pre-tool-use.md) — approve, deny, or modify tool calls -- [Post-Tool Use](./hooks/post-tool-use.md) — transform tool results -- [User Prompt Submitted](./hooks/user-prompt-submitted.md) — modify or filter user messages -- [Session Lifecycle](./hooks/session-lifecycle.md) — session start and end -- [Error Handling](./hooks/error-handling.md) — custom error handling +* [Pre-Tool Use](./hooks/pre-tool-use.md): approve, deny, or modify tool calls +* [Post-Tool Use](./hooks/post-tool-use.md): transform tool results +* [User Prompt Submitted](./hooks/user-prompt-submitted.md): modify or filter user messages +* [Session Lifecycle](./hooks/session-lifecycle.md): session start and end +* [Error Handling](./hooks/error-handling.md): custom error handling ### [Troubleshooting](./troubleshooting/debugging.md) -- [Debugging Guide](./troubleshooting/debugging.md) — common issues and solutions -- [MCP Debugging](./troubleshooting/mcp-debugging.md) — MCP-specific troubleshooting -- [Compatibility](./troubleshooting/compatibility.md) — SDK vs CLI feature matrix +* [Debugging Guide](./troubleshooting/debugging.md): common issues and solutions +* [MCP Debugging](./troubleshooting/mcp-debugging.md): MCP-specific troubleshooting +* [Compatibility](./troubleshooting/compatibility.md): SDK vs CLI feature matrix ### [Observability](./observability/opentelemetry.md) -- [OpenTelemetry Instrumentation](./observability/opentelemetry.md) — built-in TelemetryConfig and trace context propagation +* [OpenTelemetry Instrumentation](./observability/opentelemetry.md): built-in TelemetryConfig and trace context propagation ### [Integrations](./integrations/microsoft-agent-framework.md) Guides for using the SDK with other platforms and frameworks. -- [Microsoft Agent Framework](./integrations/microsoft-agent-framework.md) — MAF multi-agent workflows +* [Microsoft Agent Framework](./integrations/microsoft-agent-framework.md): MAF multi-agent workflows diff --git a/docs/integrations/index.md b/docs/integrations/index.md new file mode 100644 index 000000000..9f3e40780 --- /dev/null +++ b/docs/integrations/index.md @@ -0,0 +1,5 @@ +# Integrations + +Guides for using the GitHub Copilot SDK with other platforms and frameworks. + +* [Microsoft Agent Framework](./microsoft-agent-framework.md): MAF multi-agent workflows diff --git a/docs/integrations/microsoft-agent-framework.md b/docs/integrations/microsoft-agent-framework.md index dc37051d2..2f9f1966a 100644 --- a/docs/integrations/microsoft-agent-framework.md +++ b/docs/integrations/microsoft-agent-framework.md @@ -1,10 +1,10 @@ -# Microsoft Agent Framework Integration +# Microsoft agent framework integration Use the Copilot SDK as an agent provider inside the [Microsoft Agent Framework](https://devblogs.microsoft.com/semantic-kernel/build-ai-agents-with-github-copilot-sdk-and-microsoft-agent-framework/) (MAF) to compose multi-agent workflows alongside Azure OpenAI, Anthropic, and other providers. ## Overview -The Microsoft Agent Framework is the unified successor to Semantic Kernel and AutoGen. It provides a standard interface for building, orchestrating, and deploying AI agents. Dedicated integration packages let you wrap a Copilot SDK client as a first-class MAF agent — interchangeable with any other agent provider in the framework. +The Microsoft Agent Framework is the unified successor to Semantic Kernel and AutoGen. It provides a standard interface for building, orchestrating, and deploying AI agents. Dedicated integration packages let you wrap a Copilot SDK client as a first-class MAF agent—interchangeable with any other agent provider in the framework. | Concept | Description | |---------|-------------| @@ -13,15 +13,16 @@ The Microsoft Agent Framework is the unified successor to Semantic Kernel and Au | **Orchestrator** | A MAF component that coordinates agents in sequential, concurrent, or handoff workflows | | **A2A protocol** | Agent-to-Agent communication standard supported by the framework | -> **Note:** MAF integration packages are available for **.NET** and **Python**. For TypeScript, Go, and Java, use the Copilot SDK directly — the standard SDK APIs already provide tool calling, streaming, and custom agents. +> [!NOTE] +> MAF integration packages are available for **.NET** and **Python**. For TypeScript, Go, and Java, use the Copilot SDK directly—the standard SDK APIs already provide tool calling, streaming, and custom agents. ## Prerequisites Before you begin, ensure you have: -- A working [Copilot SDK setup](../getting-started.md) in your language of choice -- A GitHub Copilot subscription (Individual, Business, or Enterprise) -- The Copilot CLI installed or available via the SDK's bundled CLI +* A working [Copilot SDK setup](../getting-started.md) in your language of choice +* A GitHub Copilot subscription (Individual, Business, or Enterprise) +* The Copilot CLI installed or available via the SDK's bundled CLI ## Installation @@ -49,7 +50,8 @@ pip install copilot-sdk agent-framework-github-copilot
Java -> **Note:** The Java SDK does not have a dedicated MAF integration package. Use the standard Copilot SDK directly — it provides tool calling, streaming, and custom agents out of the box. +> [!NOTE] +> The Java SDK does not have a dedicated MAF integration package. Use the standard Copilot SDK directly—it provides tool calling, streaming, and custom agents out of the box. ```xml @@ -63,7 +65,7 @@ pip install copilot-sdk agent-framework-github-copilot
-## Basic Usage +## Basic usage Wrap the Copilot SDK client as a MAF agent with a single method call. The resulting agent conforms to the framework's standard interface and can be used anywhere a MAF agent is expected. @@ -135,7 +137,7 @@ client.stop().get();
-## Adding Custom Tools +## Adding custom tools Extend your Copilot agent with custom function tools. Tools defined through the standard Copilot SDK are automatically available when the agent runs inside MAF. @@ -264,11 +266,11 @@ try (var client = new CopilotClient()) {
-## Multi-Agent Workflows +## Multi-agent workflows The primary benefit of MAF integration is composing Copilot alongside other agent providers in orchestrated workflows. Use the framework's built-in orchestrators to create pipelines where different agents handle different steps. -### Sequential Workflow +### Sequential workflow Run agents one after another, passing output from one to the next: @@ -381,7 +383,7 @@ client.stop().get();
-### Concurrent Workflow +### Concurrent workflow Run multiple agents in parallel and aggregate their results: @@ -458,7 +460,7 @@ client.stop().get();
-## Streaming Responses +## Streaming responses When building interactive applications, stream agent responses to show real-time output. The MAF integration preserves the Copilot SDK's streaming capabilities. @@ -561,9 +563,9 @@ client.stop().get();
-## Configuration Reference +## Configuration reference -### MAF Agent Options +### MAF agent options | Property | Type | Description | |----------|------|-------------| @@ -572,7 +574,7 @@ client.stop().get(); | `Streaming` / `streaming` | `bool` | Enable streaming responses | | `Model` / `model` | `string` | Override the default model | -### Copilot SDK Options (Passed Through) +### Copilot SDK options (passed through) All standard [SessionConfig](../getting-started.md) options are still available when creating the underlying Copilot client. The MAF wrapper delegates to the SDK under the hood: @@ -585,7 +587,7 @@ All standard [SessionConfig](../getting-started.md) options are still available | Model selection | ✅ Overridable per agent or per call | | Streaming | ✅ Full delta event support | -## Best Practices +## Best practices ### Choose the right level of integration @@ -639,10 +641,10 @@ catch (AgentException ex) } ``` -## See Also +## See also -- [Getting Started](../getting-started.md) — initial Copilot SDK setup -- [Custom Agents](../features/custom-agents.md) — define specialized sub-agents within the SDK -- [Custom Skills](../features/skills.md) — reusable prompt modules -- [Microsoft Agent Framework documentation](https://learn.microsoft.com/en-us/agent-framework/agents/providers/github-copilot) — official MAF docs for the Copilot provider -- [Blog: Build AI Agents with GitHub Copilot SDK and Microsoft Agent Framework](https://devblogs.microsoft.com/semantic-kernel/build-ai-agents-with-github-copilot-sdk-and-microsoft-agent-framework/) +* [Getting Started](../getting-started.md): initial Copilot SDK setup +* [Custom Agents](../features/custom-agents.md): define specialized sub-agents within the SDK +* [Custom Skills](../features/skills.md): reusable prompt modules +* [Microsoft Agent Framework documentation](https://learn.microsoft.com/en-us/agent-framework/agents/providers/github-copilot): official MAF docs for the Copilot provider +* [Blog: Build AI Agents with GitHub Copilot SDK and Microsoft Agent Framework](https://devblogs.microsoft.com/semantic-kernel/build-ai-agents-with-github-copilot-sdk-and-microsoft-agent-framework/) diff --git a/docs/observability/index.md b/docs/observability/index.md new file mode 100644 index 000000000..9859cdffd --- /dev/null +++ b/docs/observability/index.md @@ -0,0 +1,5 @@ +# Observability + +Monitor and debug your GitHub Copilot SDK applications. + +* [OpenTelemetry instrumentation](./opentelemetry.md): built-in TelemetryConfig and trace context propagation diff --git a/docs/observability/opentelemetry.md b/docs/observability/opentelemetry.md index 3ac1bca9c..8ef04c832 100644 --- a/docs/observability/opentelemetry.md +++ b/docs/observability/opentelemetry.md @@ -1,8 +1,8 @@ -# OpenTelemetry Instrumentation for Copilot SDK +# OpenTelemetry instrumentation for Copilot SDK This guide shows how to add OpenTelemetry tracing to your Copilot SDK applications. -## Built-in Telemetry Support +## Built-in telemetry support The SDK has built-in support for configuring OpenTelemetry on the CLI process and propagating W3C Trace Context between the SDK and CLI. Provide a `TelemetryConfig` when creating the client to opt in: @@ -84,7 +84,7 @@ var client = new CopilotClient(new CopilotClientOptions()
-### TelemetryConfig Options +### TelemetryConfig options | Option | Node.js | Python | Go | .NET | Java | Description | |---|---|---|---|---|---|---| @@ -94,7 +94,7 @@ var client = new CopilotClient(new CopilotClientOptions() | Source name | `sourceName` | `source_name` | `SourceName` | `SourceName` | `sourceName` | Instrumentation scope name | | Capture content | `captureContent` | `capture_content` | `CaptureContent` | `CaptureContent` | `captureContent` | Whether to capture message content | -### Trace Context Propagation +### Trace context propagation > **Most users don't need this.** The `TelemetryConfig` above is all you need to collect traces from the CLI. The trace context propagation described in this section is an **advanced feature** for applications that create their own OpenTelemetry spans and want them to appear in the **same distributed trace** as the CLI's spans. @@ -119,16 +119,16 @@ const client = new CopilotClient({ }); ``` -For **Python**, **Go**, and **.NET**, trace context injection is automatic when the respective OpenTelemetry/Activity API is configured — no callback is needed. +For **Python**, **Go**, and **.NET**, trace context injection is automatic when the respective OpenTelemetry/Activity API is configured—no callback is needed. #### CLI → SDK (inbound) When the CLI invokes a tool handler, the `traceparent` and `tracestate` from the CLI's span are available in all languages: -- **Go**: The `ToolInvocation.TraceContext` field is a `context.Context` with the trace already restored — use it directly as the parent for your spans. -- **Python**: Trace context is automatically restored around the handler via `trace_context()` — child spans are parented to the CLI's span automatically. -- **.NET**: Trace context is automatically restored via `RestoreTraceContext()` — child `Activity` instances are parented to the CLI's span automatically. -- **Node.js**: Since the SDK has no OpenTelemetry dependency, `traceparent` and `tracestate` are passed as raw strings on the `ToolInvocation` object. Restore the context manually if needed: +* **Go**: The `ToolInvocation.TraceContext` field is a `context.Context` with the trace already restored—use it directly as the parent for your spans. +* **Python**: Trace context is automatically restored around the handler via `trace_context()`—child spans are parented to the CLI's span automatically. +* **.NET**: Trace context is automatically restored via `RestoreTraceContext()`—child `Activity` instances are parented to the CLI's span automatically. +* **Node.js**: Since the SDK has no OpenTelemetry dependency, `traceparent` and `tracestate` are passed as raw strings on the `ToolInvocation` object. Restore the context manually if needed: ```typescript @@ -157,19 +157,19 @@ session.registerTool(myTool, async (args, invocation) => { }); ``` -### Per-Language Dependencies +### Per-language dependencies | Language | Dependency | Notes | |---|---|---| -| Node.js | — | No dependency; provide `onGetTraceContext` callback for outbound propagation | +| Node.js |—| No dependency; provide `onGetTraceContext` callback for outbound propagation | | Python | `opentelemetry-api` | Install with `pip install copilot-sdk[telemetry]` | | Go | `go.opentelemetry.io/otel` | Required dependency | -| .NET | — | Uses built-in `System.Diagnostics.Activity` | +| .NET |—| Uses built-in `System.Diagnostics.Activity` | | Java | `io.opentelemetry:opentelemetry-api` | Add this dependency for SDK-based setup; trace context injection is automatic when the OpenTelemetry Java agent or SDK is configured | ## References -- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) -- [OpenTelemetry MCP Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/) -- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/instrumentation/python/) -- [Copilot SDK Documentation](https://github.com/github/copilot-sdk) +* [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) +* [OpenTelemetry MCP Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/) +* [OpenTelemetry Python SDK](https://opentelemetry.io/docs/instrumentation/python/) +* [Copilot SDK Documentation](https://github.com/github/copilot-sdk) diff --git a/docs/setup/azure-managed-identity.md b/docs/setup/azure-managed-identity.md index a3dfddab4..c803c7f89 100644 --- a/docs/setup/azure-managed-identity.md +++ b/docs/setup/azure-managed-identity.md @@ -1,16 +1,16 @@ -# Azure Managed Identity with BYOK +# Azure managed identity with BYOK The Copilot SDK's [BYOK mode](../auth/byok.md) accepts static API keys, but Azure deployments often use **Managed Identity** (Entra ID) instead of long-lived keys. Since the SDK doesn't natively support Entra ID authentication, you can use a short-lived bearer token via the `bearer_token` provider config field. This guide shows how to use `DefaultAzureCredential` from the [Azure Identity](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential) library to authenticate with Azure AI Foundry models through the Copilot SDK. -## How It Works +## How it works Azure AI Foundry's OpenAI-compatible endpoint accepts bearer tokens from Entra ID in place of static API keys. The pattern is: 1. Use `DefaultAzureCredential` to obtain a token for the `https://cognitiveservices.azure.com/.default` scope -2. Pass the token as the `bearer_token` in the BYOK provider config -3. Refresh the token before it expires (tokens are typically valid for ~1 hour) +1. Pass the token as the `bearer_token` in the BYOK provider config +1. Refresh the token before it expires (tokens are typically valid for ~1 hour) ```mermaid sequenceDiagram @@ -27,7 +27,7 @@ sequenceDiagram SDK-->>App: Session events ``` -## Python Example +## Python example ### Prerequisites @@ -35,7 +35,7 @@ sequenceDiagram pip install github-copilot-sdk azure-identity ``` -### Basic Usage +### Basic usage ```python import asyncio @@ -78,7 +78,7 @@ async def main(): asyncio.run(main()) ``` -### Token Refresh for Long-Running Applications +### Token refresh for long-running applications Bearer tokens expire (typically after ~1 hour). For servers or long-running agents, refresh the token before creating each session: @@ -124,7 +124,7 @@ class ManagedIdentityCopilotAgent: return response.data.content if response else "" ``` -## Node.js / TypeScript Example +## Node.js / TypeScript example ```typescript @@ -154,7 +154,7 @@ console.log(response?.data.content); await client.stop(); ``` -## .NET Example +## .NET example ```csharp @@ -186,22 +186,22 @@ var response = await session.SendAndWaitAsync( Console.WriteLine(response?.Data.Content); ``` -## Environment Configuration +## Environment configuration | Variable | Description | Example | |----------|-------------|---------| | `AZURE_AI_FOUNDRY_RESOURCE_URL` | Your Azure AI Foundry resource URL | `https://myresource.openai.azure.com` | -No API key environment variable is needed — authentication is handled by `DefaultAzureCredential`, which automatically supports: +No API key environment variable is needed—authentication is handled by `DefaultAzureCredential`, which automatically supports: -- **Managed Identity** (system-assigned or user-assigned) — for Azure-hosted apps -- **Azure CLI** (`az login`) — for local development -- **Environment variables** (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`) — for service principals -- **Workload Identity** — for Kubernetes +* **Managed Identity** (system-assigned or user-assigned): for Azure-hosted apps +* **Azure CLI** (`az login`): for local development +* **Environment variables** (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`): for service principals +* **Workload Identity**: for Kubernetes See the [DefaultAzureCredential documentation](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential) for the full credential chain. -## When to Use This Pattern +## When to use this pattern | Scenario | Recommendation | |----------|----------------| @@ -211,8 +211,8 @@ See the [DefaultAzureCredential documentation](https://learn.microsoft.com/pytho | Non-Azure environment with static API key | Use [standard BYOK](../auth/byok.md) | | GitHub Copilot subscription available | Use [GitHub OAuth](./github-oauth.md) | -## See Also +## See also -- [BYOK Setup Guide](../auth/byok.md) — Static API key configuration -- [Backend Services](./backend-services.md) — Server-side deployment -- [Azure Identity documentation](https://learn.microsoft.com/python/api/overview/azure/identity-readme) +* [BYOK Setup Guide](../auth/byok.md): Static API key configuration +* [Backend Services](./backend-services.md): Server-side deployment +* [Azure Identity documentation](https://learn.microsoft.com/python/api/overview/azure/identity-readme) diff --git a/docs/setup/backend-services.md b/docs/setup/backend-services.md index 35197eeb4..655453667 100644 --- a/docs/setup/backend-services.md +++ b/docs/setup/backend-services.md @@ -1,10 +1,10 @@ -# Backend Services Setup +# Backend services setup -Run the Copilot SDK in server-side applications — APIs, web backends, microservices, and background workers. The CLI runs as a headless server that your backend code connects to over the network. +Run the Copilot SDK in server-side applications—APIs, web backends, microservices, and background workers. The CLI runs as a headless server that your backend code connects to over the network. **Best for:** Web app backends, API services, internal tools, CI/CD integrations, any server-side workload. -## How It Works +## How it works Instead of the SDK spawning a CLI child process, you run the CLI independently in **headless server mode**. Your backend connects to it over TCP using the `cliUrl` option. @@ -31,12 +31,12 @@ flowchart TB ``` **Key characteristics:** -- CLI runs as a persistent server process (not spawned per request) -- SDK connects over TCP — CLI and app can run in different containers -- Multiple SDK clients can share one CLI server -- Works with any auth method (GitHub tokens, env vars, BYOK) +* CLI runs as a persistent server process (not spawned per request) +* SDK connects over TCP—CLI and app can run in different containers +* Multiple SDK clients can share one CLI server +* Works with any auth method (GitHub tokens, env vars, BYOK) -## Architecture: Auto-Managed vs. External CLI +## Architecture: auto-managed vs. external CLI ```mermaid flowchart LR @@ -54,7 +54,7 @@ flowchart LR style External fill:#0d1117,stroke:#3fb950,color:#c9d1d9 ``` -## Step 1: Start the CLI in Headless Mode +## Step 1: start the CLI in headless mode Run the CLI as a background server: @@ -67,7 +67,7 @@ copilot --headless # Output: Listening on http://localhost:52431 ``` -By default the headless server only accepts connections from loopback (`127.0.0.1`). To accept connections from other hosts — for example from another machine on your network — bind to a non-loopback address with `--host`: +By default the headless server only accepts connections from loopback (`127.0.0.1`). To accept connections from other hosts—for example from another machine on your network—bind to a non-loopback address with `--host`: ```bash copilot --headless --host 0.0.0.0 --port 4321 @@ -75,7 +75,8 @@ copilot --headless --host 0.0.0.0 --port 4321 For production, run it as a system service or in a container. -> **Note:** There is no official pre-built Docker image for the Copilot CLI. You can build your own from the [GitHub releases](https://github.com/github/copilot-cli/releases): +> [!NOTE] +> There is no official pre-built Docker image for the Copilot CLI. You can build your own from the [GitHub releases](https://github.com/github/copilot-cli/releases): ```dockerfile FROM debian:bookworm-slim @@ -116,7 +117,7 @@ Environment=COPILOT_GITHUB_TOKEN=your-token Restart=always ``` -## Step 2: Connect the SDK +## Step 2: connect the SDK
Node.js / TypeScript @@ -288,11 +289,11 @@ try {
-## Authentication for Backend Services +## Authentication for backend services -### Environment Variable Tokens +### Environment variable tokens -The simplest approach — set a token on the CLI server: +The simplest approach—set a token on the CLI server: ```mermaid flowchart LR @@ -313,7 +314,7 @@ export COPILOT_GITHUB_TOKEN="gho_service_account_token" copilot --headless --port 4321 ``` -### Per-User Tokens (OAuth) +### Per-user tokens (OAuth) Pass individual user tokens when creating sessions. See [GitHub OAuth](./github-oauth.md) for the full flow. @@ -339,7 +340,7 @@ app.post("/chat", authMiddleware, async (req, res) => { }); ``` -### BYOK (No GitHub Auth) +### BYOK (no GitHub auth) Use your own API keys for the model provider. See [BYOK](../auth/byok.md) for details. @@ -358,7 +359,7 @@ const session = await client.createSession({ }); ``` -## Common Backend Patterns +## Common backend patterns ### Web API with Express @@ -414,7 +415,7 @@ app.post("/api/chat", async (req, res) => { app.listen(3000); ``` -### Background Worker +### Background worker ```typescript import { CopilotClient } from "@github/copilot-sdk"; @@ -439,7 +440,7 @@ async function processJob(job: Job) { } ``` -### Docker Compose Deployment +### Docker compose deployment ```yaml version: "3.8" @@ -486,7 +487,7 @@ flowchart TB style Docker fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 ``` -## Health Checks +## Health checks Monitor the CLI server's health: @@ -502,7 +503,7 @@ async function checkCLIHealth(): Promise { } ``` -## Session Cleanup +## Session cleanup Backend services should actively clean up sessions to avoid resource leaks: @@ -533,7 +534,7 @@ setInterval(() => cleanupSessions(24 * 60 * 60 * 1000), 60 * 60 * 1000); | **Session state on local disk** | Mount persistent storage for container restarts | | **30-minute idle timeout** | Sessions without activity are auto-cleaned | -## When to Move On +## When to move on | Need | Next Guide | |------|-----------| @@ -541,8 +542,8 @@ setInterval(() => cleanupSessions(24 * 60 * 60 * 1000), 60 * 60 * 1000); | GitHub account auth for users | [GitHub OAuth](./github-oauth.md) | | Your own model keys | [BYOK](../auth/byok.md) | -## Next Steps +## Next steps -- **[Scaling & Multi-Tenancy](./scaling.md)** — Handle more users, add redundancy -- **[Session Persistence](../features/session-persistence.md)** — Resume sessions across restarts -- **[GitHub OAuth](./github-oauth.md)** — Add user authentication +* **[Scaling & Multi-Tenancy](./scaling.md)**: Handle more users, add redundancy +* **[Session Persistence](../features/session-persistence.md)**: Resume sessions across restarts +* **[GitHub OAuth](./github-oauth.md)**: Add user authentication diff --git a/docs/setup/bundled-cli.md b/docs/setup/bundled-cli.md index 7419d4c18..c19bc85b6 100644 --- a/docs/setup/bundled-cli.md +++ b/docs/setup/bundled-cli.md @@ -1,10 +1,10 @@ -# Default Setup (Bundled CLI) +# Default setup (bundled CLI) -The Node.js, Python, and .NET SDKs include the Copilot CLI as a dependency — your app ships with everything it needs, with no extra installation or configuration required. +The Node.js, Python, and .NET SDKs include the Copilot CLI as a dependency—your app ships with everything it needs, with no extra installation or configuration required. -**Best for:** Most applications — desktop apps, standalone tools, CLI utilities, prototypes, and more. +**Best for:** Most applications—desktop apps, standalone tools, CLI utilities, prototypes, and more. -## How It Works +## How it works When you install the SDK, the Copilot CLI binary is included automatically. The SDK starts it as a child process and communicates over stdio. There's nothing extra to configure. @@ -24,12 +24,12 @@ flowchart TB ``` **Key characteristics:** -- CLI binary is included with the SDK — no separate install needed -- The SDK manages the CLI version to ensure compatibility -- Users authenticate through your app (or use env vars / BYOK) -- Sessions are managed per-user on their machine +* CLI binary is included with the SDK—no separate install needed +* The SDK manages the CLI version to ensure compatibility +* Users authenticate through your app (or use env vars / BYOK) +* Sessions are managed per-user on their machine -## Quick Start +## Quick start
Node.js / TypeScript @@ -70,7 +70,8 @@ await client.stop()
Go -> **Note:** The Go SDK does not bundle the CLI. You must install the CLI separately or set `CLIPath` to point to an existing binary. See [Local CLI Setup](./local-cli.md) for details. +> [!NOTE] +> The Go SDK does not bundle the CLI. You must install the CLI separately or set `CLIPath` to point to an existing binary. See [Local CLI Setup](./local-cli.md) for details. ```go @@ -135,7 +136,8 @@ Console.WriteLine(response?.Data.Content);
Java -> **Note:** The Java SDK does not bundle or embed the Copilot CLI. You must install the CLI separately and configure its path via `cliPath` or the `COPILOT_CLI_PATH` environment variable. +> [!NOTE] +> The Java SDK does not bundle or embed the Copilot CLI. You must install the CLI separately and configure its path via `cliPath` or the `COPILOT_CLI_PATH` environment variable. ```java import com.github.copilot.sdk.CopilotClient; @@ -162,7 +164,7 @@ client.stop().get();
-## Authentication Strategies +## Authentication strategies You need to decide how your users will authenticate. Here are the common patterns: @@ -181,16 +183,16 @@ flowchart TB style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 ``` -### Option A: User's Signed-In Credentials (Simplest) +### Option A: user's signed-in credentials (simplest) -The user signs in to the CLI once, and your app uses those credentials. No extra code needed — this is the default behavior. +The user signs in to the CLI once, and your app uses those credentials. No extra code needed—this is the default behavior. ```typescript const client = new CopilotClient(); // Default: uses signed-in user credentials ``` -### Option B: Token via Environment Variable +### Option B: token via environment variable Ship your app with instructions to set a token, or set it programmatically: @@ -202,7 +204,7 @@ const client = new CopilotClient({ }); ``` -### Option C: BYOK (No GitHub Auth Needed) +### Option C: BYOK (no GitHub auth needed) If you manage your own model provider keys, users don't need GitHub accounts at all: @@ -221,7 +223,7 @@ const session = await client.createSession({ See the **[BYOK guide](../auth/byok.md)** for full details. -## Session Management +## Session management Apps typically want named sessions so users can resume conversations: @@ -242,7 +244,7 @@ const resumed = await client.resumeSession(sessionId); Session state persists at `~/.copilot/session-state/{sessionId}/`. -## When to Move On +## When to move on | Need | Next Guide | |------|-----------| @@ -250,8 +252,8 @@ Session state persists at `~/.copilot/session-state/{sessionId}/`. | Run on a server instead of user machines | [Backend Services](./backend-services.md) | | Use your own model keys | [BYOK](../auth/byok.md) | -## Next Steps +## Next steps -- **[BYOK guide](../auth/byok.md)** — Use your own model provider keys -- **[Session Persistence](../features/session-persistence.md)** — Advanced session management -- **[Getting Started tutorial](../getting-started.md)** — Build a complete app +* **[BYOK guide](../auth/byok.md)**: Use your own model provider keys +* **[Session Persistence](../features/session-persistence.md)**: Advanced session management +* **[Getting Started tutorial](../getting-started.md)**: Build a complete app diff --git a/docs/setup/choosing-a-setup-path.md b/docs/setup/choosing-a-setup-path.md new file mode 100644 index 000000000..f1c4636c6 --- /dev/null +++ b/docs/setup/choosing-a-setup-path.md @@ -0,0 +1,142 @@ +# Setup guides + +These guides walk you through configuring the Copilot SDK for your specific use case—from personal side projects to production platforms serving thousands of users. + +## Architecture at a glance + +Every Copilot SDK integration follows the same core pattern: your application talks to the SDK, which communicates with the Copilot CLI over JSON-RPC. What changes across setups is **where the CLI runs**, **how users authenticate**, and **how sessions are managed**. + +```mermaid +flowchart TB + subgraph YourApp["Your Application"] + SDK["SDK Client"] + end + + subgraph CLI["Copilot CLI"] + direction TB + RPC["JSON-RPC Server"] + Auth["Authentication"] + Sessions["Session Manager"] + Models["Model Provider"] + end + + SDK -- "JSON-RPC
(stdio or TCP)" --> RPC + RPC --> Auth + RPC --> Sessions + Auth --> Models + + style YourApp fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLI fill:#161b22,stroke:#3fb950,color:#c9d1d9 +``` + +The setup guides below help you configure each layer for your scenario. + +## Who are you? + +### 🧑‍💻 Hobbyist + +You're building a personal assistant, side project, or experimental app. You want the simplest path to getting Copilot in your code. + +**Start with:** +1. **[Default Setup](./bundled-cli.md)**—The SDK includes the CLI automatically—just install and go +1. **[Local CLI](./local-cli.md)**—Use your own CLI binary or running instance (advanced) + +### 🏢 Internal app developer + +You're building tools for your team or company. Users are employees who need to authenticate with their enterprise GitHub accounts or org memberships. + +**Start with:** +1. **[GitHub OAuth](./github-oauth.md)**—Let employees sign in with their GitHub accounts +1. **[Backend Services](./backend-services.md)**—Run the SDK in your internal services + +**If scaling beyond a single server:** +1. **[Scaling & Multi-Tenancy](./scaling.md)**—Handle multiple users and services + +### 🚀 App developer (ISV) + +You're building a product for customers. You need to handle authentication for your users—either through GitHub or by managing identity yourself. + +**Start with:** +1. **[GitHub OAuth](./github-oauth.md)**—Let customers sign in with GitHub +1. **[BYOK](../auth/byok.md)**—Manage identity yourself with your own model keys +1. **[Backend Services](./backend-services.md)**—Power your product from server-side code + +**For production:** +1. **[Scaling & Multi-Tenancy](./scaling.md)**—Serve many customers reliably + +### 🏗️ Platform developer + +You're embedding Copilot into a platform—APIs, developer tools, or infrastructure that other developers build on. You need fine-grained control over sessions, scaling, and multi-tenancy. + +**Start with:** +1. **[Backend Services](./backend-services.md)**—Core server-side integration +1. **[Scaling & Multi-Tenancy](./scaling.md)**—Session isolation, horizontal scaling, persistence + +**Depending on your auth model:** +1. **[GitHub OAuth](./github-oauth.md)**—For GitHub-authenticated users +1. **[BYOK](../auth/byok.md)**—For self-managed identity and model access + +## Decision matrix + +Use this table to find the right guides based on what you need to do: + +| What you need | Guide | +|---------------|-------| +| Getting started quickly | [Default Setup (Bundled CLI)](./bundled-cli.md) | +| Use your own CLI binary or server | [Local CLI](./local-cli.md) | +| Users sign in with GitHub | [GitHub OAuth](./github-oauth.md) | +| Use your own model keys (OpenAI, Azure, etc.) | [BYOK](../auth/byok.md) | +| Azure BYOK with Managed Identity (no API keys) | [Azure Managed Identity](./azure-managed-identity.md) | +| Run the SDK on a server | [Backend Services](./backend-services.md) | +| Serve multiple users / scale horizontally | [Scaling & Multi-Tenancy](./scaling.md) | + +## Configuration comparison + +```mermaid +flowchart LR + subgraph Auth["Authentication"] + A1["Signed-in CLI
(local)"] + A2["GitHub OAuth
(multi-user)"] + A3["Env Vars / Tokens
(server)"] + A4["BYOK
(your keys)"] + end + + subgraph Deploy["Deployment"] + D1["Local Process
(auto-managed)"] + D2["Bundled Binary
(shipped with app)"] + D3["External Server
(headless CLI)"] + end + + subgraph Scale["Scaling"] + S1["Single User
(one CLI)"] + S2["Multi-User
(shared CLI)"] + S3["Isolated
(CLI per user)"] + end + + A1 --> D1 --> S1 + A2 --> D3 --> S2 + A3 --> D3 --> S2 + A4 --> D2 --> S1 + A2 --> D3 --> S3 + A3 --> D3 --> S3 + + style Auth fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style Deploy fill:#0d1117,stroke:#3fb950,color:#c9d1d9 + style Scale fill:#0d1117,stroke:#f0883e,color:#c9d1d9 +``` + +## Prerequisites + +All guides assume you have: + +* **One of the SDKs** installed (Node.js, Python, and .NET SDKs include the CLI automatically): + * Node.js: `npm install @github/copilot-sdk` + * Python: `pip install github-copilot-sdk` + * Go: `go get github.com/github/copilot-sdk/go` (requires separate CLI installation) + * .NET: `dotnet add package GitHub.Copilot.SDK` + +If you're brand new, start with the **[Getting Started tutorial](../getting-started.md)** first, then come back here for production configuration. + +## Next steps + +Pick the guide that matches your situation from the [decision matrix](#decision-matrix) above, or start with the persona description closest to your role. diff --git a/docs/setup/github-oauth.md b/docs/setup/github-oauth.md index 0f2be236e..74fa1fc65 100644 --- a/docs/setup/github-oauth.md +++ b/docs/setup/github-oauth.md @@ -1,10 +1,10 @@ -# GitHub OAuth Setup +# GitHub OAuth setup Let users authenticate with their GitHub accounts to use Copilot through your application. This supports individual accounts, organization memberships, and enterprise identities. **Best for:** Multi-user apps, internal tools with org access control, SaaS products, apps where users have GitHub accounts. -## How It Works +## How it works You create a GitHub OAuth App (or GitHub App), users authorize it, and you pass their access token to the SDK. Copilot requests are made on behalf of each authenticated user, using their Copilot subscription. @@ -34,10 +34,10 @@ sequenceDiagram ``` **Key characteristics:** -- Each user authenticates with their own GitHub account -- Copilot usage is billed to each user's subscription -- Supports GitHub organizations and enterprise accounts -- Your app never handles model API keys — GitHub manages everything +* Each user authenticates with their own GitHub account +* Copilot usage is billed to each user's subscription +* Supports GitHub organizations and enterprise accounts +* Your app never handles model API keys—GitHub manages everything ## Architecture @@ -72,21 +72,21 @@ flowchart TB style CLI fill:#0d1117,stroke:#3fb950,color:#c9d1d9 ``` -## Step 1: Create a GitHub OAuth App +## Step 1: create a GitHub OAuth app 1. Go to **GitHub Settings → Developer Settings → OAuth Apps → New OAuth App** (or for organizations: **Organization Settings → Developer Settings**) -2. Fill in: - - **Application name**: Your app's name - - **Homepage URL**: Your app's URL - - **Authorization callback URL**: Your OAuth callback endpoint (e.g., `https://yourapp.com/auth/callback`) +1. Fill in: + * **Application name**: Your app's name + * **Homepage URL**: Your app's URL + * **Authorization callback URL**: Your OAuth callback endpoint (e.g., `https://yourapp.com/auth/callback`) -3. Note your **Client ID** and generate a **Client Secret** +1. Note your **Client ID** and generate a **Client Secret** > **GitHub App vs OAuth App:** Both work. GitHub Apps offer finer-grained permissions and are recommended for new projects. OAuth Apps are simpler to set up. The token flow is the same from the SDK's perspective. -## Step 2: Implement the OAuth Flow +## Step 2: implement the OAuth flow Your application handles the standard GitHub OAuth flow. Here's the server-side token exchange: @@ -111,7 +111,7 @@ async function handleOAuthCallback(code: string): Promise { } ``` -## Step 3: Pass the Token to the SDK +## Step 3: pass the token to the SDK Create a SDK client for each authenticated user, passing their token: @@ -308,7 +308,7 @@ try (var client = createClientForUser("gho_user_access_token")) {
-## Enterprise & Organization Access +## Enterprise and organization access GitHub OAuth naturally supports enterprise scenarios. When users authenticate with GitHub, their org memberships and enterprise associations come along. @@ -341,7 +341,7 @@ flowchart TB style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 ``` -### Verify Organization Membership +### Verify organization membership After OAuth, check that the user belongs to your organization: @@ -365,9 +365,9 @@ if (!await verifyOrgMembership(token, "my-company")) { const client = createClientForUser(token); ``` -### Enterprise Managed Users (EMU) +### Enterprise managed users (EMU) -For GitHub Enterprise Managed Users, the flow is identical — EMU users authenticate through GitHub OAuth like any other user. Their enterprise policies (IP restrictions, SAML SSO) are enforced by GitHub automatically. +For GitHub Enterprise Managed Users, the flow is identical—EMU users authenticate through GitHub OAuth like any other user. Their enterprise policies (IP restrictions, SAML SSO) are enforced by GitHub automatically. ```typescript // No special SDK configuration needed for EMU @@ -378,7 +378,7 @@ const client = new CopilotClient({ }); ``` -## Supported Token Types +## Supported token types | Token Prefix | Source | Works? | |-------------|--------|--------| @@ -387,7 +387,7 @@ const client = new CopilotClient({ | `github_pat_` | Fine-grained personal access token | ✅ | | `ghp_` | Classic personal access token | ❌ (deprecated) | -## Token Lifecycle +## Token lifecycle ```mermaid flowchart LR @@ -404,9 +404,9 @@ flowchart LR style E fill:#0d1117,stroke:#f0883e,color:#c9d1d9 ``` -**Important:** Your application is responsible for token storage, refresh, and expiration handling. The SDK uses whatever token you provide — it doesn't manage the OAuth lifecycle. +**Important:** Your application is responsible for token storage, refresh, and expiration handling. The SDK uses whatever token you provide—it doesn't manage the OAuth lifecycle. -### Token Refresh Pattern +### Token refresh pattern ```typescript async function getOrRefreshToken(userId: string): Promise { @@ -426,9 +426,9 @@ async function getOrRefreshToken(userId: string): Promise { } ``` -## Multi-User Patterns +## Multi-user patterns -### One Client Per User (Recommended) +### One client per user (recommended) Each user gets their own SDK client with their own token. This provides the strongest isolation. @@ -446,7 +446,7 @@ function getClientForUser(userId: string, token: string): CopilotClient { } ``` -### Shared CLI with Per-Request Tokens +### Shared CLI with per-request tokens For a lighter resource footprint, you can run a single external CLI server and pass tokens per session. See [Backend Services](./backend-services.md) for this pattern. @@ -459,7 +459,7 @@ For a lighter resource footprint, you can run a single external CLI server and p | **GitHub account required** | Users must have GitHub accounts | | **Rate limits per user** | Subject to each user's Copilot rate limits | -## When to Move On +## When to move on | Need | Next Guide | |------|-----------| @@ -467,8 +467,8 @@ For a lighter resource footprint, you can run a single external CLI server and p | Run the SDK on servers | [Backend Services](./backend-services.md) | | Handle many concurrent users | [Scaling & Multi-Tenancy](./scaling.md) | -## Next Steps +## Next steps -- **[Authentication docs](../auth/index.md)** — Full auth method reference -- **[Backend Services](./backend-services.md)** — Run the SDK server-side -- **[Scaling & Multi-Tenancy](./scaling.md)** — Handle many users at scale +* **[Authentication docs](../auth/authenticate.md)**: Full auth method reference +* **[Backend Services](./backend-services.md)**: Run the SDK server-side +* **[Scaling & Multi-Tenancy](./scaling.md)**: Handle many users at scale diff --git a/docs/setup/index.md b/docs/setup/index.md index 68daaa008..d077cc6bb 100644 --- a/docs/setup/index.md +++ b/docs/setup/index.md @@ -1,142 +1,11 @@ -# Setup Guides +# Set up Copilot SDK -These guides walk you through configuring the Copilot SDK for your specific use case — from personal side projects to production platforms serving thousands of users. +Configure and deploy the GitHub Copilot SDK for your use case. -## Architecture at a Glance - -Every Copilot SDK integration follows the same core pattern: your application talks to the SDK, which communicates with the Copilot CLI over JSON-RPC. What changes across setups is **where the CLI runs**, **how users authenticate**, and **how sessions are managed**. - -```mermaid -flowchart TB - subgraph YourApp["Your Application"] - SDK["SDK Client"] - end - - subgraph CLI["Copilot CLI"] - direction TB - RPC["JSON-RPC Server"] - Auth["Authentication"] - Sessions["Session Manager"] - Models["Model Provider"] - end - - SDK -- "JSON-RPC
(stdio or TCP)" --> RPC - RPC --> Auth - RPC --> Sessions - Auth --> Models - - style YourApp fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 - style CLI fill:#161b22,stroke:#3fb950,color:#c9d1d9 -``` - -The setup guides below help you configure each layer for your scenario. - -## Who Are You? - -### 🧑‍💻 Hobbyist - -You're building a personal assistant, side project, or experimental app. You want the simplest path to getting Copilot in your code. - -**Start with:** -1. **[Default Setup](./bundled-cli.md)** — The SDK includes the CLI automatically — just install and go -2. **[Local CLI](./local-cli.md)** — Use your own CLI binary or running instance (advanced) - -### 🏢 Internal App Developer - -You're building tools for your team or company. Users are employees who need to authenticate with their enterprise GitHub accounts or org memberships. - -**Start with:** -1. **[GitHub OAuth](./github-oauth.md)** — Let employees sign in with their GitHub accounts -2. **[Backend Services](./backend-services.md)** — Run the SDK in your internal services - -**If scaling beyond a single server:** -3. **[Scaling & Multi-Tenancy](./scaling.md)** — Handle multiple users and services - -### 🚀 App Developer (ISV) - -You're building a product for customers. You need to handle authentication for your users — either through GitHub or by managing identity yourself. - -**Start with:** -1. **[GitHub OAuth](./github-oauth.md)** — Let customers sign in with GitHub -2. **[BYOK](../auth/byok.md)** — Manage identity yourself with your own model keys -3. **[Backend Services](./backend-services.md)** — Power your product from server-side code - -**For production:** -4. **[Scaling & Multi-Tenancy](./scaling.md)** — Serve many customers reliably - -### 🏗️ Platform Developer - -You're embedding Copilot into a platform — APIs, developer tools, or infrastructure that other developers build on. You need fine-grained control over sessions, scaling, and multi-tenancy. - -**Start with:** -1. **[Backend Services](./backend-services.md)** — Core server-side integration -2. **[Scaling & Multi-Tenancy](./scaling.md)** — Session isolation, horizontal scaling, persistence - -**Depending on your auth model:** -3. **[GitHub OAuth](./github-oauth.md)** — For GitHub-authenticated users -4. **[BYOK](../auth/byok.md)** — For self-managed identity and model access - -## Decision Matrix - -Use this table to find the right guides based on what you need to do: - -| What you need | Guide | -|---------------|-------| -| Getting started quickly | [Default Setup (Bundled CLI)](./bundled-cli.md) | -| Use your own CLI binary or server | [Local CLI](./local-cli.md) | -| Users sign in with GitHub | [GitHub OAuth](./github-oauth.md) | -| Use your own model keys (OpenAI, Azure, etc.) | [BYOK](../auth/byok.md) | -| Azure BYOK with Managed Identity (no API keys) | [Azure Managed Identity](./azure-managed-identity.md) | -| Run the SDK on a server | [Backend Services](./backend-services.md) | -| Serve multiple users / scale horizontally | [Scaling & Multi-Tenancy](./scaling.md) | - -## Configuration Comparison - -```mermaid -flowchart LR - subgraph Auth["Authentication"] - A1["Signed-in CLI
(local)"] - A2["GitHub OAuth
(multi-user)"] - A3["Env Vars / Tokens
(server)"] - A4["BYOK
(your keys)"] - end - - subgraph Deploy["Deployment"] - D1["Local Process
(auto-managed)"] - D2["Bundled Binary
(shipped with app)"] - D3["External Server
(headless CLI)"] - end - - subgraph Scale["Scaling"] - S1["Single User
(one CLI)"] - S2["Multi-User
(shared CLI)"] - S3["Isolated
(CLI per user)"] - end - - A1 --> D1 --> S1 - A2 --> D3 --> S2 - A3 --> D3 --> S2 - A4 --> D2 --> S1 - A2 --> D3 --> S3 - A3 --> D3 --> S3 - - style Auth fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 - style Deploy fill:#0d1117,stroke:#3fb950,color:#c9d1d9 - style Scale fill:#0d1117,stroke:#f0883e,color:#c9d1d9 -``` - -## Prerequisites - -All guides assume you have: - -- **One of the SDKs** installed (Node.js, Python, and .NET SDKs include the CLI automatically): - - Node.js: `npm install @github/copilot-sdk` - - Python: `pip install github-copilot-sdk` - - Go: `go get github.com/github/copilot-sdk/go` (requires separate CLI installation) - - .NET: `dotnet add package GitHub.Copilot.SDK` - -If you're brand new, start with the **[Getting Started tutorial](../getting-started.md)** first, then come back here for production configuration. - -## Next Steps - -Pick the guide that matches your situation from the [decision matrix](#decision-matrix) above, or start with the persona description closest to your role. +* [Choosing a setup path](./choosing-a-setup-path.md): architecture, personas, and decision matrix +* [Default setup (bundled CLI)](./bundled-cli.md): the SDK includes the CLI automatically +* [Local CLI](./local-cli.md): use your own CLI binary or running instance +* [Backend services](./backend-services.md): server-side with headless CLI over TCP +* [GitHub OAuth](./github-oauth.md): implement the OAuth flow +* [Azure managed identity](./azure-managed-identity.md): BYOK with Azure AI Foundry +* [Scaling and multi-tenancy](./scaling.md): horizontal scaling, isolation patterns diff --git a/docs/setup/local-cli.md b/docs/setup/local-cli.md index 0e2d11020..28bef7b20 100644 --- a/docs/setup/local-cli.md +++ b/docs/setup/local-cli.md @@ -1,12 +1,12 @@ -# Local CLI Setup +# Local CLI setup -Use a specific CLI binary instead of the SDK's bundled CLI. This is an advanced option — you supply the CLI path explicitly, and you are responsible for ensuring version compatibility with the SDK. +Use a specific CLI binary instead of the SDK's bundled CLI. This is an advanced option—you supply the CLI path explicitly, and you are responsible for ensuring version compatibility with the SDK. **Use when:** You need to pin a specific CLI version, or work with the Go SDK (which does not bundle a CLI). -## How It Works +## How it works -By default, the Node.js, Python, and .NET SDKs include their own CLI dependency (see [Default Setup](./bundled-cli.md)). If you need to override this — for example, to use a system-installed CLI — you can use the `cliPath` option. +By default, the Node.js, Python, and .NET SDKs include their own CLI dependency (see [Default Setup](./bundled-cli.md)). If you need to override this—for example, to use a system-installed CLI—you can use the `cliPath` option. ```mermaid flowchart LR @@ -21,10 +21,10 @@ flowchart LR ``` **Key characteristics:** -- You explicitly provide the CLI binary path -- You are responsible for CLI version compatibility with the SDK -- Authentication uses the signed-in user's credentials from the system keychain (or env vars) -- Communication happens over stdio +* You explicitly provide the CLI binary path +* You are responsible for CLI version compatibility with the SDK +* Authentication uses the signed-in user's credentials from the system keychain (or env vars) +* Communication happens over stdio ## Configuration @@ -77,7 +77,8 @@ await client.stop()
Go -> **Note:** The Go SDK does not bundle a CLI, so you must always provide `CLIPath`. +> [!NOTE] +> The Go SDK does not bundle a CLI, so you must always provide `CLIPath`. ```go @@ -151,7 +152,7 @@ Console.WriteLine(response?.Data.Content);
-## Additional Options +## Additional options ```typescript const client = new CopilotClient({ @@ -168,7 +169,7 @@ const client = new CopilotClient({ }); ``` -## Using Environment Variables +## Using environment variables Instead of the keychain, you can authenticate via environment variables. This is useful for CI or when you don't want interactive login. @@ -179,9 +180,9 @@ export GH_TOKEN="gho_xxxx" # GitHub CLI compatible export GITHUB_TOKEN="gho_xxxx" # GitHub Actions compatible ``` -The SDK picks these up automatically — no code changes needed. +The SDK picks these up automatically—no code changes needed. -## Managing Sessions +## Managing sessions Sessions default to ephemeral. To create resumable sessions, provide your own session ID: @@ -207,8 +208,8 @@ Session state is stored locally at `~/.copilot/session-state/{sessionId}/`. | **Local only** | The CLI runs on the same machine as your app | | **No multi-tenant** | Can't serve multiple users from one CLI instance | -## Next Steps +## Next steps -- **[Default Setup](./bundled-cli.md)** — Use the SDK's built-in CLI (recommended for most use cases) -- **[Getting Started tutorial](../getting-started.md)** — Build a complete interactive app -- **[Authentication docs](../auth/index.md)** — All auth methods in detail +* **[Default Setup](./bundled-cli.md)**: Use the SDK's built-in CLI (recommended for most use cases) +* **[Getting Started tutorial](../getting-started.md)**: Build a complete interactive app +* **[Authentication docs](../auth/authenticate.md)**: All auth methods in detail diff --git a/docs/setup/scaling.md b/docs/setup/scaling.md index bc294980d..371a402b3 100644 --- a/docs/setup/scaling.md +++ b/docs/setup/scaling.md @@ -1,10 +1,10 @@ -# Scaling & Multi-Tenancy +# Scaling and multi-tenancy Design your Copilot SDK deployment to serve multiple users, handle concurrent sessions, and scale horizontally across infrastructure. This guide covers session isolation patterns, scaling topologies, and production best practices. **Best for:** Platform developers, SaaS builders, any deployment serving more than a handful of concurrent users. -## Core Concepts +## Core concepts Before choosing a pattern, understand three dimensions of scaling: @@ -24,11 +24,11 @@ flowchart TB style Dimensions fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 ``` -## Session Isolation Patterns +## Session isolation patterns -### Pattern 1: Isolated CLI Per User +### Pattern 1: isolated CLI per user -Each user gets their own CLI server instance. Strongest isolation — a user's sessions, memory, and processes are completely separated. +Each user gets their own CLI server instance. Strongest isolation—a user's sessions, memory, and processes are completely separated. ```mermaid flowchart TB @@ -59,9 +59,9 @@ flowchart TB ``` **When to use:** -- Multi-tenant SaaS where data isolation is critical -- Users with different auth credentials -- Compliance requirements (SOC 2, HIPAA) +* Multi-tenant SaaS where data isolation is critical +* Users with different auth credentials +* Compliance requirements (SOC 2, HIPAA) ```typescript // CLI pool manager — one CLI per user @@ -97,7 +97,7 @@ class CLIPool { } ``` -### Pattern 2: Shared CLI with Session Isolation +### Pattern 2: shared CLI with session isolation Multiple users share one CLI server but have isolated sessions via unique session IDs. Lighter on resources, but weaker isolation. @@ -130,9 +130,9 @@ flowchart TB ``` **When to use:** -- Internal tools with trusted users -- Resource-constrained environments -- Lower isolation requirements +* Internal tools with trusted users +* Resource-constrained environments +* Lower isolation requirements ```typescript const sharedClient = new CopilotClient({ @@ -157,9 +157,9 @@ async function resumeSessionWithAuth( } ``` -### Pattern 3: Shared Sessions (Collaborative) +### Pattern 3: shared sessions (collaborative) -Multiple users interact with the same session — like a shared chat room with Copilot. +Multiple users interact with the same session—like a shared chat room with Copilot. ```mermaid flowchart TB @@ -188,9 +188,9 @@ flowchart TB ``` **When to use:** -- Team collaboration tools -- Shared code review sessions -- Pair programming assistants +* Team collaboration tools +* Shared code review sessions +* Pair programming assistants > ⚠️ **Important:** The SDK doesn't provide built-in session locking. You **must** serialize access to prevent concurrent writes to the same session. @@ -235,7 +235,7 @@ app.post("/team-chat", authMiddleware, async (req, res) => { }); ``` -## Comparison of Isolation Patterns +## Comparison of isolation patterns | | Isolated CLI Per User | Shared CLI + Session Isolation | Shared Sessions | |---|---|---|---| @@ -245,9 +245,9 @@ app.post("/team-chat", authMiddleware, async (req, res) => { | **Auth flexibility** | ✅ Per-user tokens | ⚠️ Service token | ⚠️ Service token | | **Best for** | Multi-tenant SaaS | Internal tools | Collaboration | -## Horizontal Scaling +## Horizontal scaling -### Multiple CLI Servers Behind a Load Balancer +### Multiple CLI servers behind a load balancer ```mermaid flowchart TB @@ -330,7 +330,7 @@ app.post("/chat", async (req, res) => { }); ``` -### Sticky Sessions vs. Shared Storage +### Sticky sessions vs. shared storage ```mermaid flowchart LR @@ -354,13 +354,13 @@ flowchart LR style Shared fill:#0d1117,stroke:#3fb950,color:#c9d1d9 ``` -**Sticky sessions** are simpler — pin users to specific CLI servers. No shared storage needed, but load distribution is uneven. +**Sticky sessions** are simpler—pin users to specific CLI servers. No shared storage needed, but load distribution is uneven. **Shared storage** enables any CLI to handle any session. Better load distribution, but requires networked storage for `~/.copilot/session-state/`. -## Vertical Scaling +## Vertical scaling -### Tuning a Single CLI Server +### Tuning a single CLI server A single CLI server can handle many concurrent sessions. Key considerations: @@ -419,7 +419,7 @@ class SessionManager { } ``` -## Ephemeral vs. Persistent Sessions +## Ephemeral vs. persistent sessions ```mermaid flowchart LR @@ -441,7 +441,7 @@ flowchart LR style Persistent fill:#0d1117,stroke:#3fb950,color:#c9d1d9 ``` -### Ephemeral Sessions +### Ephemeral sessions For stateless API endpoints where each request is independent: @@ -462,7 +462,7 @@ app.post("/api/analyze", async (req, res) => { }); ``` -### Persistent Sessions +### Persistent sessions For conversational interfaces or long-running workflows: @@ -498,9 +498,9 @@ app.post("/api/chat/end", async (req, res) => { }); ``` -## Container Deployments +## Container deployments -### Kubernetes with Persistent Storage +### Kubernetes with persistent storage ```yaml apiVersion: apps/v1 @@ -589,7 +589,7 @@ volumes: storageAccountName: myaccount ``` -## Production Checklist +## Production checklist ```mermaid flowchart TB @@ -627,9 +627,9 @@ flowchart TB | **30-minute idle timeout** | Sessions without activity are auto-cleaned by the CLI | | **CLI is single-process** | Scale by adding more CLI server instances, not threads | -## Next Steps +## Next steps -- **[Session Persistence](../features/session-persistence.md)** — Deep dive on resumable sessions -- **[Backend Services](./backend-services.md)** — Core server-side setup -- **[GitHub OAuth](./github-oauth.md)** — Multi-user authentication -- **[BYOK](../auth/byok.md)** — Use your own model provider +* **[Session Persistence](../features/session-persistence.md)**: Deep dive on resumable sessions +* **[Backend Services](./backend-services.md)**: Core server-side setup +* **[GitHub OAuth](./github-oauth.md)**: Multi-user authentication +* **[BYOK](../auth/byok.md)**: Use your own model provider diff --git a/docs/troubleshooting/compatibility.md b/docs/troubleshooting/compatibility.md index aed5286bf..8e65128ce 100644 --- a/docs/troubleshooting/compatibility.md +++ b/docs/troubleshooting/compatibility.md @@ -1,4 +1,4 @@ -# SDK and CLI Compatibility +# SDK and CLI compatibility This document outlines which Copilot CLI features are available through the SDK and which are CLI-only. @@ -6,7 +6,7 @@ This document outlines which Copilot CLI features are available through the SDK The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must be explicitly exposed through this protocol to be available in the SDK. Many interactive CLI features are terminal-specific and not available programmatically. -## Feature Comparison +## Feature comparison ### ✅ Available in SDK @@ -90,7 +90,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | History truncation | `session.rpc.history.truncate()` | Remove events from a point onward | | Session forking | `server.rpc.sessions.fork()` | Fork a session at a point in history | -### ❌ Not Available in SDK (CLI-Only) +### ❌ Not available in SDK (CLI-only) | Feature | CLI Command/Option | Reason | |---------|-------------------|--------| @@ -169,7 +169,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b ## Workarounds -### Session Export +### Session export The `--share` option is not available via SDK. Workarounds: @@ -182,9 +182,9 @@ The `--share` option is not available via SDK. Workarounds: // Format as markdown yourself ``` -2. **Use CLI directly for export** - Run the CLI with `--share` for one-off exports. +1. **Use CLI directly for export** - Run the CLI with `--share` for one-off exports. -### Permission Control +### Permission control The SDK uses a **deny-by-default** permission model. All permission requests (file writes, shell commands, URL fetches, etc.) are denied unless your app provides an `onPermissionRequest` handler. @@ -196,7 +196,7 @@ const session = await client.createSession({ }); ``` -### Token Usage Tracking +### Token usage tracking Instead of `/usage`, subscribe to usage events: @@ -209,7 +209,7 @@ session.on("assistant.usage", (event) => { }); ``` -### Context Compaction +### Context compaction Instead of `/compact`, configure automatic compaction or trigger it manually: @@ -228,9 +228,10 @@ const result = await session.rpc.history.compact(); console.log(`Removed ${result.tokensRemoved} tokens, ${result.messagesRemoved} messages`); ``` -> **Note:** Thresholds are context utilization ratios (0.0-1.0), not absolute token counts. +> [!NOTE] +> Thresholds are context utilization ratios (0.0-1.0), not absolute token counts. -### Plan Management +### Plan management Read and write session plans programmatically: @@ -248,7 +249,7 @@ await session.rpc.plan.update({ content: "# My Plan\n- Step 1\n- Step 2" }); await session.rpc.plan.delete(); ``` -### Message Steering +### Message steering Inject a message into the current LLM turn without aborting: @@ -260,22 +261,22 @@ await session.send({ prompt: "Focus on error handling first", mode: "immediate" await session.send({ prompt: "Next, add tests" }); ``` -## Protocol Limitations +## Protocol limitations The SDK can only access features exposed through the CLI's JSON-RPC protocol. If you need a CLI feature that's not available: 1. **Check for alternatives** - Many features have SDK equivalents (see workarounds above) -2. **Use the CLI directly** - For one-off operations, invoke the CLI -3. **Request the feature** - Open an issue to request protocol support +1. **Use the CLI directly** - For one-off operations, invoke the CLI +1. **Request the feature** - Open an issue to request protocol support -## Version Compatibility +## Version compatibility | SDK Protocol Range | CLI Protocol Version | Compatibility | |--------------------|---------------------|---------------| | v2–v3 | v3 | Full support | | v2–v3 | v2 | Supported with automatic v2 adapters | -The SDK negotiates protocol versions with the CLI at startup. The SDK supports protocol versions 2 through 3. When connecting to a v2 CLI server, the SDK automatically adapts `tool.call` and `permission.request` messages to the v3 event model — no code changes required. +The SDK negotiates protocol versions with the CLI at startup. The SDK supports protocol versions 2 through 3. When connecting to a v2 CLI server, the SDK automatically adapts `tool.call` and `permission.request` messages to the v3 event model—no code changes required. Check versions at runtime: @@ -284,9 +285,9 @@ const status = await client.getStatus(); console.log("Protocol version:", status.protocolVersion); ``` -## See Also +## See also -- [Getting Started Guide](../getting-started.md) -- [Hooks Documentation](../hooks/index.md) -- [MCP Servers Guide](../features/mcp.md) -- [Debugging Guide](./debugging.md) +* [Getting Started Guide](../getting-started.md) +* [Hooks Documentation](../hooks/hooks-overview.md) +* [MCP Servers Guide](../features/mcp.md) +* [Debugging Guide](./debugging.md) diff --git a/docs/troubleshooting/debugging.md b/docs/troubleshooting/debugging.md index 4d060cdd3..7092899fd 100644 --- a/docs/troubleshooting/debugging.md +++ b/docs/troubleshooting/debugging.md @@ -1,19 +1,17 @@ -# Debugging Guide +# Debugging guide This guide covers common issues and debugging techniques for the Copilot SDK across all supported languages. -## Table of Contents +## Table of contents -- [Enable Debug Logging](#enable-debug-logging) -- [Common Issues](#common-issues) -- [MCP Server Debugging](#mcp-server-debugging) -- [Connection Issues](#connection-issues) -- [Tool Execution Issues](#tool-execution-issues) -- [Platform-Specific Issues](#platform-specific-issues) +* [Enable Debug Logging](#enable-debug-logging) +* [Common Issues](#common-issues) +* [MCP Server Debugging](#mcp-server-debugging) +* [Connection Issues](#connection-issues) +* [Tool Execution Issues](#tool-execution-issues) +* [Platform-Specific Issues](#platform-specific-issues) ---- - -## Enable Debug Logging +## Enable debug logging The first step in debugging is enabling verbose logging to see what's happening under the hood. @@ -108,7 +106,7 @@ var client = new CopilotClient(new CopilotClientOptions()
-### Log Directory +### Log directory The CLI writes logs to a directory. You can specify a custom location: @@ -132,7 +130,8 @@ const client = new CopilotClient({ # the CLI when running in server mode. ``` -> **Note:** Python SDK logging configuration is limited. For advanced logging, run the CLI manually with `--log-dir` and connect via `cli_url`. +> [!NOTE] +> Python SDK logging configuration is limited. For advanced logging, run the CLI manually with `--log-dir` and connect via `cli_url`. @@ -182,11 +181,9 @@ var client = new CopilotClient(new CopilotClientOptions ---- - -## Common Issues +## Common issues -### "CLI not found" / "copilot: command not found" +### "CLI not found" / "Copilot: command not found" **Cause:** The Copilot CLI is not installed or not in PATH. @@ -194,12 +191,12 @@ var client = new CopilotClient(new CopilotClientOptions 1. Install the CLI: [Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) -2. Verify installation: +1. Verify installation: ```bash copilot --version ``` -3. Or specify the full path: +1. Or specify the full path:
Node.js @@ -261,7 +258,7 @@ var client = new CopilotClient(new CopilotClientOptions copilot auth login ``` -2. Or provide a token programmatically: +1. Or provide a token programmatically:
Node.js @@ -325,7 +322,7 @@ var client = new CopilotClient(new CopilotClientOptions // Don't use session after this! ``` -2. For resuming sessions, verify the session ID exists: +1. For resuming sessions, verify the session ID exists: ```typescript const sessions = await client.listSessions(); console.log("Available sessions:", sessions); @@ -342,7 +339,7 @@ var client = new CopilotClient(new CopilotClientOptions copilot --server --stdio ``` -2. Check for port conflicts if using TCP mode: +1. Check for port conflicts if using TCP mode: ```typescript const client = new CopilotClient({ useStdio: false, @@ -350,21 +347,19 @@ var client = new CopilotClient(new CopilotClientOptions }); ``` ---- - -## MCP Server Debugging +## MCP server debugging MCP (Model Context Protocol) servers can be tricky to debug. For comprehensive MCP debugging guidance, see the dedicated **[MCP Debugging Guide](./mcp-debugging.md)**. -### Quick MCP Checklist +### Quick MCP checklist -- [ ] MCP server executable exists and runs independently -- [ ] Command path is correct (use absolute paths) -- [ ] Tools are enabled: `tools: ["*"]` -- [ ] Server responds to `initialize` request correctly -- [ ] Working directory (`cwd`) is set if needed +* [ ] MCP server executable exists and runs independently +* [ ] Command path is correct (use absolute paths) +* [ ] Tools are enabled: `tools: ["*"]` +* [ ] Server responds to `initialize` request correctly +* [ ] Working directory (`cwd`) is set if needed -### Test Your MCP Server +### Test your MCP server Before integrating with the SDK, verify your MCP server works: @@ -374,11 +369,9 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion": See [MCP Debugging Guide](./mcp-debugging.md) for detailed troubleshooting. ---- - -## Connection Issues +## Connection issues -### Stdio vs TCP Mode +### stdio vs TCP mode The SDK supports two transport modes: @@ -409,7 +402,7 @@ const client = new CopilotClient({ }); ``` -### Diagnosing Connection Failures +### Diagnosing connection failures 1. **Check client state:** ```typescript @@ -417,24 +410,22 @@ const client = new CopilotClient({ // Should be "connected" after start() ``` -2. **Listen for state changes:** +1. **Listen for state changes:** ```typescript client.on("stateChange", (state) => { console.log("State changed to:", state); }); ``` -3. **Verify CLI process is running:** +1. **Verify CLI process is running:** ```bash # Check for copilot processes ps aux | grep copilot ``` ---- +## Tool execution issues -## Tool Execution Issues - -### Custom Tool Not Being Called +### Custom tool not being called 1. **Verify tool registration:** ```typescript @@ -446,7 +437,7 @@ const client = new CopilotClient({ console.log("Registered tools:", session.getTools?.()); ``` -2. **Check tool schema is valid JSON Schema:** +1. **Check tool schema is valid JSON Schema:** ```typescript const myTool = { name: "get_weather", @@ -464,7 +455,7 @@ const client = new CopilotClient({ }; ``` -3. **Ensure handler returns valid result:** +1. **Ensure handler returns valid result:** ```typescript handler: async (args) => { // Must return something JSON-serializable @@ -474,7 +465,7 @@ const client = new CopilotClient({ } ``` -### Tool Errors Not Surfacing +### Tool errors not surfacing Subscribe to error events: @@ -488,9 +479,7 @@ session.on("error", (event) => { }); ``` ---- - -## Platform-Specific Issues +## Platform-specific issues ### Windows @@ -501,13 +490,13 @@ session.on("error", (event) => { CliPath = "C:/Program Files/GitHub/copilot.exe" ``` -2. **PATHEXT resolution:** The SDK handles this automatically, but if issues persist: +1. **PATHEXT resolution:** The SDK handles this automatically, but if issues persist: ```csharp // Explicitly specify .exe Command = "myserver.exe" // Not just "myserver" ``` -3. **Console encoding:** Ensure UTF-8 for proper JSON handling: +1. **Console encoding:** Ensure UTF-8 for proper JSON handling: ```csharp Console.OutputEncoding = System.Text.Encoding.UTF8; ``` @@ -519,7 +508,7 @@ session.on("error", (event) => { xattr -d com.apple.quarantine /path/to/copilot ``` -2. **PATH issues in GUI apps:** GUI applications may not inherit shell PATH: +1. **PATH issues in GUI apps:** GUI applications may not inherit shell PATH: ```typescript const client = new CopilotClient({ cliPath: "/opt/homebrew/bin/copilot", // Full path @@ -533,31 +522,29 @@ session.on("error", (event) => { chmod +x /path/to/copilot ``` -2. **Missing libraries:** Check for required shared libraries: +1. **Missing libraries:** Check for required shared libraries: ```bash ldd /path/to/copilot ``` ---- - -## Getting Help +## Getting help If you're still stuck: 1. **Collect debug information:** - - SDK version - - CLI version (`copilot --version`) - - Operating system - - Debug logs - - Minimal reproduction code + * SDK version + * CLI version (`copilot --version`) + * Operating system + * Debug logs + * Minimal reproduction code -2. **Search existing issues:** [GitHub Issues](https://github.com/github/copilot-sdk/issues) +1. **Search existing issues:** [GitHub Issues](https://github.com/github/copilot-sdk/issues) -3. **Open a new issue** with the collected information +1. **Open a new issue** with the collected information -## See Also +## See also -- [Getting Started Guide](../getting-started.md) -- [MCP Overview](../features/mcp.md) - MCP configuration and setup -- [MCP Debugging Guide](./mcp-debugging.md) - Detailed MCP troubleshooting -- [API Reference](https://github.com/github/copilot-sdk) +* [Getting Started Guide](../getting-started.md) +* [MCP Overview](../features/mcp.md) - MCP configuration and setup +* [MCP Debugging Guide](./mcp-debugging.md) - Detailed MCP troubleshooting +* [API Reference](https://github.com/github/copilot-sdk) diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md new file mode 100644 index 000000000..7392e6b82 --- /dev/null +++ b/docs/troubleshooting/index.md @@ -0,0 +1,7 @@ +# Troubleshooting + +Diagnose and resolve issues with the GitHub Copilot SDK. + +* [Debugging guide](./debugging.md): common issues and solutions +* [MCP server debugging](./mcp-debugging.md): MCP-specific troubleshooting +* [SDK and CLI compatibility](./compatibility.md): feature matrix and version compatibility diff --git a/docs/troubleshooting/mcp-debugging.md b/docs/troubleshooting/mcp-debugging.md index d7b455ecf..12ce8f070 100644 --- a/docs/troubleshooting/mcp-debugging.md +++ b/docs/troubleshooting/mcp-debugging.md @@ -1,30 +1,28 @@ -# MCP Server Debugging Guide +# MCP server debugging guide This guide covers debugging techniques specific to MCP (Model Context Protocol) servers when using the Copilot SDK. -## Table of Contents +## Table of contents -- [Quick Diagnostics](#quick-diagnostics) -- [Testing MCP Servers Independently](#testing-mcp-servers-independently) -- [Common Issues](#common-issues) -- [Platform-Specific Issues](#platform-specific-issues) -- [Advanced Debugging](#advanced-debugging) +* [Quick Diagnostics](#quick-diagnostics) +* [Testing MCP Servers Independently](#testing-mcp-servers-independently) +* [Common Issues](#common-issues) +* [Platform-Specific Issues](#platform-specific-issues) +* [Advanced Debugging](#advanced-debugging) ---- - -## Quick Diagnostics +## Quick diagnostics ### Checklist Before diving deep, verify these basics: -- [ ] MCP server executable exists and is runnable -- [ ] Command path is correct (use absolute paths when in doubt) -- [ ] Tools are enabled (`tools: ["*"]` or specific tool names) -- [ ] Server implements MCP protocol correctly (responds to `initialize`) -- [ ] No firewall/antivirus blocking the process (Windows) +* [ ] MCP server executable exists and is runnable +* [ ] Command path is correct (use absolute paths when in doubt) +* [ ] Tools are enabled (`tools: ["*"]` or specific tool names) +* [ ] Server implements MCP protocol correctly (responds to `initialize`) +* [ ] No firewall/antivirus blocking the process (Windows) -### Enable MCP Debug Logging +### Enable MCP debug logging Add environment variables to your MCP server config: @@ -43,13 +41,11 @@ mcpServers: { } ``` ---- - -## Testing MCP Servers Independently +## Testing MCP servers independently Always test your MCP server outside the SDK first. -### Manual Protocol Test +### Manual protocol test Send an `initialize` request via stdin: @@ -66,7 +62,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion": {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"your-server","version":"1.0"}}} ``` -### Test Tool Listing +### Test tool listing After initialization, request the tools list: @@ -79,7 +75,7 @@ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | /path/to/you {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"my_tool","description":"Does something","inputSchema":{...}}]}} ``` -### Interactive Testing Script +### Interactive testing script Create a test script to interactively debug your MCP server: @@ -107,11 +103,9 @@ Usage: ./test-mcp.sh | /path/to/mcp-server ``` ---- - -## Common Issues +## Common issues -### Server Not Starting +### Server not starting **Symptoms:** No tools appear, no errors in logs. @@ -131,7 +125,7 @@ cd /expected/working/dir /path/to/command arg1 arg2 ``` -### Server Starts But Tools Don't Appear +### Server starts but tools don't appear **Symptoms:** Server process runs but no tools are available. @@ -147,15 +141,15 @@ cd /expected/working/dir } ``` -2. **Server doesn't expose tools:** - - Test with `tools/list` request manually - - Check server implements `tools/list` method +1. **Server doesn't expose tools:** + * Test with `tools/list` request manually + * Check server implements `tools/list` method -3. **Initialization handshake fails:** - - Server must respond to `initialize` correctly - - Server must handle `notifications/initialized` +1. **Initialization handshake fails:** + * Server must respond to `initialize` correctly + * Server must handle `notifications/initialized` -### Tools Listed But Never Called +### Tools listed but never called **Symptoms:** Tools appear in debug logs but model doesn't use them. @@ -172,7 +166,7 @@ cd /expected/working/dir }); ``` -2. **Tool description unclear:** +1. **Tool description unclear:** ```typescript // Bad - model doesn't know when to use it { name: "do_thing", description: "Does a thing" } @@ -181,11 +175,11 @@ cd /expected/working/dir { name: "get_weather", description: "Get current weather conditions for a city. Returns temperature, humidity, and conditions." } ``` -3. **Tool schema issues:** - - Ensure `inputSchema` is valid JSON Schema - - Required fields must be in `required` array +1. **Tool schema issues:** + * Ensure `inputSchema` is valid JSON Schema + * Required fields must be in `required` array -### Timeout Errors +### Timeout errors **Symptoms:** `MCP tool call timed out` errors. @@ -201,22 +195,22 @@ cd /expected/working/dir } ``` -2. **Optimize server performance:** - - Add progress logging to identify bottleneck - - Consider async operations - - Check for blocking I/O +1. **Optimize server performance:** + * Add progress logging to identify bottleneck + * Consider async operations + * Check for blocking I/O -3. **For long-running tools**, consider streaming responses if supported. +1. **For long-running tools**, consider streaming responses if supported. -### JSON-RPC Errors +### JSON-RPC errors **Symptoms:** Parse errors, invalid request errors. **Common causes:** 1. **Server writes to stdout incorrectly:** - - Debug output going to stdout instead of stderr - - Extra newlines or whitespace + * Debug output going to stdout instead of stderr + * Extra newlines or whitespace ```typescript // Wrong - pollutes stdout @@ -226,21 +220,19 @@ cd /expected/working/dir console.error("Debug info"); ``` -2. **Encoding issues:** - - Ensure UTF-8 encoding - - No BOM (Byte Order Mark) +1. **Encoding issues:** + * Ensure UTF-8 encoding + * No BOM (Byte Order Mark) -3. **Message framing:** - - Each message must be a complete JSON object - - Newline-delimited (one message per line) +1. **Message framing:** + * Each message must be a complete JSON object + * Newline-delimited (one message per line) ---- - -## Platform-Specific Issues +## Platform-specific issues ### Windows -#### .NET Console Apps / Tools +#### .NET console apps / tools ```csharp @@ -291,7 +283,7 @@ public static class McpDotnetConfigExample } ``` -#### NPX Commands +#### npx commands ```csharp @@ -324,30 +316,30 @@ public static class McpNpxConfigExample } ``` -#### Path Issues +#### Path issues -- Use raw strings (`@"C:\path"`) or forward slashes (`"C:/path"`) -- Avoid spaces in paths when possible -- If spaces required, ensure proper quoting +* Use raw strings (`@"C:\path"`) or forward slashes (`"C:/path"`) +* Avoid spaces in paths when possible +* If spaces required, ensure proper quoting -#### Antivirus/Firewall +#### Antivirus/firewall Windows Defender or other AV may block: -- New executables -- Processes communicating via stdin/stdout +* New executables +* Processes communicating via stdin/stdout **Solution:** Add exclusions for your MCP server executable. ### macOS -#### Gatekeeper Blocking +#### Gatekeeper blocking ```bash # If server is blocked xattr -d com.apple.quarantine /path/to/mcp-server ``` -#### Homebrew Paths +#### Homebrew paths ```typescript @@ -374,13 +366,13 @@ mcpServers: { ### Linux -#### Permission Issues +#### Permission issues ```bash chmod +x /path/to/mcp-server ``` -#### Missing Shared Libraries +#### Missing shared libraries ```bash # Check dependencies @@ -391,11 +383,9 @@ apt install libfoo # Debian/Ubuntu yum install libfoo # RHEL/CentOS ``` ---- - -## Advanced Debugging +## Advanced debugging -### Capture All MCP Traffic +### Capture all MCP traffic Create a wrapper script to log all communication: @@ -426,7 +416,7 @@ mcpServers: { } ``` -### Inspect with MCP Inspector +### Inspect with MCP inspector Use the official MCP Inspector tool: @@ -435,11 +425,11 @@ npx @modelcontextprotocol/inspector /path/to/your/mcp-server ``` This provides a web UI to: -- Send test requests -- View responses -- Inspect tool schemas +* Send test requests +* View responses +* Inspect tool schemas -### Protocol Version Mismatches +### Protocol version mismatches Check your server supports the protocol version the SDK uses: @@ -450,23 +440,21 @@ Check your server supports the protocol version the SDK uses: If versions don't match, update your MCP server library. ---- - -## Debugging Checklist +## Debugging checklist When opening an issue or asking for help, collect: -- [ ] SDK language and version -- [ ] CLI version (`copilot --version`) -- [ ] MCP server type (Node.js, Python, .NET, Go, etc.) -- [ ] Full MCP server configuration (redact secrets) -- [ ] Result of manual `initialize` test -- [ ] Result of manual `tools/list` test -- [ ] Debug logs from SDK -- [ ] Any error messages +* [ ] SDK language and version +* [ ] CLI version (`copilot --version`) +* [ ] MCP server type (Node.js, Python, .NET, Go, etc.) +* [ ] Full MCP server configuration (redact secrets) +* [ ] Result of manual `initialize` test +* [ ] Result of manual `tools/list` test +* [ ] Debug logs from SDK +* [ ] Any error messages -## See Also +## See also -- [MCP Overview](../features/mcp.md) - Configuration and setup -- [General Debugging Guide](./debugging.md) - SDK-wide debugging -- [MCP Specification](https://modelcontextprotocol.io/) - Official protocol docs +* [MCP Overview](../features/mcp.md) - Configuration and setup +* [General Debugging Guide](./debugging.md) - SDK-wide debugging +* [MCP Specification](https://modelcontextprotocol.io/) - Official protocol docs From e7597006cdee0db41d8d6ce7cfb87aa80280d09e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 May 2026 22:59:40 -0400 Subject: [PATCH 11/33] Use string enums for .NET session events (#1226) * Use string enums for session event values Generate string-backed value types for session event schema enums so unknown values emitted by newer runtimes deserialize without throwing and preserve their raw string values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use string enums for all generated C# enums Extend the generated string-backed value type enum model to RPC schema enums so unknown wire values from newer runtimes deserialize without throwing and preserve their raw values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Normalize default string enum values Update generated C# string enum value types to back Value with a nullable field so default instances expose an empty string instead of null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix C# enum converter token validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Share generated string enum JSON helpers Move generated string enum JSON read/write validation into a handwritten SDK helper and have generated converters delegate to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move generated string enum values after constructors Update the C# generator so known string enum static properties are emitted after the constructor, then regenerate the C# protocol outputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move generated string enum Value after constructors Update the C# generator so generated string enum structs place only the backing field before the constructor, with Value and known static properties after it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 1773 ++++++++++++++--- dotnet/src/Generated/SessionEvents.cs | 1677 +++++++++++++--- dotnet/src/Types.cs | 29 + .../test/E2E/RpcExtensionsLoadedE2ETests.cs | 9 +- dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs | 2 +- dotnet/test/E2E/RpcSessionStateE2ETests.cs | 9 +- dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs | 7 +- .../test/E2E/RpcTasksAndHandlersE2ETests.cs | 3 +- dotnet/test/Unit/ForwardCompatibilityTests.cs | 95 + scripts/codegen/csharp.ts | 61 +- 10 files changed, 3076 insertions(+), 589 deletions(-) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index cee6ce945..fc95890a1 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -8,7 +8,9 @@ #pragma warning disable CS0612 // Type or member is obsolete #pragma warning disable CS0618 // Type or member is obsolete (with message) +using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -2764,400 +2766,1527 @@ public sealed class SessionFsRenameRequest } /// Configuration source. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum DiscoveredMcpServerSource +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct DiscoveredMcpServerSource : IEquatable { - /// The user variant. - [JsonStringEnumMemberName("user")] - User, - /// The workspace variant. - [JsonStringEnumMemberName("workspace")] - Workspace, - /// The plugin variant. - [JsonStringEnumMemberName("plugin")] - Plugin, - /// The builtin variant. - [JsonStringEnumMemberName("builtin")] - Builtin, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public DiscoveredMcpServerSource(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the user value. + public static DiscoveredMcpServerSource User { get; } = new("user"); + + /// Gets the workspace value. + public static DiscoveredMcpServerSource Workspace { get; } = new("workspace"); + + /// Gets the plugin value. + public static DiscoveredMcpServerSource Plugin { get; } = new("plugin"); + + /// Gets the builtin value. + public static DiscoveredMcpServerSource Builtin { get; } = new("builtin"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(DiscoveredMcpServerSource left, DiscoveredMcpServerSource right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(DiscoveredMcpServerSource left, DiscoveredMcpServerSource right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is DiscoveredMcpServerSource other && Equals(other); + + /// + public bool Equals(DiscoveredMcpServerSource other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override DiscoveredMcpServerSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, DiscoveredMcpServerSource value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(DiscoveredMcpServerSource)); + } + } } /// Server transport type: stdio, http, sse, or memory (local configs are normalized to stdio). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum DiscoveredMcpServerType +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct DiscoveredMcpServerType : IEquatable { - /// The stdio variant. - [JsonStringEnumMemberName("stdio")] - Stdio, - /// The http variant. - [JsonStringEnumMemberName("http")] - Http, - /// The sse variant. - [JsonStringEnumMemberName("sse")] - Sse, - /// The memory variant. - [JsonStringEnumMemberName("memory")] - Memory, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public DiscoveredMcpServerType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the stdio value. + public static DiscoveredMcpServerType Stdio { get; } = new("stdio"); + + /// Gets the http value. + public static DiscoveredMcpServerType Http { get; } = new("http"); + + /// Gets the sse value. + public static DiscoveredMcpServerType Sse { get; } = new("sse"); + + /// Gets the memory value. + public static DiscoveredMcpServerType Memory { get; } = new("memory"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(DiscoveredMcpServerType left, DiscoveredMcpServerType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(DiscoveredMcpServerType left, DiscoveredMcpServerType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is DiscoveredMcpServerType other && Equals(other); + + /// + public bool Equals(DiscoveredMcpServerType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override DiscoveredMcpServerType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, DiscoveredMcpServerType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(DiscoveredMcpServerType)); + } + } } /// Path conventions used by this filesystem. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SessionFsSetProviderConventions +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SessionFsSetProviderConventions : IEquatable { - /// The windows variant. - [JsonStringEnumMemberName("windows")] - Windows, - /// The posix variant. - [JsonStringEnumMemberName("posix")] - Posix, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SessionFsSetProviderConventions(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the windows value. + public static SessionFsSetProviderConventions Windows { get; } = new("windows"); + + /// Gets the posix value. + public static SessionFsSetProviderConventions Posix { get; } = new("posix"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SessionFsSetProviderConventions left, SessionFsSetProviderConventions right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SessionFsSetProviderConventions left, SessionFsSetProviderConventions right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SessionFsSetProviderConventions other && Equals(other); + + /// + public bool Equals(SessionFsSetProviderConventions other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SessionFsSetProviderConventions Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SessionFsSetProviderConventions value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SessionFsSetProviderConventions)); + } + } } /// Log severity level. Determines how the message is displayed in the timeline. Defaults to "info". -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SessionLogLevel +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SessionLogLevel : IEquatable { - /// The info variant. - [JsonStringEnumMemberName("info")] - Info, - /// The warning variant. - [JsonStringEnumMemberName("warning")] - Warning, - /// The error variant. - [JsonStringEnumMemberName("error")] - Error, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SessionLogLevel(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the info value. + public static SessionLogLevel Info { get; } = new("info"); + + /// Gets the warning value. + public static SessionLogLevel Warning { get; } = new("warning"); + + /// Gets the error value. + public static SessionLogLevel Error { get; } = new("error"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SessionLogLevel left, SessionLogLevel right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SessionLogLevel left, SessionLogLevel right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SessionLogLevel other && Equals(other); + + /// + public bool Equals(SessionLogLevel other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SessionLogLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SessionLogLevel value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SessionLogLevel)); + } + } } /// Authentication type. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum AuthInfoType -{ - /// The hmac variant. - [JsonStringEnumMemberName("hmac")] - Hmac, - /// The env variant. - [JsonStringEnumMemberName("env")] - Env, - /// The user variant. - [JsonStringEnumMemberName("user")] - User, - /// The gh-cli variant. - [JsonStringEnumMemberName("gh-cli")] - GhCli, - /// The api-key variant. - [JsonStringEnumMemberName("api-key")] - ApiKey, - /// The token variant. - [JsonStringEnumMemberName("token")] - Token, - /// The copilot-api-token variant. - [JsonStringEnumMemberName("copilot-api-token")] - CopilotApiToken, +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct AuthInfoType : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public AuthInfoType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the hmac value. + public static AuthInfoType Hmac { get; } = new("hmac"); + + /// Gets the env value. + public static AuthInfoType Env { get; } = new("env"); + + /// Gets the user value. + public static AuthInfoType User { get; } = new("user"); + + /// Gets the gh-cli value. + public static AuthInfoType GhCli { get; } = new("gh-cli"); + + /// Gets the api-key value. + public static AuthInfoType ApiKey { get; } = new("api-key"); + + /// Gets the token value. + public static AuthInfoType Token { get; } = new("token"); + + /// Gets the copilot-api-token value. + public static AuthInfoType CopilotApiToken { get; } = new("copilot-api-token"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(AuthInfoType left, AuthInfoType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(AuthInfoType left, AuthInfoType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is AuthInfoType other && Equals(other); + + /// + public bool Equals(AuthInfoType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override AuthInfoType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, AuthInfoType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(AuthInfoType)); + } + } } /// The agent mode. Valid values: "interactive", "plan", "autopilot". -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SessionMode +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SessionMode : IEquatable { - /// The interactive variant. - [JsonStringEnumMemberName("interactive")] - Interactive, - /// The plan variant. - [JsonStringEnumMemberName("plan")] - Plan, - /// The autopilot variant. - [JsonStringEnumMemberName("autopilot")] - Autopilot, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SessionMode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the interactive value. + public static SessionMode Interactive { get; } = new("interactive"); + + /// Gets the plan value. + public static SessionMode Plan { get; } = new("plan"); + + /// Gets the autopilot value. + public static SessionMode Autopilot { get; } = new("autopilot"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SessionMode left, SessionMode right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SessionMode left, SessionMode right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SessionMode other && Equals(other); + + /// + public bool Equals(SessionMode other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SessionMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SessionMode value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SessionMode)); + } + } } /// Defines the allowed values. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum WorkspacesGetWorkspaceResultWorkspaceHostType +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct WorkspacesGetWorkspaceResultWorkspaceHostType : IEquatable { - /// The github variant. - [JsonStringEnumMemberName("github")] - Github, - /// The ado variant. - [JsonStringEnumMemberName("ado")] - Ado, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public WorkspacesGetWorkspaceResultWorkspaceHostType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the github value. + public static WorkspacesGetWorkspaceResultWorkspaceHostType Github { get; } = new("github"); + + /// Gets the ado value. + public static WorkspacesGetWorkspaceResultWorkspaceHostType Ado { get; } = new("ado"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(WorkspacesGetWorkspaceResultWorkspaceHostType left, WorkspacesGetWorkspaceResultWorkspaceHostType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(WorkspacesGetWorkspaceResultWorkspaceHostType left, WorkspacesGetWorkspaceResultWorkspaceHostType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is WorkspacesGetWorkspaceResultWorkspaceHostType other && Equals(other); + + /// + public bool Equals(WorkspacesGetWorkspaceResultWorkspaceHostType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override WorkspacesGetWorkspaceResultWorkspaceHostType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, WorkspacesGetWorkspaceResultWorkspaceHostType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(WorkspacesGetWorkspaceResultWorkspaceHostType)); + } + } } /// Defines the allowed values. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel : IEquatable { - /// The local variant. - [JsonStringEnumMemberName("local")] - Local, - /// The user variant. - [JsonStringEnumMemberName("user")] - User, - /// The repo_and_user variant. - [JsonStringEnumMemberName("repo_and_user")] - RepoAndUser, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the local value. + public static WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel Local { get; } = new("local"); + + /// Gets the user value. + public static WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel User { get; } = new("user"); + + /// Gets the repo_and_user value. + public static WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel RepoAndUser { get; } = new("repo_and_user"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel left, WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel left, WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel other && Equals(other); + + /// + public bool Equals(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel)); + } + } } /// Where this source lives — used for UI grouping. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum InstructionsSourcesLocation +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct InstructionsSourcesLocation : IEquatable { - /// The user variant. - [JsonStringEnumMemberName("user")] - User, - /// The repository variant. - [JsonStringEnumMemberName("repository")] - Repository, - /// The working-directory variant. - [JsonStringEnumMemberName("working-directory")] - WorkingDirectory, -} + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public InstructionsSourcesLocation(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; -/// Category of instruction source — used for merge logic. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum InstructionsSourcesType -{ - /// The home variant. - [JsonStringEnumMemberName("home")] - Home, - /// The repo variant. - [JsonStringEnumMemberName("repo")] - Repo, - /// The model variant. - [JsonStringEnumMemberName("model")] - Model, - /// The vscode variant. - [JsonStringEnumMemberName("vscode")] - Vscode, - /// The nested-agents variant. - [JsonStringEnumMemberName("nested-agents")] - NestedAgents, - /// The child-instructions variant. - [JsonStringEnumMemberName("child-instructions")] - ChildInstructions, -} + /// Gets the user value. + public static InstructionsSourcesLocation User { get; } = new("user"); + /// Gets the repository value. + public static InstructionsSourcesLocation Repository { get; } = new("repository"); -/// How the agent is currently being managed by the runtime. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum TaskAgentInfoExecutionMode -{ - /// The sync variant. - [JsonStringEnumMemberName("sync")] - Sync, - /// The background variant. - [JsonStringEnumMemberName("background")] - Background, -} + /// Gets the working-directory value. + public static InstructionsSourcesLocation WorkingDirectory { get; } = new("working-directory"); + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(InstructionsSourcesLocation left, InstructionsSourcesLocation right) => left.Equals(right); -/// Current lifecycle status of the task. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum TaskAgentInfoStatus -{ - /// The running variant. - [JsonStringEnumMemberName("running")] - Running, - /// The idle variant. - [JsonStringEnumMemberName("idle")] - Idle, - /// The completed variant. - [JsonStringEnumMemberName("completed")] - Completed, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, - /// The cancelled variant. - [JsonStringEnumMemberName("cancelled")] - Cancelled, -} + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(InstructionsSourcesLocation left, InstructionsSourcesLocation right) => !(left == right); + /// + public override bool Equals(object? obj) => obj is InstructionsSourcesLocation other && Equals(other); -/// Whether the shell runs inside a managed PTY session or as an independent background process. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum TaskShellInfoAttachmentMode -{ - /// The attached variant. - [JsonStringEnumMemberName("attached")] - Attached, - /// The detached variant. - [JsonStringEnumMemberName("detached")] - Detached, -} + /// + public bool Equals(InstructionsSourcesLocation other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); -/// Whether the shell command is currently sync-waited or background-managed. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum TaskShellInfoExecutionMode -{ - /// The sync variant. - [JsonStringEnumMemberName("sync")] - Sync, - /// The background variant. - [JsonStringEnumMemberName("background")] - Background, -} + /// + public override string ToString() => Value; + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override InstructionsSourcesLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } -/// Current lifecycle status of the task. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum TaskShellInfoStatus -{ - /// The running variant. - [JsonStringEnumMemberName("running")] - Running, - /// The idle variant. - [JsonStringEnumMemberName("idle")] - Idle, - /// The completed variant. - [JsonStringEnumMemberName("completed")] - Completed, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, - /// The cancelled variant. - [JsonStringEnumMemberName("cancelled")] - Cancelled, + /// + public override void Write(Utf8JsonWriter writer, InstructionsSourcesLocation value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(InstructionsSourcesLocation)); + } + } } -/// Configuration source: user, workspace, plugin, or builtin. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum McpServerSource +/// Category of instruction source — used for merge logic. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct InstructionsSourcesType : IEquatable { - /// The user variant. - [JsonStringEnumMemberName("user")] - User, - /// The workspace variant. - [JsonStringEnumMemberName("workspace")] - Workspace, - /// The plugin variant. - [JsonStringEnumMemberName("plugin")] - Plugin, - /// The builtin variant. - [JsonStringEnumMemberName("builtin")] - Builtin, -} + private readonly string? _value; + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public InstructionsSourcesType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } -/// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum McpServerStatus -{ - /// The connected variant. - [JsonStringEnumMemberName("connected")] - Connected, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, - /// The needs-auth variant. - [JsonStringEnumMemberName("needs-auth")] - NeedsAuth, - /// The pending variant. - [JsonStringEnumMemberName("pending")] - Pending, - /// The disabled variant. - [JsonStringEnumMemberName("disabled")] - Disabled, - /// The not_configured variant. - [JsonStringEnumMemberName("not_configured")] - NotConfigured, -} + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + /// Gets the home value. + public static InstructionsSourcesType Home { get; } = new("home"); -/// Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ExtensionSource -{ - /// The project variant. - [JsonStringEnumMemberName("project")] - Project, - /// The user variant. - [JsonStringEnumMemberName("user")] - User, -} + /// Gets the repo value. + public static InstructionsSourcesType Repo { get; } = new("repo"); + /// Gets the model value. + public static InstructionsSourcesType Model { get; } = new("model"); -/// Current status: running, disabled, failed, or starting. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ExtensionStatus -{ - /// The running variant. - [JsonStringEnumMemberName("running")] - Running, - /// The disabled variant. - [JsonStringEnumMemberName("disabled")] - Disabled, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, - /// The starting variant. - [JsonStringEnumMemberName("starting")] - Starting, -} + /// Gets the vscode value. + public static InstructionsSourcesType Vscode { get; } = new("vscode"); + /// Gets the nested-agents value. + public static InstructionsSourcesType NestedAgents { get; } = new("nested-agents"); -/// The user's response: accept (submitted), decline (rejected), or cancel (dismissed). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum UIElicitationResponseAction -{ - /// The accept variant. - [JsonStringEnumMemberName("accept")] - Accept, - /// The decline variant. - [JsonStringEnumMemberName("decline")] - Decline, - /// The cancel variant. - [JsonStringEnumMemberName("cancel")] - Cancel, -} + /// Gets the child-instructions value. + public static InstructionsSourcesType ChildInstructions { get; } = new("child-instructions"); + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(InstructionsSourcesType left, InstructionsSourcesType right) => left.Equals(right); -/// Signal to send (default: SIGTERM). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ShellKillSignal -{ - /// The SIGTERM variant. - [JsonStringEnumMemberName("SIGTERM")] - SIGTERM, - /// The SIGKILL variant. - [JsonStringEnumMemberName("SIGKILL")] - SIGKILL, - /// The SIGINT variant. - [JsonStringEnumMemberName("SIGINT")] - SIGINT, + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(InstructionsSourcesType left, InstructionsSourcesType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is InstructionsSourcesType other && Equals(other); + + /// + public bool Equals(InstructionsSourcesType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override InstructionsSourcesType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, InstructionsSourcesType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(InstructionsSourcesType)); + } + } } -/// Error classification. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SessionFsErrorCode +/// How the agent is currently being managed by the runtime. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct TaskAgentInfoExecutionMode : IEquatable { - /// The ENOENT variant. - [JsonStringEnumMemberName("ENOENT")] - ENOENT, - /// The UNKNOWN variant. - [JsonStringEnumMemberName("UNKNOWN")] - UNKNOWN, -} + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public TaskAgentInfoExecutionMode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; -/// Entry type. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SessionFsReaddirWithTypesEntryType -{ - /// The file variant. - [JsonStringEnumMemberName("file")] - File, - /// The directory variant. - [JsonStringEnumMemberName("directory")] - Directory, + /// Gets the sync value. + public static TaskAgentInfoExecutionMode Sync { get; } = new("sync"); + + /// Gets the background value. + public static TaskAgentInfoExecutionMode Background { get; } = new("background"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(TaskAgentInfoExecutionMode left, TaskAgentInfoExecutionMode right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(TaskAgentInfoExecutionMode left, TaskAgentInfoExecutionMode right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is TaskAgentInfoExecutionMode other && Equals(other); + + /// + public bool Equals(TaskAgentInfoExecutionMode other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override TaskAgentInfoExecutionMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, TaskAgentInfoExecutionMode value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(TaskAgentInfoExecutionMode)); + } + } +} + + +/// Current lifecycle status of the task. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct TaskAgentInfoStatus : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public TaskAgentInfoStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the running value. + public static TaskAgentInfoStatus Running { get; } = new("running"); + + /// Gets the idle value. + public static TaskAgentInfoStatus Idle { get; } = new("idle"); + + /// Gets the completed value. + public static TaskAgentInfoStatus Completed { get; } = new("completed"); + + /// Gets the failed value. + public static TaskAgentInfoStatus Failed { get; } = new("failed"); + + /// Gets the cancelled value. + public static TaskAgentInfoStatus Cancelled { get; } = new("cancelled"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(TaskAgentInfoStatus left, TaskAgentInfoStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(TaskAgentInfoStatus left, TaskAgentInfoStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is TaskAgentInfoStatus other && Equals(other); + + /// + public bool Equals(TaskAgentInfoStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override TaskAgentInfoStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, TaskAgentInfoStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(TaskAgentInfoStatus)); + } + } +} + + +/// Whether the shell runs inside a managed PTY session or as an independent background process. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct TaskShellInfoAttachmentMode : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public TaskShellInfoAttachmentMode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the attached value. + public static TaskShellInfoAttachmentMode Attached { get; } = new("attached"); + + /// Gets the detached value. + public static TaskShellInfoAttachmentMode Detached { get; } = new("detached"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(TaskShellInfoAttachmentMode left, TaskShellInfoAttachmentMode right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(TaskShellInfoAttachmentMode left, TaskShellInfoAttachmentMode right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is TaskShellInfoAttachmentMode other && Equals(other); + + /// + public bool Equals(TaskShellInfoAttachmentMode other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override TaskShellInfoAttachmentMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, TaskShellInfoAttachmentMode value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(TaskShellInfoAttachmentMode)); + } + } +} + + +/// Whether the shell command is currently sync-waited or background-managed. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct TaskShellInfoExecutionMode : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public TaskShellInfoExecutionMode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the sync value. + public static TaskShellInfoExecutionMode Sync { get; } = new("sync"); + + /// Gets the background value. + public static TaskShellInfoExecutionMode Background { get; } = new("background"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(TaskShellInfoExecutionMode left, TaskShellInfoExecutionMode right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(TaskShellInfoExecutionMode left, TaskShellInfoExecutionMode right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is TaskShellInfoExecutionMode other && Equals(other); + + /// + public bool Equals(TaskShellInfoExecutionMode other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override TaskShellInfoExecutionMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, TaskShellInfoExecutionMode value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(TaskShellInfoExecutionMode)); + } + } +} + + +/// Current lifecycle status of the task. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct TaskShellInfoStatus : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public TaskShellInfoStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the running value. + public static TaskShellInfoStatus Running { get; } = new("running"); + + /// Gets the idle value. + public static TaskShellInfoStatus Idle { get; } = new("idle"); + + /// Gets the completed value. + public static TaskShellInfoStatus Completed { get; } = new("completed"); + + /// Gets the failed value. + public static TaskShellInfoStatus Failed { get; } = new("failed"); + + /// Gets the cancelled value. + public static TaskShellInfoStatus Cancelled { get; } = new("cancelled"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(TaskShellInfoStatus left, TaskShellInfoStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(TaskShellInfoStatus left, TaskShellInfoStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is TaskShellInfoStatus other && Equals(other); + + /// + public bool Equals(TaskShellInfoStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override TaskShellInfoStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, TaskShellInfoStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(TaskShellInfoStatus)); + } + } +} + + +/// Configuration source: user, workspace, plugin, or builtin. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpServerSource : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpServerSource(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the user value. + public static McpServerSource User { get; } = new("user"); + + /// Gets the workspace value. + public static McpServerSource Workspace { get; } = new("workspace"); + + /// Gets the plugin value. + public static McpServerSource Plugin { get; } = new("plugin"); + + /// Gets the builtin value. + public static McpServerSource Builtin { get; } = new("builtin"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpServerSource left, McpServerSource right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpServerSource left, McpServerSource right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpServerSource other && Equals(other); + + /// + public bool Equals(McpServerSource other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpServerSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpServerSource value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpServerSource)); + } + } +} + + +/// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpServerStatus : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpServerStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the connected value. + public static McpServerStatus Connected { get; } = new("connected"); + + /// Gets the failed value. + public static McpServerStatus Failed { get; } = new("failed"); + + /// Gets the needs-auth value. + public static McpServerStatus NeedsAuth { get; } = new("needs-auth"); + + /// Gets the pending value. + public static McpServerStatus Pending { get; } = new("pending"); + + /// Gets the disabled value. + public static McpServerStatus Disabled { get; } = new("disabled"); + + /// Gets the not_configured value. + public static McpServerStatus NotConfigured { get; } = new("not_configured"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpServerStatus left, McpServerStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpServerStatus left, McpServerStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpServerStatus other && Equals(other); + + /// + public bool Equals(McpServerStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpServerStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpServerStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpServerStatus)); + } + } +} + + +/// Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/). +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ExtensionSource : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ExtensionSource(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the project value. + public static ExtensionSource Project { get; } = new("project"); + + /// Gets the user value. + public static ExtensionSource User { get; } = new("user"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ExtensionSource left, ExtensionSource right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ExtensionSource left, ExtensionSource right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ExtensionSource other && Equals(other); + + /// + public bool Equals(ExtensionSource other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ExtensionSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ExtensionSource value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ExtensionSource)); + } + } +} + + +/// Current status: running, disabled, failed, or starting. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ExtensionStatus : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ExtensionStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the running value. + public static ExtensionStatus Running { get; } = new("running"); + + /// Gets the disabled value. + public static ExtensionStatus Disabled { get; } = new("disabled"); + + /// Gets the failed value. + public static ExtensionStatus Failed { get; } = new("failed"); + + /// Gets the starting value. + public static ExtensionStatus Starting { get; } = new("starting"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ExtensionStatus left, ExtensionStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ExtensionStatus left, ExtensionStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ExtensionStatus other && Equals(other); + + /// + public bool Equals(ExtensionStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ExtensionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ExtensionStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ExtensionStatus)); + } + } +} + + +/// The user's response: accept (submitted), decline (rejected), or cancel (dismissed). +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct UIElicitationResponseAction : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public UIElicitationResponseAction(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the accept value. + public static UIElicitationResponseAction Accept { get; } = new("accept"); + + /// Gets the decline value. + public static UIElicitationResponseAction Decline { get; } = new("decline"); + + /// Gets the cancel value. + public static UIElicitationResponseAction Cancel { get; } = new("cancel"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(UIElicitationResponseAction left, UIElicitationResponseAction right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(UIElicitationResponseAction left, UIElicitationResponseAction right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is UIElicitationResponseAction other && Equals(other); + + /// + public bool Equals(UIElicitationResponseAction other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override UIElicitationResponseAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, UIElicitationResponseAction value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(UIElicitationResponseAction)); + } + } +} + + +/// Signal to send (default: SIGTERM). +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ShellKillSignal : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ShellKillSignal(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the SIGTERM value. + public static ShellKillSignal SIGTERM { get; } = new("SIGTERM"); + + /// Gets the SIGKILL value. + public static ShellKillSignal SIGKILL { get; } = new("SIGKILL"); + + /// Gets the SIGINT value. + public static ShellKillSignal SIGINT { get; } = new("SIGINT"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ShellKillSignal left, ShellKillSignal right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ShellKillSignal left, ShellKillSignal right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ShellKillSignal other && Equals(other); + + /// + public bool Equals(ShellKillSignal other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ShellKillSignal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ShellKillSignal value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ShellKillSignal)); + } + } +} + + +/// Error classification. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SessionFsErrorCode : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SessionFsErrorCode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the ENOENT value. + public static SessionFsErrorCode ENOENT { get; } = new("ENOENT"); + + /// Gets the UNKNOWN value. + public static SessionFsErrorCode UNKNOWN { get; } = new("UNKNOWN"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SessionFsErrorCode left, SessionFsErrorCode right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SessionFsErrorCode left, SessionFsErrorCode right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SessionFsErrorCode other && Equals(other); + + /// + public bool Equals(SessionFsErrorCode other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SessionFsErrorCode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SessionFsErrorCode value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SessionFsErrorCode)); + } + } +} + + +/// Entry type. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SessionFsReaddirWithTypesEntryType : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SessionFsReaddirWithTypesEntryType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the file value. + public static SessionFsReaddirWithTypesEntryType File { get; } = new("file"); + + /// Gets the directory value. + public static SessionFsReaddirWithTypesEntryType Directory { get; } = new("directory"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SessionFsReaddirWithTypesEntryType left, SessionFsReaddirWithTypesEntryType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SessionFsReaddirWithTypesEntryType left, SessionFsReaddirWithTypesEntryType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SessionFsReaddirWithTypesEntryType other && Equals(other); + + /// + public bool Equals(SessionFsReaddirWithTypesEntryType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SessionFsReaddirWithTypesEntryType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SessionFsReaddirWithTypesEntryType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SessionFsReaddirWithTypesEntryType)); + } + } } diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index b428e39f5..cd4061872 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -8,6 +8,7 @@ #pragma warning disable CS0612 // Type or member is obsolete #pragma warning disable CS0618 // Type or member is obsolete (with message) +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -4880,345 +4881,1521 @@ public partial class ExtensionsLoadedExtension } /// Hosting platform type of the repository (github or ado). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum WorkingDirectoryContextHostType +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct WorkingDirectoryContextHostType : IEquatable { - /// The github variant. - [JsonStringEnumMemberName("github")] - Github, - /// The ado variant. - [JsonStringEnumMemberName("ado")] - Ado, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public WorkingDirectoryContextHostType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the github value. + public static WorkingDirectoryContextHostType Github { get; } = new("github"); + + /// Gets the ado value. + public static WorkingDirectoryContextHostType Ado { get; } = new("ado"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(WorkingDirectoryContextHostType left, WorkingDirectoryContextHostType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(WorkingDirectoryContextHostType left, WorkingDirectoryContextHostType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is WorkingDirectoryContextHostType other && Equals(other); + + /// + public bool Equals(WorkingDirectoryContextHostType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override WorkingDirectoryContextHostType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, WorkingDirectoryContextHostType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(WorkingDirectoryContextHostType)); + } + } } /// The type of operation performed on the plan file. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum PlanChangedOperation +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PlanChangedOperation : IEquatable { - /// The create variant. - [JsonStringEnumMemberName("create")] - Create, - /// The update variant. - [JsonStringEnumMemberName("update")] - Update, - /// The delete variant. - [JsonStringEnumMemberName("delete")] - Delete, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public PlanChangedOperation(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the create value. + public static PlanChangedOperation Create { get; } = new("create"); + + /// Gets the update value. + public static PlanChangedOperation Update { get; } = new("update"); + + /// Gets the delete value. + public static PlanChangedOperation Delete { get; } = new("delete"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(PlanChangedOperation left, PlanChangedOperation right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(PlanChangedOperation left, PlanChangedOperation right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is PlanChangedOperation other && Equals(other); + + /// + public bool Equals(PlanChangedOperation other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PlanChangedOperation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, PlanChangedOperation value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(PlanChangedOperation)); + } + } } /// Whether the file was newly created or updated. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum WorkspaceFileChangedOperation +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct WorkspaceFileChangedOperation : IEquatable { - /// The create variant. - [JsonStringEnumMemberName("create")] - Create, - /// The update variant. - [JsonStringEnumMemberName("update")] - Update, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public WorkspaceFileChangedOperation(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the create value. + public static WorkspaceFileChangedOperation Create { get; } = new("create"); + + /// Gets the update value. + public static WorkspaceFileChangedOperation Update { get; } = new("update"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(WorkspaceFileChangedOperation left, WorkspaceFileChangedOperation right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(WorkspaceFileChangedOperation left, WorkspaceFileChangedOperation right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is WorkspaceFileChangedOperation other && Equals(other); + + /// + public bool Equals(WorkspaceFileChangedOperation other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override WorkspaceFileChangedOperation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, WorkspaceFileChangedOperation value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(WorkspaceFileChangedOperation)); + } + } } /// Origin type of the session being handed off. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum HandoffSourceType +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct HandoffSourceType : IEquatable { - /// The remote variant. - [JsonStringEnumMemberName("remote")] - Remote, - /// The local variant. - [JsonStringEnumMemberName("local")] - Local, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public HandoffSourceType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the remote value. + public static HandoffSourceType Remote { get; } = new("remote"); + + /// Gets the local value. + public static HandoffSourceType Local { get; } = new("local"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(HandoffSourceType left, HandoffSourceType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(HandoffSourceType left, HandoffSourceType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is HandoffSourceType other && Equals(other); + + /// + public bool Equals(HandoffSourceType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override HandoffSourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, HandoffSourceType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(HandoffSourceType)); + } + } } /// Whether the session ended normally ("routine") or due to a crash/fatal error ("error"). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ShutdownType +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ShutdownType : IEquatable { - /// The routine variant. - [JsonStringEnumMemberName("routine")] - Routine, - /// The error variant. - [JsonStringEnumMemberName("error")] - Error, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ShutdownType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the routine value. + public static ShutdownType Routine { get; } = new("routine"); + + /// Gets the error value. + public static ShutdownType Error { get; } = new("error"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ShutdownType left, ShutdownType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ShutdownType left, ShutdownType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ShutdownType other && Equals(other); + + /// + public bool Equals(ShutdownType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ShutdownType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ShutdownType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ShutdownType)); + } + } } /// The agent mode that was active when this message was sent. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum UserMessageAgentMode -{ - /// The interactive variant. - [JsonStringEnumMemberName("interactive")] - Interactive, - /// The plan variant. - [JsonStringEnumMemberName("plan")] - Plan, - /// The autopilot variant. - [JsonStringEnumMemberName("autopilot")] - Autopilot, - /// The shell variant. - [JsonStringEnumMemberName("shell")] - Shell, +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct UserMessageAgentMode : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public UserMessageAgentMode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the interactive value. + public static UserMessageAgentMode Interactive { get; } = new("interactive"); + + /// Gets the plan value. + public static UserMessageAgentMode Plan { get; } = new("plan"); + + /// Gets the autopilot value. + public static UserMessageAgentMode Autopilot { get; } = new("autopilot"); + + /// Gets the shell value. + public static UserMessageAgentMode Shell { get; } = new("shell"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(UserMessageAgentMode left, UserMessageAgentMode right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(UserMessageAgentMode left, UserMessageAgentMode right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is UserMessageAgentMode other && Equals(other); + + /// + public bool Equals(UserMessageAgentMode other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override UserMessageAgentMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, UserMessageAgentMode value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(UserMessageAgentMode)); + } + } } /// Type of GitHub reference. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum UserMessageAttachmentGithubReferenceType +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct UserMessageAttachmentGithubReferenceType : IEquatable { - /// The issue variant. - [JsonStringEnumMemberName("issue")] - Issue, - /// The pr variant. - [JsonStringEnumMemberName("pr")] - Pr, - /// The discussion variant. - [JsonStringEnumMemberName("discussion")] - Discussion, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public UserMessageAttachmentGithubReferenceType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the issue value. + public static UserMessageAttachmentGithubReferenceType Issue { get; } = new("issue"); + + /// Gets the pr value. + public static UserMessageAttachmentGithubReferenceType Pr { get; } = new("pr"); + + /// Gets the discussion value. + public static UserMessageAttachmentGithubReferenceType Discussion { get; } = new("discussion"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(UserMessageAttachmentGithubReferenceType left, UserMessageAttachmentGithubReferenceType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(UserMessageAttachmentGithubReferenceType left, UserMessageAttachmentGithubReferenceType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is UserMessageAttachmentGithubReferenceType other && Equals(other); + + /// + public bool Equals(UserMessageAttachmentGithubReferenceType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override UserMessageAttachmentGithubReferenceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, UserMessageAttachmentGithubReferenceType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(UserMessageAttachmentGithubReferenceType)); + } + } } /// Tool call type: "function" for standard tool calls, "custom" for grammar-based tool calls. Defaults to "function" when absent. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum AssistantMessageToolRequestType +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct AssistantMessageToolRequestType : IEquatable { - /// The function variant. - [JsonStringEnumMemberName("function")] - Function, - /// The custom variant. - [JsonStringEnumMemberName("custom")] - Custom, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public AssistantMessageToolRequestType(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the function value. + public static AssistantMessageToolRequestType Function { get; } = new("function"); + + /// Gets the custom value. + public static AssistantMessageToolRequestType Custom { get; } = new("custom"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(AssistantMessageToolRequestType left, AssistantMessageToolRequestType right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(AssistantMessageToolRequestType left, AssistantMessageToolRequestType right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is AssistantMessageToolRequestType other && Equals(other); + + /// + public bool Equals(AssistantMessageToolRequestType other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override AssistantMessageToolRequestType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, AssistantMessageToolRequestType value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(AssistantMessageToolRequestType)); + } + } } /// Where the failed model call originated. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ModelCallFailureSource +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ModelCallFailureSource : IEquatable { - /// The top_level variant. - [JsonStringEnumMemberName("top_level")] - TopLevel, - /// The subagent variant. - [JsonStringEnumMemberName("subagent")] - Subagent, - /// The mcp_sampling variant. - [JsonStringEnumMemberName("mcp_sampling")] - McpSampling, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ModelCallFailureSource(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the top_level value. + public static ModelCallFailureSource TopLevel { get; } = new("top_level"); + + /// Gets the subagent value. + public static ModelCallFailureSource Subagent { get; } = new("subagent"); + + /// Gets the mcp_sampling value. + public static ModelCallFailureSource McpSampling { get; } = new("mcp_sampling"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ModelCallFailureSource left, ModelCallFailureSource right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ModelCallFailureSource left, ModelCallFailureSource right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ModelCallFailureSource other && Equals(other); + + /// + public bool Equals(ModelCallFailureSource other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ModelCallFailureSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ModelCallFailureSource value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ModelCallFailureSource)); + } + } } /// Finite reason code describing why the current turn was aborted. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum AbortReason +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct AbortReason : IEquatable { - /// The user_initiated variant. - [JsonStringEnumMemberName("user_initiated")] - UserInitiated, - /// The remote_command variant. - [JsonStringEnumMemberName("remote_command")] - RemoteCommand, - /// The user_abort variant. - [JsonStringEnumMemberName("user_abort")] - UserAbort, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public AbortReason(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the user_initiated value. + public static AbortReason UserInitiated { get; } = new("user_initiated"); + + /// Gets the remote_command value. + public static AbortReason RemoteCommand { get; } = new("remote_command"); + + /// Gets the user_abort value. + public static AbortReason UserAbort { get; } = new("user_abort"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(AbortReason left, AbortReason right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(AbortReason left, AbortReason right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is AbortReason other && Equals(other); + + /// + public bool Equals(AbortReason other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override AbortReason Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, AbortReason value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(AbortReason)); + } + } } /// Theme variant this icon is intended for. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ToolExecutionCompleteContentResourceLinkIconTheme +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ToolExecutionCompleteContentResourceLinkIconTheme : IEquatable { - /// The light variant. - [JsonStringEnumMemberName("light")] - Light, - /// The dark variant. - [JsonStringEnumMemberName("dark")] - Dark, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ToolExecutionCompleteContentResourceLinkIconTheme(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the light value. + public static ToolExecutionCompleteContentResourceLinkIconTheme Light { get; } = new("light"); + + /// Gets the dark value. + public static ToolExecutionCompleteContentResourceLinkIconTheme Dark { get; } = new("dark"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ToolExecutionCompleteContentResourceLinkIconTheme left, ToolExecutionCompleteContentResourceLinkIconTheme right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ToolExecutionCompleteContentResourceLinkIconTheme left, ToolExecutionCompleteContentResourceLinkIconTheme right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ToolExecutionCompleteContentResourceLinkIconTheme other && Equals(other); + + /// + public bool Equals(ToolExecutionCompleteContentResourceLinkIconTheme other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ToolExecutionCompleteContentResourceLinkIconTheme Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ToolExecutionCompleteContentResourceLinkIconTheme value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ToolExecutionCompleteContentResourceLinkIconTheme)); + } + } } /// Message role: "system" for system prompts, "developer" for developer-injected instructions. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SystemMessageRole +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SystemMessageRole : IEquatable { - /// The system variant. - [JsonStringEnumMemberName("system")] - System, - /// The developer variant. - [JsonStringEnumMemberName("developer")] - Developer, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SystemMessageRole(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the system value. + public static SystemMessageRole System { get; } = new("system"); + + /// Gets the developer value. + public static SystemMessageRole Developer { get; } = new("developer"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SystemMessageRole left, SystemMessageRole right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SystemMessageRole left, SystemMessageRole right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SystemMessageRole other && Equals(other); + + /// + public bool Equals(SystemMessageRole other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SystemMessageRole Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SystemMessageRole value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SystemMessageRole)); + } + } } /// Whether the agent completed successfully or failed. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SystemNotificationAgentCompletedStatus +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SystemNotificationAgentCompletedStatus : IEquatable { - /// The completed variant. - [JsonStringEnumMemberName("completed")] - Completed, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SystemNotificationAgentCompletedStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the completed value. + public static SystemNotificationAgentCompletedStatus Completed { get; } = new("completed"); + + /// Gets the failed value. + public static SystemNotificationAgentCompletedStatus Failed { get; } = new("failed"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SystemNotificationAgentCompletedStatus left, SystemNotificationAgentCompletedStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SystemNotificationAgentCompletedStatus left, SystemNotificationAgentCompletedStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SystemNotificationAgentCompletedStatus other && Equals(other); + + /// + public bool Equals(SystemNotificationAgentCompletedStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SystemNotificationAgentCompletedStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SystemNotificationAgentCompletedStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SystemNotificationAgentCompletedStatus)); + } + } } /// Whether this is a store or vote memory operation. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum PermissionRequestMemoryAction +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PermissionRequestMemoryAction : IEquatable { - /// The store variant. - [JsonStringEnumMemberName("store")] - Store, - /// The vote variant. - [JsonStringEnumMemberName("vote")] - Vote, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public PermissionRequestMemoryAction(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the store value. + public static PermissionRequestMemoryAction Store { get; } = new("store"); + + /// Gets the vote value. + public static PermissionRequestMemoryAction Vote { get; } = new("vote"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(PermissionRequestMemoryAction left, PermissionRequestMemoryAction right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(PermissionRequestMemoryAction left, PermissionRequestMemoryAction right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is PermissionRequestMemoryAction other && Equals(other); + + /// + public bool Equals(PermissionRequestMemoryAction other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PermissionRequestMemoryAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionRequestMemoryAction value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(PermissionRequestMemoryAction)); + } + } } /// Vote direction (vote only). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum PermissionRequestMemoryDirection +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PermissionRequestMemoryDirection : IEquatable { - /// The upvote variant. - [JsonStringEnumMemberName("upvote")] - Upvote, - /// The downvote variant. - [JsonStringEnumMemberName("downvote")] - Downvote, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public PermissionRequestMemoryDirection(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the upvote value. + public static PermissionRequestMemoryDirection Upvote { get; } = new("upvote"); + + /// Gets the downvote value. + public static PermissionRequestMemoryDirection Downvote { get; } = new("downvote"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(PermissionRequestMemoryDirection left, PermissionRequestMemoryDirection right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(PermissionRequestMemoryDirection left, PermissionRequestMemoryDirection right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is PermissionRequestMemoryDirection other && Equals(other); + + /// + public bool Equals(PermissionRequestMemoryDirection other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PermissionRequestMemoryDirection Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionRequestMemoryDirection value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(PermissionRequestMemoryDirection)); + } + } } /// Whether this is a store or vote memory operation. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum PermissionPromptRequestMemoryAction +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PermissionPromptRequestMemoryAction : IEquatable { - /// The store variant. - [JsonStringEnumMemberName("store")] - Store, - /// The vote variant. - [JsonStringEnumMemberName("vote")] - Vote, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public PermissionPromptRequestMemoryAction(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the store value. + public static PermissionPromptRequestMemoryAction Store { get; } = new("store"); + + /// Gets the vote value. + public static PermissionPromptRequestMemoryAction Vote { get; } = new("vote"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(PermissionPromptRequestMemoryAction left, PermissionPromptRequestMemoryAction right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(PermissionPromptRequestMemoryAction left, PermissionPromptRequestMemoryAction right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is PermissionPromptRequestMemoryAction other && Equals(other); + + /// + public bool Equals(PermissionPromptRequestMemoryAction other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PermissionPromptRequestMemoryAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionPromptRequestMemoryAction value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(PermissionPromptRequestMemoryAction)); + } + } } /// Vote direction (vote only). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum PermissionPromptRequestMemoryDirection +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PermissionPromptRequestMemoryDirection : IEquatable { - /// The upvote variant. - [JsonStringEnumMemberName("upvote")] - Upvote, - /// The downvote variant. - [JsonStringEnumMemberName("downvote")] - Downvote, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public PermissionPromptRequestMemoryDirection(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the upvote value. + public static PermissionPromptRequestMemoryDirection Upvote { get; } = new("upvote"); + + /// Gets the downvote value. + public static PermissionPromptRequestMemoryDirection Downvote { get; } = new("downvote"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(PermissionPromptRequestMemoryDirection left, PermissionPromptRequestMemoryDirection right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(PermissionPromptRequestMemoryDirection left, PermissionPromptRequestMemoryDirection right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is PermissionPromptRequestMemoryDirection other && Equals(other); + + /// + public bool Equals(PermissionPromptRequestMemoryDirection other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PermissionPromptRequestMemoryDirection Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionPromptRequestMemoryDirection value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(PermissionPromptRequestMemoryDirection)); + } + } } /// Underlying permission kind that needs path approval. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum PermissionPromptRequestPathAccessKind +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PermissionPromptRequestPathAccessKind : IEquatable { - /// The read variant. - [JsonStringEnumMemberName("read")] - Read, - /// The shell variant. - [JsonStringEnumMemberName("shell")] - Shell, - /// The write variant. - [JsonStringEnumMemberName("write")] - Write, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public PermissionPromptRequestPathAccessKind(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the read value. + public static PermissionPromptRequestPathAccessKind Read { get; } = new("read"); + + /// Gets the shell value. + public static PermissionPromptRequestPathAccessKind Shell { get; } = new("shell"); + + /// Gets the write value. + public static PermissionPromptRequestPathAccessKind Write { get; } = new("write"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(PermissionPromptRequestPathAccessKind left, PermissionPromptRequestPathAccessKind right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(PermissionPromptRequestPathAccessKind left, PermissionPromptRequestPathAccessKind right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is PermissionPromptRequestPathAccessKind other && Equals(other); + + /// + public bool Equals(PermissionPromptRequestPathAccessKind other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PermissionPromptRequestPathAccessKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionPromptRequestPathAccessKind value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(PermissionPromptRequestPathAccessKind)); + } + } } /// Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to "form" when absent. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ElicitationRequestedMode +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ElicitationRequestedMode : IEquatable { - /// The form variant. - [JsonStringEnumMemberName("form")] - Form, - /// The url variant. - [JsonStringEnumMemberName("url")] - Url, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ElicitationRequestedMode(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the form value. + public static ElicitationRequestedMode Form { get; } = new("form"); + + /// Gets the url value. + public static ElicitationRequestedMode Url { get; } = new("url"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ElicitationRequestedMode left, ElicitationRequestedMode right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ElicitationRequestedMode left, ElicitationRequestedMode right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ElicitationRequestedMode other && Equals(other); + + /// + public bool Equals(ElicitationRequestedMode other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ElicitationRequestedMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ElicitationRequestedMode value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ElicitationRequestedMode)); + } + } } /// The user action: "accept" (submitted form), "decline" (explicitly refused), or "cancel" (dismissed). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ElicitationCompletedAction +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ElicitationCompletedAction : IEquatable { - /// The accept variant. - [JsonStringEnumMemberName("accept")] - Accept, - /// The decline variant. - [JsonStringEnumMemberName("decline")] - Decline, - /// The cancel variant. - [JsonStringEnumMemberName("cancel")] - Cancel, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ElicitationCompletedAction(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the accept value. + public static ElicitationCompletedAction Accept { get; } = new("accept"); + + /// Gets the decline value. + public static ElicitationCompletedAction Decline { get; } = new("decline"); + + /// Gets the cancel value. + public static ElicitationCompletedAction Cancel { get; } = new("cancel"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ElicitationCompletedAction left, ElicitationCompletedAction right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ElicitationCompletedAction left, ElicitationCompletedAction right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ElicitationCompletedAction other && Equals(other); + + /// + public bool Equals(ElicitationCompletedAction other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ElicitationCompletedAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ElicitationCompletedAction value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ElicitationCompletedAction)); + } + } } /// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum McpServersLoadedServerStatus -{ - /// The connected variant. - [JsonStringEnumMemberName("connected")] - Connected, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, - /// The needs-auth variant. - [JsonStringEnumMemberName("needs-auth")] - NeedsAuth, - /// The pending variant. - [JsonStringEnumMemberName("pending")] - Pending, - /// The disabled variant. - [JsonStringEnumMemberName("disabled")] - Disabled, - /// The not_configured variant. - [JsonStringEnumMemberName("not_configured")] - NotConfigured, +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpServersLoadedServerStatus : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpServersLoadedServerStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the connected value. + public static McpServersLoadedServerStatus Connected { get; } = new("connected"); + + /// Gets the failed value. + public static McpServersLoadedServerStatus Failed { get; } = new("failed"); + + /// Gets the needs-auth value. + public static McpServersLoadedServerStatus NeedsAuth { get; } = new("needs-auth"); + + /// Gets the pending value. + public static McpServersLoadedServerStatus Pending { get; } = new("pending"); + + /// Gets the disabled value. + public static McpServersLoadedServerStatus Disabled { get; } = new("disabled"); + + /// Gets the not_configured value. + public static McpServersLoadedServerStatus NotConfigured { get; } = new("not_configured"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpServersLoadedServerStatus left, McpServersLoadedServerStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpServersLoadedServerStatus left, McpServersLoadedServerStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpServersLoadedServerStatus other && Equals(other); + + /// + public bool Equals(McpServersLoadedServerStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpServersLoadedServerStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpServersLoadedServerStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpServersLoadedServerStatus)); + } + } } /// New connection status: connected, failed, needs-auth, pending, disabled, or not_configured. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum McpServerStatusChangedStatus -{ - /// The connected variant. - [JsonStringEnumMemberName("connected")] - Connected, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, - /// The needs-auth variant. - [JsonStringEnumMemberName("needs-auth")] - NeedsAuth, - /// The pending variant. - [JsonStringEnumMemberName("pending")] - Pending, - /// The disabled variant. - [JsonStringEnumMemberName("disabled")] - Disabled, - /// The not_configured variant. - [JsonStringEnumMemberName("not_configured")] - NotConfigured, +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpServerStatusChangedStatus : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpServerStatusChangedStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the connected value. + public static McpServerStatusChangedStatus Connected { get; } = new("connected"); + + /// Gets the failed value. + public static McpServerStatusChangedStatus Failed { get; } = new("failed"); + + /// Gets the needs-auth value. + public static McpServerStatusChangedStatus NeedsAuth { get; } = new("needs-auth"); + + /// Gets the pending value. + public static McpServerStatusChangedStatus Pending { get; } = new("pending"); + + /// Gets the disabled value. + public static McpServerStatusChangedStatus Disabled { get; } = new("disabled"); + + /// Gets the not_configured value. + public static McpServerStatusChangedStatus NotConfigured { get; } = new("not_configured"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpServerStatusChangedStatus left, McpServerStatusChangedStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpServerStatusChangedStatus left, McpServerStatusChangedStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpServerStatusChangedStatus other && Equals(other); + + /// + public bool Equals(McpServerStatusChangedStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpServerStatusChangedStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpServerStatusChangedStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpServerStatusChangedStatus)); + } + } } /// Discovery source. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ExtensionsLoadedExtensionSource +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ExtensionsLoadedExtensionSource : IEquatable { - /// The project variant. - [JsonStringEnumMemberName("project")] - Project, - /// The user variant. - [JsonStringEnumMemberName("user")] - User, + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ExtensionsLoadedExtensionSource(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the project value. + public static ExtensionsLoadedExtensionSource Project { get; } = new("project"); + + /// Gets the user value. + public static ExtensionsLoadedExtensionSource User { get; } = new("user"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ExtensionsLoadedExtensionSource left, ExtensionsLoadedExtensionSource right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ExtensionsLoadedExtensionSource left, ExtensionsLoadedExtensionSource right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ExtensionsLoadedExtensionSource other && Equals(other); + + /// + public bool Equals(ExtensionsLoadedExtensionSource other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ExtensionsLoadedExtensionSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionSource value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ExtensionsLoadedExtensionSource)); + } + } } /// Current status: running, disabled, failed, or starting. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ExtensionsLoadedExtensionStatus -{ - /// The running variant. - [JsonStringEnumMemberName("running")] - Running, - /// The disabled variant. - [JsonStringEnumMemberName("disabled")] - Disabled, - /// The failed variant. - [JsonStringEnumMemberName("failed")] - Failed, - /// The starting variant. - [JsonStringEnumMemberName("starting")] - Starting, +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ExtensionsLoadedExtensionStatus : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ExtensionsLoadedExtensionStatus(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the running value. + public static ExtensionsLoadedExtensionStatus Running { get; } = new("running"); + + /// Gets the disabled value. + public static ExtensionsLoadedExtensionStatus Disabled { get; } = new("disabled"); + + /// Gets the failed value. + public static ExtensionsLoadedExtensionStatus Failed { get; } = new("failed"); + + /// Gets the starting value. + public static ExtensionsLoadedExtensionStatus Starting { get; } = new("starting"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ExtensionsLoadedExtensionStatus left, ExtensionsLoadedExtensionStatus right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ExtensionsLoadedExtensionStatus left, ExtensionsLoadedExtensionStatus right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ExtensionsLoadedExtensionStatus other && Equals(other); + + /// + public bool Equals(ExtensionsLoadedExtensionStatus other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ExtensionsLoadedExtensionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatus value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ExtensionsLoadedExtensionStatus)); + } + } } [JsonSourceGenerationOptions( diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index adf629eef..925091d1c 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -13,6 +13,35 @@ namespace GitHub.Copilot.SDK; +internal static class GeneratedStringEnumJson +{ + internal static string ReadValue(ref Utf8JsonReader reader, Type typeToConvert) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException($"Expected a string token when reading {typeToConvert.Name}, but found {reader.TokenType}."); + } + + var value = reader.GetString(); + if (string.IsNullOrWhiteSpace(value)) + { + throw new JsonException($"Expected a non-empty string token when reading {typeToConvert.Name}."); + } + + return value; + } + + internal static void WriteValue(Utf8JsonWriter writer, string value, Type typeToConvert) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new JsonException($"Expected a non-empty string value when writing {typeToConvert.Name}."); + } + + writer.WriteStringValue(value); + } +} + /// /// Represents the connection state of the Copilot client. /// diff --git a/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs b/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs index 7900cc794..4d4388b22 100644 --- a/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs +++ b/dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs @@ -165,10 +165,11 @@ await TestHelper.WaitForConditionAsync( } [Theory] - [InlineData(ExtensionSource.User)] - [InlineData(ExtensionSource.Project)] - public async Task Discovers_Loads_And_Reports_Running_Extension(ExtensionSource source) + [InlineData("user")] + [InlineData("project")] + public async Task Discovers_Loads_And_Reports_Running_Extension(string sourceValue) { + var source = new ExtensionSource(sourceValue); string extName; string extId; string? workingDirectory; @@ -184,7 +185,7 @@ public async Task Discovers_Loads_And_Reports_Running_Extension(ExtensionSource } else { - throw new ArgumentOutOfRangeException(nameof(source), source, null); + throw new ArgumentOutOfRangeException(nameof(sourceValue), sourceValue, null); } await using var client = CreateExtensionsClient(); diff --git a/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs b/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs index f314ba56a..03cab27de 100644 --- a/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs +++ b/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs @@ -81,7 +81,7 @@ public async Task Should_List_Mcp_Servers_With_Configured_Server() var result = await session.Rpc.Mcp.ListAsync(); var server = Assert.Single(result.Servers, server => string.Equals(server.Name, serverName, StringComparison.Ordinal)); - Assert.True(Enum.IsDefined(server.Status)); + Assert.False(string.IsNullOrWhiteSpace(server.Status.Value)); } [Fact] diff --git a/dotnet/test/E2E/RpcSessionStateE2ETests.cs b/dotnet/test/E2E/RpcSessionStateE2ETests.cs index 6e8118bb0..53f3af7b8 100644 --- a/dotnet/test/E2E/RpcSessionStateE2ETests.cs +++ b/dotnet/test/E2E/RpcSessionStateE2ETests.cs @@ -63,12 +63,13 @@ public async Task Should_Get_And_Set_Session_Mode() } [Theory] - [InlineData(SessionMode.Interactive)] - [InlineData(SessionMode.Plan)] - [InlineData(SessionMode.Autopilot)] - public async Task Should_Set_And_Get_Each_Session_Mode_Value(SessionMode mode) + [InlineData("interactive")] + [InlineData("plan")] + [InlineData("autopilot")] + public async Task Should_Set_And_Get_Each_Session_Mode_Value(string modeValue) { await using var session = await CreateSessionAsync(); + var mode = new SessionMode(modeValue); await session.Rpc.Mode.SetAsync(mode); Assert.Equal(mode, await session.Rpc.Mode.GetAsync()); diff --git a/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs b/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs index 8b9126b8b..6b7a9c5e0 100644 --- a/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs +++ b/dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs @@ -113,11 +113,12 @@ public async Task Shell_Kill_Unknown_ProcessId_Returns_False() } [Theory] - [InlineData(ShellKillSignal.SIGTERM)] - [InlineData(ShellKillSignal.SIGKILL)] - public async Task Shell_Kill_Cleans_Up_After_Terminating_Signal(ShellKillSignal signal) + [InlineData("SIGTERM")] + [InlineData("SIGKILL")] + public async Task Shell_Kill_Cleans_Up_After_Terminating_Signal(string signalValue) { var session = await CreateSessionAsync(); + var signal = new ShellKillSignal(signalValue); var command = OperatingSystem.IsWindows() ? "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 60\"" : "sleep 60"; diff --git a/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs b/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs index da8b2166f..4ccdee858 100644 --- a/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs +++ b/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs @@ -114,7 +114,8 @@ await TestHelper.WaitForConditionAsync( task = await FindAgentTaskAsync(session, started.AgentId); return task?.LatestResponse?.Contains("TASK_AGENT_DONE", StringComparison.Ordinal) == true || task?.Result?.Contains("TASK_AGENT_DONE", StringComparison.Ordinal) == true - || task?.Status is TaskAgentInfoStatus.Completed or TaskAgentInfoStatus.Failed; + || task?.Status == TaskAgentInfoStatus.Completed + || task?.Status == TaskAgentInfoStatus.Failed; }, timeout: TimeSpan.FromSeconds(60), timeoutMessage: $"Background agent task '{started.AgentId}' did not produce a final observable state."); diff --git a/dotnet/test/Unit/ForwardCompatibilityTests.cs b/dotnet/test/Unit/ForwardCompatibilityTests.cs index 048d983e1..a3b362344 100644 --- a/dotnet/test/Unit/ForwardCompatibilityTests.cs +++ b/dotnet/test/Unit/ForwardCompatibilityTests.cs @@ -2,6 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using System.Text.Json; +using System.Text.Json.Serialization; using Xunit; namespace GitHub.Copilot.SDK.Test.Unit; @@ -166,6 +168,96 @@ public void FromJson_UnknownEventType_WithUnknownEnumInData_DoesNotThrow() Assert.Equal("unknown", result.Type); } + [Fact] + public void FromJson_KnownEventType_WithUnknownEnumInData_PreservesValue() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "abort", + "data": { + "reason": "future_abort_reason" + } + } + """; + + var result = SessionEvent.FromJson(json); + + var abort = Assert.IsType(result); + Assert.Equal("future_abort_reason", abort.Data.Reason.Value); + } + + [Fact] + public void FromJson_KnownEventType_WithNonStringEnumInData_ThrowsJsonException() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "abort", + "data": { + "reason": false + } + } + """; + + var exception = Assert.Throws(() => SessionEvent.FromJson(json)); + Assert.Contains("AbortReason", exception.Message); + } + + [Fact] + public void RpcEnum_WithUnknownValue_PreservesValue() + { + var mode = JsonSerializer.Deserialize( + """ + "future_mode" + """, + ForwardCompatibilityJsonContext.Default.SessionMode); + + Assert.Equal("future_mode", mode.Value); + Assert.Equal( + """ + "future_mode" + """, + JsonSerializer.Serialize(mode, ForwardCompatibilityJsonContext.Default.SessionMode)); + } + + [Fact] + public void RpcEnum_WithNonStringValue_ThrowsJsonException() + { + var exception = Assert.Throws(() => JsonSerializer.Deserialize( + """ + 42 + """, + ForwardCompatibilityJsonContext.Default.SessionMode)); + + Assert.Contains("SessionMode", exception.Message); + } + + [Fact] + public void RpcEnum_DefaultValue_HasEmptyStringValue() + { + GitHub.Copilot.SDK.Rpc.SessionMode mode = default; + + Assert.Equal(string.Empty, mode.Value); + Assert.Equal(string.Empty, mode.ToString()); + } + + [Fact] + public void RpcEnum_DefaultValueSerialization_ThrowsJsonException() + { + GitHub.Copilot.SDK.Rpc.SessionMode mode = default; + + var exception = Assert.Throws(() => JsonSerializer.Serialize( + mode, + ForwardCompatibilityJsonContext.Default.SessionMode)); + + Assert.Contains("SessionMode", exception.Message); + } + [Fact] public void FromJson_KnownEventType_WithNullOptionalFields_DoesNotThrow() { @@ -211,3 +303,6 @@ public void FromJson_UnknownEventType_PreservesAgentIdNull() Assert.Null(result.AgentId); } } + +[JsonSerializable(typeof(GitHub.Copilot.SDK.Rpc.SessionMode))] +internal partial class ForwardCompatibilityJsonContext : JsonSerializerContext; diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index f43d08c89..b624abcc3 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -325,7 +325,16 @@ let generatedEnums = new Map(); /** Schema definitions available during session event generation (for $ref resolution). */ let sessionDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; -function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[], description?: string, explicitName?: string, deprecated?: boolean): string { +/** Emits a schema enum as a string-backed value type that preserves unknown runtime values. */ +function getOrCreateEnum( + parentClassName: string, + propName: string, + values: string[], + enumOutput: string[], + description?: string, + explicitName?: string, + deprecated?: boolean +): string { const enumName = explicitName ?? `${parentClassName}${propName}`; const existing = generatedEnums.get(enumName); if (existing) return existing.enumName; @@ -334,11 +343,52 @@ function getOrCreateEnum(parentClassName: string, propName: string, values: stri const lines: string[] = []; lines.push(...xmlDocEnumComment(description, "")); if (deprecated) lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); - lines.push(`[JsonConverter(typeof(JsonStringEnumConverter<${enumName}>))]`, `public enum ${enumName}`, `{`); + lines.push(`[JsonConverter(typeof(Converter))]`); + lines.push(`[DebuggerDisplay("{Value,nq}")]`); + lines.push(`public readonly struct ${enumName} : IEquatable<${enumName}>`); + lines.push(`{`); + lines.push(` private readonly string? _value;`, ""); + lines.push(` /// Initializes a new instance of the struct.`); + lines.push(` /// The value to associate with this .`); + lines.push(` [JsonConstructor]`); + lines.push(` public ${enumName}(string value)`); + lines.push(` {`); + lines.push(` ArgumentException.ThrowIfNullOrWhiteSpace(value);`); + lines.push(` _value = value;`); + lines.push(` }`, ""); + lines.push(` /// Gets the value associated with this .`); + lines.push(` public string Value => _value ?? string.Empty;`, ""); for (const value of values) { - lines.push(` /// The ${escapeXml(value)} variant.`); - lines.push(` [JsonStringEnumMemberName("${value}")]`, ` ${toPascalCaseEnumMember(value)},`); + lines.push(` /// Gets the ${escapeXml(value)} value.`); + lines.push(` public static ${enumName} ${toPascalCaseEnumMember(value)} { get; } = new("${value}");`, ""); } + lines.push(` /// Returns a value indicating whether two instances are equivalent.`); + lines.push(` public static bool operator ==(${enumName} left, ${enumName} right) => left.Equals(right);`, ""); + lines.push(` /// Returns a value indicating whether two instances are not equivalent.`); + lines.push(` public static bool operator !=(${enumName} left, ${enumName} right) => !(left == right);`, ""); + lines.push(` /// `); + lines.push(` public override bool Equals(object? obj) => obj is ${enumName} other && Equals(other);`, ""); + lines.push(` /// `); + lines.push(` public bool Equals(${enumName} other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase);`, ""); + lines.push(` /// `); + lines.push(` public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value);`, ""); + lines.push(` /// `); + lines.push(` public override string ToString() => Value;`, ""); + lines.push(` /// Provides a for serializing instances.`); + lines.push(` [EditorBrowsable(EditorBrowsableState.Never)]`); + lines.push(` public sealed class Converter : JsonConverter<${enumName}>`); + lines.push(` {`); + lines.push(` /// `); + lines.push(` public override ${enumName} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`); + lines.push(` {`); + lines.push(` return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert));`); + lines.push(` }`, ""); + lines.push(` /// `); + lines.push(` public override void Write(Utf8JsonWriter writer, ${enumName} value, JsonSerializerOptions options)`); + lines.push(` {`); + lines.push(` GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(${enumName}));`); + lines.push(` }`); + lines.push(` }`); lines.push(`}`, ""); enumOutput.push(lines.join("\n")); return enumName; @@ -718,6 +768,7 @@ function generateSessionEventsCode(schema: JSONSchema7): string { #pragma warning disable CS0612 // Type or member is obsolete #pragma warning disable CS0618 // Type or member is obsolete (with message) +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -1556,7 +1607,9 @@ function generateRpcCode(schema: ApiSchema): string { #pragma warning disable CS0612 // Type or member is obsolete #pragma warning disable CS0618 // Type or member is obsolete (with message) +using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; From 671b50a3acfa7465e2a83d19f641fd06b9160f6e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 7 May 2026 23:23:34 -0400 Subject: [PATCH 12/33] Restore mode handler APIs across SDKs (#1228) * Restore mode handler APIs across SDKs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address mode handler review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Python mode handler typing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stabilize Node custom config dir test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 55 ++ dotnet/src/Session.cs | 73 ++ dotnet/src/Types.cs | 155 ++++ dotnet/test/E2E/ModeHandlersE2ETests.cs | 189 +++++ dotnet/test/Unit/CloneTests.cs | 19 + dotnet/test/Unit/SerializationTests.cs | 45 ++ go/client.go | 80 +++ go/client_test.go | 145 ++++ go/internal/e2e/mode_handlers_e2e_test.go | 269 +++++++ go/session.go | 92 ++- go/types.go | 83 +++ nodejs/src/client.ts | 80 +++ nodejs/src/index.ts | 6 + nodejs/src/session.ts | 58 ++ nodejs/src/types.ts | 71 ++ nodejs/test/client.test.ts | 113 +++ nodejs/test/e2e/mode_handlers.e2e.test.ts | 198 ++++++ nodejs/test/e2e/session.e2e.test.ts | 12 +- python/copilot/__init__.py | 12 + python/copilot/client.py | 63 ++ python/copilot/session.py | 121 ++++ python/e2e/test_mode_handlers_e2e.py | 207 ++++++ python/test_commands_and_elicitation.py | 140 ++++ rust/CHANGELOG.md | 5 +- rust/src/handler.rs | 130 +++- rust/src/session.rs | 80 ++- rust/src/types.rs | 96 +++ rust/tests/mode_handlers_e2e_test.rs | 663 ++++++++++++++++++ rust/tests/session_test.rs | 113 ++- test/harness/replayingCapiProxy.ts | 86 +++ ...mode_switch_handler_when_rate_limited.yaml | 22 + ...lan_mode_handler_when_model_uses_tool.yaml | 23 + 32 files changed, 3455 insertions(+), 49 deletions(-) create mode 100644 dotnet/test/E2E/ModeHandlersE2ETests.cs create mode 100644 go/internal/e2e/mode_handlers_e2e_test.go create mode 100644 nodejs/test/e2e/mode_handlers.e2e.test.ts create mode 100644 python/e2e/test_mode_handlers_e2e.py create mode 100644 rust/tests/mode_handlers_e2e_test.rs create mode 100644 test/snapshots/mode_handlers/should_invoke_auto_mode_switch_handler_when_rate_limited.yaml create mode 100644 test/snapshots/mode_handlers/should_invoke_exit_plan_mode_handler_when_model_uses_tool.yaml diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 372df4d4b..6f7acc747 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -562,6 +562,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.RegisterPermissionHandler(config.OnPermissionRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); + session.RegisterExitPlanModeHandler(config.OnExitPlanMode); + session.RegisterAutoModeSwitchHandler(config.OnAutoModeSwitch); if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); @@ -605,6 +607,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.EnableSessionTelemetry, (bool?)true, config.OnUserInputRequest != null ? true : null, + config.OnExitPlanMode != null ? true : null, + config.OnAutoModeSwitch != null ? true : null, hasHooks ? true : null, config.WorkingDirectory, config.Streaming is true ? true : null, @@ -714,6 +718,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.RegisterPermissionHandler(config.OnPermissionRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); + session.RegisterExitPlanModeHandler(config.OnExitPlanMode); + session.RegisterAutoModeSwitchHandler(config.OnAutoModeSwitch); if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); @@ -757,6 +763,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.EnableSessionTelemetry, (bool?)true, config.OnUserInputRequest != null ? true : null, + config.OnExitPlanMode != null ? true : null, + config.OnAutoModeSwitch != null ? true : null, hasHooks ? true : null, config.WorkingDirectory, config.ConfigDir, @@ -1645,6 +1653,8 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.SetLocalRpcMethod("tool.call", handler.OnToolCallV2); rpc.SetLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); rpc.SetLocalRpcMethod("userInput.request", handler.OnUserInputRequest); + rpc.SetLocalRpcMethod("exitPlanMode.request", handler.OnExitPlanModeRequest); + rpc.SetLocalRpcMethod("autoModeSwitch.request", handler.OnAutoModeSwitchRequest); rpc.SetLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.SetLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => @@ -1763,6 +1773,39 @@ public async ValueTask OnUserInputRequest(string sessi return new UserInputRequestResponse(result.Answer, result.WasFreeform); } + public async ValueTask OnExitPlanModeRequest( + string sessionId, + string summary, + string? planContent = null, + IList? actions = null, + string? recommendedAction = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + var request = new ExitPlanModeRequest + { + Summary = summary, + PlanContent = planContent, + Actions = actions ?? [], + RecommendedAction = recommendedAction ?? "autopilot" + }; + + return await session.HandleExitPlanModeRequestAsync(request); + } + + public async ValueTask OnAutoModeSwitchRequest( + string sessionId, + string? errorCode = null, + double? retryAfterSeconds = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + var response = await session.HandleAutoModeSwitchRequestAsync(new AutoModeSwitchRequest + { + ErrorCode = errorCode, + RetryAfterSeconds = retryAfterSeconds + }); + return new AutoModeSwitchRequestResponse(response); + } + public async ValueTask OnHooksInvoke(string sessionId, string hookType, JsonElement input) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); @@ -1916,6 +1959,8 @@ internal record CreateSessionRequest( bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, + bool? RequestExitPlanMode, + bool? RequestAutoModeSwitch, bool? Hooks, string? WorkingDirectory, bool? Streaming, @@ -1973,6 +2018,8 @@ internal record ResumeSessionRequest( bool? EnableSessionTelemetry, bool? RequestPermission, bool? RequestUserInput, + bool? RequestExitPlanMode, + bool? RequestAutoModeSwitch, bool? Hooks, string? WorkingDirectory, string? ConfigDir, @@ -2035,6 +2082,9 @@ internal record UserInputRequestResponse( string Answer, bool WasFreeform); + internal record AutoModeSwitchRequestResponse( + AutoModeSwitchResponse Response); + internal record HooksInvokeResponse( object? Output); @@ -2052,9 +2102,14 @@ internal record PermissionRequestResponseV2( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(CreateSessionRequest))] [JsonSerializable(typeof(CreateSessionResponse))] + [JsonSerializable(typeof(AutoModeSwitchRequest))] + [JsonSerializable(typeof(AutoModeSwitchRequestResponse))] + [JsonSerializable(typeof(AutoModeSwitchResponse))] [JsonSerializable(typeof(CustomAgentConfig))] [JsonSerializable(typeof(DeleteSessionRequest))] [JsonSerializable(typeof(DeleteSessionResponse))] + [JsonSerializable(typeof(ExitPlanModeRequest))] + [JsonSerializable(typeof(ExitPlanModeResult))] [JsonSerializable(typeof(GetLastSessionIdResponse))] [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsRequest))] diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index b5dc10849..856b85866 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -64,6 +64,8 @@ public sealed partial class CopilotSession : IAsyncDisposable private volatile PermissionRequestHandler? _permissionHandler; private volatile UserInputHandler? _userInputHandler; private volatile ElicitationHandler? _elicitationHandler; + private volatile ExitPlanModeHandler? _exitPlanModeHandler; + private volatile AutoModeSwitchHandler? _autoModeSwitchHandler; private ImmutableArray _eventHandlers = ImmutableArray.Empty; private SessionHooks? _hooks; @@ -759,6 +761,24 @@ internal void RegisterElicitationHandler(ElicitationHandler? handler) _elicitationHandler = handler; } + /// + /// Registers an exit-plan-mode handler for this session. + /// + /// The handler to invoke when an exit-plan-mode request is received. + internal void RegisterExitPlanModeHandler(ExitPlanModeHandler? handler) + { + _exitPlanModeHandler = handler; + } + + /// + /// Registers an auto-mode-switch handler for this session. + /// + /// The handler to invoke when an auto-mode-switch request is received. + internal void RegisterAutoModeSwitchHandler(AutoModeSwitchHandler? handler) + { + _autoModeSwitchHandler = handler; + } + /// /// Sets the capabilities reported by the host for this session. /// @@ -1016,6 +1036,52 @@ internal async Task HandleUserInputRequestAsync(UserInputRequ return response; } + /// + /// Handles an exit-plan-mode request from the Copilot CLI. + /// + /// The exit-plan-mode request from the CLI. + /// A task that resolves with the user's decision. + internal async Task HandleExitPlanModeRequestAsync(ExitPlanModeRequest request) + { + var handler = _exitPlanModeHandler; + if (handler is null) + { + return new ExitPlanModeResult { Approved = true }; + } + + var invocation = new ExitPlanModeInvocation { SessionId = SessionId }; + var timestamp = Stopwatch.GetTimestamp(); + var response = await handler(request, invocation); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandleExitPlanModeRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", + timestamp, + SessionId); + return response; + } + + /// + /// Handles an auto-mode-switch request from the Copilot CLI. + /// + /// The auto-mode-switch request from the CLI. + /// A task that resolves with the user's decision. + internal async Task HandleAutoModeSwitchRequestAsync(AutoModeSwitchRequest request) + { + var handler = _autoModeSwitchHandler; + if (handler is null) + { + return AutoModeSwitchResponse.No; + } + + var invocation = new AutoModeSwitchInvocation { SessionId = SessionId }; + var timestamp = Stopwatch.GetTimestamp(); + var response = await handler(request, invocation); + LogTiming(_logger, LogLevel.Debug, null, + "CopilotSession.HandleAutoModeSwitchRequestAsync dispatch. Elapsed={Elapsed}, SessionId={SessionId}", + timestamp, + SessionId); + return response; + } + /// /// Registers hook handlers for this session. /// @@ -1349,7 +1415,10 @@ await InvokeRpcAsync( _commandHandlers.Clear(); _permissionHandler = null; + _userInputHandler = null; _elicitationHandler = null; + _exitPlanModeHandler = null; + _autoModeSwitchHandler = null; } [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception in broadcast event handler")] @@ -1406,6 +1475,10 @@ internal record SessionDestroyRequest [JsonSerializable(typeof(SessionAbortRequest))] [JsonSerializable(typeof(SessionDestroyRequest))] [JsonSerializable(typeof(UserMessageAttachment))] + [JsonSerializable(typeof(AutoModeSwitchRequest))] + [JsonSerializable(typeof(AutoModeSwitchResponse))] + [JsonSerializable(typeof(ExitPlanModeRequest))] + [JsonSerializable(typeof(ExitPlanModeResult))] [JsonSerializable(typeof(PreToolUseHookInput))] [JsonSerializable(typeof(PreToolUseHookOutput))] [JsonSerializable(typeof(PostToolUseHookInput))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 925091d1c..18b956f07 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -699,6 +699,129 @@ public class UserInputInvocation /// public delegate Task UserInputHandler(UserInputRequest request, UserInputInvocation invocation); +/// +/// Request to exit plan mode and continue with a selected action. +/// +public class ExitPlanModeRequest +{ + /// + /// Summary of the plan or proposed next step. + /// + [JsonPropertyName("summary")] + public string Summary { get; set; } = string.Empty; + + /// + /// Full plan content, when available. + /// + [JsonPropertyName("planContent")] + public string? PlanContent { get; set; } + + /// + /// Available actions the user can select. + /// + [JsonPropertyName("actions")] + public IList Actions { get => field ??= []; set; } + + /// + /// The action recommended by the runtime. + /// + [JsonPropertyName("recommendedAction")] + public string RecommendedAction { get; set; } = "autopilot"; +} + +/// +/// Response to an exit-plan-mode request. +/// +public class ExitPlanModeResult +{ + /// + /// Whether the user approved exiting plan mode. + /// + [JsonPropertyName("approved")] + public bool Approved { get; set; } = true; + + /// + /// Selected action, if the user chose one. + /// + [JsonPropertyName("selectedAction")] + public string? SelectedAction { get; set; } + + /// + /// Optional feedback provided by the user. + /// + [JsonPropertyName("feedback")] + public string? Feedback { get; set; } +} + +/// +/// Context for an exit-plan-mode request invocation. +/// +public class ExitPlanModeInvocation +{ + /// + /// Identifier of the session that triggered the request. + /// + public string SessionId { get; set; } = string.Empty; +} + +/// +/// Handler for exit-plan-mode requests from the agent. +/// +public delegate Task ExitPlanModeHandler(ExitPlanModeRequest request, ExitPlanModeInvocation invocation); + +/// +/// Request to switch to auto mode after an eligible rate limit. +/// +public class AutoModeSwitchRequest +{ + /// + /// The rate-limit error code that triggered the request. + /// + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } + + /// + /// Seconds until the rate limit resets, when known. + /// + [JsonPropertyName("retryAfterSeconds")] + public double? RetryAfterSeconds { get; set; } +} + +/// +/// Response to an auto-mode-switch request. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AutoModeSwitchResponse +{ + /// Approve the switch for this rate-limit cycle. + [JsonStringEnumMemberName("yes")] + Yes, + + /// Approve and remember the choice for this session. + [JsonStringEnumMemberName("yes_always")] + YesAlways, + + /// Decline the switch. + [JsonStringEnumMemberName("no")] + No +} + +/// +/// Context for an auto-mode-switch request invocation. +/// +public class AutoModeSwitchInvocation +{ + /// + /// Identifier of the session that triggered the request. + /// + public string SessionId { get; set; } = string.Empty; +} + +/// +/// Handler for auto-mode-switch requests from the agent. +/// +public delegate Task AutoModeSwitchHandler(AutoModeSwitchRequest request, AutoModeSwitchInvocation invocation); + // ============================================================================ // Command Handler Types // ============================================================================ @@ -1883,8 +2006,10 @@ protected SessionConfig(SessionConfig? other) : null; Model = other.Model; ModelCapabilities = other.ModelCapabilities; + OnAutoModeSwitch = other.OnAutoModeSwitch; OnElicitationRequest = other.OnElicitationRequest; OnEvent = other.OnEvent; + OnExitPlanMode = other.OnExitPlanMode; OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; @@ -2007,6 +2132,18 @@ protected SessionConfig(SessionConfig? other) /// public ElicitationHandler? OnElicitationRequest { get; set; } + /// + /// Handler for exit-plan-mode requests from the server. + /// When provided, the server will route exitPlanMode.request callbacks to this handler. + /// + public ExitPlanModeHandler? OnExitPlanMode { get; set; } + + /// + /// Handler for auto-mode-switch requests from the server. + /// When provided, the server will route autoModeSwitch.request callbacks to this handler. + /// + public AutoModeSwitchHandler? OnAutoModeSwitch { get; set; } + /// /// Hook handlers for session lifecycle events. /// @@ -2160,8 +2297,10 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) : null; Model = other.Model; ModelCapabilities = other.ModelCapabilities; + OnAutoModeSwitch = other.OnAutoModeSwitch; OnElicitationRequest = other.OnElicitationRequest; OnEvent = other.OnEvent; + OnExitPlanMode = other.OnExitPlanMode; OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; @@ -2264,6 +2403,18 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public ElicitationHandler? OnElicitationRequest { get; set; } + /// + /// Handler for exit-plan-mode requests from the server. + /// When provided, the server will route exitPlanMode.request callbacks to this handler. + /// + public ExitPlanModeHandler? OnExitPlanMode { get; set; } + + /// + /// Handler for auto-mode-switch requests from the server. + /// When provided, the server will route autoModeSwitch.request callbacks to this handler. + /// + public AutoModeSwitchHandler? OnAutoModeSwitch { get; set; } + /// /// Hook handlers for session lifecycle events. /// @@ -2900,7 +3051,11 @@ public class SystemMessageTransformRpcResponse NumberHandling = JsonNumberHandling.AllowReadingFromString, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(AzureOptions))] +[JsonSerializable(typeof(AutoModeSwitchRequest))] +[JsonSerializable(typeof(AutoModeSwitchResponse))] [JsonSerializable(typeof(CustomAgentConfig))] +[JsonSerializable(typeof(ExitPlanModeRequest))] +[JsonSerializable(typeof(ExitPlanModeResult))] [JsonSerializable(typeof(GetAuthStatusResponse))] [JsonSerializable(typeof(GetForegroundSessionResponse))] [JsonSerializable(typeof(GetModelsResponse))] diff --git a/dotnet/test/E2E/ModeHandlersE2ETests.cs b/dotnet/test/E2E/ModeHandlersE2ETests.cs new file mode 100644 index 000000000..a98faa0bf --- /dev/null +++ b/dotnet/test/E2E/ModeHandlersE2ETests.cs @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test.E2E; + +public class ModeHandlersE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "mode_handlers", output) +{ + private const string Token = "mode-handler-token"; + private const string AutoModePrompt = "Explain that auto mode recovered from a rate limit in one short sentence."; + + [Fact] + public async Task Should_Invoke_Exit_Plan_Mode_Handler_When_Model_Uses_Tool() + { + const string summary = "Greeting file implementation plan"; + await ConfigureAuthenticatedUserAsync(); + + var handlerTask = new TaskCompletionSource<(ExitPlanModeRequest Request, ExitPlanModeInvocation Invocation)>( + TaskCreationOptions.RunContinuationsAsynchronously); + + await using var client = CreateAuthenticatedClient(); + var session = await client.CreateSessionAsync(new SessionConfig + { + GitHubToken = Token, + OnPermissionRequest = PermissionHandler.ApproveAll, + OnExitPlanMode = (request, invocation) => + { + handlerTask.TrySetResult((request, invocation)); + return Task.FromResult(new ExitPlanModeResult + { + Approved = true, + SelectedAction = "interactive", + Feedback = "Approved by the C# E2E test", + }); + }, + }); + + var requestedEventTask = TestHelper.GetNextEventOfTypeAsync( + session, + evt => evt.Data.Summary == summary, + TimeSpan.FromSeconds(30), + timeoutDescription: "exit_plan_mode.requested event"); + var completedEventTask = TestHelper.GetNextEventOfTypeAsync( + session, + evt => evt.Data.Approved == true && evt.Data.SelectedAction == "interactive", + TimeSpan.FromSeconds(30), + timeoutDescription: "exit_plan_mode.completed event"); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Mode = "plan", + Prompt = "Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode.", + }, timeout: TimeSpan.FromSeconds(120)); + + var (request, invocation) = await handlerTask.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(session.SessionId, invocation.SessionId); + Assert.Equal(summary, request.Summary); + Assert.Equal(["interactive", "autopilot", "exit_only"], request.Actions); + Assert.Equal("interactive", request.RecommendedAction); + Assert.NotNull(request.PlanContent); + + var requestedEvent = await requestedEventTask; + Assert.Equal(request.Summary, requestedEvent.Data.Summary); + Assert.Equal(request.Actions, requestedEvent.Data.Actions); + Assert.Equal(request.RecommendedAction, requestedEvent.Data.RecommendedAction); + + var completedEvent = await completedEventTask; + Assert.True(completedEvent.Data.Approved); + Assert.Equal("interactive", completedEvent.Data.SelectedAction); + Assert.Equal("Approved by the C# E2E test", completedEvent.Data.Feedback); + + Assert.NotNull(response); + } + + [Fact] + public async Task Should_Invoke_Auto_Mode_Switch_Handler_When_Rate_Limited() + { + await ConfigureAuthenticatedUserAsync(); + + var handlerTask = new TaskCompletionSource<(AutoModeSwitchRequest Request, AutoModeSwitchInvocation Invocation)>( + TaskCreationOptions.RunContinuationsAsynchronously); + + await using var client = CreateAuthenticatedClient(); + var session = await client.CreateSessionAsync(new SessionConfig + { + GitHubToken = Token, + OnPermissionRequest = PermissionHandler.ApproveAll, + OnAutoModeSwitch = (request, invocation) => + { + handlerTask.TrySetResult((request, invocation)); + return Task.FromResult(AutoModeSwitchResponse.Yes); + }, + }); + + var requestedEventTask = GetNextEventOfTypeAllowingRateLimitAsync( + session, + evt => evt.Data.ErrorCode == "user_weekly_rate_limited" && evt.Data.RetryAfterSeconds == 1, + TimeSpan.FromSeconds(30), + timeoutDescription: "auto_mode_switch.requested event"); + var completedEventTask = GetNextEventOfTypeAllowingRateLimitAsync( + session, + evt => evt.Data.Response == "yes", + TimeSpan.FromSeconds(30), + timeoutDescription: "auto_mode_switch.completed event"); + var modelChangeTask = GetNextEventOfTypeAllowingRateLimitAsync( + session, + evt => evt.Data.Cause == "rate_limit_auto_switch", + TimeSpan.FromSeconds(30), + timeoutDescription: "rate-limit auto-mode model change"); + var idleEventTask = GetNextEventOfTypeAllowingRateLimitAsync( + session, + static _ => true, + TimeSpan.FromSeconds(30), + timeoutDescription: "session.idle after auto-mode switch"); + + var messageId = await session.SendAsync(new MessageOptions + { + Prompt = AutoModePrompt, + }); + Assert.NotEmpty(messageId); + + var (request, invocation) = await handlerTask.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(session.SessionId, invocation.SessionId); + Assert.Equal("user_weekly_rate_limited", request.ErrorCode); + Assert.Equal(1, request.RetryAfterSeconds); + + var requestedEvent = await requestedEventTask; + Assert.Equal(request.ErrorCode, requestedEvent.Data.ErrorCode); + Assert.Equal(request.RetryAfterSeconds, requestedEvent.Data.RetryAfterSeconds); + + var completedEvent = await completedEventTask; + Assert.Equal("yes", completedEvent.Data.Response); + + var modelChange = await modelChangeTask; + Assert.Equal("rate_limit_auto_switch", modelChange.Data.Cause); + await idleEventTask; + } + + private CopilotClient CreateAuthenticatedClient() + { + var env = new Dictionary(Ctx.GetEnvironment()) + { + ["COPILOT_DEBUG_GITHUB_API_URL"] = Ctx.ProxyUrl, + }; + + return Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + } + + private Task ConfigureAuthenticatedUserAsync() + { + return Ctx.SetCopilotUserByTokenAsync(Token, new CopilotUserConfig( + Login: "mode-handler-user", + CopilotPlan: "individual_pro", + Endpoints: new CopilotUserEndpoints(Api: Ctx.ProxyUrl, Telemetry: "https://localhost:1/telemetry"), + AnalyticsTrackingId: "mode-handler-tracking-id")); + } + + private static async Task GetNextEventOfTypeAllowingRateLimitAsync( + CopilotSession session, + Func predicate, + TimeSpan? timeout = null, + string? timeoutDescription = null) where T : SessionEvent + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(30)); + + using var subscription = session.On(evt => + { + if (evt is T matched && predicate(matched)) + { + tcs.TrySetResult(matched); + } + else if (evt is SessionErrorEvent { Data.ErrorType: not "rate_limit" } error) + { + tcs.TrySetException(new Exception(error.Data.Message ?? "session error")); + } + }); + + cts.Token.Register(() => tcs.TrySetException( + new TimeoutException($"Timeout waiting for {timeoutDescription ?? $"event of type '{typeof(T).Name}'"}"))); + + return await tcs.Task; + } +} diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index ed2070b50..07cbde3de 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -101,6 +101,8 @@ public void SessionConfig_Clone_CopiesAllProperties() SkillDirectories = ["/skills"], InstructionDirectories = ["/instructions"], DisabledSkills = ["skill1"], + OnExitPlanMode = static (_, _) => Task.FromResult(new ExitPlanModeResult()), + OnAutoModeSwitch = static (_, _) => Task.FromResult(AutoModeSwitchResponse.No), }; var clone = original.Clone(); @@ -123,6 +125,8 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.SkillDirectories, clone.SkillDirectories); Assert.Equal(original.InstructionDirectories, clone.InstructionDirectories); Assert.Equal(original.DisabledSkills, clone.DisabledSkills); + Assert.Same(original.OnExitPlanMode, clone.OnExitPlanMode); + Assert.Same(original.OnAutoModeSwitch, clone.OnAutoModeSwitch); } [Fact] @@ -296,6 +300,21 @@ public void ResumeSessionConfig_Clone_CopiesAgentProperty() Assert.Equal("test-agent", clone.Agent); } + [Fact] + public void ResumeSessionConfig_Clone_CopiesModeSwitchHandlers() + { + var original = new ResumeSessionConfig + { + OnExitPlanMode = static (_, _) => Task.FromResult(new ExitPlanModeResult()), + OnAutoModeSwitch = static (_, _) => Task.FromResult(AutoModeSwitchResponse.No), + }; + + var clone = original.Clone(); + + Assert.Same(original.OnExitPlanMode, clone.OnExitPlanMode); + Assert.Same(original.OnAutoModeSwitch, clone.OnAutoModeSwitch); + } + [Fact] public void ResumeSessionConfig_Clone_CopiesIncludeSubAgentStreamingEvents() { diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 713c46abd..10e45fcf8 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -110,6 +110,23 @@ public void CreateSessionRequest_CanSerializeInstructionDirectories_WithSdkOptio Assert.Equal("C:\\more-instructions", root.GetProperty("instructionDirectories")[1].GetString()); } + [Fact] + public void CreateSessionRequest_CanSerializeModeRequestFlags_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("RequestExitPlanMode", true), + ("RequestAutoModeSwitch", true)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.True(root.GetProperty("requestExitPlanMode").GetBoolean()); + Assert.True(root.GetProperty("requestAutoModeSwitch").GetBoolean()); + } + [Fact] public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptions() { @@ -158,6 +175,34 @@ public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptio Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); } + [Fact] + public void ResumeSessionRequest_CanSerializeModeRequestFlags_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var request = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("RequestExitPlanMode", true), + ("RequestAutoModeSwitch", true)); + + var json = JsonSerializer.Serialize(request, requestType, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.True(root.GetProperty("requestExitPlanMode").GetBoolean()); + Assert.True(root.GetProperty("requestAutoModeSwitch").GetBoolean()); + } + + [Fact] + public void AutoModeSwitchResponse_CanSerialize_WithSdkOptions() + { + var options = GetSerializerOptions(); + + var json = JsonSerializer.Serialize(AutoModeSwitchResponse.YesAlways, options); + + Assert.Equal("\"yes_always\"", json); + } + [Fact] public void McpHttpServerConfig_CanSerializeOauthOptions_WithSdkOptions() { diff --git a/go/client.go b/go/client.go index 05b0696ff..6960fe9a2 100644 --- a/go/client.go +++ b/go/client.go @@ -655,6 +655,12 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnElicitationRequest != nil { req.RequestElicitation = Bool(true) } + if config.OnExitPlanMode != nil { + req.RequestExitPlanMode = Bool(true) + } + if config.OnAutoModeSwitch != nil { + req.RequestAutoModeSwitch = Bool(true) + } if config.Streaming { req.Streaming = Bool(true) @@ -711,6 +717,12 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.OnElicitationRequest != nil { session.registerElicitationHandler(config.OnElicitationRequest) } + if config.OnExitPlanMode != nil { + session.registerExitPlanModeHandler(config.OnExitPlanMode) + } + if config.OnAutoModeSwitch != nil { + session.registerAutoModeSwitchHandler(config.OnAutoModeSwitch) + } c.sessionsMux.Lock() c.sessions[sessionID] = session @@ -847,6 +859,12 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnElicitationRequest != nil { req.RequestElicitation = Bool(true) } + if config.OnExitPlanMode != nil { + req.RequestExitPlanMode = Bool(true) + } + if config.OnAutoModeSwitch != nil { + req.RequestAutoModeSwitch = Bool(true) + } traceparent, tracestate := getTraceContext(ctx) req.Traceparent = traceparent @@ -876,6 +894,12 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.OnElicitationRequest != nil { session.registerElicitationHandler(config.OnElicitationRequest) } + if config.OnExitPlanMode != nil { + session.registerExitPlanModeHandler(config.OnExitPlanMode) + } + if config.OnAutoModeSwitch != nil { + session.registerAutoModeSwitchHandler(config.OnAutoModeSwitch) + } c.sessionsMux.Lock() c.sessions[sessionID] = session @@ -1697,6 +1721,8 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("tool.call", jsonrpc2.RequestHandlerFor(c.handleToolCallRequestV2)) c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2)) c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) + c.client.SetRequestHandler("exitPlanMode.request", jsonrpc2.RequestHandlerFor(c.handleExitPlanModeRequest)) + c.client.SetRequestHandler("autoModeSwitch.request", jsonrpc2.RequestHandlerFor(c.handleAutoModeSwitchRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform)) rpc.RegisterClientSessionApiHandlers(c.client, func(sessionID string) *rpc.ClientSessionApiHandlers { @@ -1749,6 +1775,60 @@ func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputRespons return &userInputResponse{Answer: response.Answer, WasFreeform: response.WasFreeform}, nil } +// handleExitPlanModeRequest handles an exitPlanMode.request callback from the CLI server. +func (c *Client) handleExitPlanModeRequest(req exitPlanModeRequest) (*ExitPlanModeResult, *jsonrpc2.Error) { + if req.SessionID == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid exit plan mode request payload"} + } + recommendedAction := req.RecommendedAction + if recommendedAction == "" { + recommendedAction = "autopilot" + } + + c.sessionsMux.Lock() + session, ok := c.sessions[req.SessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + } + + response, err := session.handleExitPlanModeRequest(ExitPlanModeRequest{ + Summary: req.Summary, + PlanContent: req.PlanContent, + Actions: req.Actions, + RecommendedAction: recommendedAction, + }) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()} + } + + return &response, nil +} + +// handleAutoModeSwitchRequest handles an autoModeSwitch.request callback from the CLI server. +func (c *Client) handleAutoModeSwitchRequest(req autoModeSwitchRequest) (*autoModeSwitchResponse, *jsonrpc2.Error) { + if req.SessionID == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid auto mode switch request payload"} + } + + c.sessionsMux.Lock() + session, ok := c.sessions[req.SessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + } + + response, err := session.handleAutoModeSwitchRequest(AutoModeSwitchRequest{ + ErrorCode: req.ErrorCode, + RetryAfterSeconds: req.RetryAfterSeconds, + }) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()} + } + + return &autoModeSwitchResponse{Response: response}, nil +} + // handleHooksInvoke handles a hooks invocation from the CLI server. func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jsonrpc2.Error) { if req.SessionID == "" || req.Type == "" { diff --git a/go/client_test.go b/go/client_test.go index 28b44086e..f9f47fc30 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -930,6 +930,42 @@ func TestCreateSessionRequest_RequestElicitation(t *testing.T) { }) } +func TestCreateSessionRequest_ModeCallbackFlags(t *testing.T) { + t.Run("sends mode callback flags when handlers are provided", func(t *testing.T) { + req := createSessionRequest{ + RequestExitPlanMode: Bool(true), + RequestAutoModeSwitch: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["requestExitPlanMode"] != true { + t.Errorf("Expected requestExitPlanMode to be true, got %v", m["requestExitPlanMode"]) + } + if m["requestAutoModeSwitch"] != true { + t.Errorf("Expected requestAutoModeSwitch to be true, got %v", m["requestAutoModeSwitch"]) + } + }) + + t.Run("omits mode callback flags when handlers are not provided", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["requestExitPlanMode"]; ok { + t.Error("Expected requestExitPlanMode to be omitted when not set") + } + if _, ok := m["requestAutoModeSwitch"]; ok { + t.Error("Expected requestAutoModeSwitch to be omitted when not set") + } + }) +} + func TestResumeSessionRequest_RequestElicitation(t *testing.T) { t.Run("sends requestElicitation flag when OnElicitationRequest is provided", func(t *testing.T) { req := resumeSessionRequest{ @@ -960,6 +996,115 @@ func TestResumeSessionRequest_RequestElicitation(t *testing.T) { }) } +func TestResumeSessionRequest_ModeCallbackFlags(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + RequestExitPlanMode: Bool(true), + RequestAutoModeSwitch: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["requestExitPlanMode"] != true { + t.Errorf("Expected requestExitPlanMode to be true, got %v", m["requestExitPlanMode"]) + } + if m["requestAutoModeSwitch"] != true { + t.Errorf("Expected requestAutoModeSwitch to be true, got %v", m["requestAutoModeSwitch"]) + } +} + +func TestModeCallbackRequestHandlers(t *testing.T) { + session := &Session{SessionID: "s1"} + client := &Client{sessions: map[string]*Session{"s1": session}} + + expectedSummary := "Review the plan" + expectedPlanContent := "Plan body" + expectedActions := []string{"interactive", "autopilot"} + expectedRecommendedAction := "autopilot" + session.registerExitPlanModeHandler(func(request ExitPlanModeRequest, invocation ExitPlanModeInvocation) (ExitPlanModeResult, error) { + if invocation.SessionID != "s1" { + t.Fatalf("Expected session ID s1, got %s", invocation.SessionID) + } + if request.Summary != expectedSummary { + t.Fatalf("Expected summary, got %q", request.Summary) + } + if request.PlanContent != expectedPlanContent { + t.Fatalf("Expected plan content, got %q", request.PlanContent) + } + if !reflect.DeepEqual(request.Actions, expectedActions) { + t.Fatalf("Expected actions to round-trip, got %#v", request.Actions) + } + if request.RecommendedAction != expectedRecommendedAction { + t.Fatalf("Expected recommended action, got %q", request.RecommendedAction) + } + return ExitPlanModeResult{ + Approved: true, + SelectedAction: "interactive", + Feedback: "Looks good", + }, nil + }) + + errorCode := "user_weekly_rate_limited" + retryAfter := float64(3600) + session.registerAutoModeSwitchHandler(func(request AutoModeSwitchRequest, invocation AutoModeSwitchInvocation) (AutoModeSwitchResponse, error) { + if invocation.SessionID != "s1" { + t.Fatalf("Expected session ID s1, got %s", invocation.SessionID) + } + if request.ErrorCode == nil || *request.ErrorCode != errorCode { + t.Fatalf("Expected error code %q, got %#v", errorCode, request.ErrorCode) + } + if request.RetryAfterSeconds == nil || *request.RetryAfterSeconds != retryAfter { + t.Fatalf("Expected retry-after %v, got %#v", retryAfter, request.RetryAfterSeconds) + } + return AutoModeSwitchResponseYesAlways, nil + }) + + exitResult, rpcErr := client.handleExitPlanModeRequest(exitPlanModeRequest{ + SessionID: "s1", + Summary: "Review the plan", + PlanContent: "Plan body", + Actions: []string{"interactive", "autopilot"}, + RecommendedAction: "autopilot", + }) + if rpcErr != nil { + t.Fatalf("Unexpected RPC error: %v", rpcErr) + } + if !exitResult.Approved || exitResult.SelectedAction != "interactive" || exitResult.Feedback != "Looks good" { + t.Fatalf("Unexpected exit-plan-mode result: %#v", exitResult) + } + + expectedSummary = "" + expectedPlanContent = "" + expectedActions = nil + expectedRecommendedAction = "autopilot" + exitResult, rpcErr = client.handleExitPlanModeRequest(exitPlanModeRequest{ + SessionID: "s1", + }) + if rpcErr != nil { + t.Fatalf("Unexpected RPC error for minimal exit-plan-mode request: %v", rpcErr) + } + if !exitResult.Approved { + t.Fatalf("Unexpected minimal exit-plan-mode result: %#v", exitResult) + } + + autoResult, rpcErr := client.handleAutoModeSwitchRequest(autoModeSwitchRequest{ + SessionID: "s1", + ErrorCode: &errorCode, + RetryAfterSeconds: &retryAfter, + }) + if rpcErr != nil { + t.Fatalf("Unexpected RPC error: %v", rpcErr) + } + if autoResult.Response != AutoModeSwitchResponseYesAlways { + t.Fatalf("Expected yes_always, got %q", autoResult.Response) + } +} + func TestResumeSessionRequest_ContinuePendingWork(t *testing.T) { t.Run("forwards continuePendingWork when true", func(t *testing.T) { req := resumeSessionRequest{ diff --git a/go/internal/e2e/mode_handlers_e2e_test.go b/go/internal/e2e/mode_handlers_e2e_test.go new file mode 100644 index 000000000..d4ed134ff --- /dev/null +++ b/go/internal/e2e/mode_handlers_e2e_test.go @@ -0,0 +1,269 @@ +package e2e + +import ( + "fmt" + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +const ( + modeHandlerToken = "mode-handler-token" + planSummary = "Greeting file implementation plan" + planPrompt = "Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode." + autoModePrompt = "Explain that auto mode recovered from a rate limit in one short sentence." +) + +func TestModeHandlersE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + + client := ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.Env = append(opts.Env, "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL) + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := ctx.SetCopilotUserByToken(modeHandlerToken, map[string]interface{}{ + "login": "mode-handler-user", + "copilot_plan": "individual_pro", + "endpoints": map[string]interface{}{"api": ctx.ProxyURL, "telemetry": "https://localhost:1/telemetry"}, + "analytics_tracking_id": "mode-handler-tracking-id", + }); err != nil { + t.Fatalf("Failed to set copilot user for mode handler test: %v", err) + } + + t.Run("should invoke exit plan mode handler when model uses tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var mu sync.Mutex + var exitPlanModeRequests []copilot.ExitPlanModeRequest + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + GitHubToken: modeHandlerToken, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnExitPlanMode: func(request copilot.ExitPlanModeRequest, invocation copilot.ExitPlanModeInvocation) (copilot.ExitPlanModeResult, error) { + mu.Lock() + exitPlanModeRequests = append(exitPlanModeRequests, request) + mu.Unlock() + + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + + return copilot.ExitPlanModeResult{ + Approved: true, + SelectedAction: "interactive", + Feedback: "Approved by the Go E2E test", + }, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + defer session.Disconnect() + + awaitRequested := waitForMatchingEvent( + session, + copilot.SessionEventTypeExitPlanModeRequested, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.ExitPlanModeRequestedData) + return ok && data.Summary == planSummary + }, + "exit_plan_mode.requested event", + ) + awaitCompleted := waitForMatchingEvent( + session, + copilot.SessionEventTypeExitPlanModeCompleted, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.ExitPlanModeCompletedData) + return ok && data.Approved != nil && *data.Approved && data.SelectedAction != nil && *data.SelectedAction == "interactive" + }, + "exit_plan_mode.completed event", + ) + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: planPrompt, + Mode: "plan", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + if len(exitPlanModeRequests) != 1 { + t.Fatalf("Expected one exit-plan-mode request, got %d", len(exitPlanModeRequests)) + } + request := exitPlanModeRequests[0] + mu.Unlock() + + if request.Summary != planSummary { + t.Fatalf("Expected summary %q, got %q", planSummary, request.Summary) + } + if len(request.Actions) != 3 || request.Actions[0] != "interactive" || request.Actions[1] != "autopilot" || request.Actions[2] != "exit_only" { + t.Fatalf("Unexpected actions: %#v", request.Actions) + } + if request.RecommendedAction != "interactive" { + t.Fatalf("Expected recommended action interactive, got %q", request.RecommendedAction) + } + requested := awaitEvent(t, awaitRequested) + if data := requested.Data.(*copilot.ExitPlanModeRequestedData); data.Summary != planSummary { + t.Fatalf("Expected requested event summary %q, got %+v", planSummary, data) + } + + completed := awaitEvent(t, awaitCompleted) + completedData := completed.Data.(*copilot.ExitPlanModeCompletedData) + if completedData.Feedback == nil || *completedData.Feedback != "Approved by the Go E2E test" { + t.Fatalf("Unexpected completed event feedback: %+v", completedData) + } + + if response == nil { + t.Fatal("Expected non-nil response") + } + }) + + t.Run("should invoke auto mode switch handler when rate limited", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var mu sync.Mutex + var autoModeSwitchRequests []copilot.AutoModeSwitchRequest + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + GitHubToken: modeHandlerToken, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnAutoModeSwitch: func(request copilot.AutoModeSwitchRequest, invocation copilot.AutoModeSwitchInvocation) (copilot.AutoModeSwitchResponse, error) { + mu.Lock() + autoModeSwitchRequests = append(autoModeSwitchRequests, request) + mu.Unlock() + + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + + return copilot.AutoModeSwitchResponseYes, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + defer session.Disconnect() + + awaitRequested := waitForMatchingEventAllowingRateLimit( + session, + copilot.SessionEventTypeAutoModeSwitchRequested, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.AutoModeSwitchRequestedData) + return ok && + data.ErrorCode != nil && *data.ErrorCode == "user_weekly_rate_limited" && + data.RetryAfterSeconds != nil && *data.RetryAfterSeconds == 1 + }, + "auto_mode_switch.requested event", + ) + awaitCompleted := waitForMatchingEventAllowingRateLimit( + session, + copilot.SessionEventTypeAutoModeSwitchCompleted, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.AutoModeSwitchCompletedData) + return ok && data.Response == "yes" + }, + "auto_mode_switch.completed event", + ) + awaitModelChange := waitForMatchingEventAllowingRateLimit( + session, + copilot.SessionEventTypeSessionModelChange, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.SessionModelChangeData) + return ok && data.Cause != nil && *data.Cause == "rate_limit_auto_switch" + }, + "rate-limit auto-mode model change", + ) + awaitIdle := waitForMatchingEventAllowingRateLimit( + session, + copilot.SessionEventTypeSessionIdle, + func(event copilot.SessionEvent) bool { + _, ok := event.Data.(*copilot.SessionIdleData) + return ok + }, + "session.idle after auto-mode switch", + ) + + messageID, err := session.Send(t.Context(), copilot.MessageOptions{ + Prompt: autoModePrompt, + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if messageID == "" { + t.Fatal("Expected non-empty message ID") + } + + requested := awaitEvent(t, awaitRequested) + requestedData := requested.Data.(*copilot.AutoModeSwitchRequestedData) + if requestedData.ErrorCode == nil || *requestedData.ErrorCode != "user_weekly_rate_limited" { + t.Fatalf("Unexpected requested event error code: %+v", requestedData) + } + + completed := awaitEvent(t, awaitCompleted) + if data := completed.Data.(*copilot.AutoModeSwitchCompletedData); data.Response != "yes" { + t.Fatalf("Unexpected completed event response: %+v", data) + } + + modelChange := awaitEvent(t, awaitModelChange) + if data := modelChange.Data.(*copilot.SessionModelChangeData); data.Cause == nil || *data.Cause != "rate_limit_auto_switch" { + t.Fatalf("Unexpected model change event: %+v", data) + } + awaitEvent(t, awaitIdle) + + mu.Lock() + if len(autoModeSwitchRequests) != 1 { + t.Fatalf("Expected one auto-mode-switch request, got %d", len(autoModeSwitchRequests)) + } + request := autoModeSwitchRequests[0] + mu.Unlock() + + if request.ErrorCode == nil || *request.ErrorCode != "user_weekly_rate_limited" { + t.Fatalf("Unexpected auto-mode-switch request error code: %+v", request) + } + if request.RetryAfterSeconds == nil || *request.RetryAfterSeconds != 1 { + t.Fatalf("Unexpected auto-mode-switch retry-after value: %+v", request) + } + }) +} + +func waitForMatchingEventAllowingRateLimit(session *copilot.Session, eventType copilot.SessionEventType, predicate func(copilot.SessionEvent) bool, description string) func() (*copilot.SessionEvent, error) { + result := make(chan *copilot.SessionEvent, 1) + errCh := make(chan error, 1) + unsubscribe := session.On(func(event copilot.SessionEvent) { + if event.Type == eventType && predicate(event) { + select { + case result <- &event: + default: + } + } else if event.Type == copilot.SessionEventTypeSessionError { + if data, ok := event.Data.(*copilot.SessionErrorData); ok && data.ErrorType == "rate_limit" { + return + } + msg := "session error" + if data, ok := event.Data.(*copilot.SessionErrorData); ok { + msg = data.Message + } + select { + case errCh <- fmt.Errorf("%s while waiting for %s", msg, description): + default: + } + } + }) + + return func() (*copilot.SessionEvent, error) { + defer unsubscribe() + select { + case event := <-result: + return event, nil + case err := <-errCh: + return nil, err + case <-time.After(30 * time.Second): + return nil, fmt.Errorf("timed out waiting for %s", description) + } + } +} diff --git a/go/session.go b/go/session.go index b58972c15..884a3773d 100644 --- a/go/session.go +++ b/go/session.go @@ -50,29 +50,33 @@ type sessionHandler struct { // }) type Session struct { // SessionID is the unique identifier for this session. - SessionID string - workspacePath string - client *jsonrpc2.Client - clientSessionApis *rpc.ClientSessionApiHandlers - handlers []sessionHandler - nextHandlerID uint64 - handlerMutex sync.RWMutex - toolHandlers map[string]ToolHandler - toolHandlersM sync.RWMutex - permissionHandler PermissionHandlerFunc - permissionMux sync.RWMutex - userInputHandler UserInputHandler - userInputMux sync.RWMutex - hooks *SessionHooks - hooksMux sync.RWMutex - transformCallbacks map[string]SectionTransformFn - transformMu sync.Mutex - commandHandlers map[string]CommandHandler - commandHandlersMu sync.RWMutex - elicitationHandler ElicitationHandler - elicitationMu sync.RWMutex - capabilities SessionCapabilities - capabilitiesMu sync.RWMutex + SessionID string + workspacePath string + client *jsonrpc2.Client + clientSessionApis *rpc.ClientSessionApiHandlers + handlers []sessionHandler + nextHandlerID uint64 + handlerMutex sync.RWMutex + toolHandlers map[string]ToolHandler + toolHandlersM sync.RWMutex + permissionHandler PermissionHandlerFunc + permissionMux sync.RWMutex + userInputHandler UserInputHandler + userInputMux sync.RWMutex + exitPlanModeHandler ExitPlanModeHandler + exitPlanModeMu sync.RWMutex + autoModeSwitchHandler AutoModeSwitchHandler + autoModeSwitchMu sync.RWMutex + hooks *SessionHooks + hooksMux sync.RWMutex + transformCallbacks map[string]SectionTransformFn + transformMu sync.Mutex + commandHandlers map[string]CommandHandler + commandHandlersMu sync.RWMutex + elicitationHandler ElicitationHandler + elicitationMu sync.RWMutex + capabilities SessionCapabilities + capabilitiesMu sync.RWMutex // eventCh serializes user event handler dispatch. dispatchEvent enqueues; // a single goroutine (processEvents) dequeues and invokes handlers in FIFO order. @@ -359,6 +363,48 @@ func (s *Session) handleUserInputRequest(request UserInputRequest) (UserInputRes return handler(request, invocation) } +func (s *Session) registerExitPlanModeHandler(handler ExitPlanModeHandler) { + s.exitPlanModeMu.Lock() + defer s.exitPlanModeMu.Unlock() + s.exitPlanModeHandler = handler +} + +func (s *Session) getExitPlanModeHandler() ExitPlanModeHandler { + s.exitPlanModeMu.RLock() + defer s.exitPlanModeMu.RUnlock() + return s.exitPlanModeHandler +} + +func (s *Session) handleExitPlanModeRequest(request ExitPlanModeRequest) (ExitPlanModeResult, error) { + handler := s.getExitPlanModeHandler() + if handler == nil { + return ExitPlanModeResult{Approved: true}, nil + } + + return handler(request, ExitPlanModeInvocation{SessionID: s.SessionID}) +} + +func (s *Session) registerAutoModeSwitchHandler(handler AutoModeSwitchHandler) { + s.autoModeSwitchMu.Lock() + defer s.autoModeSwitchMu.Unlock() + s.autoModeSwitchHandler = handler +} + +func (s *Session) getAutoModeSwitchHandler() AutoModeSwitchHandler { + s.autoModeSwitchMu.RLock() + defer s.autoModeSwitchMu.RUnlock() + return s.autoModeSwitchHandler +} + +func (s *Session) handleAutoModeSwitchRequest(request AutoModeSwitchRequest) (AutoModeSwitchResponse, error) { + handler := s.getAutoModeSwitchHandler() + if handler == nil { + return AutoModeSwitchResponseNo, nil + } + + return handler(request, AutoModeSwitchInvocation{SessionID: s.SessionID}) +} + // registerHooks registers hook handlers for this session. // // Hooks are called at various points during session execution to allow diff --git a/go/types.go b/go/types.go index 161b798d6..19b67dfd6 100644 --- a/go/types.go +++ b/go/types.go @@ -286,6 +286,55 @@ type UserInputInvocation struct { SessionID string } +// ExitPlanModeRequest represents a request to exit plan mode and continue with a selected action. +type ExitPlanModeRequest struct { + Summary string `json:"summary"` + PlanContent string `json:"planContent,omitempty"` + Actions []string `json:"actions"` + RecommendedAction string `json:"recommendedAction"` +} + +// ExitPlanModeResult is the response to an exit-plan-mode request. +type ExitPlanModeResult struct { + Approved bool `json:"approved"` + SelectedAction string `json:"selectedAction,omitempty"` + Feedback string `json:"feedback,omitempty"` +} + +// ExitPlanModeInvocation provides context about an exit-plan-mode request. +type ExitPlanModeInvocation struct { + SessionID string +} + +// ExitPlanModeHandler handles exit-plan-mode requests from the agent. +type ExitPlanModeHandler func(request ExitPlanModeRequest, invocation ExitPlanModeInvocation) (ExitPlanModeResult, error) + +// AutoModeSwitchRequest represents a request to switch to auto mode after an eligible rate limit. +type AutoModeSwitchRequest struct { + ErrorCode *string `json:"errorCode,omitempty"` + RetryAfterSeconds *float64 `json:"retryAfterSeconds,omitempty"` +} + +// AutoModeSwitchResponse is the user's response to an auto-mode-switch request. +type AutoModeSwitchResponse string + +const ( + // AutoModeSwitchResponseYes approves the switch for this rate-limit cycle. + AutoModeSwitchResponseYes AutoModeSwitchResponse = "yes" + // AutoModeSwitchResponseYesAlways approves and remembers the choice for this session. + AutoModeSwitchResponseYesAlways AutoModeSwitchResponse = "yes_always" + // AutoModeSwitchResponseNo declines the switch. + AutoModeSwitchResponseNo AutoModeSwitchResponse = "no" +) + +// AutoModeSwitchInvocation provides context about an auto-mode-switch request. +type AutoModeSwitchInvocation struct { + SessionID string +} + +// AutoModeSwitchHandler handles auto-mode-switch requests from the agent. +type AutoModeSwitchHandler func(request AutoModeSwitchRequest, invocation AutoModeSwitchInvocation) (AutoModeSwitchResponse, error) + // PreToolUseHookInput is the input for a pre-tool-use hook type PreToolUseHookInput struct { Timestamp int64 `json:"timestamp"` @@ -621,6 +670,12 @@ type SessionConfig struct { // When provided, the server may call back to this client for form-based UI dialogs // (e.g. from MCP tools). Also enables the elicitation capability on the session. OnElicitationRequest ElicitationHandler + // OnExitPlanMode is a handler for exit-plan-mode requests from the server. + // When provided, enables exitPlanMode.request callbacks for the session. + OnExitPlanMode ExitPlanModeHandler + // OnAutoModeSwitch is a handler for auto-mode-switch requests from the server. + // When provided, enables autoModeSwitch.request callbacks for the session. + OnAutoModeSwitch AutoModeSwitchHandler // GitHubToken is an optional per-session GitHub token used for authentication. // When provided, the session authenticates as the token's owner instead of // using the global client-level auth. @@ -859,6 +914,12 @@ type ResumeSessionConfig struct { // OnElicitationRequest is a handler for elicitation requests from the server. // See SessionConfig.OnElicitationRequest. OnElicitationRequest ElicitationHandler + // OnExitPlanMode is a handler for exit-plan-mode requests from the server. + // See SessionConfig.OnExitPlanMode. + OnExitPlanMode ExitPlanModeHandler + // OnAutoModeSwitch is a handler for auto-mode-switch requests from the server. + // See SessionConfig.OnAutoModeSwitch. + OnAutoModeSwitch AutoModeSwitchHandler } type ProviderConfig struct { // Type is the provider type: "openai", "azure", or "anthropic". Defaults to "openai". @@ -1061,6 +1122,8 @@ type createSessionRequest struct { ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` + RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` + RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` Hooks *bool `json:"hooks,omitempty"` WorkingDirectory string `json:"workingDirectory,omitempty"` Streaming *bool `json:"streaming,omitempty"` @@ -1111,6 +1174,8 @@ type resumeSessionRequest struct { ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` RequestUserInput *bool `json:"requestUserInput,omitempty"` + RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` + RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` Hooks *bool `json:"hooks,omitempty"` WorkingDirectory string `json:"workingDirectory,omitempty"` ConfigDir string `json:"configDir,omitempty"` @@ -1301,3 +1366,21 @@ type userInputResponse struct { Answer string `json:"answer"` WasFreeform bool `json:"wasFreeform"` } + +type exitPlanModeRequest struct { + SessionID string `json:"sessionId"` + Summary string `json:"summary"` + PlanContent string `json:"planContent,omitempty"` + Actions []string `json:"actions"` + RecommendedAction string `json:"recommendedAction"` +} + +type autoModeSwitchRequest struct { + SessionID string `json:"sessionId"` + ErrorCode *string `json:"errorCode,omitempty"` + RetryAfterSeconds *float64 `json:"retryAfterSeconds,omitempty"` +} + +type autoModeSwitchResponse struct { + Response AutoModeSwitchResponse `json:"response"` +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index bcbb07064..264e0a575 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -36,8 +36,12 @@ import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { createSessionFsAdapter } from "./sessionFsProvider.js"; import { getTraceContext } from "./telemetry.js"; import type { + AutoModeSwitchRequest, + AutoModeSwitchResponse, ConnectionState, CopilotClientOptions, + ExitPlanModeRequest, + ExitPlanModeResult, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, @@ -752,6 +756,12 @@ export class CopilotClient { if (config.onElicitationRequest) { session.registerElicitationHandler(config.onElicitationRequest); } + if (config.onExitPlanMode) { + session.registerExitPlanModeHandler(config.onExitPlanMode); + } + if (config.onAutoModeSwitch) { + session.registerAutoModeSwitchHandler(config.onAutoModeSwitch); + } if (config.hooks) { session.registerHooks(config.hooks); } @@ -807,6 +817,8 @@ export class CopilotClient { requestPermission: true, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + requestExitPlanMode: !!config.onExitPlanMode, + requestAutoModeSwitch: !!config.onAutoModeSwitch, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, streaming: config.streaming, @@ -896,6 +908,12 @@ export class CopilotClient { if (config.onElicitationRequest) { session.registerElicitationHandler(config.onElicitationRequest); } + if (config.onExitPlanMode) { + session.registerExitPlanModeHandler(config.onExitPlanMode); + } + if (config.onAutoModeSwitch) { + session.registerAutoModeSwitchHandler(config.onAutoModeSwitch); + } if (config.hooks) { session.registerHooks(config.hooks); } @@ -952,6 +970,8 @@ export class CopilotClient { config.onPermissionRequest !== defaultJoinSessionPermissionHandler, requestUserInput: !!config.onUserInputRequest, requestElicitation: !!config.onElicitationRequest, + requestExitPlanMode: !!config.onExitPlanMode, + requestAutoModeSwitch: !!config.onAutoModeSwitch, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, configDir: config.configDir, @@ -1805,6 +1825,21 @@ export class CopilotClient { await this.handleUserInputRequest(params) ); + this.connection.onRequest( + "exitPlanMode.request", + async ( + params: ExitPlanModeRequest & { sessionId: string } + ): Promise => await this.handleExitPlanModeRequest(params) + ); + + this.connection.onRequest( + "autoModeSwitch.request", + async ( + params: AutoModeSwitchRequest & { sessionId: string } + ): Promise<{ response: AutoModeSwitchResponse }> => + await this.handleAutoModeSwitchRequest(params) + ); + this.connection.onRequest( "hooks.invoke", async (params: { @@ -1920,6 +1955,51 @@ export class CopilotClient { return result; } + private async handleExitPlanModeRequest( + params: ExitPlanModeRequest & { sessionId: string } + ): Promise { + if ( + !params || + typeof params.sessionId !== "string" || + typeof params.summary !== "string" || + !Array.isArray(params.actions) || + typeof params.recommendedAction !== "string" + ) { + throw new Error("Invalid exit plan mode request payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + return await session._handleExitPlanModeRequest({ + summary: params.summary, + planContent: params.planContent, + actions: params.actions, + recommendedAction: params.recommendedAction, + }); + } + + private async handleAutoModeSwitchRequest( + params: AutoModeSwitchRequest & { sessionId: string } + ): Promise<{ response: AutoModeSwitchResponse }> { + if (!params || typeof params.sessionId !== "string") { + throw new Error("Invalid auto mode switch request payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const response = await session._handleAutoModeSwitchRequest({ + errorCode: params.errorCode, + retryAfterSeconds: params.retryAfterSeconds, + }); + return { response }; + } + private async handleHooksInvoke(params: { sessionId: string; hookType: string; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index cc98cbcc8..0c6b25ecd 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -21,6 +21,9 @@ export type { CommandContext, CommandDefinition, CommandHandler, + AutoModeSwitchHandler, + AutoModeSwitchRequest, + AutoModeSwitchResponse, ConnectionState, CopilotClientOptions, CustomAgentConfig, @@ -31,6 +34,9 @@ export type { ElicitationResult, ElicitationSchema, ElicitationSchemaField, + ExitPlanModeHandler, + ExitPlanModeRequest, + ExitPlanModeResult, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index f2ea1de36..9da0e288e 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -14,10 +14,16 @@ import type { ClientSessionApiHandlers } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, + AutoModeSwitchHandler, + AutoModeSwitchRequest, + AutoModeSwitchResponse, ElicitationHandler, ElicitationParams, ElicitationResult, ElicitationContext, + ExitPlanModeHandler, + ExitPlanModeRequest, + ExitPlanModeResult, InputOptions, MessageOptions, PermissionHandler, @@ -84,6 +90,8 @@ export class CopilotSession { private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private elicitationHandler?: ElicitationHandler; + private exitPlanModeHandler?: ExitPlanModeHandler; + private autoModeSwitchHandler?: AutoModeSwitchHandler; private hooks?: SessionHooks; private transformCallbacks?: Map; private _rpc: ReturnType | null = null; @@ -626,6 +634,26 @@ export class CopilotSession { this.elicitationHandler = handler; } + /** + * Registers the exit-plan-mode handler for this session. + * + * @param handler - The handler to invoke when the server dispatches an exit-plan-mode request + * @internal This method is typically called internally when creating/resuming a session. + */ + registerExitPlanModeHandler(handler?: ExitPlanModeHandler): void { + this.exitPlanModeHandler = handler; + } + + /** + * Registers the auto-mode-switch handler for this session. + * + * @param handler - The handler to invoke when the server dispatches an auto-mode-switch request + * @internal This method is typically called internally when creating/resuming a session. + */ + registerAutoModeSwitchHandler(handler?: AutoModeSwitchHandler): void { + this.autoModeSwitchHandler = handler; + } + /** * Handles an elicitation.requested broadcast event. * Invokes the registered handler and responds via handlePendingElicitation RPC. @@ -654,6 +682,32 @@ export class CopilotSession { } } + /** + * Handles an exitPlanMode.request callback from the runtime. + * @internal + */ + async _handleExitPlanModeRequest(request: ExitPlanModeRequest): Promise { + if (!this.exitPlanModeHandler) { + return { approved: true }; + } + + return await this.exitPlanModeHandler(request, { sessionId: this.sessionId }); + } + + /** + * Handles an autoModeSwitch.request callback from the runtime. + * @internal + */ + async _handleAutoModeSwitchRequest( + request: AutoModeSwitchRequest + ): Promise { + if (!this.autoModeSwitchHandler) { + return "no"; + } + + return await this.autoModeSwitchHandler(request, { sessionId: this.sessionId }); + } + /** * Sets the host capabilities for this session. * @@ -972,6 +1026,10 @@ export class CopilotSession { this.typedEventHandlers.clear(); this.toolHandlers.clear(); this.permissionHandler = undefined; + this.userInputHandler = undefined; + this.elicitationHandler = undefined; + this.exitPlanModeHandler = undefined; + this.autoModeSwitchHandler = undefined; } /** diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a5a621c73..c881bfd2c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -846,6 +846,63 @@ export type UserInputHandler = ( invocation: { sessionId: string } ) => Promise | UserInputResponse; +/** + * Request to exit plan mode and continue with a selected action. + */ +export interface ExitPlanModeRequest { + /** Summary of the plan or proposed next step. */ + summary: string; + /** Full plan content, when available. */ + planContent?: string; + /** Available actions the user can select. */ + actions: string[]; + /** The action recommended by the runtime. */ + recommendedAction: string; +} + +/** + * Response to an exit-plan-mode request. + */ +export interface ExitPlanModeResult { + /** Whether the user approved exiting plan mode. */ + approved: boolean; + /** Selected action, if the user chose one. */ + selectedAction?: string; + /** Optional feedback provided by the user. */ + feedback?: string; +} + +/** + * Handler for exit-plan-mode requests from the agent. + */ +export type ExitPlanModeHandler = ( + request: ExitPlanModeRequest, + invocation: { sessionId: string } +) => Promise | ExitPlanModeResult; + +/** + * Request to switch to auto mode after an eligible rate limit. + */ +export interface AutoModeSwitchRequest { + /** The rate-limit error code that triggered the request. */ + errorCode?: string; + /** Seconds until the rate limit resets, when known. */ + retryAfterSeconds?: number; +} + +/** + * Response to an auto-mode-switch request. + */ +export type AutoModeSwitchResponse = "yes" | "yes_always" | "no"; + +/** + * Handler for auto-mode-switch requests from the agent. + */ +export type AutoModeSwitchHandler = ( + request: AutoModeSwitchRequest, + invocation: { sessionId: string } +) => Promise | AutoModeSwitchResponse; + // ============================================================================ // Hook Types // ============================================================================ @@ -1312,6 +1369,18 @@ export interface SessionConfig { */ onElicitationRequest?: ElicitationHandler; + /** + * Handler for exit-plan-mode requests from the agent. + * When provided, enables `exitPlanMode.request` callbacks. + */ + onExitPlanMode?: ExitPlanModeHandler; + + /** + * Handler for auto-mode-switch requests from the agent. + * When provided, enables `autoModeSwitch.request` callbacks. + */ + onAutoModeSwitch?: AutoModeSwitchHandler; + /** * Hook handlers for intercepting session lifecycle events. * When provided, enables hooks callback allowing custom logic at various points. @@ -1443,6 +1512,8 @@ export type ResumeSessionConfig = Pick< | "onPermissionRequest" | "onUserInputRequest" | "onElicitationRequest" + | "onExitPlanMode" + | "onAutoModeSwitch" | "hooks" | "workingDirectory" | "configDir" diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 7328ebc1e..f33046bbc 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -464,6 +464,36 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("forwards mode callback request flags in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + onExitPlanMode: () => ({ approved: true }), + onAutoModeSwitch: () => "yes", + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ + sessionId: session.sessionId, + requestExitPlanMode: true, + requestAutoModeSwitch: true, + }) + ); + spy.mockRestore(); + }); + it("sends session.model.switchTo RPC with correct params", async () => { const client = new CopilotClient(); await client.start(); @@ -1385,6 +1415,89 @@ describe("CopilotClient", () => { rpcSpy.mockRestore(); }); + it("sends mode callback request flags based on handler presence", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); + + await client.createSession({ + onPermissionRequest: approveAll, + onExitPlanMode: () => ({ approved: true }), + onAutoModeSwitch: () => "yes_always", + }); + + const createCallWithHandlers = rpcSpy.mock.calls.find((c) => c[0] === "session.create"); + expect(createCallWithHandlers![1]).toEqual( + expect.objectContaining({ + requestExitPlanMode: true, + requestAutoModeSwitch: true, + }) + ); + + rpcSpy.mockClear(); + await client.createSession({ onPermissionRequest: approveAll }); + const createCallWithoutHandlers = rpcSpy.mock.calls.find( + (c) => c[0] === "session.create" + ); + expect(createCallWithoutHandlers![1]).toEqual( + expect.objectContaining({ + requestExitPlanMode: false, + requestAutoModeSwitch: false, + }) + ); + rpcSpy.mockRestore(); + }); + + it("dispatches mode callback requests to registered handlers", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + onExitPlanMode: (request, invocation) => { + expect(invocation.sessionId).toBeDefined(); + expect(request.summary).toBe("Review the plan"); + expect(request.planContent).toBe("Plan body"); + expect(request.actions).toEqual(["interactive", "autopilot"]); + expect(request.recommendedAction).toBe("autopilot"); + return { + approved: true, + selectedAction: "interactive", + feedback: "Looks good", + }; + }, + onAutoModeSwitch: (request, invocation) => { + expect(invocation.sessionId).toBeDefined(); + expect(request.errorCode).toBe("user_weekly_rate_limited"); + expect(request.retryAfterSeconds).toBe(3600); + return "yes_always"; + }, + }); + + const exitResult = await (client as any).handleExitPlanModeRequest({ + sessionId: session.sessionId, + summary: "Review the plan", + planContent: "Plan body", + actions: ["interactive", "autopilot"], + recommendedAction: "autopilot", + }); + expect(exitResult).toEqual({ + approved: true, + selectedAction: "interactive", + feedback: "Looks good", + }); + + const autoResult = await (client as any).handleAutoModeSwitchRequest({ + sessionId: session.sessionId, + errorCode: "user_weekly_rate_limited", + retryAfterSeconds: 3600, + }); + expect(autoResult).toEqual({ response: "yes_always" }); + }); + it("sends cancel when elicitation handler throws", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/mode_handlers.e2e.test.ts b/nodejs/test/e2e/mode_handlers.e2e.test.ts new file mode 100644 index 000000000..702a2d649 --- /dev/null +++ b/nodejs/test/e2e/mode_handlers.e2e.test.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import type { + AutoModeSwitchRequest, + CopilotSession, + ExitPlanModeRequest, + ExitPlanModeResult, + SessionEvent, +} from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +const EVENT_TIMEOUT_MS = 30_000; +const MODE_HANDLER_TOKEN = "mode-handler-token"; +const PLAN_SUMMARY = "Greeting file implementation plan"; +const PLAN_PROMPT = + "Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode."; +const AUTO_MODE_PROMPT = + "Explain that auto mode recovered from a rate limit in one short sentence."; + +function waitForEvent( + session: CopilotSession, + predicate: (event: SessionEvent) => event is T, + description: string, + timeoutMs = EVENT_TIMEOUT_MS, + allowRateLimitError = false +): Promise { + return new Promise((resolve, reject) => { + let unsubscribe: () => void = () => {}; + const timer = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeoutMs); + + unsubscribe = session.on((event) => { + if (predicate(event)) { + clearTimeout(timer); + unsubscribe(); + resolve(event); + } else if ( + event.type === "session.error" && + !(allowRateLimitError && event.data.errorType === "rate_limit") + ) { + clearTimeout(timer); + unsubscribe(); + reject(new Error(`${event.data.message}\n${event.data.stack ?? ""}`)); + } + }); + }); +} + +describe("Mode handlers", async () => { + const { copilotClient: client, openAiEndpoint, env } = await createSdkTestContext(); + + env.COPILOT_DEBUG_GITHUB_API_URL = env.COPILOT_API_URL; + await openAiEndpoint.setCopilotUserByToken(MODE_HANDLER_TOKEN, { + login: "mode-handler-user", + copilot_plan: "individual_pro", + endpoints: { + api: env.COPILOT_API_URL, + telemetry: "https://localhost:1/telemetry", + }, + analytics_tracking_id: "mode-handler-tracking-id", + }); + + it("should invoke exit plan mode handler when model uses tool", async () => { + const exitPlanModeRequests: ExitPlanModeRequest[] = []; + let session: CopilotSession | undefined; + + session = await client.createSession({ + gitHubToken: MODE_HANDLER_TOKEN, + onPermissionRequest: approveAll, + onExitPlanMode: async (request, invocation): Promise => { + exitPlanModeRequests.push(request); + expect(invocation.sessionId).toBe(session?.sessionId); + + return { + approved: true, + selectedAction: "interactive", + feedback: "Approved by the TypeScript E2E test", + }; + }, + }); + + try { + const requestedEvent = waitForEvent( + session, + (event): event is Extract => + event.type === "exit_plan_mode.requested" && + event.data.summary === PLAN_SUMMARY, + "exit_plan_mode.requested event" + ); + const completedEvent = waitForEvent( + session, + (event): event is Extract => + event.type === "exit_plan_mode.completed" && + event.data.approved === true && + event.data.selectedAction === "interactive", + "exit_plan_mode.completed event" + ); + + const response = await session.sendAndWait({ + prompt: PLAN_PROMPT, + mode: "plan" as unknown as NonNullable[0]["mode"]>, + }); + + expect(exitPlanModeRequests).toHaveLength(1); + expect(exitPlanModeRequests[0]).toMatchObject({ + summary: PLAN_SUMMARY, + actions: ["interactive", "autopilot", "exit_only"], + recommendedAction: "interactive", + }); + expect(exitPlanModeRequests[0].planContent).toBeDefined(); + + expect((await requestedEvent).data.summary).toBe(PLAN_SUMMARY); + const completed = await completedEvent; + expect(completed.data.approved).toBe(true); + expect(completed.data.selectedAction).toBe("interactive"); + expect(completed.data.feedback).toBe("Approved by the TypeScript E2E test"); + expect(response).toBeDefined(); + } finally { + await session.disconnect(); + } + }); + + it("should invoke auto mode switch handler when rate limited", async () => { + const autoModeSwitchRequests: AutoModeSwitchRequest[] = []; + let session: CopilotSession | undefined; + + session = await client.createSession({ + gitHubToken: MODE_HANDLER_TOKEN, + onPermissionRequest: approveAll, + onAutoModeSwitch: (request, invocation) => { + autoModeSwitchRequests.push(request); + expect(invocation.sessionId).toBe(session?.sessionId); + return "yes"; + }, + }); + + try { + const requestedEvent = waitForEvent( + session, + (event): event is Extract => + event.type === "auto_mode_switch.requested" && + event.data.errorCode === "user_weekly_rate_limited" && + event.data.retryAfterSeconds === 1, + "auto_mode_switch.requested event", + EVENT_TIMEOUT_MS, + true + ); + const completedEvent = waitForEvent( + session, + (event): event is Extract => + event.type === "auto_mode_switch.completed" && event.data.response === "yes", + "auto_mode_switch.completed event", + EVENT_TIMEOUT_MS, + true + ); + const modelChangeEvent = waitForEvent( + session, + (event): event is Extract => + event.type === "session.model_change" && + event.data.cause === "rate_limit_auto_switch", + "rate-limit auto-mode model change", + EVENT_TIMEOUT_MS, + true + ); + const idleEvent = waitForEvent( + session, + (event): event is Extract => + event.type === "session.idle", + "session.idle after auto-mode switch", + EVENT_TIMEOUT_MS, + true + ); + + const messageId = await session.send({ prompt: AUTO_MODE_PROMPT }); + expect(messageId).toBeTruthy(); + + expect((await requestedEvent).data.errorCode).toBe("user_weekly_rate_limited"); + const completed = await completedEvent; + expect(completed.data.response).toBe("yes"); + expect((await modelChangeEvent).data.cause).toBe("rate_limit_auto_switch"); + await idleEvent; + + expect(autoModeSwitchRequests).toHaveLength(1); + expect(autoModeSwitchRequests[0]).toMatchObject({ + errorCode: "user_weekly_rate_limited", + retryAfterSeconds: 1, + }); + } finally { + await session.disconnect(); + } + }); +}); diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index 50a76bdf1..ca9d2d9d4 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -491,10 +491,14 @@ describe("Sessions", async () => { expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); - // Session should work normally with custom config dir - await session.send({ prompt: "What is 1+1?" }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage.data.content).toContain("2"); + try { + // Session should work normally with custom config dir + await session.send({ prompt: "What is 1+1?" }); + const assistantMessage = await getFinalAssistantMessage(session); + expect(assistantMessage.data.content).toContain("2"); + } finally { + await session.disconnect(); + } }); it("should log messages at all levels and emit matching session events", async () => { diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index ad9e28803..1963a2d41 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -14,6 +14,9 @@ SubprocessConfig, ) from .session import ( + AutoModeSwitchHandler, + AutoModeSwitchRequest, + AutoModeSwitchResponse, CommandContext, CommandDefinition, CopilotSession, @@ -22,6 +25,9 @@ ElicitationHandler, ElicitationParams, ElicitationResult, + ExitPlanModeHandler, + ExitPlanModeRequest, + ExitPlanModeResult, InputOptions, ProviderConfig, SessionCapabilities, @@ -40,6 +46,9 @@ __all__ = [ "CommandContext", + "AutoModeSwitchHandler", + "AutoModeSwitchRequest", + "AutoModeSwitchResponse", "CommandDefinition", "CopilotClient", "CopilotSession", @@ -48,6 +57,9 @@ "ElicitationParams", "ElicitationContext", "ElicitationResult", + "ExitPlanModeHandler", + "ExitPlanModeRequest", + "ExitPlanModeResult", "ExternalServerConfig", "InputOptions", "ModelCapabilitiesOverride", diff --git a/python/copilot/client.py b/python/copilot/client.py index f0098b58d..4de7289bd 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -48,12 +48,14 @@ session_event_from_dict, ) from .session import ( + AutoModeSwitchHandler, CommandDefinition, CopilotSession, CreateSessionFsHandler, CustomAgentConfig, DefaultAgentConfig, ElicitationHandler, + ExitPlanModeHandler, InfiniteSessionConfig, MCPServerConfig, ProviderConfig, @@ -1319,6 +1321,8 @@ async def create_session( on_event: Callable[[SessionEvent], None] | None = None, commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, + on_exit_plan_mode: ExitPlanModeHandler | None = None, + on_auto_mode_switch: AutoModeSwitchHandler | None = None, create_session_fs_handler: CreateSessionFsHandler | None = None, github_token: str | None = None, ) -> CopilotSession: @@ -1457,6 +1461,8 @@ async def create_session( # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) + payload["requestExitPlanMode"] = bool(on_exit_plan_mode) + payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch) # Serialize commands (name + description only) into payload if commands: @@ -1583,6 +1589,10 @@ async def create_session( session._register_user_input_handler(on_user_input_request) if on_elicitation_request: session._register_elicitation_handler(on_elicitation_request) + if on_exit_plan_mode: + session._register_exit_plan_mode_handler(on_exit_plan_mode) + if on_auto_mode_switch: + session._register_auto_mode_switch_handler(on_auto_mode_switch) if hooks: session._register_hooks(hooks) if transform_callbacks: @@ -1671,6 +1681,8 @@ async def resume_session( on_event: Callable[[SessionEvent], None] | None = None, commands: list[CommandDefinition] | None = None, on_elicitation_request: ElicitationHandler | None = None, + on_exit_plan_mode: ExitPlanModeHandler | None = None, + on_auto_mode_switch: AutoModeSwitchHandler | None = None, create_session_fs_handler: CreateSessionFsHandler | None = None, github_token: str | None = None, continue_pending_work: bool | None = None, @@ -1828,6 +1840,8 @@ async def resume_session( # Enable elicitation request callback if handler provided payload["requestElicitation"] = bool(on_elicitation_request) + payload["requestExitPlanMode"] = bool(on_exit_plan_mode) + payload["requestAutoModeSwitch"] = bool(on_auto_mode_switch) # Serialize commands (name + description only) into payload if commands: @@ -1917,6 +1931,10 @@ async def resume_session( session._register_user_input_handler(on_user_input_request) if on_elicitation_request: session._register_elicitation_handler(on_elicitation_request) + if on_exit_plan_mode: + session._register_exit_plan_mode_handler(on_exit_plan_mode) + if on_auto_mode_switch: + session._register_auto_mode_switch_handler(on_auto_mode_switch) if hooks: session._register_hooks(hooks) if transform_callbacks: @@ -2731,6 +2749,12 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler("tool.call", self._handle_tool_call_request_v2) self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) + self._client.set_request_handler( + "exitPlanMode.request", self._handle_exit_plan_mode_request + ) + self._client.set_request_handler( + "autoModeSwitch.request", self._handle_auto_mode_switch_request + ) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform @@ -2849,6 +2873,12 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler("tool.call", self._handle_tool_call_request_v2) self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) + self._client.set_request_handler( + "exitPlanMode.request", self._handle_exit_plan_mode_request + ) + self._client.set_request_handler( + "autoModeSwitch.request", self._handle_auto_mode_switch_request + ) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform @@ -2906,6 +2936,39 @@ async def _handle_user_input_request(self, params: dict) -> dict: result = await session._handle_user_input_request(params) return {"answer": result["answer"], "wasFreeform": result["wasFreeform"]} + async def _handle_exit_plan_mode_request(self, params: dict) -> dict: + """Handle an exitPlanMode.request callback from the CLI server.""" + session_id = params.get("sessionId") + summary = params.get("summary") + actions = params.get("actions") + recommended_action = params.get("recommendedAction") + + if not session_id or not isinstance(summary, str): + raise ValueError("invalid exit plan mode request payload") + if not isinstance(actions, list) or not isinstance(recommended_action, str): + raise ValueError("invalid exit plan mode request payload") + + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") + + return dict(await session._handle_exit_plan_mode_request(params)) + + async def _handle_auto_mode_switch_request(self, params: dict) -> dict: + """Handle an autoModeSwitch.request callback from the CLI server.""" + session_id = params.get("sessionId") + if not session_id: + raise ValueError("invalid auto mode switch request payload") + + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") + + response = await session._handle_auto_mode_switch_request(params) + return {"response": response} + async def _handle_hooks_invoke(self, params: dict) -> dict: """ Handle a hooks invocation from the CLI server. diff --git a/python/copilot/session.py b/python/copilot/session.py index 86c5b8443..b682d22d7 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -280,6 +280,45 @@ class UserInputResponse(TypedDict): UserInputResponse | Awaitable[UserInputResponse], ] + +class ExitPlanModeRequest(TypedDict, total=False): + """Request to exit plan mode and continue with a selected action.""" + + summary: Required[str] + planContent: NotRequired[str] + actions: Required[list[str]] + recommendedAction: Required[str] + + +class ExitPlanModeResult(TypedDict, total=False): + """Response to an exit-plan-mode request.""" + + approved: Required[bool] + selectedAction: NotRequired[str] + feedback: NotRequired[str] + + +ExitPlanModeHandler = Callable[ + [ExitPlanModeRequest, dict[str, str]], + ExitPlanModeResult | Awaitable[ExitPlanModeResult], +] + + +class AutoModeSwitchRequest(TypedDict, total=False): + """Request to switch to auto mode after an eligible rate limit.""" + + errorCode: NotRequired[str] + retryAfterSeconds: NotRequired[float] + + +AutoModeSwitchResponse = Literal["yes", "yes_always", "no"] + + +AutoModeSwitchHandler = Callable[ + [AutoModeSwitchRequest, dict[str, str]], + AutoModeSwitchResponse | Awaitable[AutoModeSwitchResponse], +] + # ============================================================================ # Command Types # ============================================================================ @@ -940,6 +979,10 @@ class SessionConfig(TypedDict, total=False): # Handler for elicitation requests from the server. # When provided, the server calls back to this client for form-based UI dialogs. on_elicitation_request: ElicitationHandler + # Handler for exit-plan-mode requests from the server. + on_exit_plan_mode: ExitPlanModeHandler + # Handler for auto-mode-switch requests from the server. + on_auto_mode_switch: AutoModeSwitchHandler # Handler factory for session-scoped sessionFs operations. create_session_fs_handler: CreateSessionFsHandler @@ -1024,6 +1067,10 @@ class ResumeSessionConfig(TypedDict, total=False): commands: list[CommandDefinition] # Handler for elicitation requests from the server. on_elicitation_request: ElicitationHandler + # Handler for exit-plan-mode requests from the server. + on_exit_plan_mode: ExitPlanModeHandler + # Handler for auto-mode-switch requests from the server. + on_auto_mode_switch: AutoModeSwitchHandler # Handler factory for session-scoped sessionFs operations. create_session_fs_handler: CreateSessionFsHandler @@ -1086,6 +1133,10 @@ def __init__( self._permission_handler_lock = threading.Lock() self._user_input_handler: UserInputHandler | None = None self._user_input_handler_lock = threading.Lock() + self._exit_plan_mode_handler: ExitPlanModeHandler | None = None + self._exit_plan_mode_handler_lock = threading.Lock() + self._auto_mode_switch_handler: AutoModeSwitchHandler | None = None + self._auto_mode_switch_handler_lock = threading.Lock() self._hooks: SessionHooks | None = None self._hooks_lock = threading.Lock() self._transform_callbacks: dict[str, SectionTransformFn] | None = None @@ -1808,6 +1859,16 @@ def _register_elicitation_handler(self, handler: ElicitationHandler | None) -> N with self._elicitation_handler_lock: self._elicitation_handler = handler + def _register_exit_plan_mode_handler(self, handler: ExitPlanModeHandler | None) -> None: + """Register the exit-plan-mode handler for this session.""" + with self._exit_plan_mode_handler_lock: + self._exit_plan_mode_handler = handler + + def _register_auto_mode_switch_handler(self, handler: AutoModeSwitchHandler | None) -> None: + """Register the auto-mode-switch handler for this session.""" + with self._auto_mode_switch_handler_lock: + self._auto_mode_switch_handler = handler + def _set_capabilities(self, capabilities: SessionCapabilities | None) -> None: """Set the host capabilities for this session. @@ -1977,6 +2038,62 @@ async def _handle_user_input_request(self, request: dict) -> UserInputResponse: except Exception: raise + async def _handle_exit_plan_mode_request(self, request: dict) -> ExitPlanModeResult: + """Handle an exitPlanMode.request callback from the runtime.""" + with self._exit_plan_mode_handler_lock: + handler = self._exit_plan_mode_handler + + if not handler: + return {"approved": True} + + handler_start = time.perf_counter() + typed_request = ExitPlanModeRequest( + summary=request.get("summary", ""), + actions=request.get("actions") or [], + recommendedAction=request.get("recommendedAction", "autopilot"), + ) + if request.get("planContent") is not None: + typed_request["planContent"] = request["planContent"] + + result = handler(typed_request, {"session_id": self.session_id}) + if inspect.isawaitable(result): + result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_exit_plan_mode_request dispatch", + handler_start, + session_id=self.session_id, + ) + return cast(ExitPlanModeResult, result) + + async def _handle_auto_mode_switch_request(self, request: dict) -> AutoModeSwitchResponse: + """Handle an autoModeSwitch.request callback from the runtime.""" + with self._auto_mode_switch_handler_lock: + handler = self._auto_mode_switch_handler + + if not handler: + return "no" + + handler_start = time.perf_counter() + typed_request = AutoModeSwitchRequest() + if request.get("errorCode") is not None: + typed_request["errorCode"] = request["errorCode"] + if request.get("retryAfterSeconds") is not None: + typed_request["retryAfterSeconds"] = request["retryAfterSeconds"] + + result = handler(typed_request, {"session_id": self.session_id}) + if inspect.isawaitable(result): + result = await result + log_timing( + logger, + logging.DEBUG, + "CopilotSession._handle_auto_mode_switch_request dispatch", + handler_start, + session_id=self.session_id, + ) + return result + def _register_transform_callbacks( self, callbacks: dict[str, SectionTransformFn] | None ) -> None: @@ -2158,6 +2275,10 @@ async def disconnect(self) -> None: self._command_handlers.clear() with self._elicitation_handler_lock: self._elicitation_handler = None + with self._exit_plan_mode_handler_lock: + self._exit_plan_mode_handler = None + with self._auto_mode_switch_handler_lock: + self._auto_mode_switch_handler = None async def destroy(self) -> None: """ diff --git a/python/e2e/test_mode_handlers_e2e.py b/python/e2e/test_mode_handlers_e2e.py new file mode 100644 index 000000000..981a9ca8b --- /dev/null +++ b/python/e2e/test_mode_handlers_e2e.py @@ -0,0 +1,207 @@ +"""E2E tests for mode handlers.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from copilot.generated.session_events import ( + AutoModeSwitchCompletedData, + AutoModeSwitchRequestedData, + ExitPlanModeCompletedData, + ExitPlanModeRequestedData, + SessionIdleData, + SessionModelChangeData, +) +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + +MODE_HANDLER_TOKEN = "mode-handler-token" +PLAN_SUMMARY = "Greeting file implementation plan" +PLAN_PROMPT = ( + "Create a brief implementation plan for adding a greeting.txt file, then request " + "approval with exit_plan_mode." +) +AUTO_MODE_PROMPT = "Explain that auto mode recovered from a rate limit in one short sentence." + + +@pytest.fixture(scope="module") +async def mode_ctx(ctx: E2ETestContext): + """Configure per-token user responses for mode-handler tests.""" + proxy_url = ctx.proxy_url + ctx.client._config.env["COPILOT_DEBUG_GITHUB_API_URL"] = proxy_url + + await ctx.set_copilot_user_by_token( + MODE_HANDLER_TOKEN, + { + "login": "mode-handler-user", + "copilot_plan": "individual_pro", + "endpoints": { + "api": proxy_url, + "telemetry": "https://localhost:1/telemetry", + }, + "analytics_tracking_id": "mode-handler-tracking-id", + }, + ) + + return ctx + + +async def _wait_for_event(session, predicate, timeout: float = 30.0): + """Wait for the first session event matching predicate.""" + loop = asyncio.get_event_loop() + fut: asyncio.Future = loop.create_future() + + def on_event(event): + if not fut.done() and predicate(event): + fut.set_result(event) + + unsubscribe = session.on(on_event) + try: + return await asyncio.wait_for(fut, timeout=timeout) + finally: + unsubscribe() + + +class TestModeHandlers: + async def test_should_invoke_exit_plan_mode_handler_when_model_uses_tool( + self, mode_ctx: E2ETestContext + ): + exit_plan_mode_requests = [] + + async def on_exit_plan_mode(request, invocation): + exit_plan_mode_requests.append(request) + assert invocation["session_id"] == session.session_id + return { + "approved": True, + "selectedAction": "interactive", + "feedback": "Approved by the Python E2E test", + } + + session = await mode_ctx.client.create_session( + github_token=MODE_HANDLER_TOKEN, + on_permission_request=PermissionHandler.approve_all, + on_exit_plan_mode=on_exit_plan_mode, + ) + + try: + requested_event = asyncio.create_task( + _wait_for_event( + session, + lambda event: ( + isinstance(event.data, ExitPlanModeRequestedData) + and event.data.summary == PLAN_SUMMARY + ), + ) + ) + completed_event = asyncio.create_task( + _wait_for_event( + session, + lambda event: ( + isinstance(event.data, ExitPlanModeCompletedData) + and event.data.approved is True + and event.data.selected_action == "interactive" + ), + ) + ) + + response = await session.send_and_wait( + PLAN_PROMPT, + mode="plan", # type: ignore[arg-type] + ) + + assert len(exit_plan_mode_requests) == 1 + request = exit_plan_mode_requests[0] + assert request["summary"] == PLAN_SUMMARY + assert request["actions"] == ["interactive", "autopilot", "exit_only"] + assert request["recommendedAction"] == "interactive" + assert request.get("planContent") is not None + + requested = await requested_event + assert requested.data.summary == PLAN_SUMMARY + + completed = await completed_event + assert completed.data.approved is True + assert completed.data.selected_action == "interactive" + assert completed.data.feedback == "Approved by the Python E2E test" + assert response is not None + finally: + await session.disconnect() + + async def test_should_invoke_auto_mode_switch_handler_when_rate_limited( + self, mode_ctx: E2ETestContext + ): + auto_mode_switch_requests = [] + + async def on_auto_mode_switch(request, invocation): + auto_mode_switch_requests.append(request) + assert invocation["session_id"] == session.session_id + return "yes" + + session = await mode_ctx.client.create_session( + github_token=MODE_HANDLER_TOKEN, + on_permission_request=PermissionHandler.approve_all, + on_auto_mode_switch=on_auto_mode_switch, + ) + + try: + requested_event = asyncio.create_task( + _wait_for_event( + session, + lambda event: ( + isinstance(event.data, AutoModeSwitchRequestedData) + and event.data.error_code == "user_weekly_rate_limited" + and event.data.retry_after_seconds == 1 + ), + ) + ) + completed_event = asyncio.create_task( + _wait_for_event( + session, + lambda event: ( + isinstance(event.data, AutoModeSwitchCompletedData) + and event.data.response == "yes" + ), + ) + ) + model_change_event = asyncio.create_task( + _wait_for_event( + session, + lambda event: ( + isinstance(event.data, SessionModelChangeData) + and event.data.cause == "rate_limit_auto_switch" + ), + ) + ) + idle_event = asyncio.create_task( + _wait_for_event( + session, + lambda event: isinstance(event.data, SessionIdleData), + ) + ) + + message_id = await session.send(AUTO_MODE_PROMPT) + assert message_id + + requested = await requested_event + assert requested.data.error_code == "user_weekly_rate_limited" + assert requested.data.retry_after_seconds == 1 + + completed = await completed_event + assert completed.data.response == "yes" + + model_change = await model_change_event + assert model_change.data.cause == "rate_limit_auto_switch" + idle = await idle_event + assert isinstance(idle.data, SessionIdleData) + + assert len(auto_mode_switch_requests) == 1 + request = auto_mode_switch_requests[0] + assert request["errorCode"] == "user_weekly_rate_limited" + assert request["retryAfterSeconds"] == 1 + finally: + await session.disconnect() diff --git a/python/test_commands_and_elicitation.py b/python/test_commands_and_elicitation.py index 41b4e8fe2..470e2f8f3 100644 --- a/python/test_commands_and_elicitation.py +++ b/python/test_commands_and_elicitation.py @@ -13,10 +13,14 @@ from copilot import CopilotClient from copilot.client import SubprocessConfig from copilot.session import ( + AutoModeSwitchRequest, + AutoModeSwitchResponse, CommandContext, CommandDefinition, ElicitationContext, ElicitationResult, + ExitPlanModeRequest, + ExitPlanModeResult, PermissionHandler, ) from e2e.testharness import CLI_PATH @@ -457,6 +461,142 @@ async def mock_request(method, params): payload = captured["session.create"] assert payload["requestElicitation"] is False + assert payload["requestExitPlanMode"] is False + assert payload["requestAutoModeSwitch"] is False + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_sends_mode_callback_flags_when_handlers_provided(self): + """Verifies mode callback flags are sent when handlers are provided.""" + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + captured: dict = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + + def exit_handler( + request: ExitPlanModeRequest, invocation: dict[str, str] + ) -> ExitPlanModeResult: + return {"approved": True} + + def auto_handler( + request: AutoModeSwitchRequest, invocation: dict[str, str] + ) -> AutoModeSwitchResponse: + return "yes_always" + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_exit_plan_mode=exit_handler, + on_auto_mode_switch=auto_handler, + ) + assert session is not None + + payload = captured["session.create"] + assert payload["requestExitPlanMode"] is True + assert payload["requestAutoModeSwitch"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_sends_mode_callback_flags_on_resume_when_handlers_provided(self): + """Verifies mode callback flags are sent on session.resume.""" + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + captured: dict = {} + + async def mock_request(method, params): + captured[method] = params + if method == "session.resume": + return {"sessionId": params["sessionId"]} + raise RuntimeError(f"Unexpected method: {method}") + + client._client.request = mock_request + + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + on_exit_plan_mode=lambda request, invocation: {"approved": True}, + on_auto_mode_switch=lambda request, invocation: "yes", + ) + + payload = captured["session.resume"] + assert payload["requestExitPlanMode"] is True + assert payload["requestAutoModeSwitch"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_dispatches_mode_callback_requests_to_registered_handlers(self): + """Verifies direct mode requests are dispatched to registered handlers.""" + client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH)) + await client.start() + + try: + + async def exit_handler( + request: ExitPlanModeRequest, invocation: dict[str, str] + ) -> ExitPlanModeResult: + assert invocation["session_id"] == session.session_id + assert request["summary"] == "Review the plan" + assert request["planContent"] == "Plan body" + assert request["actions"] == ["interactive", "autopilot"] + assert request["recommendedAction"] == "autopilot" + return { + "approved": True, + "selectedAction": "interactive", + "feedback": "Looks good", + } + + async def auto_handler( + request: AutoModeSwitchRequest, invocation: dict[str, str] + ) -> AutoModeSwitchResponse: + assert invocation["session_id"] == session.session_id + assert request["errorCode"] == "user_weekly_rate_limited" + assert request["retryAfterSeconds"] == 3600 + return "yes_always" + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_exit_plan_mode=exit_handler, + on_auto_mode_switch=auto_handler, + ) + + exit_result = await client._handle_exit_plan_mode_request( + { + "sessionId": session.session_id, + "summary": "Review the plan", + "planContent": "Plan body", + "actions": ["interactive", "autopilot"], + "recommendedAction": "autopilot", + } + ) + assert exit_result == { + "approved": True, + "selectedAction": "interactive", + "feedback": "Looks good", + } + + auto_result = await client._handle_auto_mode_switch_request( + { + "sessionId": session.session_id, + "errorCode": "user_weekly_rate_limited", + "retryAfterSeconds": 3600, + } + ) + assert auto_result == {"response": "yes_always"} finally: await client.force_stop() diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index e0dcadea7..37dff98ab 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -132,11 +132,10 @@ public surface. auto-mode-switch recovery path. Lets UI render contextual copy. - `AutoModeSwitchRequestedData.retry_after_seconds: Option` — seconds until the rate limit resets, when known. Clients can - render a humanized reset time alongside the prompt. (The request- + render a humanized reset time alongside the prompt. The request- callback path's `retry_after_seconds` parameter on [`SessionHandler::on_auto_mode_switch`](crate::handler::SessionHandler::on_auto_mode_switch) - uses `Option` for HTTP `Retry-After` `delta-seconds` - semantics.) + uses the same `Option` representation. #### Types - Newtype `SessionId`, plus generated RPC types under `github_copilot_sdk::generated`. diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 69a488563..4520dd5e3 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -5,10 +5,11 @@ //! CLI events, permission requests, tool calls, and user input prompts. use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use crate::types::{ - ElicitationRequest, ElicitationResult, PermissionRequestData, RequestId, SessionEvent, - SessionId, ToolInvocation, ToolResult, + ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId, + SessionEvent, SessionId, ToolInvocation, ToolResult, }; /// Events dispatched by the SDK session event loop to the handler. @@ -68,6 +69,26 @@ pub enum HandlerEvent { /// The elicitation request payload. request: ElicitationRequest, }, + + /// The CLI requests exiting plan mode. Return `HandlerResponse::ExitPlanMode(..)`. + ExitPlanMode { + /// The requesting session. + session_id: SessionId, + /// Plan mode exit payload. + data: ExitPlanModeData, + }, + + /// The CLI asks whether to switch to auto model when an eligible rate + /// limit is hit. Return [`HandlerResponse::AutoModeSwitch`]. + AutoModeSwitch { + /// The requesting session. + session_id: SessionId, + /// The specific rate-limit error code that triggered the request, + /// if known (e.g. `user_weekly_rate_limited`, `user_global_rate_limited`). + error_code: Option, + /// Seconds until the rate limit resets, when known. + retry_after_seconds: Option, + }, } /// Response from the handler back to the SDK, used to construct the @@ -85,6 +106,10 @@ pub enum HandlerResponse { ToolResult(ToolResult), /// Elicitation result (accept/decline/cancel with optional form data). Elicitation(ElicitationResult), + /// Exit plan mode decision. + ExitPlanMode(ExitPlanModeResult), + /// Auto-mode-switch decision. + AutoModeSwitch(AutoModeSwitchResponse), } /// Result of a permission request. @@ -137,11 +162,53 @@ pub struct UserInputResponse { pub was_freeform: bool, } +/// Result of an exit-plan-mode request. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExitPlanModeResult { + /// Whether the user approved exiting plan mode. + pub approved: bool, + /// The action the user selected (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_action: Option, + /// Optional feedback text from the user. + #[serde(skip_serializing_if = "Option::is_none")] + pub feedback: Option, +} + +impl Default for ExitPlanModeResult { + fn default() -> Self { + Self { + approved: true, + selected_action: None, + feedback: None, + } + } +} + +/// Response to a [`HandlerEvent::AutoModeSwitch`] request. +/// +/// Wire serialization matches the CLI's `autoModeSwitch.request` response +/// schema: `"yes"`, `"yes_always"`, or `"no"`. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AutoModeSwitchResponse { + /// Approve the auto-mode switch for this rate-limit cycle only. + Yes, + /// Approve and remember — auto-accept future auto-mode switches in this + /// session without prompting. + YesAlways, + /// Decline the auto-mode switch. The session stays on the current model + /// and surfaces the rate-limit error. + No, +} + /// Callback trait for session events. /// /// Implement this trait to control how a session responds to CLI events, -/// permission requests, tool calls, user input prompts, and elicitations. -/// There are two styles of implementation — pick whichever +/// permission requests, tool calls, user input prompts, elicitations, and +/// plan-mode exits. There are two styles of implementation — pick whichever /// fits your use case: /// /// 1. **Per-event methods (recommended for most handlers).** Override the @@ -166,15 +233,18 @@ pub struct UserInputResponse { /// - User input → `None` (no answer available). /// - External tool calls → failure result with "no handler registered". /// - Elicitation → `"cancel"`. +/// - Exit plan mode → [`ExitPlanModeResult::default`]. +/// - Auto-mode-switch → [`AutoModeSwitchResponse::No`] (decline by default; the +/// session stays on its current model and surfaces the rate-limit error). /// - Session events → ignored (fire-and-forget). /// /// # Concurrency /// /// **Request-triggered events** (`UserInput`, `ExternalTool` via `tool.call`, -/// `PermissionRequest` via `permission.request`) are awaited inline in the -/// event loop and therefore processed **serially** per session. Blocking here -/// pauses that session's event loop — which is correct, since the CLI is also -/// blocked waiting for the response. +/// `ExitPlanMode`, `PermissionRequest` via `permission.request`) are awaited +/// inline in the event loop and therefore processed **serially** per session. +/// Blocking here pauses that session's event loop — which is correct, since +/// the CLI is also blocked waiting for the response. /// /// **Notification-triggered events** (`PermissionRequest` via /// `permission.requested`, `ExternalTool` via `external_tool.requested`) are @@ -251,6 +321,17 @@ pub trait SessionHandler: Send + Sync + 'static { } => HandlerResponse::Elicitation( self.on_elicitation(session_id, request_id, request).await, ), + HandlerEvent::ExitPlanMode { session_id, data } => { + HandlerResponse::ExitPlanMode(self.on_exit_plan_mode(session_id, data).await) + } + HandlerEvent::AutoModeSwitch { + session_id, + error_code, + retry_after_seconds, + } => HandlerResponse::AutoModeSwitch( + self.on_auto_mode_switch(session_id, error_code, retry_after_seconds) + .await, + ), } } @@ -325,6 +406,35 @@ pub trait SessionHandler: Send + Sync + 'static { content: None, } } + + /// The CLI is asking the user whether to exit plan mode. + /// + /// Default: [`ExitPlanModeResult::default`] (approved with no action). + async fn on_exit_plan_mode( + &self, + _session_id: SessionId, + _data: ExitPlanModeData, + ) -> ExitPlanModeResult { + ExitPlanModeResult::default() + } + + /// The CLI is asking whether to switch to auto model after an eligible + /// rate limit. + /// + /// `retry_after_seconds`, when present, is the number of seconds until the + /// rate limit resets. Handlers can use it to render a humanized reset time + /// alongside the prompt. + /// + /// Default: [`AutoModeSwitchResponse::No`] — decline. Override only if + /// your application surfaces a UX for the rate-limit-recovery prompt. + async fn on_auto_mode_switch( + &self, + _session_id: SessionId, + _error_code: Option, + _retry_after_seconds: Option, + ) -> AutoModeSwitchResponse { + AutoModeSwitchResponse::No + } } /// A [`SessionHandler`] that auto-approves all permissions and ignores all events. @@ -348,8 +458,8 @@ impl SessionHandler for ApproveAllHandler { /// A [`SessionHandler`] that denies all permission requests and otherwise /// relies on the trait's default fallback responses for every other event -/// (e.g. tool invocations return "unhandled", elicitations cancel). This is the -/// safe default used when no handler is set on +/// (e.g. tool invocations return "unhandled", elicitations cancel, plan-mode +/// prompts decline). This is the safe default used when no handler is set on /// [`SessionConfig::handler`](crate::types::SessionConfig::handler) — sessions /// will not stall on permission prompts (they're denied immediately) but no /// privileged actions will be taken without an explicit opt-in. diff --git a/rust/src/session.rs b/rust/src/session.rs index 8800ffb98..b0d5faef4 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -19,7 +19,8 @@ use crate::generated::session_events::{ SessionEventType, }; use crate::handler::{ - HandlerEvent, HandlerResponse, PermissionResult, SessionHandler, UserInputResponse, + AutoModeSwitchResponse, HandlerEvent, HandlerResponse, PermissionResult, SessionHandler, + UserInputResponse, }; use crate::hooks::SessionHooks; use crate::session_fs::SessionFsProvider; @@ -27,10 +28,10 @@ use crate::trace_context::inject_trace_context; use crate::transforms::SystemMessageTransform; use crate::types::{ CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest, - ElicitationResult, GetMessagesResponse, InputOptions, MessageOptions, PermissionRequestData, - RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, SessionConfig, - SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, ToolResult, - ToolResultResponse, TraceContext, ensure_attachment_display_names, + ElicitationResult, ExitPlanModeData, GetMessagesResponse, InputOptions, MessageOptions, + PermissionRequestData, RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, + SessionConfig, SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, + ToolResult, ToolResultResponse, TraceContext, ensure_attachment_display_names, }; use crate::{Client, Error, JsonRpcResponse, SessionError, SessionEventNotification, error_codes}; @@ -1732,6 +1733,75 @@ async fn handle_request( let _ = client.send_response(&rpc_response).await; } + "exitPlanMode.request" => { + let params = request + .params + .as_ref() + .cloned() + .unwrap_or(Value::Object(serde_json::Map::new())); + let data: ExitPlanModeData = match serde_json::from_value(params) { + Ok(d) => d, + Err(e) => { + warn!(error = %e, "failed to deserialize exitPlanMode.request params, using defaults"); + ExitPlanModeData::default() + } + }; + + let response = handler + .on_event(HandlerEvent::ExitPlanMode { + session_id: sid, + data, + }) + .await; + + let rpc_result = match response { + HandlerResponse::ExitPlanMode(result) => serde_json::to_value(result) + .expect("ExitPlanModeResult serialization cannot fail"), + _ => serde_json::json!({ "approved": true }), + }; + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(rpc_result), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + } + + "autoModeSwitch.request" => { + let error_code = request + .params + .as_ref() + .and_then(|p| p.get("errorCode")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let retry_after_seconds = request + .params + .as_ref() + .and_then(|p| p.get("retryAfterSeconds")) + .and_then(|v| v.as_f64()); + + let response = handler + .on_event(HandlerEvent::AutoModeSwitch { + session_id: sid, + error_code, + retry_after_seconds, + }) + .await; + + let answer = match response { + HandlerResponse::AutoModeSwitch(answer) => answer, + _ => AutoModeSwitchResponse::No, + }; + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(serde_json::json!({ "response": answer })), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + } + "permission.request" => { let Some(request_id) = request .params diff --git a/rust/src/types.rs b/rust/src/types.rs index 3831e02d6..e2f7aed3c 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1000,6 +1000,17 @@ pub struct SessionConfig { /// requests so the wire surface is safe out-of-the-box. #[serde(skip_serializing_if = "Option::is_none")] pub request_permission: Option, + /// Enable `exitPlanMode.request` JSON-RPC calls for plan approval. + /// Defaults to `Some(true)` via [`SessionConfig::default`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_exit_plan_mode: Option, + /// Enable `autoModeSwitch.request` JSON-RPC calls. When `true`, the CLI + /// asks the handler whether to switch to auto model when an eligible + /// rate limit is hit. Defaults to `Some(true)` via + /// [`SessionConfig::default`]. Without this flag, the CLI surfaces the + /// rate-limit error directly without offering the auto-mode switch. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_auto_mode_switch: Option, /// Advertise elicitation provider capability. When true, the CLI sends /// `elicitation.requested` events that the handler can respond to. /// Defaults to `Some(true)` via [`SessionConfig::default`]. @@ -1121,6 +1132,8 @@ impl std::fmt::Debug for SessionConfig { .field("enable_config_discovery", &self.enable_config_discovery) .field("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) + .field("request_exit_plan_mode", &self.request_exit_plan_mode) + .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) @@ -1181,6 +1194,8 @@ impl Default for SessionConfig { enable_config_discovery: None, request_user_input: Some(true), request_permission: Some(true), + request_exit_plan_mode: Some(true), + request_auto_mode_switch: Some(true), request_elicitation: Some(true), skill_directories: None, instruction_directories: None, @@ -1383,6 +1398,18 @@ impl SessionConfig { self } + /// Enable `exitPlanMode.request` JSON-RPC calls. Defaults to `Some(true)`. + pub fn with_request_exit_plan_mode(mut self, enable: bool) -> Self { + self.request_exit_plan_mode = Some(enable); + self + } + + /// Enable `autoModeSwitch.request` JSON-RPC calls. Defaults to `Some(true)`. + pub fn with_request_auto_mode_switch(mut self, enable: bool) -> Self { + self.request_auto_mode_switch = Some(enable); + self + } + /// Advertise elicitation provider capability. Defaults to `Some(true)`. pub fn with_request_elicitation(mut self, enable: bool) -> Self { self.request_elicitation = Some(enable); @@ -1548,6 +1575,14 @@ pub struct ResumeSessionConfig { /// Enable permission request RPCs. #[serde(skip_serializing_if = "Option::is_none")] pub request_permission: Option, + /// Enable exit-plan-mode request RPCs. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_exit_plan_mode: Option, + /// Enable auto-mode-switch request RPCs on resume. Defaults to + /// `Some(true)` via [`ResumeSessionConfig::new`]. See + /// [`SessionConfig::request_auto_mode_switch`] for details. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_auto_mode_switch: Option, /// Advertise elicitation provider capability on resume. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, @@ -1653,6 +1688,8 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("enable_config_discovery", &self.enable_config_discovery) .field("request_user_input", &self.request_user_input) .field("request_permission", &self.request_permission) + .field("request_exit_plan_mode", &self.request_exit_plan_mode) + .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) @@ -1711,6 +1748,8 @@ impl ResumeSessionConfig { enable_config_discovery: None, request_user_input: Some(true), request_permission: Some(true), + request_exit_plan_mode: Some(true), + request_auto_mode_switch: Some(true), request_elicitation: Some(true), skill_directories: None, instruction_directories: None, @@ -1878,6 +1917,18 @@ impl ResumeSessionConfig { self } + /// Enable `exitPlanMode.request` JSON-RPC calls. Defaults to `Some(true)`. + pub fn with_request_exit_plan_mode(mut self, enable: bool) -> Self { + self.request_exit_plan_mode = Some(enable); + self + } + + /// Enable `autoModeSwitch.request` JSON-RPC calls. Defaults to `Some(true)`. + pub fn with_request_auto_mode_switch(mut self, enable: bool) -> Self { + self.request_auto_mode_switch = Some(enable); + self + } + /// Advertise elicitation provider capability on resume. Defaults to `Some(true)`. pub fn with_request_elicitation(mut self, enable: bool) -> Self { self.request_elicitation = Some(enable); @@ -3031,6 +3082,39 @@ pub struct PermissionRequestData { pub extra: Value, } +/// Data sent by the CLI with an `exitPlanMode.request` RPC call. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExitPlanModeData { + /// Markdown summary of the plan presented to the user. + #[serde(default)] + pub summary: String, + /// Full plan content (e.g. the plan.md body), if available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan_content: Option, + /// Allowed exit actions (e.g. "interactive", "autopilot", "autopilot_fleet"). + #[serde(default)] + pub actions: Vec, + /// Which action the CLI recommends, defaults to "autopilot". + #[serde(default = "default_recommended_action")] + pub recommended_action: String, +} + +fn default_recommended_action() -> String { + "autopilot".to_string() +} + +impl Default for ExitPlanModeData { + fn default() -> Self { + Self { + summary: String::new(), + plan_content: None, + actions: Vec::new(), + recommended_action: default_recommended_action(), + } + } +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -3079,6 +3163,8 @@ mod tests { assert_eq!(cfg.request_user_input, Some(true)); assert_eq!(cfg.request_permission, Some(true)); assert_eq!(cfg.request_elicitation, Some(true)); + assert_eq!(cfg.request_exit_plan_mode, Some(true)); + assert_eq!(cfg.request_auto_mode_switch, Some(true)); } #[test] @@ -3087,6 +3173,8 @@ mod tests { assert_eq!(cfg.request_user_input, Some(true)); assert_eq!(cfg.request_permission, Some(true)); assert_eq!(cfg.request_elicitation, Some(true)); + assert_eq!(cfg.request_exit_plan_mode, Some(true)); + assert_eq!(cfg.request_auto_mode_switch, Some(true)); } #[test] @@ -3105,6 +3193,8 @@ mod tests { .with_mcp_servers(HashMap::new()) .with_enable_config_discovery(true) .with_request_user_input(false) + .with_request_exit_plan_mode(false) + .with_request_auto_mode_switch(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) .with_agent("researcher") @@ -3132,6 +3222,8 @@ mod tests { assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!(cfg.request_user_input, Some(false)); // overrode default assert_eq!(cfg.request_permission, Some(true)); // default preserved + assert_eq!(cfg.request_exit_plan_mode, Some(false)); + assert_eq!(cfg.request_auto_mode_switch, Some(false)); assert_eq!( cfg.skill_directories.as_deref(), Some(&[PathBuf::from("/tmp/skills")][..]) @@ -3161,6 +3253,8 @@ mod tests { .with_mcp_servers(HashMap::new()) .with_enable_config_discovery(true) .with_request_user_input(false) + .with_request_exit_plan_mode(false) + .with_request_auto_mode_switch(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) .with_agent("researcher") @@ -3188,6 +3282,8 @@ mod tests { assert_eq!(cfg.enable_config_discovery, Some(true)); assert_eq!(cfg.request_user_input, Some(false)); // overrode default assert_eq!(cfg.request_permission, Some(true)); // default preserved + assert_eq!(cfg.request_exit_plan_mode, Some(false)); + assert_eq!(cfg.request_auto_mode_switch, Some(false)); assert_eq!( cfg.skill_directories.as_deref(), Some(&[PathBuf::from("/tmp/skills")][..]) diff --git a/rust/tests/mode_handlers_e2e_test.rs b/rust/tests/mode_handlers_e2e_test.rs new file mode 100644 index 000000000..419124850 --- /dev/null +++ b/rust/tests/mode_handlers_e2e_test.rs @@ -0,0 +1,663 @@ +#![allow(clippy::unwrap_used)] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use github_copilot_sdk::generated::session_events::{ + AutoModeSwitchCompletedData, AutoModeSwitchRequestedData, ExitPlanModeCompletedData, + ExitPlanModeRequestedData, SessionEventType, SessionModelChangeData, +}; +use github_copilot_sdk::handler::{AutoModeSwitchResponse, ExitPlanModeResult, SessionHandler}; +use github_copilot_sdk::subscription::EventSubscription; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, ExitPlanModeData, SessionConfig, SessionEvent, SessionId, +}; +use serde_json::json; +use tokio::sync::mpsc; + +const MODE_HANDLER_TOKEN: &str = "mode-handler-token"; +const PLAN_SUMMARY: &str = "Greeting file implementation plan"; +const PLAN_PROMPT: &str = "Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode."; +const AUTO_MODE_PROMPT: &str = + "Explain that auto mode recovered from a rate limit in one short sentence."; + +#[derive(Debug)] +struct ModeHandler { + requests: mpsc::UnboundedSender<(SessionId, ExitPlanModeData)>, +} + +#[derive(Debug)] +struct AutoModeHandler { + requests: mpsc::UnboundedSender<(SessionId, Option, Option)>, +} + +#[async_trait] +impl SessionHandler for ModeHandler { + async fn on_exit_plan_mode( + &self, + session_id: SessionId, + data: ExitPlanModeData, + ) -> ExitPlanModeResult { + let _ = self.requests.send((session_id, data)); + ExitPlanModeResult { + approved: true, + selected_action: Some("interactive".to_string()), + feedback: Some("Approved by the Rust E2E test".to_string()), + } + } +} + +#[async_trait] +impl SessionHandler for AutoModeHandler { + async fn on_auto_mode_switch( + &self, + session_id: SessionId, + error_code: Option, + retry_after_seconds: Option, + ) -> AutoModeSwitchResponse { + let _ = self + .requests + .send((session_id, error_code, retry_after_seconds)); + AutoModeSwitchResponse::Yes + } +} + +#[tokio::test] +#[ignore] // requires the Node CLI and shared replay proxy dependencies +async fn should_invoke_exit_plan_mode_handler_when_model_uses_tool() { + let repo_root = repo_root(); + let cli_path = repo_root + .join("nodejs") + .join("node_modules") + .join("@github") + .join("copilot") + .join("index.js"); + assert!( + cli_path.exists(), + "CLI not found at {}; run npm install in nodejs first", + cli_path.display() + ); + + let home_dir = tempfile::tempdir().expect("create home dir"); + let work_dir = tempfile::tempdir().expect("create work dir"); + let mut proxy = CapiProxy::start(&repo_root).expect("start replay proxy"); + proxy + .configure( + &repo_root + .join("test") + .join("snapshots") + .join("mode_handlers") + .join("should_invoke_exit_plan_mode_handler_when_model_uses_tool.yaml"), + work_dir.path(), + ) + .expect("configure replay proxy"); + proxy + .set_copilot_user_by_token( + MODE_HANDLER_TOKEN, + json!({ + "login": "mode-handler-user", + "copilot_plan": "individual_pro", + "endpoints": { + "api": proxy.url(), + "telemetry": "https://localhost:1/telemetry" + }, + "analytics_tracking_id": "mode-handler-tracking-id" + }), + ) + .expect("configure copilot user"); + + let mut env = proxy.proxy_env(); + env.extend([ + ("COPILOT_API_URL".into(), proxy.url().into()), + ("COPILOT_DEBUG_GITHUB_API_URL".into(), proxy.url().into()), + ( + "COPILOT_HOME".into(), + home_dir.path().as_os_str().to_owned(), + ), + ( + "GH_CONFIG_DIR".into(), + home_dir.path().as_os_str().to_owned(), + ), + ( + "XDG_CONFIG_HOME".into(), + home_dir.path().as_os_str().to_owned(), + ), + ( + "XDG_STATE_HOME".into(), + home_dir.path().as_os_str().to_owned(), + ), + ]); + + let client = Client::start( + ClientOptions::new() + .with_program(CliProgram::Path(PathBuf::from(node_program()))) + .with_prefix_args([cli_path.as_os_str().to_owned()]) + .with_cwd(work_dir.path()) + .with_env(env) + .with_use_logged_in_user(false), + ) + .await + .expect("start client"); + + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(MODE_HANDLER_TOKEN) + .with_handler(Arc::new(ModeHandler { + requests: request_tx, + })) + .approve_all_permissions(), + ) + .await + .expect("create session"); + + let requested_event = tokio::spawn(wait_for_event( + session.subscribe(), + "exit_plan_mode.requested event", + |event| { + event.parsed_type() == SessionEventType::ExitPlanModeRequested + && event + .typed_data::() + .is_some_and(|data| data.summary == PLAN_SUMMARY) + }, + )); + let completed_event = tokio::spawn(wait_for_event( + session.subscribe(), + "exit_plan_mode.completed event", + |event| { + event.parsed_type() == SessionEventType::ExitPlanModeCompleted + && event + .typed_data::() + .is_some_and(|data| { + data.approved == Some(true) + && data.selected_action.as_deref() == Some("interactive") + }) + }, + )); + let idle_event = tokio::spawn(wait_for_event( + session.subscribe(), + "session.idle event", + |event| event.parsed_type() == SessionEventType::SessionIdle, + )); + + let send_result = session + .client() + .call( + "session.send", + Some(json!({ + "sessionId": session.id().as_str(), + "prompt": PLAN_PROMPT, + "mode": "plan", + })), + ) + .await + .expect("send plan-mode prompt"); + assert!( + send_result.get("messageId").is_some(), + "expected messageId in send result" + ); + + let (session_id, request) = tokio::time::timeout(Duration::from_secs(10), request_rx.recv()) + .await + .expect("timed out waiting for exit-plan-mode request") + .expect("exit-plan-mode request channel closed"); + assert_eq!(session_id, session.id().clone()); + assert_eq!(request.summary, PLAN_SUMMARY); + assert_eq!( + request.actions, + ["interactive", "autopilot", "exit_only"].map(str::to_string) + ); + assert_eq!(request.recommended_action, "interactive"); + + let requested = requested_event + .await + .expect("requested task") + .expect("requested event"); + let requested_data = requested + .typed_data::() + .expect("typed requested event"); + assert_eq!(requested_data.summary, request.summary); + assert_eq!(requested_data.actions, request.actions); + assert_eq!( + requested_data.recommended_action, + request.recommended_action + ); + + let completed = completed_event + .await + .expect("completed task") + .expect("completed event"); + let completed_data = completed + .typed_data::() + .expect("typed completed event"); + assert_eq!(completed_data.approved, Some(true)); + assert_eq!( + completed_data.selected_action.as_deref(), + Some("interactive") + ); + assert_eq!( + completed_data.feedback.as_deref(), + Some("Approved by the Rust E2E test") + ); + idle_event.await.expect("idle task").expect("idle event"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + proxy.stop(true).expect("stop replay proxy"); +} + +#[tokio::test] +#[ignore] // requires the Node CLI and shared replay proxy dependencies +async fn should_invoke_auto_mode_switch_handler_when_rate_limited() { + let repo_root = repo_root(); + let cli_path = repo_root + .join("nodejs") + .join("node_modules") + .join("@github") + .join("copilot") + .join("index.js"); + assert!( + cli_path.exists(), + "CLI not found at {}; run npm install in nodejs first", + cli_path.display() + ); + + let home_dir = tempfile::tempdir().expect("create home dir"); + let work_dir = tempfile::tempdir().expect("create work dir"); + let mut proxy = CapiProxy::start(&repo_root).expect("start replay proxy"); + proxy + .configure( + &repo_root + .join("test") + .join("snapshots") + .join("mode_handlers") + .join("should_invoke_auto_mode_switch_handler_when_rate_limited.yaml"), + work_dir.path(), + ) + .expect("configure replay proxy"); + proxy + .set_copilot_user_by_token( + MODE_HANDLER_TOKEN, + json!({ + "login": "mode-handler-user", + "copilot_plan": "individual_pro", + "endpoints": { + "api": proxy.url(), + "telemetry": "https://localhost:1/telemetry" + }, + "analytics_tracking_id": "mode-handler-tracking-id" + }), + ) + .expect("configure copilot user"); + + let mut env = proxy.proxy_env(); + env.extend([ + ("COPILOT_API_URL".into(), proxy.url().into()), + ("COPILOT_DEBUG_GITHUB_API_URL".into(), proxy.url().into()), + ( + "COPILOT_HOME".into(), + home_dir.path().as_os_str().to_owned(), + ), + ( + "GH_CONFIG_DIR".into(), + home_dir.path().as_os_str().to_owned(), + ), + ( + "XDG_CONFIG_HOME".into(), + home_dir.path().as_os_str().to_owned(), + ), + ( + "XDG_STATE_HOME".into(), + home_dir.path().as_os_str().to_owned(), + ), + ]); + + let client = Client::start( + ClientOptions::new() + .with_program(CliProgram::Path(PathBuf::from(node_program()))) + .with_prefix_args([cli_path.as_os_str().to_owned()]) + .with_cwd(work_dir.path()) + .with_env(env) + .with_use_logged_in_user(false), + ) + .await + .expect("start client"); + + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(MODE_HANDLER_TOKEN) + .with_handler(Arc::new(AutoModeHandler { + requests: request_tx, + })) + .approve_all_permissions(), + ) + .await + .expect("create session"); + + let requested_event = tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "auto_mode_switch.requested event", + |event| { + event.parsed_type() == SessionEventType::AutoModeSwitchRequested + && event + .typed_data::() + .is_some_and(|data| { + data.error_code.as_deref() == Some("user_weekly_rate_limited") + && data.retry_after_seconds == Some(1.0) + }) + }, + )); + let completed_event = tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "auto_mode_switch.completed event", + |event| { + event.parsed_type() == SessionEventType::AutoModeSwitchCompleted + && event + .typed_data::() + .is_some_and(|data| data.response == "yes") + }, + )); + let model_change_event = tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "rate-limit auto-mode model change", + |event| { + event.parsed_type() == SessionEventType::SessionModelChange + && event + .typed_data::() + .is_some_and(|data| data.cause.as_deref() == Some("rate_limit_auto_switch")) + }, + )); + let idle_event = tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "session.idle after auto-mode switch", + |event| event.parsed_type() == SessionEventType::SessionIdle, + )); + + let message_id = session + .send(AUTO_MODE_PROMPT) + .await + .expect("send auto-mode-switch prompt"); + assert!(!message_id.is_empty(), "expected message ID"); + + let (session_id, error_code, retry_after_seconds) = + tokio::time::timeout(Duration::from_secs(10), request_rx.recv()) + .await + .expect("timed out waiting for auto-mode-switch request") + .expect("auto-mode-switch request channel closed"); + assert_eq!(session_id, session.id().clone()); + assert_eq!(error_code.as_deref(), Some("user_weekly_rate_limited")); + assert_eq!(retry_after_seconds, Some(1.0)); + + let requested = requested_event + .await + .expect("requested task") + .expect("requested event"); + let requested_data = requested + .typed_data::() + .expect("typed requested event"); + assert_eq!(requested_data.error_code, error_code); + assert_eq!(requested_data.retry_after_seconds, retry_after_seconds); + + let completed = completed_event + .await + .expect("completed task") + .expect("completed event"); + let completed_data = completed + .typed_data::() + .expect("typed completed event"); + assert_eq!(completed_data.response, "yes"); + + let model_change = model_change_event + .await + .expect("model change task") + .expect("model change event"); + let model_change_data = model_change + .typed_data::() + .expect("typed model change event"); + assert_eq!( + model_change_data.cause.as_deref(), + Some("rate_limit_auto_switch") + ); + idle_event.await.expect("idle task").expect("idle event"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + proxy.stop(true).expect("stop replay proxy"); +} + +async fn wait_for_event( + mut events: EventSubscription, + description: &'static str, + predicate: fn(&SessionEvent) -> bool, +) -> Result { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let event = events.recv().await.map_err(|err| { + format!("event stream closed while waiting for {description}: {err}") + })?; + if event.parsed_type() == SessionEventType::SessionError { + return Err(format!( + "session.error while waiting for {description}: {}", + event.data + )); + } + if predicate(&event) { + return Ok(event); + } + } + }) + .await + .map_err(|_| format!("timed out waiting for {description}"))? +} + +async fn wait_for_event_allowing_rate_limit( + mut events: EventSubscription, + description: &'static str, + predicate: fn(&SessionEvent) -> bool, +) -> Result { + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let event = events.recv().await.map_err(|err| { + format!("event stream closed while waiting for {description}: {err}") + })?; + if event.parsed_type() == SessionEventType::SessionError + && event.data.get("errorType").and_then(|value| value.as_str()) + != Some("rate_limit") + { + return Err(format!( + "session.error while waiting for {description}: {}", + event.data + )); + } + if predicate(&event) { + return Ok(event); + } + } + }) + .await + .map_err(|_| format!("timed out waiting for {description}"))? +} + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("rust package has parent repo") + .to_path_buf() +} + +struct CapiProxy { + child: Option, + proxy_url: String, + connect_proxy_url: String, + ca_file_path: String, +} + +impl CapiProxy { + fn start(repo_root: &Path) -> std::io::Result { + let mut child = Command::new(npx_program()) + .args(["tsx", "server.ts"]) + .current_dir(repo_root.join("test").join("harness")) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + + let stdout = child.stdout.take().expect("proxy stdout"); + let reader = BufReader::new(stdout); + let re = regex::Regex::new(r"Listening: (http://[^\s]+)\s+(\{.*\})$").unwrap(); + for line in reader.lines() { + let line = line?; + if let Some(captures) = re.captures(&line) { + let metadata: serde_json::Value = + serde_json::from_str(captures.get(2).unwrap().as_str())?; + let connect_proxy_url = metadata + .get("connectProxyUrl") + .and_then(|value| value.as_str()) + .expect("connectProxyUrl") + .to_string(); + let ca_file_path = metadata + .get("caFilePath") + .and_then(|value| value.as_str()) + .expect("caFilePath") + .to_string(); + return Ok(Self { + child: Some(child), + proxy_url: captures.get(1).unwrap().as_str().to_string(), + connect_proxy_url, + ca_file_path, + }); + } + if line.contains("Listening: ") { + return Err(std::io::Error::other(format!( + "proxy startup line missing metadata: {line}" + ))); + } + } + + Err(std::io::Error::other("proxy exited before startup")) + } + + fn url(&self) -> &str { + &self.proxy_url + } + + fn configure(&self, file_path: &Path, work_dir: &Path) -> std::io::Result<()> { + self.post_json( + "/config", + &json!({ + "filePath": file_path, + "workDir": work_dir, + }) + .to_string(), + ) + } + + fn set_copilot_user_by_token( + &self, + token: &str, + response: serde_json::Value, + ) -> std::io::Result<()> { + self.post_json( + "/copilot-user-config", + &json!({ + "token": token, + "response": response, + }) + .to_string(), + ) + } + + fn stop(&mut self, skip_writing_cache: bool) -> std::io::Result<()> { + let path = if skip_writing_cache { + "/stop?skipWritingCache=true" + } else { + "/stop" + }; + let result = self.post_json(path, ""); + if let Some(mut child) = self.child.take() { + let _ = child.wait(); + } + result + } + + fn proxy_env(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)> { + let no_proxy = "127.0.0.1,localhost,::1"; + [ + ("HTTP_PROXY", self.connect_proxy_url.as_str()), + ("HTTPS_PROXY", self.connect_proxy_url.as_str()), + ("http_proxy", self.connect_proxy_url.as_str()), + ("https_proxy", self.connect_proxy_url.as_str()), + ("NO_PROXY", no_proxy), + ("no_proxy", no_proxy), + ("NODE_EXTRA_CA_CERTS", self.ca_file_path.as_str()), + ("SSL_CERT_FILE", self.ca_file_path.as_str()), + ("REQUESTS_CA_BUNDLE", self.ca_file_path.as_str()), + ("CURL_CA_BUNDLE", self.ca_file_path.as_str()), + ("GIT_SSL_CAINFO", self.ca_file_path.as_str()), + ("GH_TOKEN", ""), + ("GITHUB_TOKEN", ""), + ("GH_ENTERPRISE_TOKEN", ""), + ("GITHUB_ENTERPRISE_TOKEN", ""), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect() + } + + fn post_json(&self, path: &str, body: &str) -> std::io::Result<()> { + let (host, port) = parse_http_url(&self.proxy_url)?; + let mut stream = TcpStream::connect((host.as_str(), port))?; + write!( + stream, + "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.1 204") { + return Err(std::io::Error::other(format!( + "proxy POST {path} failed: {response}" + ))); + } + Ok(()) + } +} + +impl Drop for CapiProxy { + fn drop(&mut self) { + if self.child.is_some() { + let _ = self.stop(true); + } + } +} + +fn node_program() -> &'static str { + if cfg!(windows) { "node.exe" } else { "node" } +} + +fn npx_program() -> &'static str { + if cfg!(windows) { "npx.cmd" } else { "npx" } +} + +fn parse_http_url(url: &str) -> std::io::Result<(String, u16)> { + let without_scheme = url + .strip_prefix("http://") + .ok_or_else(|| std::io::Error::other(format!("expected http URL, got {url}")))?; + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + let (host, port) = authority + .rsplit_once(':') + .ok_or_else(|| std::io::Error::other(format!("missing port in URL {url}")))?; + let port = port + .parse() + .map_err(|err| std::io::Error::other(format!("invalid port in URL {url}: {err}")))?; + Ok((host.to_string(), port)) +} diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 4e05960e7..74c6eb90b 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -8,12 +8,12 @@ use std::time::Duration; use async_trait::async_trait; use github_copilot_sdk::Client; use github_copilot_sdk::handler::{ - ApproveAllHandler, HandlerEvent, HandlerResponse, PermissionResult, SessionHandler, - UserInputResponse, + ApproveAllHandler, AutoModeSwitchResponse, ExitPlanModeResult, HandlerEvent, HandlerResponse, + PermissionResult, SessionHandler, UserInputResponse, }; use github_copilot_sdk::types::{ - CommandContext, CommandDefinition, CommandHandler, DeliveryMode, MessageOptions, SessionConfig, - SessionId, ToolResult, + CommandContext, CommandDefinition, CommandHandler, DeliveryMode, ExitPlanModeData, + MessageOptions, SessionConfig, SessionId, ToolResult, }; use serde_json::Value; use tokio::io::{AsyncWrite, AsyncWriteExt, duplex}; @@ -1158,6 +1158,109 @@ async fn user_input_request_dispatches_to_handler() { assert_eq!(response["result"]["wasFreeform"], true); } +#[tokio::test] +async fn exit_plan_mode_request_dispatches_to_handler() { + struct ExitHandler; + #[async_trait] + impl SessionHandler for ExitHandler { + async fn on_exit_plan_mode( + &self, + _session_id: SessionId, + data: ExitPlanModeData, + ) -> ExitPlanModeResult { + assert_eq!(data.summary, "Ready to implement"); + assert_eq!(data.plan_content.as_deref(), Some("Plan text")); + assert_eq!( + data.actions, + vec!["interactive".to_string(), "autopilot".to_string()] + ); + assert_eq!(data.recommended_action, "autopilot"); + ExitPlanModeResult { + approved: true, + selected_action: Some("interactive".to_string()), + feedback: Some("Looks good".to_string()), + } + } + } + + let (_session, mut server) = create_session_pair(Arc::new(ExitHandler)).await; + server + .send_request( + 310, + "exitPlanMode.request", + serde_json::json!({ + "sessionId": server.session_id, + "summary": "Ready to implement", + "planContent": "Plan text", + "actions": ["interactive", "autopilot"], + "recommendedAction": "autopilot", + }), + ) + .await; + + let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); + assert_eq!(response["id"], 310); + assert_eq!(response["result"]["approved"], true); + assert_eq!(response["result"]["selectedAction"], "interactive"); + assert_eq!(response["result"]["feedback"], "Looks good"); +} + +#[tokio::test] +async fn auto_mode_switch_request_dispatches_to_handler() { + struct AutoModeHandler; + #[async_trait] + impl SessionHandler for AutoModeHandler { + async fn on_auto_mode_switch( + &self, + _session_id: SessionId, + error_code: Option, + retry_after_seconds: Option, + ) -> AutoModeSwitchResponse { + assert_eq!(error_code.as_deref(), Some("user_weekly_rate_limited")); + assert_eq!(retry_after_seconds, Some(3600.5)); + AutoModeSwitchResponse::YesAlways + } + } + + let (_session, mut server) = create_session_pair(Arc::new(AutoModeHandler)).await; + server + .send_request( + 311, + "autoModeSwitch.request", + serde_json::json!({ + "sessionId": server.session_id, + "errorCode": "user_weekly_rate_limited", + "retryAfterSeconds": 3600.5, + }), + ) + .await; + + let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); + assert_eq!(response["id"], 311); + assert_eq!(response["result"]["response"], "yes_always"); +} + +#[tokio::test] +async fn default_exit_plan_mode_response_omits_optional_fields() { + let (_session, mut server) = create_session_pair(Arc::new(ApproveAllHandler)).await; + server + .send_request( + 312, + "exitPlanMode.request", + serde_json::json!({ + "sessionId": server.session_id, + "summary": "Ready to implement", + }), + ) + .await; + + let response = timeout(TIMEOUT, server.read_response()).await.unwrap(); + assert_eq!(response["id"], 312); + assert_eq!(response["result"]["approved"], true); + assert!(response["result"].get("selectedAction").is_none()); + assert!(response["result"].get("feedback").is_none()); +} + #[tokio::test] async fn user_input_requested_notification_does_not_double_dispatch() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1946,6 +2049,8 @@ async fn request_elicitation_sent_in_create_params() { let request = read_framed(&mut server_read).await; assert_eq!(request["method"], "session.create"); assert_eq!(request["params"]["requestElicitation"], true); + assert_eq!(request["params"]["requestExitPlanMode"], true); + assert_eq!(request["params"]["requestAutoModeSwitch"], true); let id = request["id"].as_u64().unwrap(); let response = serde_json::json!({ diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index cd6399922..3007a80df 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -327,6 +327,38 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { options.requestOptions.path === chatCompletionEndpoint && options.body ) { + const savedError = await findSavedChatCompletionError( + state.storedData, + options.body, + state.workDir, + state.toolResultNormalizers, + ); + + if (savedError) { + const headers = { + "content-type": "application/json", + ...commonResponseHeaders, + ...(savedError.retryAfterSeconds !== undefined + ? { "retry-after": String(savedError.retryAfterSeconds) } + : {}), + }; + options.onResponseStart(savedError.status, headers); + options.onData( + Buffer.from( + JSON.stringify({ + error: { + message: + savedError.message ?? "Rate limited by test snapshot", + type: savedError.code ?? "rate_limited", + code: savedError.code ?? "rate_limited", + }, + }), + ), + ); + options.onResponseEnd(); + return; + } + const savedResponse = await findSavedChatCompletionResponse( state.storedData, options.body, @@ -445,6 +477,19 @@ async function writeCapturesToDisk( state.workDir, state.toolResultNormalizers, ); + const preservedErrors = state.storedData?.errors; + if (preservedErrors && preservedErrors.length > 0) { + data.errors = preservedErrors; + data.models = [ + ...new Set([ + ...(state.storedData?.models ?? []), + ...data.models, + ...preservedErrors + .map((error) => error.model) + .filter((model): model is string => model !== undefined), + ]), + ]; + } if (data.conversations.length > 0) { let yamlText = yaml.stringify(data, { lineWidth: 120 }); @@ -612,6 +657,37 @@ async function findSavedChatCompletionResponse( return undefined; } +async function findSavedChatCompletionError( + storedData: NormalizedData, + requestBody: string | undefined, + workDir: string, + toolResultNormalizers: ToolResultNormalizer[], +): Promise { + const normalized = await parseAndNormalizeRequest( + requestBody, + workDir, + toolResultNormalizers, + ); + const requestMessages = normalized.conversations[0]?.messages ?? []; + const requestModel = normalized.models[0]; + + for (const error of storedData.errors ?? []) { + if (error.model && error.model !== requestModel) { + continue; + } + if ( + requestMessages.length === error.messages.length && + requestMessages.every( + (msg, i) => JSON.stringify(msg) === JSON.stringify(error.messages[i]), + ) + ) { + return error; + } + } + + return undefined; +} + // Checks if the request matches a snapshot that has no assistant response. // This handles timeout test scenarios where the snapshot only records the request. async function isRequestOnlySnapshot( @@ -1391,8 +1467,18 @@ interface NormalizedConversation { messages: NormalizedMessage[]; } +interface NormalizedErrorResponse { + model?: string; + status: number; + code?: string; + message?: string; + retryAfterSeconds?: number; + messages: NormalizedMessage[]; +} + export interface NormalizedData { models: string[]; + errors?: NormalizedErrorResponse[]; conversations: NormalizedConversation[]; } diff --git a/test/snapshots/mode_handlers/should_invoke_auto_mode_switch_handler_when_rate_limited.yaml b/test/snapshots/mode_handlers/should_invoke_auto_mode_switch_handler_when_rate_limited.yaml new file mode 100644 index 000000000..19c271b4f --- /dev/null +++ b/test/snapshots/mode_handlers/should_invoke_auto_mode_switch_handler_when_rate_limited.yaml @@ -0,0 +1,22 @@ +models: + - claude-sonnet-4.5 + - auto +errors: + - model: claude-sonnet-4.5 + status: 429 + code: user_weekly_rate_limited + message: You've reached your weekly rate limit. + retryAfterSeconds: 1 + messages: + - role: system + content: ${system} + - role: user + content: Explain that auto mode recovered from a rate limit in one short sentence. +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Explain that auto mode recovered from a rate limit in one short sentence. + - role: assistant + content: Auto mode recovered from the rate limit and the session can continue. diff --git a/test/snapshots/mode_handlers/should_invoke_exit_plan_mode_handler_when_model_uses_tool.yaml b/test/snapshots/mode_handlers/should_invoke_exit_plan_mode_handler_when_model_uses_tool.yaml new file mode 100644 index 000000000..69987f17a --- /dev/null +++ b/test/snapshots/mode_handlers/should_invoke_exit_plan_mode_handler_when_model_uses_tool.yaml @@ -0,0 +1,23 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: exit_plan_mode + arguments: '{"summary":"Greeting file implementation plan","actions":["interactive","autopilot","exit_only"],"recommendedAction":"interactive"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + Plan approved! Exited plan mode. + + You are now in interactive mode (edits require manual approval). Proceed with implementing the plan. + - role: assistant + content: Plan approved; I will wait for the next instruction before making changes. From b0d1c8e287afe2de9a1e0b4e6e85b4db16a91a18 Mon Sep 17 00:00:00 2001 From: Christopher Schleiden Date: Fri, 8 May 2026 03:49:50 -0700 Subject: [PATCH 13/33] feat(rust): support binary tool results (#1222) * feat(rust): support binary tool results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: default empty MCP resource MIME types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Stephen Toub --- nodejs/src/types.ts | 6 +- nodejs/test/call-tool-result.test.ts | 5 ++ python/copilot/tools.py | 8 +- python/test_tools.py | 26 ++++++ rust/src/tool.rs | 119 +++++++++++++++++++++++++++ rust/src/types.rs | 61 +++++++++++++- 6 files changed, 219 insertions(+), 6 deletions(-) diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index c881bfd2c..8be0cca8c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -322,9 +322,13 @@ export function convertMcpCallToolResult(callResult: McpCallToolResult): ToolRes textParts.push(block.resource.text); } if (block.resource?.blob) { + const mimeType = block.resource.mimeType; binaryResults.push({ data: block.resource.blob, - mimeType: block.resource.mimeType ?? "application/octet-stream", + mimeType: + typeof mimeType === "string" && mimeType + ? mimeType + : "application/octet-stream", type: "resource", description: block.resource.uri, }); diff --git a/nodejs/test/call-tool-result.test.ts b/nodejs/test/call-tool-result.test.ts index 132e482bd..c7c1d2979 100644 --- a/nodejs/test/call-tool-result.test.ts +++ b/nodejs/test/call-tool-result.test.ts @@ -130,12 +130,17 @@ describe("convertMcpCallToolResult", () => { type: "resource", resource: { uri: "file:///data.bin", blob: "binarydata" }, }, + { + type: "resource", + resource: { uri: "file:///empty-mime.bin", blob: "binarydata2", mimeType: "" }, + }, ], }; const result = convertMcpCallToolResult(input); expect(result.binaryResultsForLlm![0]!.mimeType).toBe("application/octet-stream"); + expect(result.binaryResultsForLlm![1]!.mimeType).toBe("application/octet-stream"); }); it("handles text block with missing text field without corrupting output", () => { diff --git a/python/copilot/tools.py b/python/copilot/tools.py index c94c396e9..d98cb19e2 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -307,14 +307,14 @@ def convert_mcp_call_tool_result(call_result: dict[str, Any]) -> ToolResult: text_parts.append(text) blob = resource.get("blob") if isinstance(blob, str) and blob: - mime_type = resource.get("mimeType", "application/octet-stream") + mime_type = resource.get("mimeType") + if not isinstance(mime_type, str) or not mime_type: + mime_type = "application/octet-stream" uri = resource.get("uri", "") binary_results.append( ToolBinaryResult( data=blob, - mime_type=mime_type - if isinstance(mime_type, str) - else "application/octet-stream", + mime_type=mime_type, type="resource", description=uri if isinstance(uri, str) else "", ) diff --git a/python/test_tools.py b/python/test_tools.py index bbbe2190f..3447529ac 100644 --- a/python/test_tools.py +++ b/python/test_tools.py @@ -373,8 +373,34 @@ def test_resource_blob_to_binary(self): assert result.binary_results_for_llm is not None assert len(result.binary_results_for_llm) == 1 assert result.binary_results_for_llm[0].data == "blobdata" + assert result.binary_results_for_llm[0].mime_type == "image/png" assert result.binary_results_for_llm[0].description == "file:///img.png" + def test_resource_blob_defaults_missing_or_empty_mime_type(self): + result = convert_mcp_call_tool_result( + { + "content": [ + { + "type": "resource", + "resource": {"uri": "file:///data.bin", "blob": "binarydata"}, + }, + { + "type": "resource", + "resource": { + "uri": "file:///empty-mime.bin", + "blob": "binarydata2", + "mimeType": "", + }, + }, + ], + } + ) + + assert result.binary_results_for_llm is not None + assert len(result.binary_results_for_llm) == 2 + assert result.binary_results_for_llm[0].mime_type == "application/octet-stream" + assert result.binary_results_for_llm[1].mime_type == "application/octet-stream" + def test_empty_content_array(self): result = convert_mcp_call_tool_result({"content": []}) assert result.text_result_for_llm == "" diff --git a/rust/src/tool.rs b/rust/src/tool.rs index 108bf6fa0..3342f4b9f 100644 --- a/rust/src/tool.rs +++ b/rust/src/tool.rs @@ -139,6 +139,7 @@ pub fn convert_mcp_call_tool_result(value: &serde_json::Value) -> Option Date: Fri, 8 May 2026 14:06:06 +0100 Subject: [PATCH 14/33] Disable CI workflows on forked repositories (#1232) * Restrict CI workflows to non-forked repositories Add if: github.event.repository.fork == false to all workflow jobs to ensure CI only runs in the main repository. This improves security and conserves resources by preventing workflow execution in forked repos. * Restrict Rust CLI build job to non-forked repos Added a condition to the "Rust SDK Bundled CLI Build" job in rust-sdk-tests.yml to ensure it only runs when the workflow is triggered from the main repository, preventing execution on forked repositories. --- .github/workflows/codegen-check.yml | 1 + .github/workflows/copilot-setup-steps.yml | 1 + .github/workflows/corrections-tests.yml | 1 + .github/workflows/docs-validation.yml | 4 ++++ .github/workflows/dotnet-sdk-tests.yml | 1 + .github/workflows/go-sdk-tests.yml | 1 + .github/workflows/nodejs-sdk-tests.yml | 1 + .github/workflows/python-sdk-tests.yml | 1 + .github/workflows/rust-publish-release.yml | 1 + .github/workflows/rust-sdk-tests.yml | 2 ++ .github/workflows/scenario-builds.yml | 5 +++++ .github/workflows/verify-compiled.yml | 1 + 12 files changed, 20 insertions(+) diff --git a/.github/workflows/codegen-check.yml b/.github/workflows/codegen-check.yml index d48b6a491..0bef14531 100644 --- a/.github/workflows/codegen-check.yml +++ b/.github/workflows/codegen-check.yml @@ -23,6 +23,7 @@ permissions: jobs: check: name: "Verify generated files are up-to-date" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index afe9b03bd..daa9d6694 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -14,6 +14,7 @@ on: jobs: # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent copilot-setup-steps: + if: github.event.repository.fork == false runs-on: ubuntu-latest # Set minimal permissions for setup steps diff --git a/.github/workflows/corrections-tests.yml b/.github/workflows/corrections-tests.yml index 7654f3c9b..693b4a408 100644 --- a/.github/workflows/corrections-tests.yml +++ b/.github/workflows/corrections-tests.yml @@ -16,6 +16,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + if: github.event.repository.fork == false steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index 4c26e9ec1..e7f64be5b 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -20,6 +20,7 @@ permissions: jobs: validate-typescript: name: "Validate TypeScript" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -44,6 +45,7 @@ jobs: validate-python: name: "Validate Python" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -76,6 +78,7 @@ jobs: validate-go: name: "Validate Go" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -99,6 +102,7 @@ jobs: validate-csharp: name: "Validate C#" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index 872f06668..d3b2ef162 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -29,6 +29,7 @@ permissions: jobs: test: name: ".NET SDK Tests" + if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off strategy: diff --git a/.github/workflows/go-sdk-tests.yml b/.github/workflows/go-sdk-tests.yml index 733954f1d..e26296109 100644 --- a/.github/workflows/go-sdk-tests.yml +++ b/.github/workflows/go-sdk-tests.yml @@ -30,6 +30,7 @@ permissions: jobs: test: name: "Go SDK Tests" + if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off strategy: diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 141b161b6..8880cadfa 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -32,6 +32,7 @@ permissions: jobs: test: name: "Node.js SDK Tests" + if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off strategy: diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 5b305ed09..e6260dd0b 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -32,6 +32,7 @@ permissions: jobs: test: name: "Python SDK Tests" + if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off strategy: diff --git a/.github/workflows/rust-publish-release.yml b/.github/workflows/rust-publish-release.yml index 348d2acf0..daf768929 100644 --- a/.github/workflows/rust-publish-release.yml +++ b/.github/workflows/rust-publish-release.yml @@ -24,6 +24,7 @@ concurrency: jobs: publish: name: Publish to crates.io + if: github.event.repository.fork == false runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 201841784..5dc100e72 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -30,6 +30,7 @@ permissions: jobs: test: name: "Rust SDK Tests" + if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off CARGO_TERM_COLOR: always @@ -119,6 +120,7 @@ jobs: # bundled-CLI release pipeline) hit them downstream. bundle: name: "Rust SDK Bundled CLI Build" + if: github.event.repository.fork == false env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 diff --git a/.github/workflows/scenario-builds.yml b/.github/workflows/scenario-builds.yml index 923560aba..8114176e7 100644 --- a/.github/workflows/scenario-builds.yml +++ b/.github/workflows/scenario-builds.yml @@ -28,6 +28,7 @@ jobs: # ── TypeScript ────────────────────────────────────────────────────── build-typescript: name: "TypeScript scenarios" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -74,6 +75,7 @@ jobs: # ── Python ────────────────────────────────────────────────────────── build-python: name: "Python scenarios" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -112,6 +114,7 @@ jobs: # ── Go ────────────────────────────────────────────────────────────── build-go: name: "Go scenarios" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -149,6 +152,7 @@ jobs: # ── C# ───────────────────────────────────────────────────────────── build-csharp: name: "C# scenarios" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -191,6 +195,7 @@ jobs: # ── Rust ──────────────────────────────────────────────────────────── build-rust: name: "Rust scenarios" + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/verify-compiled.yml b/.github/workflows/verify-compiled.yml index 792dac172..1d265dc49 100644 --- a/.github/workflows/verify-compiled.yml +++ b/.github/workflows/verify-compiled.yml @@ -12,6 +12,7 @@ permissions: jobs: verify: + if: github.event.repository.fork == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 299ea2116b058a7239865307d592f4e1db0e7234 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 09:31:37 -0400 Subject: [PATCH 15/33] Default publish workflow to prerelease (#1233) Agent-Logs-Url: https://github.com/github/copilot-sdk/sessions/c70da0eb-ac13-4b57-b27b-628b04b13c2e Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6add87e28..a567522f6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,7 @@ on: description: "Tag to publish under" type: choice required: true - default: "latest" + default: "prerelease" options: - latest - prerelease From ce56eb81a1b57f72f603f8cd3e7fb5ce9ee2dbc3 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 8 May 2026 11:58:35 -0400 Subject: [PATCH 16/33] Fix SDK documentation typos (#1235) * Fix SDK documentation typos Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve embedded CLI verbose log output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/setup/github-oauth.md | 2 +- docs/troubleshooting/mcp-debugging.md | 2 +- dotnet/README.md | 2 +- dotnet/src/JsonRpc.cs | 2 +- go/internal/jsonrpc2/frame.go | 2 +- nodejs/README.md | 2 +- nodejs/src/sessionFsProvider.ts | 2 +- python/copilot/_jsonrpc.py | 18 +++++++++--------- python/test_jsonrpc.py | 2 +- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 838847820..186cf0b98 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Advanced: You can override the bundled CLI using `cliPath` or `cliUrl` if you wa ### What tools are enabled by default? -By default, the SDK will operate the Copilot CLI in the equivalent of `--allow-all` being passed to the CLI, enabling all first-party tools, which means that the agents can perform a wide range of actions, including file system operations, Git operations, and web requests. You can customize tool availability by configuring the SDK client options to enable and disable specific tools. Refer to the individual SDK documentation for details on tool configuration and Copilot CLI for the list of tools available. +By default, the SDK operates the Copilot CLI as if `--allow-all` were passed, enabling all first-party tools. This means that agents can perform a wide range of actions, including file system operations, Git operations, and web requests. You can customize tool availability by configuring the SDK client options to enable and disable specific tools. Refer to the individual SDK documentation for details on tool configuration and to the Copilot CLI documentation for the list of available tools. ### Can I use custom agents, skills or tools? diff --git a/docs/setup/github-oauth.md b/docs/setup/github-oauth.md index 74fa1fc65..31a3b9001 100644 --- a/docs/setup/github-oauth.md +++ b/docs/setup/github-oauth.md @@ -113,7 +113,7 @@ async function handleOAuthCallback(code: string): Promise { ## Step 3: pass the token to the SDK -Create a SDK client for each authenticated user, passing their token: +Create an SDK client for each authenticated user, passing their token:
Node.js / TypeScript diff --git a/docs/troubleshooting/mcp-debugging.md b/docs/troubleshooting/mcp-debugging.md index 12ce8f070..fe001a13f 100644 --- a/docs/troubleshooting/mcp-debugging.md +++ b/docs/troubleshooting/mcp-debugging.md @@ -335,7 +335,7 @@ Windows Defender or other AV may block: #### Gatekeeper blocking ```bash -# If server is blocked +# If the server is blocked xattr -d com.apple.quarantine /path/to/mcp-server ``` diff --git a/dotnet/README.md b/dotnet/README.md index 37de80afd..6b76f3913 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -35,7 +35,7 @@ await using var session = await client.CreateSessionAsync(new SessionConfig OnPermissionRequest = PermissionHandler.ApproveAll, }); -// Wait for response using session.idle event +// Wait for the response using the session.idle event var done = new TaskCompletionSource(); session.On(evt => diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index c57166aee..7480aa8ff 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -365,7 +365,7 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken) // line; we walk the lines and require an exact "Content-Length: " prefix at the // start of one of them. A substring match anywhere in the header block would // false-positive on values like "X-Trace: Content-Length: 5" and desync the stream. - // A missing or unparseable Content-Length means the framing is broken — there's + // A missing or unparsable Content-Length means the framing is broken — there's // no safe way to resync, so throw and let the read loop terminate the connection. int contentLength = -1; ReadOnlySpan prefix = "Content-Length: "u8; diff --git a/go/internal/jsonrpc2/frame.go b/go/internal/jsonrpc2/frame.go index 6cd931dc6..b54f2857b 100644 --- a/go/internal/jsonrpc2/frame.go +++ b/go/internal/jsonrpc2/frame.go @@ -82,7 +82,7 @@ func newHeaderWriter(w io.Writer) *headerWriter { return &headerWriter{out: w} } -// Write sends a single frame with Content-Length header. +// Write sends a single frame with a Content-Length header. func (w *headerWriter) Write(data []byte) error { if _, err := fmt.Fprintf(w.out, "Content-Length: %d\r\n\r\n", len(data)); err != nil { return err diff --git a/nodejs/README.md b/nodejs/README.md index 93861c4e2..a8ada97ed 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -38,7 +38,7 @@ const session = await client.createSession({ onPermissionRequest: approveAll, }); -// Wait for response using typed event handlers +// Wait for the response using typed event handlers const done = new Promise((resolve) => { session.on("assistant.message", (event) => { console.log(event.data.content); diff --git a/nodejs/src/sessionFsProvider.ts b/nodejs/src/sessionFsProvider.ts index 721a990ec..920ea3cd1 100644 --- a/nodejs/src/sessionFsProvider.ts +++ b/nodejs/src/sessionFsProvider.ts @@ -17,7 +17,7 @@ import type { export type SessionFsFileInfo = Omit; /** - * Interface for session filesystem providers. Implementors use idiomatic + * Interface for session filesystem providers. Implementers use idiomatic * TypeScript patterns: throw on error, return values directly. Use * {@link createSessionFsAdapter} to convert a provider into the * {@link SessionFsHandler} expected by the SDK. diff --git a/python/copilot/_jsonrpc.py b/python/copilot/_jsonrpc.py index 61e216968..ecae75b6b 100644 --- a/python/copilot/_jsonrpc.py +++ b/python/copilot/_jsonrpc.py @@ -137,7 +137,7 @@ async def request( self, method: str, params: dict | None = None, timeout: float | None = None ) -> Any: """ - Send a JSON-RPC request and wait for response + Send a JSON-RPC request and wait for the response. Args: method: Method name @@ -149,8 +149,8 @@ async def request( The result from the response Raises: - JsonRpcError: If server returns an error - asyncio.TimeoutError: If request times out (only when timeout is set) + JsonRpcError: If the server returns an error + asyncio.TimeoutError: If the request times out (only when timeout is set) """ request_start = time.perf_counter() request_id = str(uuid.uuid4()) @@ -198,7 +198,7 @@ async def request( async def notify(self, method: str, params: dict | None = None): """ - Send a JSON-RPC notification (no response expected) + Send a JSON-RPC notification (no response expected). Args: method: Method name @@ -212,7 +212,7 @@ async def notify(self, method: str, params: dict | None = None): await self._send_message(message) def set_notification_handler(self, handler: Callable[[str, dict], None]): - """Set handler for incoming notifications from server""" + """Set the handler for incoming notifications from the server.""" self.notification_handler = handler def set_request_handler(self, method: str, handler: RequestHandler): @@ -222,7 +222,7 @@ def set_request_handler(self, method: str, handler: RequestHandler): self.request_handlers[method] = handler async def _send_message(self, message: dict): - """Send a JSON-RPC message with Content-Length header""" + """Send a JSON-RPC message with a Content-Length header.""" loop = self._loop or asyncio.get_event_loop() def write(): @@ -311,10 +311,10 @@ def _read_exact(self, num_bytes: int) -> bytes: def _read_message(self) -> dict | None: """ - Read a single JSON-RPC message with Content-Length header (blocking) + Read a single JSON-RPC message with a Content-Length header (blocking). Returns: - Parsed JSON message or None if connection closed + Parsed JSON message, or None if the connection is closed. """ # Read header line header_line = self.process.stdout.readline() @@ -362,7 +362,7 @@ def _handle_message(self, message: dict): loop.call_soon_threadsafe(future.set_exception, exc) return - # Check if it's a notification from server + # Check if it's a notification from the server if "method" in message and "id" not in message: if self.notification_handler and self._loop: method = message["method"] diff --git a/python/test_jsonrpc.py b/python/test_jsonrpc.py index c0ab2c6f4..56ce44e37 100644 --- a/python/test_jsonrpc.py +++ b/python/test_jsonrpc.py @@ -166,7 +166,7 @@ class TestReadMessageWithLargePayloads: """Tests for _read_message() with large JSON-RPC messages""" def create_jsonrpc_message(self, content_dict: dict) -> bytes: - """Create a complete JSON-RPC message with Content-Length header""" + """Create a complete JSON-RPC message with a Content-Length header.""" content = json.dumps(content_dict, separators=(",", ":")) content_bytes = content.encode("utf-8") header = f"Content-Length: {len(content_bytes)}\r\n\r\n" From 55b3b1ce276bf33fdd38d05f3478ce9ef425e7df Mon Sep 17 00:00:00 2001 From: Timothy Clem Date: Fri, 8 May 2026 19:07:50 -0700 Subject: [PATCH 17/33] Unify Rust SDK release with publish.yml workflow (#1237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Unify Rust SDK release with publish.yml workflow Fold the Rust crate into the same manual publish.yml workflow that ships Node, .NET, and Python. Retire release-plz, the two-PR flow, and the hand-curated CHANGELOG.md. Rust ships only on dist-tag=latest runs and is hard-guarded to ^0\. versions to prevent accidentally publishing 1.x while the crate is still pre-1.0. The cross-language release notes continue at vX.Y.Z; Rust gets its own scoped GitHub Release at rust/vX.Y.Z with notes auto-generated from PR titles since the previous Rust tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Allow Rust SDK to ship prereleases on crates.io Drop the pre-1.0 ^0\. guard and the 'latest only' gate on the Rust publish job. Rust now follows the same dist-tag rules as Python and .NET: publish on 'latest' and 'prerelease', skip on 'unstable'. Cargo's standard semver behavior already protects stable users from prereleases — 'cargo add' and bare version requirements skip prereleases by default, so users have to opt in explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop cargo semver-checks from Rust CI None of the other language SDKs enforce semver compatibility in CI; Rust shouldn't either. Out of step with the rest of the repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - Use 'grep -vFx' instead of 'grep -v "^TAG$"' for prev-tag lookup. Tag values contain dots that would be treated as regex metachars. - Pass --prerelease to the Rust 'gh release create' when the workflow is triggered with dist-tag=prerelease, matching how the cross-language release step is labeled. - Replace BSD-sed invocations in rust/RELEASING.md with portable 'perl -i -pe' so the manual publish steps work on both macOS and Linux. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish.yml | 76 ++- .github/workflows/rust-publish-release.yml | 55 --- .github/workflows/rust-release-pr.yml | 56 --- .github/workflows/rust-sdk-tests.yml | 12 - rust/CHANGELOG.md | 511 --------------------- rust/Cargo.lock | 2 +- rust/Cargo.toml | 3 +- rust/README.md | 2 + rust/RELEASING.md | 233 +++------- rust/release-plz.toml | 35 -- 10 files changed, 147 insertions(+), 838 deletions(-) delete mode 100644 .github/workflows/rust-publish-release.yml delete mode 100644 .github/workflows/rust-release-pr.yml delete mode 100644 rust/CHANGELOG.md delete mode 100644 rust/release-plz.toml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a567522f6..6e79e6aaf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -147,6 +147,38 @@ jobs: if: github.ref == 'refs/heads/main' run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + publish-rust: + name: Publish Rust SDK + if: github.event.inputs.dist-tag != 'unstable' + needs: version + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./rust + steps: + - uses: actions/checkout@v6.0.2 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.94.0" + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "rust" + - name: Set version + run: sed -i -E 's/^version = ".*"$/version = "${{ needs.version.outputs.version }}"/' Cargo.toml + - name: Package (dry run) + run: cargo publish --dry-run --allow-dirty + - name: Upload artifact + uses: actions/upload-artifact@v7.0.0 + with: + name: rust-package + path: rust/target/package/*.crate + - name: Publish to crates.io + if: github.ref == 'refs/heads/main' + run: cargo publish --allow-dirty + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + publish-python: name: Publish Python SDK if: github.event.inputs.dist-tag != 'unstable' @@ -185,7 +217,7 @@ jobs: github-release: name: Create GitHub Release - needs: [version, publish-nodejs, publish-dotnet, publish-python] + needs: [version, publish-nodejs, publish-dotnet, publish-python, publish-rust] if: github.ref == 'refs/heads/main' && github.event.inputs.dist-tag != 'unstable' runs-on: ubuntu-latest steps: @@ -238,3 +270,45 @@ jobs: fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Tag Rust SDK and create Rust GitHub Release + # Rust gets its own version-scoped GitHub Release with notes + # derived from PR titles since the previous Rust tag. The + # cross-language `vX.Y.Z` release above still exists; this one + # is the canonical reference for Rust users. + if: github.event.inputs.dist-tag == 'latest' || github.event.inputs.dist-tag == 'prerelease' + run: | + set -e + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch --tags + VERSION="${{ needs.version.outputs.version }}" + TAG_NAME="rust/v${VERSION}" + if git tag "$TAG_NAME" ${{ github.sha }} 2>/dev/null; then + git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git "$TAG_NAME" + echo "Created and pushed tag $TAG_NAME" + else + echo "Tag $TAG_NAME already exists, skipping tag push" + fi + # Find the previous Rust tag for note generation. Prefer rust/v*, + # fall back to the historical rust-v* tags from the release-plz era. + PREV_TAG=$(git tag --list 'rust/v*' --sort=-v:refname | grep -vFx "$TAG_NAME" | head -n1) + if [ -z "$PREV_TAG" ]; then + PREV_TAG=$(git tag --list 'rust-v*' --sort=-v:refname | head -n1) + fi + NOTES_FLAG="" + if [ -n "$PREV_TAG" ]; then + NOTES_FLAG="--notes-start-tag $PREV_TAG" + echo "Generating notes from $PREV_TAG..$TAG_NAME" + else + echo "No previous Rust tag found; generating notes from full history" + fi + PRERELEASE_FLAG="" + if [ "${{ github.event.inputs.dist-tag }}" = "prerelease" ]; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "$TAG_NAME" \ + --title "$TAG_NAME" \ + --generate-notes $NOTES_FLAG $PRERELEASE_FLAG \ + --target ${{ github.sha }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rust-publish-release.yml b/.github/workflows/rust-publish-release.yml deleted file mode 100644 index daf768929..000000000 --- a/.github/workflows/rust-publish-release.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: "Rust SDK: Publish Release" - -# Publishes the `copilot-sdk` crate to crates.io when a release-plz -# version-bump PR is merged to `main`. See rust/RELEASING.md for the -# full release process and one-time setup (CARGO_REGISTRY_TOKEN, etc). - -on: - push: - branches: - - main - paths: - - 'rust/Cargo.toml' - - 'rust/Cargo.lock' - - 'rust/release-plz.toml' - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: rust-release-plz-publish - cancel-in-progress: false - -jobs: - publish: - name: Publish to crates.io - if: github.event.repository.fork == false - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./rust - steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.94.0" - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: "rust" - - - name: Run release-plz release - uses: release-plz/action@v0.5 - with: - command: release - manifest_path: rust/Cargo.toml - config: rust/release-plz.toml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/rust-release-pr.yml b/.github/workflows/rust-release-pr.yml deleted file mode 100644 index 41420f3e4..000000000 --- a/.github/workflows/rust-release-pr.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: "Rust SDK: Create Release PR" - -# release-plz opens a PR that bumps the `copilot-sdk` version in -# `rust/Cargo.toml` and updates `rust/CHANGELOG.md` based on -# conventional-commit history since the last `rust-vX.Y.Z` tag. -# -# Review and merge that PR on the maintainer's schedule. Publishing to -# crates.io happens separately in `rust-publish-release.yml` once the -# version bump lands on `main`. -# -# Runs manually only — we don't want a PR to race with every push. - -on: - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: rust-release-plz-pr - cancel-in-progress: false - -jobs: - release-pr: - name: Create Release PR - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./rust - steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.94.0" - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: "rust" - - - name: Run release-plz release-pr - uses: release-plz/action@v0.5 - with: - command: release-pr - manifest_path: rust/Cargo.toml - config: rust/release-plz.toml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # CARGO_REGISTRY_TOKEN is not required for release-pr (no publish), - # but release-plz inspects the crate on crates.io to compute the - # next version. Public crate inspection doesn't need auth. diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 5dc100e72..207ed6de9 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -99,18 +99,6 @@ jobs: COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} run: cargo test --features test-support - # Detects accidental public-API breakage against the crate's last - # published version on crates.io. Non-blocking until the crate has - # a first published release — once a 0.1.0 ships, flip - # `continue-on-error` to `false` to enforce SemVer. - - name: cargo semver-checks - if: runner.os == 'Linux' - continue-on-error: true - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - package: github-copilot-sdk - manifest-path: rust/Cargo.toml - # Validates the `embedded-cli` build path on all three supported # platforms. This is the only place `build.rs` actually runs (the # default `cargo test` job above has `COPILOT_CLI_VERSION` unset, so diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md deleted file mode 100644 index 37dff98ab..000000000 --- a/rust/CHANGELOG.md +++ /dev/null @@ -1,511 +0,0 @@ -# Changelog - -All notable changes to the `github-copilot-sdk` crate will be documented in this file. - -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -After 0.1.0 ships, [release-plz](https://release-plz.dev/) will prepend new -entries from conventional-commit history. The Unreleased entry below is -hand-curated so that crates.io readers get a usable summary of the public -surface on first publish, not a flat list of merge commits — release-plz -will rename `[Unreleased]` to `[0.1.0] - ` and add a fresh empty -`[Unreleased]` above it when it cuts the first release PR. - -## [Unreleased] - -Initial public release. Programmatic Rust access to the GitHub Copilot CLI -over JSON-RPC 2.0 (stdio or TCP), with handler-based event dispatch, typed -tool/permission/elicitation helpers, and runtime session management. - -This is a **technical preview**. The crate is pre-1.0 and the public API may -change in breaking ways before 1.0. The rendered docs on -[docs.rs](https://docs.rs/github-copilot-sdk) are the canonical reference for the -public surface. - -### Added - -#### Client lifecycle -- `Client::start` — spawn and manage a GitHub Copilot CLI child process. -- `Client::from_streams` — connect to a CLI server over caller-supplied - `AsyncRead`/`AsyncWrite` (testing, custom transports). -- `Client::stop` / `Client::force_stop` — graceful and immediate shutdown. -- `Client::state` returning `ConnectionState` (`Connecting`, `Connected`, - `Disconnecting`, `Disconnected`). -- `Client::subscribe_lifecycle` returning a `LifecycleSubscription` for - runtime observation of created / destroyed / foreground / background - events. Implements `tokio_stream::Stream` and offers an inherent - `recv()`; drop the value to unsubscribe. -- `Client::ping(message)` returning typed `PingResponse` and - `Client::verify_protocol_version` for handshake validation. -- `Client::list_sessions`, `get_session_metadata`, `delete_session`, - `get_last_session_id`, `get_foreground_session_id`, - `set_foreground_session_id`. -- `Client::list_models`, `get_status` (typed `GetStatusResponse`), - `get_auth_status` (typed `GetAuthStatusResponse`). - -#### Sessions -- `Client::create_session` and `Client::resume_session` accepting - `SessionConfig` with handler, capabilities, system message, mode, model, - permission policy, working directory, and resume parameters. -- `Session::send` returning the assigned message ID for - correlation with later events. -- `Session::send_and_wait` for synchronous prompt → final-event flows. -- `Session::subscribe` returning an `EventSubscription` for observe-only - access to the session's event stream. Implements `tokio_stream::Stream` - and offers an inherent `recv()`; drop the value to unsubscribe. -- `Session::set_model(model, SetModelOptions)` with `reasoning_effort` - and `model_capabilities` overrides (matches Node/Python/.NET). -- UI primitives: `session.ui().elicitation()`, `confirm()`, `select()`, - `input()` — grouped under a `SessionUi` sub-API to mirror .NET / Python / - Go. -- `Session::log(message, LogOptions)` with optional severity and - ephemeral flag. -- `Session::abort` (matches all other SDKs). -- `Session::disconnect` (canonical) and `Session::destroy` (alias) - preserve on-disk session state for later resume. -- `Session::stop_event_loop` for shutting down the per-session loop. -- `Session::cancellation_token()` returns a [`tokio_util::sync::CancellationToken`] - child token that fires when the session shuts down (via - `stop_event_loop`, `destroy`, or `Drop`). Lets external tasks bind their - lifetime to a session via `tokio::select!` without taking a strong - reference to the session. Cancelling the returned child token does not - shut the session down — only `stop_event_loop` (or dropping the session) - does. - -#### Handlers + helpers -- `SessionHandler` trait with default fallback impls for each event - (permissions, external tools, elicitation, plan-mode prompts). -- `ApproveAllHandler` / `DenyAllHandler` reference handlers. -- Permission policy helpers: `permission::approve_all`, - `permission::deny_all`, `permission::approve_if`, plus chainable - builders on `SessionConfig` (`approve_all_permissions`, - `deny_all_permissions`, `approve_if`). -- `PermissionResult` is `#[non_exhaustive]` and supports `Approved`, - `Denied`, `Deferred` (handler will resolve via - `handlePendingPermissionRequest` itself — notification path only; - direct RPC falls back to `Approved`), and - `Custom(serde_json::Value)` for response shapes beyond - `{ "kind": "approve-once" | "reject" }` (e.g. allowlist payloads). -- All extension-point and protocol-evolving public enums are - `#[non_exhaustive]` so future variants are additive (non-breaking): - `Error`, `ProtocolError`, `SessionError`, `Transport`, `Attachment`, - `ToolResult`, `ElicitationMode`, `InputFormat`, `GitHubReferenceType`, - `SessionLifecycleEventType`, plus the handler/hook event/response enums. - Closed taxonomies (`LogLevel`, `ConnectionState`, `CliProgram`) remain - exhaustive so callers benefit from compile-time exhaustiveness checks. -- Tool helpers: `tool::DefineTool`, `tool::tool_schema_for`, - `tool::ToolHandlerRouter`, derive support via `derive` feature. - `ToolHandlerRouter` overrides each `SessionHandler` per-event method - directly, so callers can use the narrow-typed entry points (e.g. - `router.on_external_tool(invocation).await -> ToolResult`) instead of - unwrapping a `HandlerResponse` from `on_event`. The default `on_event` - still routes correctly through the per-event methods, so legacy - callers are unaffected. -- Hooks API for instrumenting send/receive flows (`github_copilot_sdk::hooks`). -- `SessionHandler::on_auto_mode_switch` — typed handler for the CLI's - rate-limit-recovery prompt (`autoModeSwitch.request` JSON-RPC - callback, added in copilot-agent-runtime PR #7024). Returns a typed - [`AutoModeSwitchResponse`] enum with `Yes`, `YesAlways`, `No` - variants (`#[serde(rename_all = "snake_case")]`, wire values byte- - identical to the runtime's `"yes" | "yes_always" | "no"` schema). - Default impl declines (`No`); override only if your application - surfaces a UX for the prompt. `SessionConfig::request_auto_mode_switch` - and `ResumeSessionConfig::request_auto_mode_switch` default to - `Some(true)` so the CLI advertises the callback to the SDK out of the - box. **Cross-SDK divergence:** typed handler is Rust-only as of 0.1.0. - Node, Python, Go, and .NET observe the request as a raw JSON-RPC - callback today; parity ports for those SDKs are post-release follow-up - work. -- New session-event fields surfaced by the `@github/copilot ^1.0.39` - schema bump: - - `SessionErrorData.eligible_for_auto_switch: Option` — set on - `errorType: "rate_limit"` to signal the runtime will follow with an - `auto_mode_switch.requested` event. UI clients can suppress - duplicate rendering of the rate-limit error when they show their - own auto-mode-switch prompt. - - `SessionErrorData.error_code: Option` — fine-grained - upstream provider error code (e.g. - `"user_weekly_rate_limited"`, `"integration_rate_limited"`). - - `SessionModelChangeData.cause: Option` — - `"rate_limit_auto_switch"` for changes triggered by the - auto-mode-switch recovery path. Lets UI render contextual copy. - - `AutoModeSwitchRequestedData.retry_after_seconds: Option` — - seconds until the rate limit resets, when known. Clients can - render a humanized reset time alongside the prompt. The request- - callback path's `retry_after_seconds` parameter on - [`SessionHandler::on_auto_mode_switch`](crate::handler::SessionHandler::on_auto_mode_switch) - uses the same `Option` representation. - -#### Types -- Newtype `SessionId`, plus generated RPC types under `github_copilot_sdk::generated`. -- `LogLevel`, `LogOptions`, `SetModelOptions`, `PingResponse`, - `SessionLifecycleEvent`, `SessionLifecycleEventType`, `ConnectionState`, - `SystemMessageConfig`, `MessageOptions`, `SectionOverride`, `Attachment`, - `InputFormat`, `InputOptions`. -- Strongly-typed `Error` and `ProtocolError` with `is_transport_failure` - classifier and `error_codes` constants. - -#### Typed RPC namespace -- `Client::rpc()` and `Session::rpc()` accessors exposing a generated, typed - view over the full GitHub Copilot CLI JSON-RPC API. Sub-namespaces mirror the - schema (e.g. `client.rpc().models().list()`, `session.rpc().workspaces() - .list_files()`, `session.rpc().agent().list()`, - `session.rpc().tasks().list()`). -- All hand-authored helpers (`list_workspace_files`, `read_plan`, `set_mode`, - `list_models`, `get_quota`, etc.) are now thin one-line delegations over - this namespace. Wire-method strings exist in exactly one place - (`generated/rpc.rs`), making typo bugs like the `session.workspace.*` - → `session.workspaces.*` regression structurally impossible. Public - helper signatures are unchanged. - -#### Configuration parity -- All remaining public configuration types are now `#[non_exhaustive]` - for forward-compatibility — adding fields post-1.0 is non-breaking on - consumers that construct via `Default::default()` plus field - assignment or the `with_*` builders. Affected: `SessionConfig`, - `ResumeSessionConfig`, `ClientOptions`, `ProviderConfig`, - `McpServerConfig`, `Tool`, `CustomAgentConfig`, - `InfiniteSessionConfig`, `SystemMessageConfig`, `ConnectionState`. - (`HookEvent`, `HookOutput`, `MessageOptions`, `TelemetryConfig`, - `SessionFsConfig`, `FsError`, `FileInfo`, `DirEntry`, `ToolInvocation`, - `Error`, `Transport`, `DeliveryMode` were already marked.) Callers - using exhaustive struct literals must switch to - `let mut x = Type::default(); x.field = ...;` or the available `with_*` - builders; `..Default::default()` no longer compiles for these types - outside the defining crate. -- `MessageOptions::mode` is now typed `Option` (was - `Option`). `DeliveryMode` is `#[non_exhaustive]` and serializes - to the wire strings `"enqueue"` (default) and `"immediate"`. The prior - rustdoc incorrectly described this field as a permission mode; the - field controls how the prompt is delivered relative to in-flight work. - `MessageOptions::with_mode` now takes `DeliveryMode` directly. Callers - that previously passed `"agent"` or `"autopilot"` were already silently - no-ops at the CLI level — switch to a `DeliveryMode` variant or omit - the field entirely. -- `SessionConfig::default()` and `ResumeSessionConfig::new()` now set the - four permission-flow flags (`request_user_input`, `request_permission`, - `request_exit_plan_mode`, `request_elicitation`) to `Some(true)` instead - of `None`. Mirrors Node's `client.ts` behavior of always advertising the - permission surface and deriving handler presence from the - `SessionHandler` impl. The default `DenyAllHandler` refuses all - permission requests so the wire surface is safe out-of-the-box; callers - that want the wire surface fully disabled set the flags explicitly to - `Some(false)`. -- `SessionListFilter` — typed filter for `Client::list_sessions` covering - `cwd`, `git_root`, `repository`, and `branch`. Replaces the prior - `Option` parameter. -- `McpServerConfig` tagged enum (`Stdio` / `Http` / `Sse`) with - `McpStdioServerConfig` and `McpHttpServerConfig` payload structs. - `SessionConfig::mcp_servers`, `ResumeSessionConfig::mcp_servers`, and - `CustomAgentConfig::mcp_servers` are now `Option>` instead of typeless `Value` maps. Stdio configurations - serialized by older callers (no explicit `type`, or `type: "local"`) are - accepted on the deserialize path. -- `PermissionRequestData` gains typed `kind: Option` - and `tool_call_id: Option` fields covering the eight CLI - permission categories (`shell`, `write`, `read`, `url`, `mcp`, - `custom-tool`, `memory`, `hook`); unknown values fall through to - `PermissionRequestKind::Unknown` for forward compatibility. The original - params object is still available via the existing `extra: Value` flatten. -- `PermissionResult` gains `UserNotAvailable` (sent as - `{ "kind": "user-not-available" }`) and `NoResult` (sent as - `{ "kind": "no-result" }`) variants for headless agents and explicit - fall-through-to-CLI-default responses. -- `Client::stop` cooperatively shuts down active sessions before killing - the CLI child: walks every session still registered with the client, - sends `session.destroy` for each, then kills the child. Errors from - per-session destroys and the terminal child-kill are collected into a - new `StopErrors` aggregate (`Result<(), StopErrors>`) instead of - short-circuiting on the first failure, mirroring the Node SDK's - `Error[]` return shape. `StopErrors` implements `std::error::Error` - and exposes `errors()` / `into_errors()` for inspection. Callers that - previously used `client.stop().await?` should switch to - `client.stop().await.ok();` (best-effort) or match on the aggregate. -- `ResumeSessionConfig::disable_resume: Option` — force-fail resume - if the session does not exist on disk, instead of silently starting a - new session. -- `SessionConfig` and `ResumeSessionConfig` gain six configuration knobs - matching the Node SDK shape (Bucket B.1): - - `session_id: Option` (SessionConfig only — required on - resume, where it remains `SessionId`) — supply a custom session ID - instead of letting the CLI generate one. - - `working_directory: Option` — per-session cwd override, - independent of [`ClientOptions::cwd`](crate::ClientOptions::cwd). - - `config_dir: Option` — override the default configuration - directory location for this session. - - `model_capabilities: Option` — per-property - overrides for model capabilities, deep-merged over runtime defaults. - The same type was previously available only on - `SetModelOptions::model_capabilities`. - - `github_token: Option` — per-session GitHub token. Distinct - from [`ClientOptions::github_token`], which authenticates the CLI - process; this token determines the GitHub identity used for content - exclusion, model routing, and quota checks for this session. The - field is redacted from the `Debug` output. - - `include_sub_agent_streaming_events: Option` — forward streaming - delta events from sub-agents to this connection (Node default: true). -- `ClientOptions` gains the simple subset of Node's - `CopilotClientOptions` knobs (Bucket B.2): - - `log_level: Option` — typed enum (`None`, `Error`, `Warning`, - `Info`, `Debug`, `All`) replacing the previously hard-coded - `--log-level info` argument. When unset, the SDK still passes - `--log-level info` for parity with prior behavior. - - `session_idle_timeout_seconds: Option` — server-wide idle - timeout for sessions in seconds. When `Some(n)` with `n > 0`, the - SDK passes `--session-idle-timeout `. `None` or `Some(0)` leaves - sessions running indefinitely (the CLI default). - - The Node knob `isChildProcess` (sub-CLI parent-stdio mode) and - `autoStart` (lazy-init pattern) are intentionally **not** ported — - `isChildProcess` requires a transport variant the Rust SDK does not - yet support; `autoStart` does not apply because [`Client::start`] is - a single explicit constructor rather than a deferred-init pattern. - - `on_list_models: Option>` — BYOK escape - hatch matching Node's `onListModels`. When set, [`Client::list_models`] - returns the handler's result without making a `models.list` RPC. - `ListModelsHandler` is a new public `async_trait` (mirrors the shape - of `SessionHandler` / `SessionHooks`) with a single - `async fn list_models(&self) -> Result, Error>` method. - `ClientOptions` switched from `#[derive(Debug)]` to a manual `Debug` - impl that prints the handler as `` / `None` (same precedent as - `SessionConfig::handler` and `github_token`). -- `MessageOptions` gains `request_headers: Option>` - with a corresponding [`MessageOptions::with_request_headers`] builder - method, matching Node's `MessageOptions.requestHeaders` and Go's - `MessageOptions.RequestHeaders`. Custom HTTP headers are forwarded to - the CLI via the `requestHeaders` field on `session.send`. The field is - omitted from the wire when `None` or empty (matches Node's - `omitempty` semantics). -- Slash command registration: new [`CommandHandler`] async trait, - [`CommandDefinition`] (with `new`/`with_description` builders), and - [`CommandContext`] (`session_id`, `command`, `command_name`, `args`) - hand-authored in `crate::types`. `SessionConfig::commands` and - `ResumeSessionConfig::commands` accept a `Vec` via - the new `with_commands` builder, matching Node's - `SessionConfig.commands`, Python's `SessionConfig.commands`, and Go's - `SessionConfig.Commands`. The SDK serializes only `{name, description?}` - on the wire (handlers stay client-side), and dispatches incoming - `command.execute` events to the registered handler — acking with no - error on success, `error: ` on `Err`, and - `error: "Unknown command: "` when the name is unregistered. - `CommandContext` and `CommandDefinition` are `#[non_exhaustive]` so - forward-compatible fields (e.g. aliases, completion providers) can land - without breaking callers. -- Custom session filesystem: new [`SessionFsProvider`] async trait, - [`SessionFsConfig`], [`FsError`], [`FileInfo`], [`DirEntry`], - [`DirEntryKind`], and [`SessionFsConventions`] in `crate::session_fs` - (also re-exported from `crate::types`). When [`ClientOptions::session_fs`] - is set, [`Client::start`] calls `sessionFs.setProvider` on the CLI to - delegate per-session filesystem operations to a provider supplied via - [`SessionConfig::with_session_fs_provider`] / - [`ResumeSessionConfig::with_session_fs_provider`]. Inbound `sessionFs.*` - requests dispatch to the provider; `FsError::NotFound` maps to the wire - `ENOENT` code and other `FsError` values map to `UNKNOWN`. - `From` is provided so handlers backed by `std::fs` / - `tokio::fs` can propagate errors with `?`. All trait methods have - default implementations returning `Err(FsError::Other("not supported"))`, - so providers only override the methods they need and forward-compatible - schema additions land without breaking existing implementations. - Diverges from Node/Python/Go's factory-closure pattern in favor of - direct `Arc` registration. -- W3C Trace Context propagation: new [`TraceContext`] struct and - [`TraceContextProvider`] async trait in `crate::trace_context` (also - re-exported from `crate::types`). Hybrid shape combines Node's - callback-based `onGetTraceContext` and Go's per-turn - `MessageOptions.Traceparent` / `Tracestate`: - [`ClientOptions::on_get_trace_context`] supplies an ambient provider that - injects `traceparent` / `tracestate` on `session.create`, - `session.resume`, and `session.send`, while - [`MessageOptions::with_traceparent`], [`MessageOptions::with_tracestate`], - and [`MessageOptions::with_trace_context`] override per-turn (override - wins; provider is not invoked when MessageOptions carries trace headers). - [`ToolInvocation`] is now `#[non_exhaustive]` and exposes inbound - `traceparent` / `tracestate` populated from `external_tool.requested` - events, plus a [`ToolInvocation::trace_context`] helper. Wire fields are - omitted when unset (matches Node/Go `omitempty` semantics). -- `ToolInvocation` and `SessionId` now derive `Default`. Production code - never constructs `ToolInvocation` literals (it's a CLI-emitted read-only - type), but downstream test scaffolding can now use - `ToolInvocation { tool_name: "...".into(), ..Default::default() }` and - absorb future `#[non_exhaustive]` field additions automatically. -- OpenTelemetry env-var passthrough: new [`TelemetryConfig`] struct and - [`OtelExporterType`] enum (both `#[non_exhaustive]`), wired on - [`ClientOptions::telemetry`]. When `Some(...)`, the SDK injects - `COPILOT_OTEL_ENABLED=true` plus `OTEL_EXPORTER_OTLP_ENDPOINT`, - `COPILOT_OTEL_FILE_EXPORTER_PATH`, `COPILOT_OTEL_EXPORTER_TYPE`, - `COPILOT_OTEL_SOURCE_NAME`, and - `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` into the spawned CLI - process — verbatim env-var names matching Node/Python/Go. Pure - passthrough: no `opentelemetry-rust` dependency; the CLI itself owns the - exporter. `exporter_type` is a typed enum (`OtlpHttp` / `File`) following - the [`LogLevel`](LogLevel) precedent for finite, enumerated CLI knobs; - serialized verbatim as `"otlp-http"` / `"file"`. User-supplied - `ClientOptions::env` continues to win over telemetry-injected values. -- `ClientOptions::copilot_home: Option` (and - `with_copilot_home`) — overrides the directory where the CLI persists - its state. Exported as `COPILOT_HOME` to the spawned CLI process. - Useful for sandboxing test runs or running multiple isolated SDK - instances side-by-side. Mirrors Node `copilotHome` / - Python `copilot_home`. -- `ClientOptions::tcp_connection_token: Option` (and - `with_tcp_connection_token`) — optional auth token for TCP transport. - Sent in the new `connect` JSON-RPC handshake (with backward-compat - fall-back to `ping` for legacy CLI servers) and exported as - `COPILOT_CONNECTION_TOKEN` to spawned CLI processes. When the SDK - spawns its own CLI in TCP mode and this is left unset, a UUID is - generated automatically so the loopback listener is safe by default. - Combining with `Transport::Stdio` returns - `Error::InvalidConfig` from `Client::start`. -- `SessionConfig::instruction_directories: Option>` and - `ResumeSessionConfig::instruction_directories` (plus - `with_instruction_directories` builders on both) — additional - directories searched for custom instruction files. Distinct from - `skill_directories`. Forwarded to the CLI on session create / resume. -- `Error::InvalidConfig(String)` variant for client-construction errors - that surface from `Client::start` (e.g. `tcp_connection_token` paired - with `Transport::Stdio`, empty token, etc). - -### Documentation -- `README.md` with quickstart, architecture diagram, and feature matrix. -- Examples under `examples/`: `chat`, `hooks`, `tool_server`, - `lifecycle_observer`. -- `RELEASING.md` operational runbook for maintainers. - -#### Builder ergonomics -- `ClientOptions::new()` plus a chainable `with_*` builder per public - field (`with_program`, `with_prefix_args`, `with_cwd`, `with_env`, - `with_env_remove`, `with_extra_args`, `with_transport`, - `with_github_token`, `with_use_logged_in_user`, `with_log_level`, - `with_session_idle_timeout_seconds`, `with_list_models_handler`, - `with_session_fs`, `with_trace_context_provider`, `with_telemetry`). - Mirrors the existing [`MessageOptions::new`] / `with_*` shape and - closes the cross-crate ergonomics gap on `#[non_exhaustive]` — - external callers no longer need to write - `let mut opts = ClientOptions::default(); opts.field = ...;` for - every field they touch. Existing `ClientOptions::default()` and - mut-let-and-assign continue to work unchanged. -- `Tool::new(name)` plus `with_namespaced_name`, `with_description`, - `with_instructions`, `with_parameters`, `with_overrides_built_in_tool`, - `with_skip_permission` for tool definitions. Same rationale — - `Tool` is the most-instantiated `#[non_exhaustive]` type at consumer - call sites in real-world consumer code, where the - builder shape replaces the per-consumer `make_tool(name, desc, - params)` helper that consumers were writing to smooth over the - mut-let pattern. -- Per-field `with_*` builder methods on `SessionConfig` and - `ResumeSessionConfig` covering every public scalar, vector, and - optional-struct field (~30 new methods on each). Mirrors the - `ClientOptions` / `Tool` shape; existing closure-installing - chains (`with_handler`, `with_hooks`, `with_transform`, - `with_commands`, `with_session_fs_provider`, - `approve_all_permissions`, etc.) continue to work unchanged. The - primary win: external session-construction sites collapse from - `let mut cfg = ResumeSessionConfig::new(id); cfg.client_name = - Some("...".into()); cfg.streaming = Some(true); ...` (10-15 - lines per site) to a single fluent chain. -- Round out builder coverage on the remaining consumer-facing - config structs: `CustomAgentConfig::new(name, prompt)` plus - `with_display_name`, `with_description`, `with_tools`, - `with_mcp_servers`, `with_infer`, `with_skills`; - `InfiniteSessionConfig::new()` plus `with_enabled`, - `with_background_compaction_threshold`, - `with_buffer_exhaustion_threshold`; - `ProviderConfig::new(base_url)` plus `with_provider_type`, - `with_wire_api`, `with_api_key`, `with_bearer_token`, - `with_azure`, `with_headers`, `with_model_id`, `with_wire_model`, - `with_max_prompt_tokens`, `with_max_output_tokens`; `SystemMessageConfig::new()` plus - `with_mode`, `with_content`, `with_sections`; - `TelemetryConfig::new()` plus `with_otlp_endpoint`, - `with_file_path`, `with_exporter_type`, `with_source_name`, - `with_capture_content`. `TraceContext` also gains a symmetric - `new()` + `with_traceparent` pair alongside the existing - `from_traceparent` shorthand. -- Documented the direct-field-assignment escape hatch on - `SessionConfig` and `ResumeSessionConfig` for callers forwarding - `Option` values from upstream code (matches the - `http::request::Parts` / `hyper::Body::Builder` convention; per- - field `with_*_opt` setters intentionally omitted to keep the - primary API surface small). - -#### Build infrastructure -- `build.rs` no longer shells out to `curl` for the bundled-CLI - download. The `embedded-cli` feature now downloads the - `SHA256SUMS.txt` and platform tarball through `ureq` (rustls TLS, - pure-Rust, no system dependencies). Removes the implicit `curl`- - on-PATH requirement that previously broke the build on minimal - Windows / container environments. Includes bounded retries with - exponential backoff (1s/2s/4s) on transient failures (5xx, - connect/read timeouts, transport errors) — 4xx responses still - fail fast as before. - -### Fixed -- `SessionEvent` and `TypedSessionEvent` now expose the `agentId` - envelope field added to `session-events.schema.json` upstream - (`f8cf846`, "Derive session event envelopes from schema"). Sub-agent - events were silently dropping the attribution at the deserialization - boundary; consumers had no way to distinguish events emitted by the - root agent from events emitted by a sub-agent. Other SDKs (Node, - Python, Go, .NET) all carry this field. Round-trip parity test added - in `types::tests::session_event_round_trips_agent_id_on_envelope`. -- `Session::user_input` no longer double-dispatches when the CLI sends - both a `user_input.requested` notification (for observers) and a - `userInput.request` JSON-RPC call (the actual prompt) for the same - prompt. The notification path is now a no-op; the JSON-RPC path - remains authoritative. Matches Python / Go / .NET / Node SDK - behavior, all of which only register the JSON-RPC handler. Fixes - github/github-app#4249, where consumers saw duplicate `ask_user` - and `exit_plan` widgets on every prompt. -- `SessionUi::elicitation` (and the `confirm` / `select` / `input` - convenience helpers that delegate through it) now sends the user-supplied - JSON Schema as `requestedSchema` on the wire, matching the - `session.ui.elicitation` request shape that all other SDKs ship and that - this crate's own generated `UIElicitationRequest` type expects. The - hand-authored convenience layer was sending it as `schema`, so every UI - helper call was effectively dead — the CLI saw a missing required - `requestedSchema` field. The mock-server test for elicitation - round-tripped through the same misnamed field, so the bug slipped past - unit tests; the test now asserts on `requestedSchema` and explicitly - rejects a stray `schema` key. -- `Client::list_sessions` now wraps the optional filter under `params.filter` - on the wire, matching the `session.list` request shape that Node, Python, - Go, and .NET ship. The hand-authored implementation was flattening the - filter fields directly onto `params`, which the runtime silently ignored - — so `list_sessions(Some(filter))` was functionally equivalent to - `list_sessions(None)` in 0.0.x. Same class of bug as the elicitation - wire fix above: the existing mock-server test asserted on the flat shape - it observed rather than the schema's wrapped shape, so the bug - round-tripped through both ends. The test now asserts the wrapped path - (`params.filter.repository`) and explicitly rejects the flattened - fallback (`params.repository`). -- `Client::get_status` and `Client::get_auth_status` now use the - correct wire method names (`status.get` and `auth.getStatus`) - matching Node, Go, Python, and .NET. The hand-authored - implementation was sending `getStatus` and `getAuthStatus` — names - that aren't registered on the CLI runtime — so both calls would - have returned a "method not found" error (or a misleading no-such- - method log) instead of the expected status payload. Same class of - bug as the elicitation `requestedSchema` and `list_sessions` - filter-wrapping fixes above: the mock-server test for these - methods asserted on the wrong-name strings the implementation - used, so the bugs round-tripped through both ends. The test now - asserts on the canonical wire names AND explicitly rejects the - hand-authored aliases (`assert_ne!(request["method"], "getStatus")` - / `"getAuthStatus"`). - -### Notes -- Minimum supported Rust version (MSRV): 1.94.0 (pinned via - `rust-toolchain.toml`). -- No `Client::actual_port` accessor — this SDK is strictly stream-based, - so the concept doesn't apply. See `Client::from_streams` rustdoc. -- `cargo semver-checks` runs in `continue-on-error` mode for 0.1.0; will - flip to blocking once 0.1.0 is published and serves as the baseline. -- `infinite_sessions: Option` is wired on both - `SessionConfig` and `ResumeSessionConfig` and follows the same - default-omit-on-the-wire semantics as Node/Go: when `None`, the field - is skipped and the CLI applies its own default. No behavioral - divergence from the other SDKs. -- `Client::stop` returns `Result<(), StopErrors>` and now cooperatively - shuts down each active session via `session.destroy` before killing - the CLI child, aggregating all per-session and child-kill errors into - the returned `StopErrors`. See the entry under "Configuration parity" - above for the migration note. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1163de37e..8b130628e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "github-copilot-sdk" -version = "0.1.0" +version = "0.0.0-dev" dependencies = [ "async-trait", "dirs", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0b90cc1ab..4d8831f7e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "github-copilot-sdk" -version = "0.1.0" +version = "0.0.0-dev" edition = "2024" rust-version = "1.94.0" description = "Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC. Technical preview, pre-1.0." @@ -13,7 +13,6 @@ readme = "README.md" license = "MIT" exclude = [ "RELEASING.md", - "release-plz.toml", "rust-toolchain.toml", ".rustfmt.toml", ".rustfmt.nightly.toml", diff --git a/rust/README.md b/rust/README.md index d6cf17a93..05177132d 100644 --- a/rust/README.md +++ b/rust/README.md @@ -6,6 +6,8 @@ A Rust SDK for programmatic access to the GitHub Copilot CLI. See [github/copilot-sdk](https://github.com/github/copilot-sdk) for the equivalent SDKs in TypeScript, Python, Go, and .NET. The Rust SDK seeks parity with those SDKs; see [Differences From Other SDKs](#differences-from-other-sdks) below for the small set of intentional divergences. +**Releases:** [github.com/github/copilot-sdk/releases?q=rust%2F](https://github.com/github/copilot-sdk/releases?q=rust%2F) — per-version release notes for the Rust crate. + ## Quick Start ```rust,no_run diff --git a/rust/RELEASING.md b/rust/RELEASING.md index 5361591d2..de0252de8 100644 --- a/rust/RELEASING.md +++ b/rust/RELEASING.md @@ -1,192 +1,95 @@ # Releasing `github-copilot-sdk` -This document describes how to cut a release of the `github-copilot-sdk` Rust crate -and publish it to [crates.io]. It is the operational counterpart to the -workflow files under `../.github/workflows/rust-*.yml` (which run the actual -mechanics). - -If you are adding code to the SDK, you do not need to read this. This is for -maintainers cutting a release. - -[crates.io]: https://crates.io/crates/github-copilot-sdk - ---- +The Rust crate ships through the same unified `publish.yml` workflow +as the Node, .NET, and Python SDKs. There is no Rust-specific release +workflow. ## TL;DR -1. Land your changes on `main` using conventional-commit messages. -2. Trigger the **Rust SDK: Create Release PR** workflow manually - (`workflow_dispatch`). -3. Review and merge the PR that release-plz opens. -4. The **Rust SDK: Publish Release** workflow runs automatically when that - PR merges, publishes to crates.io, tags `rust-vX.Y.Z`, and creates a - GitHub Release. - -The first 0.1.0 publish requires a one-time `CARGO_REGISTRY_TOKEN` secret -setup — see [First-time setup](#first-time-setup) below. - ---- - -## How releases are cut - -The crate uses [release-plz] in a two-PR workflow. Both PRs run unattended -through GitHub Actions; the only manual step is reviewing and merging. - -[release-plz]: https://release-plz.dev/ - -### Step 1 — `release-plz release-pr` - -Workflow: `.github/workflows/rust-release-pr.yml` (`workflow_dispatch` only). - -When you trigger it, release-plz: - -- Reads conventional-commit history since the last `rust-vX.Y.Z` tag. -- Decides the next version (patch / minor / major) per SemVer rules. -- Bumps `rust/Cargo.toml`'s `version` field. -- Renames `## [Unreleased]` in `rust/CHANGELOG.md` to `## [X.Y.Z] - - ` and prepends a fresh empty `## [Unreleased]` above it. -- Opens a PR with those changes. - -Review the PR. The CHANGELOG entry is the one users see on crates.io and on -the GitHub Release page, so make sure it reads well. Edit the PR directly if -the auto-generated entry needs tweaking. - -> **First-publish note.** The hand-curated 0.1.0 entry currently lives -> under `## [Unreleased]` so release-plz will rename it cleanly on the -> first run. If release-plz instead generates a *second* entry from -> conventional commits and prepends it above the curated one (depends on -> the configured `body` template), delete the auto-generated stub in the -> PR and keep the curated entry — you only want one 0.1.0 section. - -### Step 2 — `release-plz release` (publish) - -Workflow: `.github/workflows/rust-publish-release.yml` (auto-runs on push -to `main` when `rust/Cargo.toml`, `rust/Cargo.lock`, or `rust/release-plz.toml` -changes). - -When the release-PR from step 1 merges, this workflow detects that -`rust/Cargo.toml`'s version is newer than the latest `rust-vX.Y.Z` tag and: - -- Runs `cargo publish` to upload to crates.io. -- Creates a `rust-vX.Y.Z` git tag. -- Creates a GitHub Release with the CHANGELOG entry as the body. - -The workflow is a no-op on non-release commits, so it's safe to run on every -push. - ---- - -## First-time setup - -Before the first 0.1.0 publish, complete this checklist exactly once: - -1. **Reserve the crate name.** Have a maintainer with crates.io 2FA log in - to crates.io and run `cargo publish` for an empty stub OR claim the name - via the "New Crate" form. The owner account should be a service account - (preferred) or a senior maintainer. -2. **Generate a scoped API token.** crates.io → Account Settings → API - Tokens → New Token. Scope it to publish `github-copilot-sdk` *only* — do not - issue an unscoped token. -3. **Add the secret.** GitHub repo Settings → Secrets and variables → - Actions → New repository secret named `CARGO_REGISTRY_TOKEN`, value = - the token from step 2. -4. **Rotation.** Rotate the token annually and whenever the maintainer set - changes. There's no automated reminder for this — set a calendar event. - -Until this checklist is complete, `cargo publish` in the workflow will fail. -That's intentional: it keeps accidental publishes from happening before the -repo is ready. - ---- - -## Versioning policy - -The crate follows [SemVer]. Pre-1.0 we treat **0.x.0** as breaking and -**0.x.y** as additive — same as the Rust ecosystem convention. - -[SemVer]: https://semver.org/ - -Two CI checks defend the API surface: - -- **`cargo semver-checks`** (`.github/workflows/rust-sdk-tests.yml`) — - detects breaking changes against the latest *published* version on - crates.io. Currently `continue-on-error: true` because there's no - baseline yet. **Flip it to `false` after 0.1.0 ships** to make SemVer - enforcement blocking. - -For ad-hoc public-surface inspection, `cargo public-api -sss --features -derive,test-support` is handy — but the surface is not snapshotted in the -repo. The rendered docs on [docs.rs](https://docs.rs/github-copilot-sdk) are the -canonical reference; `cargo-semver-checks` is the gate. - -For 0.x → 1.0, do an explicit API review pass (compare against the -language siblings under `../{nodejs,python,go,dotnet}/`), -remove anything `#[doc(hidden)]` you don't intend to keep public, and -write out the 1.0 commitment in the CHANGELOG. - ---- - -## Public-disclosure gate +1. Land your changes on `main`. +2. Trigger the **Publish SDK packages** workflow + (`.github/workflows/publish.yml`) via `workflow_dispatch`. +3. Pick `dist-tag`: + - `latest` — stable release (e.g. `1.0.0`). + - `prerelease` — beta release (e.g. `1.0.0-beta.4`). Lands on + crates.io as a prerelease; users must opt in with an explicit + prerelease version requirement to install it. + - `unstable` — skipped for Rust (Cargo doesn't have a clean + equivalent of npm's `unstable` dist-tag). +4. The workflow publishes all four SDKs at the shared computed + version, tags `rust/vX.Y.Z`, and creates a Rust-scoped GitHub + Release with auto-generated notes since the previous Rust tag. + +## Version, tag, and release notes + +- **Crate version:** the in-tree `rust/Cargo.toml` carries `0.0.0-dev` + as a placeholder. CI overrides it at publish time with the version + computed by `publish.yml` (or an explicit `version` workflow input). +- **Tag:** `rust/vX.Y.Z` (matches the `go/vX.Y.Z` style used elsewhere + in this repo). The historical `rust-v0.1.0` tag from the + release-plz era stays valid as a starting point for auto-generated + release notes. +- **Release notes:** auto-generated by `gh release --generate-notes` + from PR titles between the previous Rust tag and the new one. + Write descriptive PR titles for any change that touches the Rust + surface; that's the only place those changes will be visible to + Rust users. + +## Cargo prerelease semantics + +`cargo add github-copilot-sdk` and `version = "1"` requirements skip +prereleases by default. Users who want to opt in to a beta must +write an explicit prerelease requirement: + +```toml +github-copilot-sdk = "1.0.0-beta.4" +``` -The Rust SDK release-prep work happens on `tclem/rust-sdk-release-prep` -and is held *unpushed* until product/comms gives explicit OK. Do not push -the branch, open a PR, or otherwise expose the work without that signal — -even if CI looks ready. +This matches Cargo's standard semver behavior and means a +prerelease-channel publish won't surprise stable users. -Ways to keep moving without pushing: +## Yanking a release -- Land work in local commits on the prep branch. -- Use `cargo publish --dry-run --allow-dirty` to validate package contents. -- Use `cargo public-api -sss --features derive,test-support` for ad-hoc - surface inspection. +If a published version contains a critical bug, yank it from +crates.io to prevent new installs: -When the gate opens: +```sh +cargo yank --version X.Y.Z github-copilot-sdk +``` -1. Push `tclem/rust-sdk-release-prep`. -2. Open a PR titled "Rust SDK: prepare for 0.1.0 release" (or similar). -3. Once it merges, trigger the **Rust SDK: Create Release PR** workflow and - proceed with the publish flow above. +Yanking does *not* delete the version — existing `Cargo.lock` files +keep working — but it stops new resolutions from picking it. Follow +up with a patch release that fixes the bug, and add a note to the +yanked version's GitHub Release explaining why. ---- +Reverse with `cargo yank --undo --version X.Y.Z github-copilot-sdk` +if the yank was a mistake. ## Manual publish (emergency only) -If GitHub Actions is unavailable, a maintainer with crates.io credentials -can publish locally: +If GitHub Actions is unavailable, a maintainer with crates.io +credentials can publish locally: ```sh cd rust -# Verify the package contents first. +# Set the real version (replace X.Y.Z). +perl -i -pe 's/^version = ".*"$/version = "X.Y.Z"/' Cargo.toml + +# Verify package contents. cargo publish --dry-run # Publish for real. cargo publish # Tag and push. -git tag rust-v$(cargo metadata --no-deps --format-version=1 \ - | jq -r '.packages[] | select(.name=="github-copilot-sdk") | .version' | head -1) -git push origin --tags -``` - -Manual publishes skip the release-PR review step, so write the CHANGELOG -entry by hand before publishing and commit it on `main` first. - ---- +git tag rust/vX.Y.Z +git push origin rust/vX.Y.Z -## Yanking a release - -If a published version contains a critical bug (security, data loss, panic -on common input), yank it from crates.io to prevent new installs: - -```sh -cargo yank --version X.Y.Z github-copilot-sdk +# Restore the placeholder. +perl -i -pe 's/^version = ".*"$/version = "0.0.0-dev"/' Cargo.toml ``` -Yanking does *not* delete the version — existing `Cargo.lock` files keep -working — but it stops new resolutions from picking it. Follow up with a -patch release that fixes the bug, and add a note to the yanked version's -GitHub Release explaining why. - -Reverse with `cargo yank --undo --version X.Y.Z github-copilot-sdk` if the yank -was a mistake. +Manual publishes skip the auto-generated GitHub Release. Run +`gh release create rust/vX.Y.Z --generate-notes` after pushing the +tag. diff --git a/rust/release-plz.toml b/rust/release-plz.toml deleted file mode 100644 index 82c38ffd7..000000000 --- a/rust/release-plz.toml +++ /dev/null @@ -1,35 +0,0 @@ -[workspace] -# release-plz config for the Rust github-copilot-sdk crate. -# -# The crate lives in the `rust/` subdirectory of the monorepo, so -# invoke release-plz from this directory (via the release-plz workflows -# under `.github/workflows/`). release-plz will: -# -# 1. `release-plz release-pr`: open a PR updating `rust/Cargo.toml`'s -# version and `rust/CHANGELOG.md` based on conventional-commit -# history on `tclem/rust-sdk-release-prep`-style branches. -# 2. `release-plz release`: after that PR is merged to main, publish -# the tagged version to crates.io and create a `rust-vX.Y.Z` git -# tag. -# -# Publishing requires a `CARGO_REGISTRY_TOKEN` repository secret scoped -# to the `github-copilot-sdk` crate owner account. See -# `.github/workflows/rust-publish-release.yml` for the setup checklist. -# -# Reference: https://release-plz.dev/docs/config -changelog_update = true -dependencies_update = false -git_release_enable = true -# Prefix crate git tags so they don't collide with the monorepo's -# top-level `vX.Y.Z` tags used by the other SDKs. -git_tag_name = "rust-v{{ version }}" -git_release_name = "rust-v{{ version }}" - -[[package]] -name = "github-copilot-sdk" -changelog_path = "CHANGELOG.md" -# Mark pre-1.0 publishes as prereleases on the GitHub release page so -# consumers don't pick them up as "stable" by default. Maintainers -# should flip this (or remove it) when cutting 1.0. -git_release_type = "auto" - From ac55e9ade99adc65adad6a23ef05d5d372ca96a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 22:20:38 -0400 Subject: [PATCH 18/33] Update @github/copilot to 1.0.44-3 (#1239) * Update @github/copilot to 1.0.44-3 - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code * Align ModelBilling.multiplier optional across SDK public surfaces The regenerated wire types now treat `ModelBilling.multiplier` as optional, matching the upstream schema relaxation. Update the hand-coded public surface mirrors so each language's stable API can represent an absent multiplier instead of silently coercing to zero (Go/.NET) or raising at decode time (Python). - nodejs/src/types.ts: `multiplier?: number` - go/types.go: `*float64` with `omitempty` - dotnet/src/Types.cs: `double?` - python/copilot/client.py: `float | None = None`; from_dict no longer raises on absence; to_dict skips serializing None --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 168 +++--- dotnet/src/Generated/SessionEvents.cs | 103 ++++ dotnet/src/Types.cs | 2 +- go/generated_session_events.go | 52 +- go/rpc/generated_rpc.go | 637 ++++++++++++--------- go/types.go | 2 +- nodejs/package-lock.json | 56 +- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/rpc.ts | 61 +- nodejs/src/generated/session-events.ts | 96 +++- nodejs/src/types.ts | 2 +- python/copilot/client.py | 7 +- python/copilot/generated/rpc.py | 206 ++++++- python/copilot/generated/session_events.py | 39 ++ rust/src/generated/api_types.rs | 127 +++- rust/src/generated/rpc.rs | 23 + rust/src/generated/session_events.rs | 97 ++++ test/harness/package-lock.json | 56 +- test/harness/package.json | 2 +- 20 files changed, 1252 insertions(+), 488 deletions(-) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index fc95890a1..50965b901 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -77,7 +77,7 @@ public sealed class ModelBilling { /// Billing cost multiplier relative to the base rate. [JsonPropertyName("multiplier")] - public double Multiplier { get; set; } + public double? Multiplier { get; set; } } /// Vision-specific limits. @@ -846,10 +846,6 @@ public sealed class WorkspacesGetWorkspaceResultWorkspace [JsonPropertyName("repository")] public string? Repository { get; set; } - /// Gets or sets the session_sync_level value. - [JsonPropertyName("session_sync_level")] - public WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel? SessionSyncLevel { get; set; } - /// Gets or sets the summary value. [JsonPropertyName("summary")] public string? Summary { get; set; } @@ -1395,6 +1391,40 @@ internal sealed class TasksRemoveRequest public string SessionId { get; set; } = string.Empty; } +/// RPC data type for TasksSendMessage operations. +[Experimental(Diagnostics.Experimental)] +public sealed class TasksSendMessageResult +{ + /// Error message if delivery failed. + [JsonPropertyName("error")] + public string? Error { get; set; } + + /// Whether the message was successfully delivered or steered. + [JsonPropertyName("sent")] + public bool Sent { get; set; } +} + +/// RPC data type for TasksSendMessage operations. +[Experimental(Diagnostics.Experimental)] +internal sealed class TasksSendMessageRequest +{ + /// Agent ID of the sender, if sent on behalf of another agent. + [JsonPropertyName("fromAgentId")] + public string? FromAgentId { get; set; } + + /// Agent task identifier. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Message content to send to the agent. + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// RPC data type for Skill operations. public sealed class Skill { @@ -1876,6 +1906,8 @@ public partial class PermissionDecisionApproveOnce : PermissionDecision [JsonDerivedType(typeof(PermissionDecisionApproveForSessionApprovalMcpSampling), "mcp-sampling")] [JsonDerivedType(typeof(PermissionDecisionApproveForSessionApprovalMemory), "memory")] [JsonDerivedType(typeof(PermissionDecisionApproveForSessionApprovalCustomTool), "custom-tool")] +[JsonDerivedType(typeof(PermissionDecisionApproveForSessionApprovalExtensionManagement), "extension-management")] +[JsonDerivedType(typeof(PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess), "extension-permission-access")] public partial class PermissionDecisionApproveForSessionApproval { /// The type discriminator. @@ -1960,6 +1992,31 @@ public partial class PermissionDecisionApproveForSessionApprovalCustomTool : Per public required string ToolName { get; set; } } +/// The extension-management variant of . +public partial class PermissionDecisionApproveForSessionApprovalExtensionManagement : PermissionDecisionApproveForSessionApproval +{ + /// + [JsonIgnore] + public override string Kind => "extension-management"; + + /// Gets or sets the operation value. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("operation")] + public string? Operation { get; set; } +} + +/// The extension-permission-access variant of . +public partial class PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess : PermissionDecisionApproveForSessionApproval +{ + /// + [JsonIgnore] + public override string Kind => "extension-permission-access"; + + /// Gets or sets the extensionName value. + [JsonPropertyName("extensionName")] + public required string ExtensionName { get; set; } +} + /// The approve-for-session variant of . public partial class PermissionDecisionApproveForSession : PermissionDecision { @@ -1990,6 +2047,8 @@ public partial class PermissionDecisionApproveForSession : PermissionDecision [JsonDerivedType(typeof(PermissionDecisionApproveForLocationApprovalMcpSampling), "mcp-sampling")] [JsonDerivedType(typeof(PermissionDecisionApproveForLocationApprovalMemory), "memory")] [JsonDerivedType(typeof(PermissionDecisionApproveForLocationApprovalCustomTool), "custom-tool")] +[JsonDerivedType(typeof(PermissionDecisionApproveForLocationApprovalExtensionManagement), "extension-management")] +[JsonDerivedType(typeof(PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess), "extension-permission-access")] public partial class PermissionDecisionApproveForLocationApproval { /// The type discriminator. @@ -2074,6 +2133,31 @@ public partial class PermissionDecisionApproveForLocationApprovalCustomTool : Pe public required string ToolName { get; set; } } +/// The extension-management variant of . +public partial class PermissionDecisionApproveForLocationApprovalExtensionManagement : PermissionDecisionApproveForLocationApproval +{ + /// + [JsonIgnore] + public override string Kind => "extension-management"; + + /// Gets or sets the operation value. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("operation")] + public string? Operation { get; set; } +} + +/// The extension-permission-access variant of . +public partial class PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess : PermissionDecisionApproveForLocationApproval +{ + /// + [JsonIgnore] + public override string Kind => "extension-permission-access"; + + /// Gets or sets the extensionName value. + [JsonPropertyName("extensionName")] + public required string ExtensionName { get; set; } +} + /// The approve-for-location variant of . public partial class PermissionDecisionApproveForLocation : PermissionDecision { @@ -3232,71 +3316,6 @@ public override void Write(Utf8JsonWriter writer, WorkspacesGetWorkspaceResultWo } -/// Defines the allowed values. -[JsonConverter(typeof(Converter))] -[DebuggerDisplay("{Value,nq}")] -public readonly struct WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel : IEquatable -{ - private readonly string? _value; - - /// Initializes a new instance of the struct. - /// The value to associate with this . - [JsonConstructor] - public WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel(string value) - { - ArgumentException.ThrowIfNullOrWhiteSpace(value); - _value = value; - } - - /// Gets the value associated with this . - public string Value => _value ?? string.Empty; - - /// Gets the local value. - public static WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel Local { get; } = new("local"); - - /// Gets the user value. - public static WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel User { get; } = new("user"); - - /// Gets the repo_and_user value. - public static WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel RepoAndUser { get; } = new("repo_and_user"); - - /// Returns a value indicating whether two instances are equivalent. - public static bool operator ==(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel left, WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel right) => left.Equals(right); - - /// Returns a value indicating whether two instances are not equivalent. - public static bool operator !=(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel left, WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel right) => !(left == right); - - /// - public override bool Equals(object? obj) => obj is WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel other && Equals(other); - - /// - public bool Equals(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); - - /// - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); - - /// - public override string ToString() => Value; - - /// Provides a for serializing instances. - [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class Converter : JsonConverter - { - /// - public override WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); - } - - /// - public override void Write(Utf8JsonWriter writer, WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel value, JsonSerializerOptions options) - { - GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel)); - } - } -} - - /// Where this source lives — used for UI grouping. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -4976,6 +4995,13 @@ public async Task RemoveAsync(string id, CancellationToken ca var request = new TasksRemoveRequest { SessionId = _sessionId, Id = id }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.tasks.remove", [request], cancellationToken); } + + /// Calls "session.tasks.sendMessage". + public async Task SendMessageAsync(string id, string message, string? fromAgentId = null, CancellationToken cancellationToken = default) + { + var request = new TasksSendMessageRequest { SessionId = _sessionId, Id = id, Message = message, FromAgentId = fromAgentId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.tasks.sendMessage", [request], cancellationToken); + } } /// Provides session-scoped Skills APIs. @@ -5617,6 +5643,8 @@ public static void RegisterClientSessionApiHandlers(JsonRpc rpc, FuncWhen set, identifies a parent session whose context this session continues — e.g., a detached headless rem-agent run launched on the parent's interactive shutdown. Telemetry from this session is reported under the parent's session_id. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("detachedFromSpawningParentSessionId")] + public string? DetachedFromSpawningParentSessionId { get; set; } + /// Identifier of the software producing the events (e.g., "copilot-agent"). [JsonPropertyName("producer")] public required string Producer { get; set; } @@ -4166,6 +4171,51 @@ public partial class PermissionRequestHook : PermissionRequest public required string ToolName { get; set; } } +/// Extension management permission request. +/// The extension-management variant of . +public partial class PermissionRequestExtensionManagement : PermissionRequest +{ + /// + [JsonIgnore] + public override string Kind => "extension-management"; + + /// Name of the extension being managed. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("extensionName")] + public string? ExtensionName { get; set; } + + /// The extension management operation (scaffold, reload). + [JsonPropertyName("operation")] + public required string Operation { get; set; } + + /// Tool call ID that triggered this permission request. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolCallId")] + public string? ToolCallId { get; set; } +} + +/// Extension permission access request. +/// The extension-permission-access variant of . +public partial class PermissionRequestExtensionPermissionAccess : PermissionRequest +{ + /// + [JsonIgnore] + public override string Kind => "extension-permission-access"; + + /// Capabilities the extension is requesting. + [JsonPropertyName("capabilities")] + public required string[] Capabilities { get; set; } + + /// Name of the extension requesting permission access. + [JsonPropertyName("extensionName")] + public required string ExtensionName { get; set; } + + /// Tool call ID that triggered this permission request. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolCallId")] + public string? ToolCallId { get; set; } +} + /// Details of the permission being requested. /// Polymorphic base type discriminated by kind. [JsonPolymorphic( @@ -4179,6 +4229,8 @@ public partial class PermissionRequestHook : PermissionRequest [JsonDerivedType(typeof(PermissionRequestMemory), "memory")] [JsonDerivedType(typeof(PermissionRequestCustomTool), "custom-tool")] [JsonDerivedType(typeof(PermissionRequestHook), "hook")] +[JsonDerivedType(typeof(PermissionRequestExtensionManagement), "extension-management")] +[JsonDerivedType(typeof(PermissionRequestExtensionPermissionAccess), "extension-permission-access")] public partial class PermissionRequest { /// The type discriminator. @@ -4452,6 +4504,51 @@ public partial class PermissionPromptRequestHook : PermissionPromptRequest public required string ToolName { get; set; } } +/// Extension management permission prompt. +/// The extension-management variant of . +public partial class PermissionPromptRequestExtensionManagement : PermissionPromptRequest +{ + /// + [JsonIgnore] + public override string Kind => "extension-management"; + + /// Name of the extension being managed. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("extensionName")] + public string? ExtensionName { get; set; } + + /// The extension management operation (scaffold, reload). + [JsonPropertyName("operation")] + public required string Operation { get; set; } + + /// Tool call ID that triggered this permission request. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolCallId")] + public string? ToolCallId { get; set; } +} + +/// Extension permission access prompt. +/// The extension-permission-access variant of . +public partial class PermissionPromptRequestExtensionPermissionAccess : PermissionPromptRequest +{ + /// + [JsonIgnore] + public override string Kind => "extension-permission-access"; + + /// Capabilities the extension is requesting. + [JsonPropertyName("capabilities")] + public required string[] Capabilities { get; set; } + + /// Name of the extension requesting permission access. + [JsonPropertyName("extensionName")] + public required string ExtensionName { get; set; } + + /// Tool call ID that triggered this permission request. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolCallId")] + public string? ToolCallId { get; set; } +} + /// Derived user-facing permission prompt details for UI consumers. /// Polymorphic base type discriminated by kind. [JsonPolymorphic( @@ -4466,6 +4563,8 @@ public partial class PermissionPromptRequestHook : PermissionPromptRequest [JsonDerivedType(typeof(PermissionPromptRequestCustomTool), "custom-tool")] [JsonDerivedType(typeof(PermissionPromptRequestPath), "path")] [JsonDerivedType(typeof(PermissionPromptRequestHook), "hook")] +[JsonDerivedType(typeof(PermissionPromptRequestExtensionManagement), "extension-management")] +[JsonDerivedType(typeof(PermissionPromptRequestExtensionPermissionAccess), "extension-permission-access")] public partial class PermissionPromptRequest { /// The type discriminator. @@ -6484,6 +6583,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(PermissionPromptRequest))] [JsonSerializable(typeof(PermissionPromptRequestCommands))] [JsonSerializable(typeof(PermissionPromptRequestCustomTool))] +[JsonSerializable(typeof(PermissionPromptRequestExtensionManagement))] +[JsonSerializable(typeof(PermissionPromptRequestExtensionPermissionAccess))] [JsonSerializable(typeof(PermissionPromptRequestHook))] [JsonSerializable(typeof(PermissionPromptRequestMcp))] [JsonSerializable(typeof(PermissionPromptRequestMemory))] @@ -6493,6 +6594,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(PermissionPromptRequestWrite))] [JsonSerializable(typeof(PermissionRequest))] [JsonSerializable(typeof(PermissionRequestCustomTool))] +[JsonSerializable(typeof(PermissionRequestExtensionManagement))] +[JsonSerializable(typeof(PermissionRequestExtensionPermissionAccess))] [JsonSerializable(typeof(PermissionRequestHook))] [JsonSerializable(typeof(PermissionRequestMcp))] [JsonSerializable(typeof(PermissionRequestMemory))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 18b956f07..bd3ba1b78 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2867,7 +2867,7 @@ public class ModelBilling /// Billing cost multiplier relative to the base model rate. /// [JsonPropertyName("multiplier")] - public double Multiplier { get; set; } + public double? Multiplier { get; set; } } /// diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 5026f465e..a507fa8ac 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -1286,6 +1286,8 @@ type SessionStartData struct { Context *WorkingDirectoryContext `json:"context,omitempty"` // Version string of the Copilot application CopilotVersion string `json:"copilotVersion"` + // When set, identifies a parent session whose context this session continues — e.g., a detached headless rem-agent run launched on the parent's interactive shutdown. Telemetry from this session is reported under the parent's session_id. + DetachedFromSpawningParentSessionID *string `json:"detachedFromSpawningParentSessionId,omitempty"` // Identifier of the software producing the events (e.g., "copilot-agent") Producer string `json:"producer"` // Reasoning effort level used for model calls, if applicable (e.g. "low", "medium", "high", "xhigh") @@ -1898,6 +1900,8 @@ type PermissionPromptRequest struct { Args *any `json:"args,omitempty"` // Whether the UI can offer session-wide approval for this command pattern CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` + // Capabilities the extension is requesting + Capabilities []string `json:"capabilities,omitempty"` // Source references for the stored fact (store only) Citations *string `json:"citations,omitempty"` // Command identifiers covered by this approval prompt @@ -1906,6 +1910,8 @@ type PermissionPromptRequest struct { Diff *string `json:"diff,omitempty"` // Vote direction (vote only) Direction *PermissionPromptRequestMemoryDirection `json:"direction,omitempty"` + // Name of the extension being managed + ExtensionName *string `json:"extensionName,omitempty"` // The fact being stored or voted on Fact *string `json:"fact,omitempty"` // Path of the file being written to @@ -1918,6 +1924,8 @@ type PermissionPromptRequest struct { Intention *string `json:"intention,omitempty"` // Complete new file contents for newly created files NewFileContents *string `json:"newFileContents,omitempty"` + // The extension management operation (scaffold, reload) + Operation *string `json:"operation,omitempty"` // Path of the file or directory being read Path *string `json:"path,omitempty"` // File paths that require explicit approval @@ -1954,6 +1962,8 @@ type PermissionRequest struct { Args any `json:"args,omitempty"` // Whether the UI can offer session-wide approval for this command pattern CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` + // Capabilities the extension is requesting + Capabilities []string `json:"capabilities,omitempty"` // Source references for the stored fact (store only) Citations *string `json:"citations,omitempty"` // Parsed command identifiers found in the command text @@ -1962,6 +1972,8 @@ type PermissionRequest struct { Diff *string `json:"diff,omitempty"` // Vote direction (vote only) Direction *PermissionRequestMemoryDirection `json:"direction,omitempty"` + // Name of the extension being managed + ExtensionName *string `json:"extensionName,omitempty"` // The fact being stored or voted on Fact *string `json:"fact,omitempty"` // Path of the file being written to @@ -1976,6 +1988,8 @@ type PermissionRequest struct { Intention *string `json:"intention,omitempty"` // Complete new file contents for newly created files NewFileContents *string `json:"newFileContents,omitempty"` + // The extension management operation (scaffold, reload) + Operation *string `json:"operation,omitempty"` // Path of the file or directory being read Path *string `json:"path,omitempty"` // File paths that may be read or written by the command @@ -2469,29 +2483,33 @@ const ( type PermissionPromptRequestKind string const ( - PermissionPromptRequestKindCommands PermissionPromptRequestKind = "commands" - PermissionPromptRequestKindWrite PermissionPromptRequestKind = "write" - PermissionPromptRequestKindRead PermissionPromptRequestKind = "read" - PermissionPromptRequestKindMcp PermissionPromptRequestKind = "mcp" - PermissionPromptRequestKindURL PermissionPromptRequestKind = "url" - PermissionPromptRequestKindMemory PermissionPromptRequestKind = "memory" - PermissionPromptRequestKindCustomTool PermissionPromptRequestKind = "custom-tool" - PermissionPromptRequestKindPath PermissionPromptRequestKind = "path" - PermissionPromptRequestKindHook PermissionPromptRequestKind = "hook" + PermissionPromptRequestKindCommands PermissionPromptRequestKind = "commands" + PermissionPromptRequestKindWrite PermissionPromptRequestKind = "write" + PermissionPromptRequestKindRead PermissionPromptRequestKind = "read" + PermissionPromptRequestKindMcp PermissionPromptRequestKind = "mcp" + PermissionPromptRequestKindURL PermissionPromptRequestKind = "url" + PermissionPromptRequestKindMemory PermissionPromptRequestKind = "memory" + PermissionPromptRequestKindCustomTool PermissionPromptRequestKind = "custom-tool" + PermissionPromptRequestKindPath PermissionPromptRequestKind = "path" + PermissionPromptRequestKindHook PermissionPromptRequestKind = "hook" + PermissionPromptRequestKindExtensionManagement PermissionPromptRequestKind = "extension-management" + PermissionPromptRequestKindExtensionPermissionAccess PermissionPromptRequestKind = "extension-permission-access" ) // Kind discriminator for PermissionRequest. type PermissionRequestKind string const ( - PermissionRequestKindShell PermissionRequestKind = "shell" - PermissionRequestKindWrite PermissionRequestKind = "write" - PermissionRequestKindRead PermissionRequestKind = "read" - PermissionRequestKindMcp PermissionRequestKind = "mcp" - PermissionRequestKindURL PermissionRequestKind = "url" - PermissionRequestKindMemory PermissionRequestKind = "memory" - PermissionRequestKindCustomTool PermissionRequestKind = "custom-tool" - PermissionRequestKindHook PermissionRequestKind = "hook" + PermissionRequestKindShell PermissionRequestKind = "shell" + PermissionRequestKindWrite PermissionRequestKind = "write" + PermissionRequestKindRead PermissionRequestKind = "read" + PermissionRequestKindMcp PermissionRequestKind = "mcp" + PermissionRequestKindURL PermissionRequestKind = "url" + PermissionRequestKindMemory PermissionRequestKind = "memory" + PermissionRequestKindCustomTool PermissionRequestKind = "custom-tool" + PermissionRequestKindHook PermissionRequestKind = "hook" + PermissionRequestKindExtensionManagement PermissionRequestKind = "extension-management" + PermissionRequestKindExtensionPermissionAccess PermissionRequestKind = "extension-permission-access" ) // Kind discriminator for PermissionResult. diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index 34428ada3..dce096c3d 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -13,254 +13,260 @@ import ( ) type RPCTypes struct { - AccountGetQuotaRequest AccountGetQuotaRequest `json:"AccountGetQuotaRequest"` - AccountGetQuotaResult AccountGetQuotaResult `json:"AccountGetQuotaResult"` - AccountQuotaSnapshot AccountQuotaSnapshot `json:"AccountQuotaSnapshot"` - AgentDeselectResult AgentDeselectResult `json:"AgentDeselectResult"` - AgentGetCurrentResult AgentGetCurrentResult `json:"AgentGetCurrentResult"` - AgentInfo AgentInfo `json:"AgentInfo"` - AgentList AgentList `json:"AgentList"` - AgentReloadResult AgentReloadResult `json:"AgentReloadResult"` - AgentSelectRequest AgentSelectRequest `json:"AgentSelectRequest"` - AgentSelectResult AgentSelectResult `json:"AgentSelectResult"` - AuthInfoType AuthInfoType `json:"AuthInfoType"` - CommandsHandlePendingCommandRequest CommandsHandlePendingCommandRequest `json:"CommandsHandlePendingCommandRequest"` - CommandsHandlePendingCommandResult CommandsHandlePendingCommandResult `json:"CommandsHandlePendingCommandResult"` - ConnectRequest ConnectRequest `json:"ConnectRequest"` - ConnectResult ConnectResult `json:"ConnectResult"` - CurrentModel CurrentModel `json:"CurrentModel"` - DiscoveredMCPServer DiscoveredMCPServer `json:"DiscoveredMcpServer"` - DiscoveredMCPServerSource MCPServerSource `json:"DiscoveredMcpServerSource"` - DiscoveredMCPServerType DiscoveredMCPServerType `json:"DiscoveredMcpServerType"` - EmbeddedBlobResourceContents EmbeddedBlobResourceContents `json:"EmbeddedBlobResourceContents"` - EmbeddedTextResourceContents EmbeddedTextResourceContents `json:"EmbeddedTextResourceContents"` - Extension Extension `json:"Extension"` - ExtensionList ExtensionList `json:"ExtensionList"` - ExtensionsDisableRequest ExtensionsDisableRequest `json:"ExtensionsDisableRequest"` - ExtensionsDisableResult ExtensionsDisableResult `json:"ExtensionsDisableResult"` - ExtensionsEnableRequest ExtensionsEnableRequest `json:"ExtensionsEnableRequest"` - ExtensionsEnableResult ExtensionsEnableResult `json:"ExtensionsEnableResult"` - ExtensionSource ExtensionSource `json:"ExtensionSource"` - ExtensionsReloadResult ExtensionsReloadResult `json:"ExtensionsReloadResult"` - ExtensionStatus ExtensionStatus `json:"ExtensionStatus"` - ExternalToolResult *ExternalToolResult `json:"ExternalToolResult"` - ExternalToolTextResultForLlm ExternalToolTextResultForLlm `json:"ExternalToolTextResultForLlm"` - ExternalToolTextResultForLlmContent ExternalToolTextResultForLlmContent `json:"ExternalToolTextResultForLlmContent"` - ExternalToolTextResultForLlmContentAudio ExternalToolTextResultForLlmContentAudio `json:"ExternalToolTextResultForLlmContentAudio"` - ExternalToolTextResultForLlmContentImage ExternalToolTextResultForLlmContentImage `json:"ExternalToolTextResultForLlmContentImage"` - ExternalToolTextResultForLlmContentResource ExternalToolTextResultForLlmContentResource `json:"ExternalToolTextResultForLlmContentResource"` - ExternalToolTextResultForLlmContentResourceDetails ExternalToolTextResultForLlmContentResourceDetails `json:"ExternalToolTextResultForLlmContentResourceDetails"` - ExternalToolTextResultForLlmContentResourceLink ExternalToolTextResultForLlmContentResourceLink `json:"ExternalToolTextResultForLlmContentResourceLink"` - ExternalToolTextResultForLlmContentResourceLinkIcon ExternalToolTextResultForLlmContentResourceLinkIcon `json:"ExternalToolTextResultForLlmContentResourceLinkIcon"` - ExternalToolTextResultForLlmContentResourceLinkIconTheme ExternalToolTextResultForLlmContentResourceLinkIconTheme `json:"ExternalToolTextResultForLlmContentResourceLinkIconTheme"` - ExternalToolTextResultForLlmContentTerminal ExternalToolTextResultForLlmContentTerminal `json:"ExternalToolTextResultForLlmContentTerminal"` - ExternalToolTextResultForLlmContentText ExternalToolTextResultForLlmContentText `json:"ExternalToolTextResultForLlmContentText"` - FilterMapping *FilterMapping `json:"FilterMapping"` - FilterMappingString FilterMappingString `json:"FilterMappingString"` - FilterMappingValue FilterMappingString `json:"FilterMappingValue"` - FleetStartRequest FleetStartRequest `json:"FleetStartRequest"` - FleetStartResult FleetStartResult `json:"FleetStartResult"` - HandlePendingToolCallRequest HandlePendingToolCallRequest `json:"HandlePendingToolCallRequest"` - HandlePendingToolCallResult HandlePendingToolCallResult `json:"HandlePendingToolCallResult"` - HistoryCompactContextWindow HistoryCompactContextWindow `json:"HistoryCompactContextWindow"` - HistoryCompactResult HistoryCompactResult `json:"HistoryCompactResult"` - HistoryTruncateRequest HistoryTruncateRequest `json:"HistoryTruncateRequest"` - HistoryTruncateResult HistoryTruncateResult `json:"HistoryTruncateResult"` - InstructionsGetSourcesResult InstructionsGetSourcesResult `json:"InstructionsGetSourcesResult"` - InstructionsSources InstructionsSources `json:"InstructionsSources"` - InstructionsSourcesLocation InstructionsSourcesLocation `json:"InstructionsSourcesLocation"` - InstructionsSourcesType InstructionsSourcesType `json:"InstructionsSourcesType"` - LogRequest LogRequest `json:"LogRequest"` - LogResult LogResult `json:"LogResult"` - MCPConfigAddRequest MCPConfigAddRequest `json:"McpConfigAddRequest"` - MCPConfigAddResult MCPConfigAddResult `json:"McpConfigAddResult"` - MCPConfigDisableRequest MCPConfigDisableRequest `json:"McpConfigDisableRequest"` - MCPConfigDisableResult MCPConfigDisableResult `json:"McpConfigDisableResult"` - MCPConfigEnableRequest MCPConfigEnableRequest `json:"McpConfigEnableRequest"` - MCPConfigEnableResult MCPConfigEnableResult `json:"McpConfigEnableResult"` - MCPConfigList MCPConfigList `json:"McpConfigList"` - MCPConfigRemoveRequest MCPConfigRemoveRequest `json:"McpConfigRemoveRequest"` - MCPConfigRemoveResult MCPConfigRemoveResult `json:"McpConfigRemoveResult"` - MCPConfigUpdateRequest MCPConfigUpdateRequest `json:"McpConfigUpdateRequest"` - MCPConfigUpdateResult MCPConfigUpdateResult `json:"McpConfigUpdateResult"` - MCPDisableRequest MCPDisableRequest `json:"McpDisableRequest"` - MCPDisableResult MCPDisableResult `json:"McpDisableResult"` - MCPDiscoverRequest MCPDiscoverRequest `json:"McpDiscoverRequest"` - MCPDiscoverResult MCPDiscoverResult `json:"McpDiscoverResult"` - MCPEnableRequest MCPEnableRequest `json:"McpEnableRequest"` - MCPEnableResult MCPEnableResult `json:"McpEnableResult"` - MCPOauthLoginRequest MCPOauthLoginRequest `json:"McpOauthLoginRequest"` - MCPOauthLoginResult MCPOauthLoginResult `json:"McpOauthLoginResult"` - MCPReloadResult MCPReloadResult `json:"McpReloadResult"` - MCPServer MCPServer `json:"McpServer"` - MCPServerConfig MCPServerConfig `json:"McpServerConfig"` - MCPServerConfigHTTP MCPServerConfigHTTP `json:"McpServerConfigHttp"` - MCPServerConfigHTTPOauthGrantType MCPServerConfigHTTPOauthGrantType `json:"McpServerConfigHttpOauthGrantType"` - MCPServerConfigHTTPType MCPServerConfigHTTPType `json:"McpServerConfigHttpType"` - MCPServerConfigLocal MCPServerConfigLocal `json:"McpServerConfigLocal"` - MCPServerConfigLocalType MCPServerConfigLocalType `json:"McpServerConfigLocalType"` - MCPServerList MCPServerList `json:"McpServerList"` - MCPServerSource MCPServerSource `json:"McpServerSource"` - MCPServerStatus MCPServerStatus `json:"McpServerStatus"` - Model ModelElement `json:"Model"` - ModelBilling ModelBilling `json:"ModelBilling"` - ModelCapabilities ModelCapabilities `json:"ModelCapabilities"` - ModelCapabilitiesLimits ModelCapabilitiesLimits `json:"ModelCapabilitiesLimits"` - ModelCapabilitiesLimitsVision ModelCapabilitiesLimitsVision `json:"ModelCapabilitiesLimitsVision"` - ModelCapabilitiesOverride ModelCapabilitiesOverride `json:"ModelCapabilitiesOverride"` - ModelCapabilitiesOverrideLimits ModelCapabilitiesOverrideLimits `json:"ModelCapabilitiesOverrideLimits"` - ModelCapabilitiesOverrideLimitsVision ModelCapabilitiesOverrideLimitsVision `json:"ModelCapabilitiesOverrideLimitsVision"` - ModelCapabilitiesOverrideSupports ModelCapabilitiesOverrideSupports `json:"ModelCapabilitiesOverrideSupports"` - ModelCapabilitiesSupports ModelCapabilitiesSupports `json:"ModelCapabilitiesSupports"` - ModelList ModelList `json:"ModelList"` - ModelPolicy ModelPolicy `json:"ModelPolicy"` - ModelsListRequest ModelsListRequest `json:"ModelsListRequest"` - ModelSwitchToRequest ModelSwitchToRequest `json:"ModelSwitchToRequest"` - ModelSwitchToResult ModelSwitchToResult `json:"ModelSwitchToResult"` - ModeSetRequest ModeSetRequest `json:"ModeSetRequest"` - ModeSetResult ModeSetResult `json:"ModeSetResult"` - NameGetResult NameGetResult `json:"NameGetResult"` - NameSetRequest NameSetRequest `json:"NameSetRequest"` - NameSetResult NameSetResult `json:"NameSetResult"` - PermissionDecision PermissionDecision `json:"PermissionDecision"` - PermissionDecisionApproveForLocation PermissionDecisionApproveForLocation `json:"PermissionDecisionApproveForLocation"` - PermissionDecisionApproveForLocationApproval PermissionDecisionApproveForLocationApproval `json:"PermissionDecisionApproveForLocationApproval"` - PermissionDecisionApproveForLocationApprovalCommands PermissionDecisionApproveForLocationApprovalCommands `json:"PermissionDecisionApproveForLocationApprovalCommands"` - PermissionDecisionApproveForLocationApprovalCustomTool PermissionDecisionApproveForLocationApprovalCustomTool `json:"PermissionDecisionApproveForLocationApprovalCustomTool"` - PermissionDecisionApproveForLocationApprovalMCP PermissionDecisionApproveForLocationApprovalMCP `json:"PermissionDecisionApproveForLocationApprovalMcp"` - PermissionDecisionApproveForLocationApprovalMCPSampling PermissionDecisionApproveForLocationApprovalMCPSampling `json:"PermissionDecisionApproveForLocationApprovalMcpSampling"` - PermissionDecisionApproveForLocationApprovalMemory PermissionDecisionApproveForLocationApprovalMemory `json:"PermissionDecisionApproveForLocationApprovalMemory"` - PermissionDecisionApproveForLocationApprovalRead PermissionDecisionApproveForLocationApprovalRead `json:"PermissionDecisionApproveForLocationApprovalRead"` - PermissionDecisionApproveForLocationApprovalWrite PermissionDecisionApproveForLocationApprovalWrite `json:"PermissionDecisionApproveForLocationApprovalWrite"` - PermissionDecisionApproveForSession PermissionDecisionApproveForSession `json:"PermissionDecisionApproveForSession"` - PermissionDecisionApproveForSessionApproval PermissionDecisionApproveForSessionApproval `json:"PermissionDecisionApproveForSessionApproval"` - PermissionDecisionApproveForSessionApprovalCommands PermissionDecisionApproveForSessionApprovalCommands `json:"PermissionDecisionApproveForSessionApprovalCommands"` - PermissionDecisionApproveForSessionApprovalCustomTool PermissionDecisionApproveForSessionApprovalCustomTool `json:"PermissionDecisionApproveForSessionApprovalCustomTool"` - PermissionDecisionApproveForSessionApprovalMCP PermissionDecisionApproveForSessionApprovalMCP `json:"PermissionDecisionApproveForSessionApprovalMcp"` - PermissionDecisionApproveForSessionApprovalMCPSampling PermissionDecisionApproveForSessionApprovalMCPSampling `json:"PermissionDecisionApproveForSessionApprovalMcpSampling"` - PermissionDecisionApproveForSessionApprovalMemory PermissionDecisionApproveForSessionApprovalMemory `json:"PermissionDecisionApproveForSessionApprovalMemory"` - PermissionDecisionApproveForSessionApprovalRead PermissionDecisionApproveForSessionApprovalRead `json:"PermissionDecisionApproveForSessionApprovalRead"` - PermissionDecisionApproveForSessionApprovalWrite PermissionDecisionApproveForSessionApprovalWrite `json:"PermissionDecisionApproveForSessionApprovalWrite"` - PermissionDecisionApproveOnce PermissionDecisionApproveOnce `json:"PermissionDecisionApproveOnce"` - PermissionDecisionApprovePermanently PermissionDecisionApprovePermanently `json:"PermissionDecisionApprovePermanently"` - PermissionDecisionReject PermissionDecisionReject `json:"PermissionDecisionReject"` - PermissionDecisionRequest PermissionDecisionRequest `json:"PermissionDecisionRequest"` - PermissionDecisionUserNotAvailable PermissionDecisionUserNotAvailable `json:"PermissionDecisionUserNotAvailable"` - PermissionRequestResult PermissionRequestResult `json:"PermissionRequestResult"` - PermissionsResetSessionApprovalsRequest PermissionsResetSessionApprovalsRequest `json:"PermissionsResetSessionApprovalsRequest"` - PermissionsResetSessionApprovalsResult PermissionsResetSessionApprovalsResult `json:"PermissionsResetSessionApprovalsResult"` - PermissionsSetApproveAllRequest PermissionsSetApproveAllRequest `json:"PermissionsSetApproveAllRequest"` - PermissionsSetApproveAllResult PermissionsSetApproveAllResult `json:"PermissionsSetApproveAllResult"` - PingRequest PingRequest `json:"PingRequest"` - PingResult PingResult `json:"PingResult"` - PlanDeleteResult PlanDeleteResult `json:"PlanDeleteResult"` - PlanReadResult PlanReadResult `json:"PlanReadResult"` - PlanUpdateRequest PlanUpdateRequest `json:"PlanUpdateRequest"` - PlanUpdateResult PlanUpdateResult `json:"PlanUpdateResult"` - Plugin PluginElement `json:"Plugin"` - PluginList PluginList `json:"PluginList"` - RemoteDisableResult RemoteDisableResult `json:"RemoteDisableResult"` - RemoteEnableResult RemoteEnableResult `json:"RemoteEnableResult"` - ServerSkill ServerSkill `json:"ServerSkill"` - ServerSkillList ServerSkillList `json:"ServerSkillList"` - SessionAuthStatus SessionAuthStatus `json:"SessionAuthStatus"` - SessionFSAppendFileRequest SessionFSAppendFileRequest `json:"SessionFsAppendFileRequest"` - SessionFSError SessionFSError `json:"SessionFsError"` - SessionFSErrorCode SessionFSErrorCode `json:"SessionFsErrorCode"` - SessionFSExistsRequest SessionFSExistsRequest `json:"SessionFsExistsRequest"` - SessionFSExistsResult SessionFSExistsResult `json:"SessionFsExistsResult"` - SessionFSMkdirRequest SessionFSMkdirRequest `json:"SessionFsMkdirRequest"` - SessionFSReaddirRequest SessionFSReaddirRequest `json:"SessionFsReaddirRequest"` - SessionFSReaddirResult SessionFSReaddirResult `json:"SessionFsReaddirResult"` - SessionFSReaddirWithTypesEntry SessionFSReaddirWithTypesEntry `json:"SessionFsReaddirWithTypesEntry"` - SessionFSReaddirWithTypesEntryType SessionFSReaddirWithTypesEntryType `json:"SessionFsReaddirWithTypesEntryType"` - SessionFSReaddirWithTypesRequest SessionFSReaddirWithTypesRequest `json:"SessionFsReaddirWithTypesRequest"` - SessionFSReaddirWithTypesResult SessionFSReaddirWithTypesResult `json:"SessionFsReaddirWithTypesResult"` - SessionFSReadFileRequest SessionFSReadFileRequest `json:"SessionFsReadFileRequest"` - SessionFSReadFileResult SessionFSReadFileResult `json:"SessionFsReadFileResult"` - SessionFSRenameRequest SessionFSRenameRequest `json:"SessionFsRenameRequest"` - SessionFSRmRequest SessionFSRmRequest `json:"SessionFsRmRequest"` - SessionFSSetProviderConventions SessionFSSetProviderConventions `json:"SessionFsSetProviderConventions"` - SessionFSSetProviderRequest SessionFSSetProviderRequest `json:"SessionFsSetProviderRequest"` - SessionFSSetProviderResult SessionFSSetProviderResult `json:"SessionFsSetProviderResult"` - SessionFSStatRequest SessionFSStatRequest `json:"SessionFsStatRequest"` - SessionFSStatResult SessionFSStatResult `json:"SessionFsStatResult"` - SessionFSWriteFileRequest SessionFSWriteFileRequest `json:"SessionFsWriteFileRequest"` - SessionLogLevel SessionLogLevel `json:"SessionLogLevel"` - SessionMode SessionMode `json:"SessionMode"` - SessionsForkRequest SessionsForkRequest `json:"SessionsForkRequest"` - SessionsForkResult SessionsForkResult `json:"SessionsForkResult"` - ShellExecRequest ShellExecRequest `json:"ShellExecRequest"` - ShellExecResult ShellExecResult `json:"ShellExecResult"` - ShellKillRequest ShellKillRequest `json:"ShellKillRequest"` - ShellKillResult ShellKillResult `json:"ShellKillResult"` - ShellKillSignal ShellKillSignal `json:"ShellKillSignal"` - Skill Skill `json:"Skill"` - SkillList SkillList `json:"SkillList"` - SkillsConfigSetDisabledSkillsRequest SkillsConfigSetDisabledSkillsRequest `json:"SkillsConfigSetDisabledSkillsRequest"` - SkillsConfigSetDisabledSkillsResult SkillsConfigSetDisabledSkillsResult `json:"SkillsConfigSetDisabledSkillsResult"` - SkillsDisableRequest SkillsDisableRequest `json:"SkillsDisableRequest"` - SkillsDisableResult SkillsDisableResult `json:"SkillsDisableResult"` - SkillsDiscoverRequest SkillsDiscoverRequest `json:"SkillsDiscoverRequest"` - SkillsEnableRequest SkillsEnableRequest `json:"SkillsEnableRequest"` - SkillsEnableResult SkillsEnableResult `json:"SkillsEnableResult"` - SkillsReloadResult SkillsReloadResult `json:"SkillsReloadResult"` - SuspendResult SuspendResult `json:"SuspendResult"` - TaskAgentInfo TaskAgentInfo `json:"TaskAgentInfo"` - TaskAgentInfoExecutionMode TaskInfoExecutionMode `json:"TaskAgentInfoExecutionMode"` - TaskAgentInfoStatus TaskInfoStatus `json:"TaskAgentInfoStatus"` - TaskInfo TaskInfo `json:"TaskInfo"` - TaskList TaskList `json:"TaskList"` - TasksCancelRequest TasksCancelRequest `json:"TasksCancelRequest"` - TasksCancelResult TasksCancelResult `json:"TasksCancelResult"` - TaskShellInfo TaskShellInfo `json:"TaskShellInfo"` - TaskShellInfoAttachmentMode TaskShellInfoAttachmentMode `json:"TaskShellInfoAttachmentMode"` - TaskShellInfoExecutionMode TaskInfoExecutionMode `json:"TaskShellInfoExecutionMode"` - TaskShellInfoStatus TaskInfoStatus `json:"TaskShellInfoStatus"` - TasksPromoteToBackgroundRequest TasksPromoteToBackgroundRequest `json:"TasksPromoteToBackgroundRequest"` - TasksPromoteToBackgroundResult TasksPromoteToBackgroundResult `json:"TasksPromoteToBackgroundResult"` - TasksRemoveRequest TasksRemoveRequest `json:"TasksRemoveRequest"` - TasksRemoveResult TasksRemoveResult `json:"TasksRemoveResult"` - TasksStartAgentRequest TasksStartAgentRequest `json:"TasksStartAgentRequest"` - TasksStartAgentResult TasksStartAgentResult `json:"TasksStartAgentResult"` - Tool Tool `json:"Tool"` - ToolList ToolList `json:"ToolList"` - ToolsListRequest ToolsListRequest `json:"ToolsListRequest"` - UIElicitationArrayAnyOfField UIElicitationArrayAnyOfField `json:"UIElicitationArrayAnyOfField"` - UIElicitationArrayAnyOfFieldItems UIElicitationArrayAnyOfFieldItems `json:"UIElicitationArrayAnyOfFieldItems"` - UIElicitationArrayAnyOfFieldItemsAnyOf UIElicitationArrayAnyOfFieldItemsAnyOf `json:"UIElicitationArrayAnyOfFieldItemsAnyOf"` - UIElicitationArrayEnumField UIElicitationArrayEnumField `json:"UIElicitationArrayEnumField"` - UIElicitationArrayEnumFieldItems UIElicitationArrayEnumFieldItems `json:"UIElicitationArrayEnumFieldItems"` - UIElicitationFieldValue *UIElicitationFieldValue `json:"UIElicitationFieldValue"` - UIElicitationRequest UIElicitationRequest `json:"UIElicitationRequest"` - UIElicitationResponse UIElicitationResponse `json:"UIElicitationResponse"` - UIElicitationResponseAction UIElicitationResponseAction `json:"UIElicitationResponseAction"` - UIElicitationResponseContent map[string]*UIElicitationFieldValue `json:"UIElicitationResponseContent"` - UIElicitationResult UIElicitationResult `json:"UIElicitationResult"` - UIElicitationSchema UIElicitationSchema `json:"UIElicitationSchema"` - UIElicitationSchemaProperty UIElicitationSchemaProperty `json:"UIElicitationSchemaProperty"` - UIElicitationSchemaPropertyBoolean UIElicitationSchemaPropertyBoolean `json:"UIElicitationSchemaPropertyBoolean"` - UIElicitationSchemaPropertyNumber UIElicitationSchemaPropertyNumber `json:"UIElicitationSchemaPropertyNumber"` - UIElicitationSchemaPropertyNumberType UIElicitationSchemaPropertyNumberTypeEnum `json:"UIElicitationSchemaPropertyNumberType"` - UIElicitationSchemaPropertyString UIElicitationSchemaPropertyString `json:"UIElicitationSchemaPropertyString"` - UIElicitationSchemaPropertyStringFormat UIElicitationSchemaPropertyStringFormat `json:"UIElicitationSchemaPropertyStringFormat"` - UIElicitationStringEnumField UIElicitationStringEnumField `json:"UIElicitationStringEnumField"` - UIElicitationStringOneOfField UIElicitationStringOneOfField `json:"UIElicitationStringOneOfField"` - UIElicitationStringOneOfFieldOneOf UIElicitationStringOneOfFieldOneOf `json:"UIElicitationStringOneOfFieldOneOf"` - UIHandlePendingElicitationRequest UIHandlePendingElicitationRequest `json:"UIHandlePendingElicitationRequest"` - UsageGetMetricsResult UsageGetMetricsResult `json:"UsageGetMetricsResult"` - UsageMetricsCodeChanges UsageMetricsCodeChanges `json:"UsageMetricsCodeChanges"` - UsageMetricsModelMetric UsageMetricsModelMetric `json:"UsageMetricsModelMetric"` - UsageMetricsModelMetricRequests UsageMetricsModelMetricRequests `json:"UsageMetricsModelMetricRequests"` - UsageMetricsModelMetricTokenDetail UsageMetricsModelMetricTokenDetail `json:"UsageMetricsModelMetricTokenDetail"` - UsageMetricsModelMetricUsage UsageMetricsModelMetricUsage `json:"UsageMetricsModelMetricUsage"` - UsageMetricsTokenDetail UsageMetricsTokenDetail `json:"UsageMetricsTokenDetail"` - WorkspacesCreateFileRequest WorkspacesCreateFileRequest `json:"WorkspacesCreateFileRequest"` - WorkspacesCreateFileResult WorkspacesCreateFileResult `json:"WorkspacesCreateFileResult"` - WorkspacesGetWorkspaceResult WorkspacesGetWorkspaceResult `json:"WorkspacesGetWorkspaceResult"` - WorkspacesListFilesResult WorkspacesListFilesResult `json:"WorkspacesListFilesResult"` - WorkspacesReadFileRequest WorkspacesReadFileRequest `json:"WorkspacesReadFileRequest"` - WorkspacesReadFileResult WorkspacesReadFileResult `json:"WorkspacesReadFileResult"` + AccountGetQuotaRequest AccountGetQuotaRequest `json:"AccountGetQuotaRequest"` + AccountGetQuotaResult AccountGetQuotaResult `json:"AccountGetQuotaResult"` + AccountQuotaSnapshot AccountQuotaSnapshot `json:"AccountQuotaSnapshot"` + AgentDeselectResult AgentDeselectResult `json:"AgentDeselectResult"` + AgentGetCurrentResult AgentGetCurrentResult `json:"AgentGetCurrentResult"` + AgentInfo AgentInfo `json:"AgentInfo"` + AgentList AgentList `json:"AgentList"` + AgentReloadResult AgentReloadResult `json:"AgentReloadResult"` + AgentSelectRequest AgentSelectRequest `json:"AgentSelectRequest"` + AgentSelectResult AgentSelectResult `json:"AgentSelectResult"` + AuthInfoType AuthInfoType `json:"AuthInfoType"` + CommandsHandlePendingCommandRequest CommandsHandlePendingCommandRequest `json:"CommandsHandlePendingCommandRequest"` + CommandsHandlePendingCommandResult CommandsHandlePendingCommandResult `json:"CommandsHandlePendingCommandResult"` + ConnectRequest ConnectRequest `json:"ConnectRequest"` + ConnectResult ConnectResult `json:"ConnectResult"` + CurrentModel CurrentModel `json:"CurrentModel"` + DiscoveredMCPServer DiscoveredMCPServer `json:"DiscoveredMcpServer"` + DiscoveredMCPServerSource MCPServerSource `json:"DiscoveredMcpServerSource"` + DiscoveredMCPServerType DiscoveredMCPServerType `json:"DiscoveredMcpServerType"` + EmbeddedBlobResourceContents EmbeddedBlobResourceContents `json:"EmbeddedBlobResourceContents"` + EmbeddedTextResourceContents EmbeddedTextResourceContents `json:"EmbeddedTextResourceContents"` + Extension Extension `json:"Extension"` + ExtensionList ExtensionList `json:"ExtensionList"` + ExtensionsDisableRequest ExtensionsDisableRequest `json:"ExtensionsDisableRequest"` + ExtensionsDisableResult ExtensionsDisableResult `json:"ExtensionsDisableResult"` + ExtensionsEnableRequest ExtensionsEnableRequest `json:"ExtensionsEnableRequest"` + ExtensionsEnableResult ExtensionsEnableResult `json:"ExtensionsEnableResult"` + ExtensionSource ExtensionSource `json:"ExtensionSource"` + ExtensionsReloadResult ExtensionsReloadResult `json:"ExtensionsReloadResult"` + ExtensionStatus ExtensionStatus `json:"ExtensionStatus"` + ExternalToolResult *ExternalToolResult `json:"ExternalToolResult"` + ExternalToolTextResultForLlm ExternalToolTextResultForLlm `json:"ExternalToolTextResultForLlm"` + ExternalToolTextResultForLlmContent ExternalToolTextResultForLlmContent `json:"ExternalToolTextResultForLlmContent"` + ExternalToolTextResultForLlmContentAudio ExternalToolTextResultForLlmContentAudio `json:"ExternalToolTextResultForLlmContentAudio"` + ExternalToolTextResultForLlmContentImage ExternalToolTextResultForLlmContentImage `json:"ExternalToolTextResultForLlmContentImage"` + ExternalToolTextResultForLlmContentResource ExternalToolTextResultForLlmContentResource `json:"ExternalToolTextResultForLlmContentResource"` + ExternalToolTextResultForLlmContentResourceDetails ExternalToolTextResultForLlmContentResourceDetails `json:"ExternalToolTextResultForLlmContentResourceDetails"` + ExternalToolTextResultForLlmContentResourceLink ExternalToolTextResultForLlmContentResourceLink `json:"ExternalToolTextResultForLlmContentResourceLink"` + ExternalToolTextResultForLlmContentResourceLinkIcon ExternalToolTextResultForLlmContentResourceLinkIcon `json:"ExternalToolTextResultForLlmContentResourceLinkIcon"` + ExternalToolTextResultForLlmContentResourceLinkIconTheme ExternalToolTextResultForLlmContentResourceLinkIconTheme `json:"ExternalToolTextResultForLlmContentResourceLinkIconTheme"` + ExternalToolTextResultForLlmContentTerminal ExternalToolTextResultForLlmContentTerminal `json:"ExternalToolTextResultForLlmContentTerminal"` + ExternalToolTextResultForLlmContentText ExternalToolTextResultForLlmContentText `json:"ExternalToolTextResultForLlmContentText"` + FilterMapping *FilterMapping `json:"FilterMapping"` + FilterMappingString FilterMappingString `json:"FilterMappingString"` + FilterMappingValue FilterMappingString `json:"FilterMappingValue"` + FleetStartRequest FleetStartRequest `json:"FleetStartRequest"` + FleetStartResult FleetStartResult `json:"FleetStartResult"` + HandlePendingToolCallRequest HandlePendingToolCallRequest `json:"HandlePendingToolCallRequest"` + HandlePendingToolCallResult HandlePendingToolCallResult `json:"HandlePendingToolCallResult"` + HistoryCompactContextWindow HistoryCompactContextWindow `json:"HistoryCompactContextWindow"` + HistoryCompactResult HistoryCompactResult `json:"HistoryCompactResult"` + HistoryTruncateRequest HistoryTruncateRequest `json:"HistoryTruncateRequest"` + HistoryTruncateResult HistoryTruncateResult `json:"HistoryTruncateResult"` + InstructionsGetSourcesResult InstructionsGetSourcesResult `json:"InstructionsGetSourcesResult"` + InstructionsSources InstructionsSources `json:"InstructionsSources"` + InstructionsSourcesLocation InstructionsSourcesLocation `json:"InstructionsSourcesLocation"` + InstructionsSourcesType InstructionsSourcesType `json:"InstructionsSourcesType"` + LogRequest LogRequest `json:"LogRequest"` + LogResult LogResult `json:"LogResult"` + MCPConfigAddRequest MCPConfigAddRequest `json:"McpConfigAddRequest"` + MCPConfigAddResult MCPConfigAddResult `json:"McpConfigAddResult"` + MCPConfigDisableRequest MCPConfigDisableRequest `json:"McpConfigDisableRequest"` + MCPConfigDisableResult MCPConfigDisableResult `json:"McpConfigDisableResult"` + MCPConfigEnableRequest MCPConfigEnableRequest `json:"McpConfigEnableRequest"` + MCPConfigEnableResult MCPConfigEnableResult `json:"McpConfigEnableResult"` + MCPConfigList MCPConfigList `json:"McpConfigList"` + MCPConfigRemoveRequest MCPConfigRemoveRequest `json:"McpConfigRemoveRequest"` + MCPConfigRemoveResult MCPConfigRemoveResult `json:"McpConfigRemoveResult"` + MCPConfigUpdateRequest MCPConfigUpdateRequest `json:"McpConfigUpdateRequest"` + MCPConfigUpdateResult MCPConfigUpdateResult `json:"McpConfigUpdateResult"` + MCPDisableRequest MCPDisableRequest `json:"McpDisableRequest"` + MCPDisableResult MCPDisableResult `json:"McpDisableResult"` + MCPDiscoverRequest MCPDiscoverRequest `json:"McpDiscoverRequest"` + MCPDiscoverResult MCPDiscoverResult `json:"McpDiscoverResult"` + MCPEnableRequest MCPEnableRequest `json:"McpEnableRequest"` + MCPEnableResult MCPEnableResult `json:"McpEnableResult"` + MCPOauthLoginRequest MCPOauthLoginRequest `json:"McpOauthLoginRequest"` + MCPOauthLoginResult MCPOauthLoginResult `json:"McpOauthLoginResult"` + MCPReloadResult MCPReloadResult `json:"McpReloadResult"` + MCPServer MCPServer `json:"McpServer"` + MCPServerConfig MCPServerConfig `json:"McpServerConfig"` + MCPServerConfigHTTP MCPServerConfigHTTP `json:"McpServerConfigHttp"` + MCPServerConfigHTTPOauthGrantType MCPServerConfigHTTPOauthGrantType `json:"McpServerConfigHttpOauthGrantType"` + MCPServerConfigHTTPType MCPServerConfigHTTPType `json:"McpServerConfigHttpType"` + MCPServerConfigLocal MCPServerConfigLocal `json:"McpServerConfigLocal"` + MCPServerConfigLocalType MCPServerConfigLocalType `json:"McpServerConfigLocalType"` + MCPServerList MCPServerList `json:"McpServerList"` + MCPServerSource MCPServerSource `json:"McpServerSource"` + MCPServerStatus MCPServerStatus `json:"McpServerStatus"` + Model ModelElement `json:"Model"` + ModelBilling ModelBilling `json:"ModelBilling"` + ModelCapabilities ModelCapabilities `json:"ModelCapabilities"` + ModelCapabilitiesLimits ModelCapabilitiesLimits `json:"ModelCapabilitiesLimits"` + ModelCapabilitiesLimitsVision ModelCapabilitiesLimitsVision `json:"ModelCapabilitiesLimitsVision"` + ModelCapabilitiesOverride ModelCapabilitiesOverride `json:"ModelCapabilitiesOverride"` + ModelCapabilitiesOverrideLimits ModelCapabilitiesOverrideLimits `json:"ModelCapabilitiesOverrideLimits"` + ModelCapabilitiesOverrideLimitsVision ModelCapabilitiesOverrideLimitsVision `json:"ModelCapabilitiesOverrideLimitsVision"` + ModelCapabilitiesOverrideSupports ModelCapabilitiesOverrideSupports `json:"ModelCapabilitiesOverrideSupports"` + ModelCapabilitiesSupports ModelCapabilitiesSupports `json:"ModelCapabilitiesSupports"` + ModelList ModelList `json:"ModelList"` + ModelPolicy ModelPolicy `json:"ModelPolicy"` + ModelsListRequest ModelsListRequest `json:"ModelsListRequest"` + ModelSwitchToRequest ModelSwitchToRequest `json:"ModelSwitchToRequest"` + ModelSwitchToResult ModelSwitchToResult `json:"ModelSwitchToResult"` + ModeSetRequest ModeSetRequest `json:"ModeSetRequest"` + ModeSetResult ModeSetResult `json:"ModeSetResult"` + NameGetResult NameGetResult `json:"NameGetResult"` + NameSetRequest NameSetRequest `json:"NameSetRequest"` + NameSetResult NameSetResult `json:"NameSetResult"` + PermissionDecision PermissionDecision `json:"PermissionDecision"` + PermissionDecisionApproveForLocation PermissionDecisionApproveForLocation `json:"PermissionDecisionApproveForLocation"` + PermissionDecisionApproveForLocationApproval PermissionDecisionApproveForLocationApproval `json:"PermissionDecisionApproveForLocationApproval"` + PermissionDecisionApproveForLocationApprovalCommands PermissionDecisionApproveForLocationApprovalCommands `json:"PermissionDecisionApproveForLocationApprovalCommands"` + PermissionDecisionApproveForLocationApprovalCustomTool PermissionDecisionApproveForLocationApprovalCustomTool `json:"PermissionDecisionApproveForLocationApprovalCustomTool"` + PermissionDecisionApproveForLocationApprovalExtensionManagement PermissionDecisionApproveForLocationApprovalExtensionManagement `json:"PermissionDecisionApproveForLocationApprovalExtensionManagement"` + PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess `json:"PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess"` + PermissionDecisionApproveForLocationApprovalMCP PermissionDecisionApproveForLocationApprovalMCP `json:"PermissionDecisionApproveForLocationApprovalMcp"` + PermissionDecisionApproveForLocationApprovalMCPSampling PermissionDecisionApproveForLocationApprovalMCPSampling `json:"PermissionDecisionApproveForLocationApprovalMcpSampling"` + PermissionDecisionApproveForLocationApprovalMemory PermissionDecisionApproveForLocationApprovalMemory `json:"PermissionDecisionApproveForLocationApprovalMemory"` + PermissionDecisionApproveForLocationApprovalRead PermissionDecisionApproveForLocationApprovalRead `json:"PermissionDecisionApproveForLocationApprovalRead"` + PermissionDecisionApproveForLocationApprovalWrite PermissionDecisionApproveForLocationApprovalWrite `json:"PermissionDecisionApproveForLocationApprovalWrite"` + PermissionDecisionApproveForSession PermissionDecisionApproveForSession `json:"PermissionDecisionApproveForSession"` + PermissionDecisionApproveForSessionApproval PermissionDecisionApproveForSessionApproval `json:"PermissionDecisionApproveForSessionApproval"` + PermissionDecisionApproveForSessionApprovalCommands PermissionDecisionApproveForSessionApprovalCommands `json:"PermissionDecisionApproveForSessionApprovalCommands"` + PermissionDecisionApproveForSessionApprovalCustomTool PermissionDecisionApproveForSessionApprovalCustomTool `json:"PermissionDecisionApproveForSessionApprovalCustomTool"` + PermissionDecisionApproveForSessionApprovalExtensionManagement PermissionDecisionApproveForSessionApprovalExtensionManagement `json:"PermissionDecisionApproveForSessionApprovalExtensionManagement"` + PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess `json:"PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess"` + PermissionDecisionApproveForSessionApprovalMCP PermissionDecisionApproveForSessionApprovalMCP `json:"PermissionDecisionApproveForSessionApprovalMcp"` + PermissionDecisionApproveForSessionApprovalMCPSampling PermissionDecisionApproveForSessionApprovalMCPSampling `json:"PermissionDecisionApproveForSessionApprovalMcpSampling"` + PermissionDecisionApproveForSessionApprovalMemory PermissionDecisionApproveForSessionApprovalMemory `json:"PermissionDecisionApproveForSessionApprovalMemory"` + PermissionDecisionApproveForSessionApprovalRead PermissionDecisionApproveForSessionApprovalRead `json:"PermissionDecisionApproveForSessionApprovalRead"` + PermissionDecisionApproveForSessionApprovalWrite PermissionDecisionApproveForSessionApprovalWrite `json:"PermissionDecisionApproveForSessionApprovalWrite"` + PermissionDecisionApproveOnce PermissionDecisionApproveOnce `json:"PermissionDecisionApproveOnce"` + PermissionDecisionApprovePermanently PermissionDecisionApprovePermanently `json:"PermissionDecisionApprovePermanently"` + PermissionDecisionReject PermissionDecisionReject `json:"PermissionDecisionReject"` + PermissionDecisionRequest PermissionDecisionRequest `json:"PermissionDecisionRequest"` + PermissionDecisionUserNotAvailable PermissionDecisionUserNotAvailable `json:"PermissionDecisionUserNotAvailable"` + PermissionRequestResult PermissionRequestResult `json:"PermissionRequestResult"` + PermissionsResetSessionApprovalsRequest PermissionsResetSessionApprovalsRequest `json:"PermissionsResetSessionApprovalsRequest"` + PermissionsResetSessionApprovalsResult PermissionsResetSessionApprovalsResult `json:"PermissionsResetSessionApprovalsResult"` + PermissionsSetApproveAllRequest PermissionsSetApproveAllRequest `json:"PermissionsSetApproveAllRequest"` + PermissionsSetApproveAllResult PermissionsSetApproveAllResult `json:"PermissionsSetApproveAllResult"` + PingRequest PingRequest `json:"PingRequest"` + PingResult PingResult `json:"PingResult"` + PlanDeleteResult PlanDeleteResult `json:"PlanDeleteResult"` + PlanReadResult PlanReadResult `json:"PlanReadResult"` + PlanUpdateRequest PlanUpdateRequest `json:"PlanUpdateRequest"` + PlanUpdateResult PlanUpdateResult `json:"PlanUpdateResult"` + Plugin PluginElement `json:"Plugin"` + PluginList PluginList `json:"PluginList"` + RemoteDisableResult RemoteDisableResult `json:"RemoteDisableResult"` + RemoteEnableResult RemoteEnableResult `json:"RemoteEnableResult"` + ServerSkill ServerSkill `json:"ServerSkill"` + ServerSkillList ServerSkillList `json:"ServerSkillList"` + SessionAuthStatus SessionAuthStatus `json:"SessionAuthStatus"` + SessionFSAppendFileRequest SessionFSAppendFileRequest `json:"SessionFsAppendFileRequest"` + SessionFSError SessionFSError `json:"SessionFsError"` + SessionFSErrorCode SessionFSErrorCode `json:"SessionFsErrorCode"` + SessionFSExistsRequest SessionFSExistsRequest `json:"SessionFsExistsRequest"` + SessionFSExistsResult SessionFSExistsResult `json:"SessionFsExistsResult"` + SessionFSMkdirRequest SessionFSMkdirRequest `json:"SessionFsMkdirRequest"` + SessionFSReaddirRequest SessionFSReaddirRequest `json:"SessionFsReaddirRequest"` + SessionFSReaddirResult SessionFSReaddirResult `json:"SessionFsReaddirResult"` + SessionFSReaddirWithTypesEntry SessionFSReaddirWithTypesEntry `json:"SessionFsReaddirWithTypesEntry"` + SessionFSReaddirWithTypesEntryType SessionFSReaddirWithTypesEntryType `json:"SessionFsReaddirWithTypesEntryType"` + SessionFSReaddirWithTypesRequest SessionFSReaddirWithTypesRequest `json:"SessionFsReaddirWithTypesRequest"` + SessionFSReaddirWithTypesResult SessionFSReaddirWithTypesResult `json:"SessionFsReaddirWithTypesResult"` + SessionFSReadFileRequest SessionFSReadFileRequest `json:"SessionFsReadFileRequest"` + SessionFSReadFileResult SessionFSReadFileResult `json:"SessionFsReadFileResult"` + SessionFSRenameRequest SessionFSRenameRequest `json:"SessionFsRenameRequest"` + SessionFSRmRequest SessionFSRmRequest `json:"SessionFsRmRequest"` + SessionFSSetProviderConventions SessionFSSetProviderConventions `json:"SessionFsSetProviderConventions"` + SessionFSSetProviderRequest SessionFSSetProviderRequest `json:"SessionFsSetProviderRequest"` + SessionFSSetProviderResult SessionFSSetProviderResult `json:"SessionFsSetProviderResult"` + SessionFSStatRequest SessionFSStatRequest `json:"SessionFsStatRequest"` + SessionFSStatResult SessionFSStatResult `json:"SessionFsStatResult"` + SessionFSWriteFileRequest SessionFSWriteFileRequest `json:"SessionFsWriteFileRequest"` + SessionLogLevel SessionLogLevel `json:"SessionLogLevel"` + SessionMode SessionMode `json:"SessionMode"` + SessionsForkRequest SessionsForkRequest `json:"SessionsForkRequest"` + SessionsForkResult SessionsForkResult `json:"SessionsForkResult"` + ShellExecRequest ShellExecRequest `json:"ShellExecRequest"` + ShellExecResult ShellExecResult `json:"ShellExecResult"` + ShellKillRequest ShellKillRequest `json:"ShellKillRequest"` + ShellKillResult ShellKillResult `json:"ShellKillResult"` + ShellKillSignal ShellKillSignal `json:"ShellKillSignal"` + Skill Skill `json:"Skill"` + SkillList SkillList `json:"SkillList"` + SkillsConfigSetDisabledSkillsRequest SkillsConfigSetDisabledSkillsRequest `json:"SkillsConfigSetDisabledSkillsRequest"` + SkillsConfigSetDisabledSkillsResult SkillsConfigSetDisabledSkillsResult `json:"SkillsConfigSetDisabledSkillsResult"` + SkillsDisableRequest SkillsDisableRequest `json:"SkillsDisableRequest"` + SkillsDisableResult SkillsDisableResult `json:"SkillsDisableResult"` + SkillsDiscoverRequest SkillsDiscoverRequest `json:"SkillsDiscoverRequest"` + SkillsEnableRequest SkillsEnableRequest `json:"SkillsEnableRequest"` + SkillsEnableResult SkillsEnableResult `json:"SkillsEnableResult"` + SkillsReloadResult SkillsReloadResult `json:"SkillsReloadResult"` + SuspendResult SuspendResult `json:"SuspendResult"` + TaskAgentInfo TaskAgentInfo `json:"TaskAgentInfo"` + TaskAgentInfoExecutionMode TaskInfoExecutionMode `json:"TaskAgentInfoExecutionMode"` + TaskAgentInfoStatus TaskInfoStatus `json:"TaskAgentInfoStatus"` + TaskInfo TaskInfo `json:"TaskInfo"` + TaskList TaskList `json:"TaskList"` + TasksCancelRequest TasksCancelRequest `json:"TasksCancelRequest"` + TasksCancelResult TasksCancelResult `json:"TasksCancelResult"` + TaskShellInfo TaskShellInfo `json:"TaskShellInfo"` + TaskShellInfoAttachmentMode TaskShellInfoAttachmentMode `json:"TaskShellInfoAttachmentMode"` + TaskShellInfoExecutionMode TaskInfoExecutionMode `json:"TaskShellInfoExecutionMode"` + TaskShellInfoStatus TaskInfoStatus `json:"TaskShellInfoStatus"` + TasksPromoteToBackgroundRequest TasksPromoteToBackgroundRequest `json:"TasksPromoteToBackgroundRequest"` + TasksPromoteToBackgroundResult TasksPromoteToBackgroundResult `json:"TasksPromoteToBackgroundResult"` + TasksRemoveRequest TasksRemoveRequest `json:"TasksRemoveRequest"` + TasksRemoveResult TasksRemoveResult `json:"TasksRemoveResult"` + TasksSendMessageRequest TasksSendMessageRequest `json:"TasksSendMessageRequest"` + TasksSendMessageResult TasksSendMessageResult `json:"TasksSendMessageResult"` + TasksStartAgentRequest TasksStartAgentRequest `json:"TasksStartAgentRequest"` + TasksStartAgentResult TasksStartAgentResult `json:"TasksStartAgentResult"` + Tool Tool `json:"Tool"` + ToolList ToolList `json:"ToolList"` + ToolsListRequest ToolsListRequest `json:"ToolsListRequest"` + UIElicitationArrayAnyOfField UIElicitationArrayAnyOfField `json:"UIElicitationArrayAnyOfField"` + UIElicitationArrayAnyOfFieldItems UIElicitationArrayAnyOfFieldItems `json:"UIElicitationArrayAnyOfFieldItems"` + UIElicitationArrayAnyOfFieldItemsAnyOf UIElicitationArrayAnyOfFieldItemsAnyOf `json:"UIElicitationArrayAnyOfFieldItemsAnyOf"` + UIElicitationArrayEnumField UIElicitationArrayEnumField `json:"UIElicitationArrayEnumField"` + UIElicitationArrayEnumFieldItems UIElicitationArrayEnumFieldItems `json:"UIElicitationArrayEnumFieldItems"` + UIElicitationFieldValue *UIElicitationFieldValue `json:"UIElicitationFieldValue"` + UIElicitationRequest UIElicitationRequest `json:"UIElicitationRequest"` + UIElicitationResponse UIElicitationResponse `json:"UIElicitationResponse"` + UIElicitationResponseAction UIElicitationResponseAction `json:"UIElicitationResponseAction"` + UIElicitationResponseContent map[string]*UIElicitationFieldValue `json:"UIElicitationResponseContent"` + UIElicitationResult UIElicitationResult `json:"UIElicitationResult"` + UIElicitationSchema UIElicitationSchema `json:"UIElicitationSchema"` + UIElicitationSchemaProperty UIElicitationSchemaProperty `json:"UIElicitationSchemaProperty"` + UIElicitationSchemaPropertyBoolean UIElicitationSchemaPropertyBoolean `json:"UIElicitationSchemaPropertyBoolean"` + UIElicitationSchemaPropertyNumber UIElicitationSchemaPropertyNumber `json:"UIElicitationSchemaPropertyNumber"` + UIElicitationSchemaPropertyNumberType UIElicitationSchemaPropertyNumberTypeEnum `json:"UIElicitationSchemaPropertyNumberType"` + UIElicitationSchemaPropertyString UIElicitationSchemaPropertyString `json:"UIElicitationSchemaPropertyString"` + UIElicitationSchemaPropertyStringFormat UIElicitationSchemaPropertyStringFormat `json:"UIElicitationSchemaPropertyStringFormat"` + UIElicitationStringEnumField UIElicitationStringEnumField `json:"UIElicitationStringEnumField"` + UIElicitationStringOneOfField UIElicitationStringOneOfField `json:"UIElicitationStringOneOfField"` + UIElicitationStringOneOfFieldOneOf UIElicitationStringOneOfFieldOneOf `json:"UIElicitationStringOneOfFieldOneOf"` + UIHandlePendingElicitationRequest UIHandlePendingElicitationRequest `json:"UIHandlePendingElicitationRequest"` + UsageGetMetricsResult UsageGetMetricsResult `json:"UsageGetMetricsResult"` + UsageMetricsCodeChanges UsageMetricsCodeChanges `json:"UsageMetricsCodeChanges"` + UsageMetricsModelMetric UsageMetricsModelMetric `json:"UsageMetricsModelMetric"` + UsageMetricsModelMetricRequests UsageMetricsModelMetricRequests `json:"UsageMetricsModelMetricRequests"` + UsageMetricsModelMetricTokenDetail UsageMetricsModelMetricTokenDetail `json:"UsageMetricsModelMetricTokenDetail"` + UsageMetricsModelMetricUsage UsageMetricsModelMetricUsage `json:"UsageMetricsModelMetricUsage"` + UsageMetricsTokenDetail UsageMetricsTokenDetail `json:"UsageMetricsTokenDetail"` + WorkspacesCreateFileRequest WorkspacesCreateFileRequest `json:"WorkspacesCreateFileRequest"` + WorkspacesCreateFileResult WorkspacesCreateFileResult `json:"WorkspacesCreateFileResult"` + WorkspacesGetWorkspaceResult WorkspacesGetWorkspaceResult `json:"WorkspacesGetWorkspaceResult"` + WorkspacesListFilesResult WorkspacesListFilesResult `json:"WorkspacesListFilesResult"` + WorkspacesReadFileRequest WorkspacesReadFileRequest `json:"WorkspacesReadFileRequest"` + WorkspacesReadFileResult WorkspacesReadFileResult `json:"WorkspacesReadFileResult"` } type AccountGetQuotaRequest struct { @@ -918,7 +924,7 @@ type ModelElement struct { // Billing information type ModelBilling struct { // Billing cost multiplier relative to the base rate - Multiplier float64 `json:"multiplier"` + Multiplier *float64 `json:"multiplier,omitempty"` } // Model capabilities and limits @@ -1079,6 +1085,8 @@ type PermissionDecisionApproveForLocationApproval struct { Kind ApprovalKind `json:"kind"` ServerName *string `json:"serverName,omitempty"` ToolName *string `json:"toolName,omitempty"` + Operation *string `json:"operation,omitempty"` + ExtensionName *string `json:"extensionName,omitempty"` } type PermissionDecisionApproveForLocationApprovalCommands struct { @@ -1091,6 +1099,16 @@ type PermissionDecisionApproveForLocationApprovalCustomTool struct { ToolName string `json:"toolName"` } +type PermissionDecisionApproveForLocationApprovalExtensionManagement struct { + Kind PermissionDecisionApproveForLocationApprovalExtensionManagementKind `json:"kind"` + Operation *string `json:"operation,omitempty"` +} + +type PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess struct { + ExtensionName string `json:"extensionName"` + Kind PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind `json:"kind"` +} + type PermissionDecisionApproveForLocationApprovalMCP struct { Kind PermissionDecisionApproveForLocationApprovalMCPKind `json:"kind"` ServerName string `json:"serverName"` @@ -1129,6 +1147,8 @@ type PermissionDecisionApproveForSessionApproval struct { Kind ApprovalKind `json:"kind"` ServerName *string `json:"serverName,omitempty"` ToolName *string `json:"toolName,omitempty"` + Operation *string `json:"operation,omitempty"` + ExtensionName *string `json:"extensionName,omitempty"` } type PermissionDecisionApproveForSessionApprovalCommands struct { @@ -1141,6 +1161,16 @@ type PermissionDecisionApproveForSessionApprovalCustomTool struct { ToolName string `json:"toolName"` } +type PermissionDecisionApproveForSessionApprovalExtensionManagement struct { + Kind PermissionDecisionApproveForLocationApprovalExtensionManagementKind `json:"kind"` + Operation *string `json:"operation,omitempty"` +} + +type PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess struct { + ExtensionName string `json:"extensionName"` + Kind PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind `json:"kind"` +} + type PermissionDecisionApproveForSessionApprovalMCP struct { Kind PermissionDecisionApproveForLocationApprovalMCPKind `json:"kind"` ServerName string `json:"serverName"` @@ -1745,6 +1775,24 @@ type TasksRemoveResult struct { Removed bool `json:"removed"` } +// Experimental: TasksSendMessageRequest is part of an experimental API and may change or be removed. +type TasksSendMessageRequest struct { + // Agent ID of the sender, if sent on behalf of another agent + FromAgentID *string `json:"fromAgentId,omitempty"` + // Agent task identifier + ID string `json:"id"` + // Message content to send to the agent + Message string `json:"message"` +} + +// Experimental: TasksSendMessageResult is part of an experimental API and may change or be removed. +type TasksSendMessageResult struct { + // Error message if delivery failed + Error *string `json:"error,omitempty"` + // Whether the message was successfully delivered or steered + Sent bool `json:"sent"` +} + // Experimental: TasksStartAgentRequest is part of an experimental API and may change or be removed. type TasksStartAgentRequest struct { // Type of agent to start (e.g., 'explore', 'task', 'general-purpose') @@ -2030,24 +2078,23 @@ type WorkspacesGetWorkspaceResult struct { } type WorkspaceClass struct { - Branch *string `json:"branch,omitempty"` - ChronicleSyncDismissed *bool `json:"chronicle_sync_dismissed,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - Cwd *string `json:"cwd,omitempty"` - GitRoot *string `json:"git_root,omitempty"` - HostType *HostType `json:"host_type,omitempty"` - ID string `json:"id"` - McLastEventID *string `json:"mc_last_event_id,omitempty"` - McSessionID *string `json:"mc_session_id,omitempty"` - McTaskID *string `json:"mc_task_id,omitempty"` - Name *string `json:"name,omitempty"` - RemoteSteerable *bool `json:"remote_steerable,omitempty"` - Repository *string `json:"repository,omitempty"` - SessionSyncLevel *SessionSyncLevel `json:"session_sync_level,omitempty"` - Summary *string `json:"summary,omitempty"` - SummaryCount *int64 `json:"summary_count,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - UserNamed *bool `json:"user_named,omitempty"` + Branch *string `json:"branch,omitempty"` + ChronicleSyncDismissed *bool `json:"chronicle_sync_dismissed,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Cwd *string `json:"cwd,omitempty"` + GitRoot *string `json:"git_root,omitempty"` + HostType *HostType `json:"host_type,omitempty"` + ID string `json:"id"` + McLastEventID *string `json:"mc_last_event_id,omitempty"` + McSessionID *string `json:"mc_session_id,omitempty"` + McTaskID *string `json:"mc_task_id,omitempty"` + Name *string `json:"name,omitempty"` + RemoteSteerable *bool `json:"remote_steerable,omitempty"` + Repository *string `json:"repository,omitempty"` + Summary *string `json:"summary,omitempty"` + SummaryCount *int64 `json:"summary_count,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UserNamed *bool `json:"user_named,omitempty"` } type WorkspacesListFilesResult struct { @@ -2268,13 +2315,15 @@ const ( type ApprovalKind string const ( - ApprovalKindCommands ApprovalKind = "commands" - ApprovalKindCustomTool ApprovalKind = "custom-tool" - ApprovalKindMcp ApprovalKind = "mcp" - ApprovalKindMcpSampling ApprovalKind = "mcp-sampling" - ApprovalKindMemory ApprovalKind = "memory" - ApprovalKindRead ApprovalKind = "read" - ApprovalKindWrite ApprovalKind = "write" + ApprovalKindCommands ApprovalKind = "commands" + ApprovalKindCustomTool ApprovalKind = "custom-tool" + ApprovalKindExtensionManagement ApprovalKind = "extension-management" + ApprovalKindExtensionPermissionAccess ApprovalKind = "extension-permission-access" + ApprovalKindMcp ApprovalKind = "mcp" + ApprovalKindMcpSampling ApprovalKind = "mcp-sampling" + ApprovalKindMemory ApprovalKind = "memory" + ApprovalKindRead ApprovalKind = "read" + ApprovalKindWrite ApprovalKind = "write" ) type PermissionDecisionKind string @@ -2306,6 +2355,18 @@ const ( PermissionDecisionApproveForLocationApprovalCustomToolKindCustomTool PermissionDecisionApproveForLocationApprovalCustomToolKind = "custom-tool" ) +type PermissionDecisionApproveForLocationApprovalExtensionManagementKind string + +const ( + PermissionDecisionApproveForLocationApprovalExtensionManagementKindExtensionManagement PermissionDecisionApproveForLocationApprovalExtensionManagementKind = "extension-management" +) + +type PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind string + +const ( + PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKindExtensionPermissionAccess PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind = "extension-permission-access" +) + type PermissionDecisionApproveForLocationApprovalMCPKind string const ( @@ -2514,14 +2575,6 @@ const ( HostTypeGithub HostType = "github" ) -type SessionSyncLevel string - -const ( - SessionSyncLevelRepoAndUser SessionSyncLevel = "repo_and_user" - SessionSyncLevelLocal SessionSyncLevel = "local" - SessionSyncLevelUser SessionSyncLevel = "user" -) - // Tool call result (string or expanded result object) type ExternalToolResult struct { ExternalToolTextResultForLlm *ExternalToolTextResultForLlm @@ -3225,6 +3278,26 @@ func (a *TasksApi) Remove(ctx context.Context, params *TasksRemoveRequest) (*Tas return &result, nil } +func (a *TasksApi) SendMessage(ctx context.Context, params *TasksSendMessageRequest) (*TasksSendMessageResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + req["id"] = params.ID + req["message"] = params.Message + if params.FromAgentID != nil { + req["fromAgentId"] = *params.FromAgentID + } + } + raw, err := a.client.Request("session.tasks.sendMessage", req) + if err != nil { + return nil, err + } + var result TasksSendMessageResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Experimental: SkillsApi contains experimental APIs that may change or be removed. type SkillsApi sessionApi diff --git a/go/types.go b/go/types.go index 19b67dfd6..ee973a069 100644 --- a/go/types.go +++ b/go/types.go @@ -1031,7 +1031,7 @@ type ModelPolicy struct { // ModelBilling contains model billing information type ModelBilling struct { - Multiplier float64 `json:"multiplier"` + Multiplier *float64 `json:"multiplier,omitempty"` } // ModelInfo contains information about an available model diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 7a6b44f0d..81fe4ba21 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.44-2", + "@github/copilot": "^1.0.44-3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-2.tgz", - "integrity": "sha512-MUIR4w+oXjbg1jwUS8B86eMd/bV2gVKZ61a/aEUE4gUrFFpGXO0tNk9OkfLSH5cmlhJY6lzMzb+kKQWoeAbbNQ==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-3.tgz", + "integrity": "sha512-hTsNxnmtKDK3ymh+c6LrsXWc9TbbubUHSxPuAKc4CX0d1c9iI1R4ybzS5Ihe+GxlozHIyFANd58gAg3QH3uCkA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.44-2", - "@github/copilot-darwin-x64": "1.0.44-2", - "@github/copilot-linux-arm64": "1.0.44-2", - "@github/copilot-linux-x64": "1.0.44-2", - "@github/copilot-win32-arm64": "1.0.44-2", - "@github/copilot-win32-x64": "1.0.44-2" + "@github/copilot-darwin-arm64": "1.0.44-3", + "@github/copilot-darwin-x64": "1.0.44-3", + "@github/copilot-linux-arm64": "1.0.44-3", + "@github/copilot-linux-x64": "1.0.44-3", + "@github/copilot-win32-arm64": "1.0.44-3", + "@github/copilot-win32-x64": "1.0.44-3" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-2.tgz", - "integrity": "sha512-6o/pvew0FZJG+8saG1K/L1pUIvpz4AWkZitiqH36tDfXdXKx/PUQ+zaFg/KPeHNnxtal5OdE/7iyrJwIqm2gPg==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-3.tgz", + "integrity": "sha512-59IXG1lGCf0Ni4TjNL6bqBul6G2FPFX2vh6pMnoRVtHvRrtFILIBMNRMNQFrYZo3eXYBqYXwVHu4R8zfELpK6A==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-2.tgz", - "integrity": "sha512-OMNoLNFYUynB4wiplSh4gtD5zVlvfWMKc0jKQ0oItJLGO8GRL9X0ZB2ONB+7JpVvPidz0Yy4+jU0zWNXEjMM5g==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-3.tgz", + "integrity": "sha512-I+aR9rBNzwn3OOd5oIDIpnUCkCtj3mL183Ml1LLUcJ3utxwxKVInckW/Jg36jSD2PhkbNX8gzq0l3dv0td6QYQ==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-2.tgz", - "integrity": "sha512-5WGRADU08hqBTWmQ6JVOYMximzsXGuOdFF4GFRQqfsCR8k4RE8fdPWQJa92BpqMgGWwEVPemq0wB3D4hDM5eWw==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-3.tgz", + "integrity": "sha512-Agz4tMiM0hy9zIPPxKF0SSjMZSYuLYoGMe5KbvNEwTrAApLSrSW6k8yhlOTVCiRHEBsfh69We3LCOmc8hX8jVg==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-2.tgz", - "integrity": "sha512-4ZnA2QxEwgrdCePdS5OjuksEGFpJrXgofuELANCpDSHwR3eTV7PynVyqhG6Et7ktN2KzHk7zf8kvtiWVCOxvFg==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-3.tgz", + "integrity": "sha512-Ev5/uZKqSOr6l2tcy9Xqx354tuxo8qE42Cnnd6JynGrvVc1NpzF1Kt5eCzzjxdZiRtPo6AdDXS16oAN8CVxCrg==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-2.tgz", - "integrity": "sha512-klgSdBZblz9O8BRnTh9uk9uO/INQwVeTBagXuJO7MrZ7JCfBVJyFUYky2tKIjFxlwefyhrRZuniqYeOI9fQc+A==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-3.tgz", + "integrity": "sha512-bV2JeRRNYTiTfqmCVeXdPpgYe8KY58diJFZdhYSQnQDowjKvRn59K0RBEYDGK8//AjN+NfaGPGikMq3CQm61cA==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-2.tgz", - "integrity": "sha512-ziq3abdbMCqtAqdiEWWf6cn0whlWss7rC9VMsO/Vx2gjSEVCeJkmIiRiQO45WikheyXyxEmCTAvOwZLQvs+I9g==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-3.tgz", + "integrity": "sha512-qR6q16UDC6bIO8cde62z0wwVweH351RzN1KZgMjBqQYUBJw521K8VK7p64XK0tQWoTG8uyCuqqu5djQq/4Ek+g==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 69f476b73..9ed91794f 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.44-2", + "@github/copilot": "^1.0.44-3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 6f2d1ac53..b4b177fc7 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.44-2", + "@github/copilot": "^1.0.44-3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 7de129d14..b52fecce9 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -167,7 +167,9 @@ export type PermissionDecisionApproveForSessionApproval = | PermissionDecisionApproveForSessionApprovalMcp | PermissionDecisionApproveForSessionApprovalMcpSampling | PermissionDecisionApproveForSessionApprovalMemory - | PermissionDecisionApproveForSessionApprovalCustomTool; + | PermissionDecisionApproveForSessionApprovalCustomTool + | PermissionDecisionApproveForSessionApprovalExtensionManagement + | PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess; /** * The approval to persist for this location * @@ -181,7 +183,9 @@ export type PermissionDecisionApproveForLocationApproval = | PermissionDecisionApproveForLocationApprovalMcp | PermissionDecisionApproveForLocationApprovalMcpSampling | PermissionDecisionApproveForLocationApprovalMemory - | PermissionDecisionApproveForLocationApprovalCustomTool; + | PermissionDecisionApproveForLocationApprovalCustomTool + | PermissionDecisionApproveForLocationApprovalExtensionManagement + | PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess; /** * Error classification * @@ -1138,7 +1142,7 @@ export interface ModelBilling { /** * Billing cost multiplier relative to the base rate */ - multiplier: number; + multiplier?: number; } /** * Override individual model capabilities resolved by the runtime @@ -1294,6 +1298,16 @@ export interface PermissionDecisionApproveForSessionApprovalCustomTool { toolName: string; } +export interface PermissionDecisionApproveForSessionApprovalExtensionManagement { + kind: "extension-management"; + operation?: string; +} + +export interface PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess { + kind: "extension-permission-access"; + extensionName: string; +} + export interface PermissionDecisionApproveForLocation { /** * Approved and persisted for this project location @@ -1339,6 +1353,16 @@ export interface PermissionDecisionApproveForLocationApprovalCustomTool { toolName: string; } +export interface PermissionDecisionApproveForLocationApprovalExtensionManagement { + kind: "extension-management"; + operation?: string; +} + +export interface PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess { + kind: "extension-permission-access"; + extensionName: string; +} + export interface PermissionDecisionApprovePermanently { /** * Approved and persisted across sessions @@ -2085,6 +2109,34 @@ export interface TasksRemoveResult { removed: boolean; } +/** @experimental */ +export interface TasksSendMessageRequest { + /** + * Agent task identifier + */ + id: string; + /** + * Message content to send to the agent + */ + message: string; + /** + * Agent ID of the sender, if sent on behalf of another agent + */ + fromAgentId?: string; +} + +/** @experimental */ +export interface TasksSendMessageResult { + /** + * Whether the message was successfully delivered or steered + */ + sent: boolean; + /** + * Error message if delivery failed + */ + error?: string; +} + /** @experimental */ export interface TasksStartAgentRequest { /** @@ -2476,7 +2528,6 @@ export interface WorkspacesGetWorkspaceResult { mc_task_id?: string; mc_session_id?: string; mc_last_event_id?: string; - session_sync_level?: "local" | "user" | "repo_and_user"; chronicle_sync_dismissed?: boolean; } | null; } @@ -2648,6 +2699,8 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin connection.sendRequest("session.tasks.cancel", { sessionId, ...params }), remove: async (params: TasksRemoveRequest): Promise => connection.sendRequest("session.tasks.remove", { sessionId, ...params }), + sendMessage: async (params: TasksSendMessageRequest): Promise => + connection.sendRequest("session.tasks.sendMessage", { sessionId, ...params }), }, /** @experimental */ skills: { diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index c6a222d05..a9155ea48 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -180,7 +180,9 @@ export type PermissionRequest = | PermissionRequestUrl | PermissionRequestMemory | PermissionRequestCustomTool - | PermissionRequestHook; + | PermissionRequestHook + | PermissionRequestExtensionManagement + | PermissionRequestExtensionPermissionAccess; /** * Whether this is a store or vote memory operation */ @@ -201,7 +203,9 @@ export type PermissionPromptRequest = | PermissionPromptRequestMemory | PermissionPromptRequestCustomTool | PermissionPromptRequestPath - | PermissionPromptRequestHook; + | PermissionPromptRequestHook + | PermissionPromptRequestExtensionManagement + | PermissionPromptRequestExtensionPermissionAccess; /** * Whether this is a store or vote memory operation */ @@ -312,6 +316,10 @@ export interface StartData { * Version string of the Copilot application */ copilotVersion: string; + /** + * When set, identifies a parent session whose context this session continues — e.g., a detached headless rem-agent run launched on the parent's interactive shutdown. Telemetry from this session is reported under the parent's session_id. + */ + detachedFromSpawningParentSessionId?: string; /** * Identifier of the software producing the events (e.g., "copilot-agent") */ @@ -3710,6 +3718,48 @@ export interface PermissionRequestHook { */ toolName: string; } +/** + * Extension management permission request + */ +export interface PermissionRequestExtensionManagement { + /** + * Name of the extension being managed + */ + extensionName?: string; + /** + * Permission kind discriminator + */ + kind: "extension-management"; + /** + * The extension management operation (scaffold, reload) + */ + operation: string; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; +} +/** + * Extension permission access request + */ +export interface PermissionRequestExtensionPermissionAccess { + /** + * Capabilities the extension is requesting + */ + capabilities: string[]; + /** + * Name of the extension requesting permission access + */ + extensionName: string; + /** + * Permission kind discriminator + */ + kind: "extension-permission-access"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; +} /** * Shell command permission prompt */ @@ -3947,6 +3997,48 @@ export interface PermissionPromptRequestHook { */ toolName: string; } +/** + * Extension management permission prompt + */ +export interface PermissionPromptRequestExtensionManagement { + /** + * Name of the extension being managed + */ + extensionName?: string; + /** + * Prompt kind discriminator + */ + kind: "extension-management"; + /** + * The extension management operation (scaffold, reload) + */ + operation: string; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; +} +/** + * Extension permission access prompt + */ +export interface PermissionPromptRequestExtensionPermissionAccess { + /** + * Capabilities the extension is requesting + */ + capabilities: string[]; + /** + * Name of the extension requesting permission access + */ + extensionName: string; + /** + * Prompt kind discriminator + */ + kind: "extension-permission-access"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; +} export interface PermissionCompletedEvent { /** * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 8be0cca8c..7b9348df0 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1842,7 +1842,7 @@ export interface ModelPolicy { * Model billing information */ export interface ModelBilling { - multiplier: number; + multiplier?: number; } /** diff --git a/python/copilot/client.py b/python/copilot/client.py index 4de7289bd..848af4b92 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -540,19 +540,20 @@ def to_dict(self) -> dict: class ModelBilling: """Model billing information""" - multiplier: float + multiplier: float | None = None @staticmethod def from_dict(obj: Any) -> ModelBilling: assert isinstance(obj, dict) multiplier = obj.get("multiplier") if multiplier is None: - raise ValueError("Missing required field 'multiplier' in ModelBilling") + return ModelBilling() return ModelBilling(multiplier=float(multiplier)) def to_dict(self) -> dict: result: dict = {} - result["multiplier"] = self.multiplier + if self.multiplier is not None: + result["multiplier"] = self.multiplier return result diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index b18e9c7c3..ca8fbe494 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -884,18 +884,19 @@ class SessionMode(Enum): class ModelBilling: """Billing information""" - multiplier: float + multiplier: float | None = None """Billing cost multiplier relative to the base rate""" @staticmethod def from_dict(obj: Any) -> 'ModelBilling': assert isinstance(obj, dict) - multiplier = from_float(obj.get("multiplier")) + multiplier = from_union([from_float, from_none], obj.get("multiplier")) return ModelBilling(multiplier) def to_dict(self) -> dict: result: dict = {} - result["multiplier"] = to_float(self.multiplier) + if self.multiplier is not None: + result["multiplier"] = from_union([to_float, from_none], self.multiplier) return result @dataclass @@ -1097,6 +1098,8 @@ def to_dict(self) -> dict: class ApprovalKind(Enum): COMMANDS = "commands" CUSTOM_TOOL = "custom-tool" + EXTENSION_MANAGEMENT = "extension-management" + EXTENSION_PERMISSION_ACCESS = "extension-permission-access" MCP = "mcp" MCP_SAMPLING = "mcp-sampling" MEMORY = "memory" @@ -1120,6 +1123,12 @@ class PermissionDecisionApproveForLocationApprovalCommandsKind(Enum): class PermissionDecisionApproveForLocationApprovalCustomToolKind(Enum): CUSTOM_TOOL = "custom-tool" +class PermissionDecisionApproveForLocationApprovalExtensionManagementKind(Enum): + EXTENSION_MANAGEMENT = "extension-management" + +class PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind(Enum): + EXTENSION_PERMISSION_ACCESS = "extension-permission-access" + class PermissionDecisionApproveForLocationApprovalMCPKind(Enum): MCP = "mcp" @@ -2086,6 +2095,57 @@ def to_dict(self) -> dict: result["removed"] = from_bool(self.removed) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class TasksSendMessageRequest: + id: str + """Agent task identifier""" + + message: str + """Message content to send to the agent""" + + from_agent_id: str | None = None + """Agent ID of the sender, if sent on behalf of another agent""" + + @staticmethod + def from_dict(obj: Any) -> 'TasksSendMessageRequest': + assert isinstance(obj, dict) + id = from_str(obj.get("id")) + message = from_str(obj.get("message")) + from_agent_id = from_union([from_str, from_none], obj.get("fromAgentId")) + return TasksSendMessageRequest(id, message, from_agent_id) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = from_str(self.id) + result["message"] = from_str(self.message) + if self.from_agent_id is not None: + result["fromAgentId"] = from_union([from_str, from_none], self.from_agent_id) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class TasksSendMessageResult: + sent: bool + """Whether the message was successfully delivered or steered""" + + error: str | None = None + """Error message if delivery failed""" + + @staticmethod + def from_dict(obj: Any) -> 'TasksSendMessageResult': + assert isinstance(obj, dict) + sent = from_bool(obj.get("sent")) + error = from_union([from_str, from_none], obj.get("error")) + return TasksSendMessageResult(sent, error) + + def to_dict(self) -> dict: + result: dict = {} + result["sent"] = from_bool(self.sent) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class TasksStartAgentRequest: @@ -2438,11 +2498,6 @@ class HostType(Enum): ADO = "ado" GITHUB = "github" -class SessionSyncLevel(Enum): - LOCAL = "local" - REPO_AND_USER = "repo_and_user" - USER = "user" - @dataclass class WorkspacesListFilesResult: files: list[str] @@ -3295,6 +3350,8 @@ class PermissionDecisionApproveForIonApproval: command_identifiers: list[str] | None = None server_name: str | None = None tool_name: str | None = None + operation: str | None = None + extension_name: str | None = None @staticmethod def from_dict(obj: Any) -> 'PermissionDecisionApproveForIonApproval': @@ -3303,7 +3360,9 @@ def from_dict(obj: Any) -> 'PermissionDecisionApproveForIonApproval': command_identifiers = from_union([lambda x: from_list(from_str, x), from_none], obj.get("commandIdentifiers")) server_name = from_union([from_str, from_none], obj.get("serverName")) tool_name = from_union([from_none, from_str], obj.get("toolName")) - return PermissionDecisionApproveForIonApproval(kind, command_identifiers, server_name, tool_name) + operation = from_union([from_str, from_none], obj.get("operation")) + extension_name = from_union([from_str, from_none], obj.get("extensionName")) + return PermissionDecisionApproveForIonApproval(kind, command_identifiers, server_name, tool_name, operation, extension_name) def to_dict(self) -> dict: result: dict = {} @@ -3314,6 +3373,10 @@ def to_dict(self) -> dict: result["serverName"] = from_union([from_str, from_none], self.server_name) if self.tool_name is not None: result["toolName"] = from_union([from_none, from_str], self.tool_name) + if self.operation is not None: + result["operation"] = from_union([from_str, from_none], self.operation) + if self.extension_name is not None: + result["extensionName"] = from_union([from_str, from_none], self.extension_name) return result @dataclass @@ -3324,6 +3387,8 @@ class PermissionDecisionApproveForLocationApproval: command_identifiers: list[str] | None = None server_name: str | None = None tool_name: str | None = None + operation: str | None = None + extension_name: str | None = None @staticmethod def from_dict(obj: Any) -> 'PermissionDecisionApproveForLocationApproval': @@ -3332,7 +3397,9 @@ def from_dict(obj: Any) -> 'PermissionDecisionApproveForLocationApproval': command_identifiers = from_union([lambda x: from_list(from_str, x), from_none], obj.get("commandIdentifiers")) server_name = from_union([from_str, from_none], obj.get("serverName")) tool_name = from_union([from_none, from_str], obj.get("toolName")) - return PermissionDecisionApproveForLocationApproval(kind, command_identifiers, server_name, tool_name) + operation = from_union([from_str, from_none], obj.get("operation")) + extension_name = from_union([from_str, from_none], obj.get("extensionName")) + return PermissionDecisionApproveForLocationApproval(kind, command_identifiers, server_name, tool_name, operation, extension_name) def to_dict(self) -> dict: result: dict = {} @@ -3343,6 +3410,10 @@ def to_dict(self) -> dict: result["serverName"] = from_union([from_str, from_none], self.server_name) if self.tool_name is not None: result["toolName"] = from_union([from_none, from_str], self.tool_name) + if self.operation is not None: + result["operation"] = from_union([from_str, from_none], self.operation) + if self.extension_name is not None: + result["extensionName"] = from_union([from_str, from_none], self.extension_name) return result @dataclass @@ -3353,6 +3424,8 @@ class PermissionDecisionApproveForSessionApproval: command_identifiers: list[str] | None = None server_name: str | None = None tool_name: str | None = None + operation: str | None = None + extension_name: str | None = None @staticmethod def from_dict(obj: Any) -> 'PermissionDecisionApproveForSessionApproval': @@ -3361,7 +3434,9 @@ def from_dict(obj: Any) -> 'PermissionDecisionApproveForSessionApproval': command_identifiers = from_union([lambda x: from_list(from_str, x), from_none], obj.get("commandIdentifiers")) server_name = from_union([from_str, from_none], obj.get("serverName")) tool_name = from_union([from_none, from_str], obj.get("toolName")) - return PermissionDecisionApproveForSessionApproval(kind, command_identifiers, server_name, tool_name) + operation = from_union([from_str, from_none], obj.get("operation")) + extension_name = from_union([from_str, from_none], obj.get("extensionName")) + return PermissionDecisionApproveForSessionApproval(kind, command_identifiers, server_name, tool_name, operation, extension_name) def to_dict(self) -> dict: result: dict = {} @@ -3372,6 +3447,10 @@ def to_dict(self) -> dict: result["serverName"] = from_union([from_str, from_none], self.server_name) if self.tool_name is not None: result["toolName"] = from_union([from_none, from_str], self.tool_name) + if self.operation is not None: + result["operation"] = from_union([from_str, from_none], self.operation) + if self.extension_name is not None: + result["extensionName"] = from_union([from_str, from_none], self.extension_name) return result @dataclass @@ -3446,6 +3525,80 @@ def to_dict(self) -> dict: result["toolName"] = from_str(self.tool_name) return result +@dataclass +class PermissionDecisionApproveForLocationApprovalExtensionManagement: + kind: PermissionDecisionApproveForLocationApprovalExtensionManagementKind + operation: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'PermissionDecisionApproveForLocationApprovalExtensionManagement': + assert isinstance(obj, dict) + kind = PermissionDecisionApproveForLocationApprovalExtensionManagementKind(obj.get("kind")) + operation = from_union([from_str, from_none], obj.get("operation")) + return PermissionDecisionApproveForLocationApprovalExtensionManagement(kind, operation) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = to_enum(PermissionDecisionApproveForLocationApprovalExtensionManagementKind, self.kind) + if self.operation is not None: + result["operation"] = from_union([from_str, from_none], self.operation) + return result + +@dataclass +class PermissionDecisionApproveForSessionApprovalExtensionManagement: + kind: PermissionDecisionApproveForLocationApprovalExtensionManagementKind + operation: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'PermissionDecisionApproveForSessionApprovalExtensionManagement': + assert isinstance(obj, dict) + kind = PermissionDecisionApproveForLocationApprovalExtensionManagementKind(obj.get("kind")) + operation = from_union([from_str, from_none], obj.get("operation")) + return PermissionDecisionApproveForSessionApprovalExtensionManagement(kind, operation) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = to_enum(PermissionDecisionApproveForLocationApprovalExtensionManagementKind, self.kind) + if self.operation is not None: + result["operation"] = from_union([from_str, from_none], self.operation) + return result + +@dataclass +class PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess: + extension_name: str + kind: PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind + + @staticmethod + def from_dict(obj: Any) -> 'PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess': + assert isinstance(obj, dict) + extension_name = from_str(obj.get("extensionName")) + kind = PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind(obj.get("kind")) + return PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess(extension_name, kind) + + def to_dict(self) -> dict: + result: dict = {} + result["extensionName"] = from_str(self.extension_name) + result["kind"] = to_enum(PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind, self.kind) + return result + +@dataclass +class PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess: + extension_name: str + kind: PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind + + @staticmethod + def from_dict(obj: Any) -> 'PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess': + assert isinstance(obj, dict) + extension_name = from_str(obj.get("extensionName")) + kind = PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind(obj.get("kind")) + return PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess(extension_name, kind) + + def to_dict(self) -> dict: + result: dict = {} + result["extensionName"] = from_str(self.extension_name) + result["kind"] = to_enum(PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind, self.kind) + return result + @dataclass class PermissionDecisionApproveForLocationApprovalMCP: kind: PermissionDecisionApproveForLocationApprovalMCPKind @@ -4219,7 +4372,6 @@ class Workspace: name: str | None = None remote_steerable: bool | None = None repository: str | None = None - session_sync_level: SessionSyncLevel | None = None summary: str | None = None summary_count: int | None = None updated_at: datetime | None = None @@ -4241,12 +4393,11 @@ def from_dict(obj: Any) -> 'Workspace': name = from_union([from_str, from_none], obj.get("name")) remote_steerable = from_union([from_bool, from_none], obj.get("remote_steerable")) repository = from_union([from_str, from_none], obj.get("repository")) - session_sync_level = from_union([SessionSyncLevel, from_none], obj.get("session_sync_level")) summary = from_union([from_str, from_none], obj.get("summary")) summary_count = from_union([from_int, from_none], obj.get("summary_count")) updated_at = from_union([from_datetime, from_none], obj.get("updated_at")) user_named = from_union([from_bool, from_none], obj.get("user_named")) - return Workspace(id, branch, chronicle_sync_dismissed, created_at, cwd, git_root, host_type, mc_last_event_id, mc_session_id, mc_task_id, name, remote_steerable, repository, session_sync_level, summary, summary_count, updated_at, user_named) + return Workspace(id, branch, chronicle_sync_dismissed, created_at, cwd, git_root, host_type, mc_last_event_id, mc_session_id, mc_task_id, name, remote_steerable, repository, summary, summary_count, updated_at, user_named) def to_dict(self) -> dict: result: dict = {} @@ -4275,8 +4426,6 @@ def to_dict(self) -> dict: result["remote_steerable"] = from_union([from_bool, from_none], self.remote_steerable) if self.repository is not None: result["repository"] = from_union([from_str, from_none], self.repository) - if self.session_sync_level is not None: - result["session_sync_level"] = from_union([lambda x: to_enum(SessionSyncLevel, x), from_none], self.session_sync_level) if self.summary is not None: result["summary"] = from_union([from_str, from_none], self.summary) if self.summary_count is not None: @@ -5718,6 +5867,8 @@ class RPC: permission_decision_approve_for_location_approval: PermissionDecisionApproveForLocationApproval permission_decision_approve_for_location_approval_commands: PermissionDecisionApproveForLocationApprovalCommands permission_decision_approve_for_location_approval_custom_tool: PermissionDecisionApproveForLocationApprovalCustomTool + permission_decision_approve_for_location_approval_extension_management: PermissionDecisionApproveForLocationApprovalExtensionManagement + permission_decision_approve_for_location_approval_extension_permission_access: PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess permission_decision_approve_for_location_approval_mcp: PermissionDecisionApproveForLocationApprovalMCP permission_decision_approve_for_location_approval_mcp_sampling: PermissionDecisionApproveForLocationApprovalMCPSampling permission_decision_approve_for_location_approval_memory: PermissionDecisionApproveForLocationApprovalMemory @@ -5727,6 +5878,8 @@ class RPC: permission_decision_approve_for_session_approval: PermissionDecisionApproveForSessionApproval permission_decision_approve_for_session_approval_commands: PermissionDecisionApproveForSessionApprovalCommands permission_decision_approve_for_session_approval_custom_tool: PermissionDecisionApproveForSessionApprovalCustomTool + permission_decision_approve_for_session_approval_extension_management: PermissionDecisionApproveForSessionApprovalExtensionManagement + permission_decision_approve_for_session_approval_extension_permission_access: PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess permission_decision_approve_for_session_approval_mcp: PermissionDecisionApproveForSessionApprovalMCP permission_decision_approve_for_session_approval_mcp_sampling: PermissionDecisionApproveForSessionApprovalMCPSampling permission_decision_approve_for_session_approval_memory: PermissionDecisionApproveForSessionApprovalMemory @@ -5804,6 +5957,8 @@ class RPC: tasks_promote_to_background_result: TasksPromoteToBackgroundResult tasks_remove_request: TasksRemoveRequest tasks_remove_result: TasksRemoveResult + tasks_send_message_request: TasksSendMessageRequest + tasks_send_message_result: TasksSendMessageResult tasks_start_agent_request: TasksStartAgentRequest tasks_start_agent_result: TasksStartAgentResult tool: Tool @@ -5947,6 +6102,8 @@ def from_dict(obj: Any) -> 'RPC': permission_decision_approve_for_location_approval = PermissionDecisionApproveForLocationApproval.from_dict(obj.get("PermissionDecisionApproveForLocationApproval")) permission_decision_approve_for_location_approval_commands = PermissionDecisionApproveForLocationApprovalCommands.from_dict(obj.get("PermissionDecisionApproveForLocationApprovalCommands")) permission_decision_approve_for_location_approval_custom_tool = PermissionDecisionApproveForLocationApprovalCustomTool.from_dict(obj.get("PermissionDecisionApproveForLocationApprovalCustomTool")) + permission_decision_approve_for_location_approval_extension_management = PermissionDecisionApproveForLocationApprovalExtensionManagement.from_dict(obj.get("PermissionDecisionApproveForLocationApprovalExtensionManagement")) + permission_decision_approve_for_location_approval_extension_permission_access = PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess.from_dict(obj.get("PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess")) permission_decision_approve_for_location_approval_mcp = PermissionDecisionApproveForLocationApprovalMCP.from_dict(obj.get("PermissionDecisionApproveForLocationApprovalMcp")) permission_decision_approve_for_location_approval_mcp_sampling = PermissionDecisionApproveForLocationApprovalMCPSampling.from_dict(obj.get("PermissionDecisionApproveForLocationApprovalMcpSampling")) permission_decision_approve_for_location_approval_memory = PermissionDecisionApproveForLocationApprovalMemory.from_dict(obj.get("PermissionDecisionApproveForLocationApprovalMemory")) @@ -5956,6 +6113,8 @@ def from_dict(obj: Any) -> 'RPC': permission_decision_approve_for_session_approval = PermissionDecisionApproveForSessionApproval.from_dict(obj.get("PermissionDecisionApproveForSessionApproval")) permission_decision_approve_for_session_approval_commands = PermissionDecisionApproveForSessionApprovalCommands.from_dict(obj.get("PermissionDecisionApproveForSessionApprovalCommands")) permission_decision_approve_for_session_approval_custom_tool = PermissionDecisionApproveForSessionApprovalCustomTool.from_dict(obj.get("PermissionDecisionApproveForSessionApprovalCustomTool")) + permission_decision_approve_for_session_approval_extension_management = PermissionDecisionApproveForSessionApprovalExtensionManagement.from_dict(obj.get("PermissionDecisionApproveForSessionApprovalExtensionManagement")) + permission_decision_approve_for_session_approval_extension_permission_access = PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess.from_dict(obj.get("PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess")) permission_decision_approve_for_session_approval_mcp = PermissionDecisionApproveForSessionApprovalMCP.from_dict(obj.get("PermissionDecisionApproveForSessionApprovalMcp")) permission_decision_approve_for_session_approval_mcp_sampling = PermissionDecisionApproveForSessionApprovalMCPSampling.from_dict(obj.get("PermissionDecisionApproveForSessionApprovalMcpSampling")) permission_decision_approve_for_session_approval_memory = PermissionDecisionApproveForSessionApprovalMemory.from_dict(obj.get("PermissionDecisionApproveForSessionApprovalMemory")) @@ -6033,6 +6192,8 @@ def from_dict(obj: Any) -> 'RPC': tasks_promote_to_background_result = TasksPromoteToBackgroundResult.from_dict(obj.get("TasksPromoteToBackgroundResult")) tasks_remove_request = TasksRemoveRequest.from_dict(obj.get("TasksRemoveRequest")) tasks_remove_result = TasksRemoveResult.from_dict(obj.get("TasksRemoveResult")) + tasks_send_message_request = TasksSendMessageRequest.from_dict(obj.get("TasksSendMessageRequest")) + tasks_send_message_result = TasksSendMessageResult.from_dict(obj.get("TasksSendMessageResult")) tasks_start_agent_request = TasksStartAgentRequest.from_dict(obj.get("TasksStartAgentRequest")) tasks_start_agent_result = TasksStartAgentResult.from_dict(obj.get("TasksStartAgentResult")) tool = Tool.from_dict(obj.get("Tool")) @@ -6072,7 +6233,7 @@ def from_dict(obj: Any) -> 'RPC': workspaces_list_files_result = WorkspacesListFilesResult.from_dict(obj.get("WorkspacesListFilesResult")) workspaces_read_file_request = WorkspacesReadFileRequest.from_dict(obj.get("WorkspacesReadFileRequest")) workspaces_read_file_result = WorkspacesReadFileResult.from_dict(obj.get("WorkspacesReadFileResult")) - return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, remote_enable_result, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) + return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, remote_enable_result, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) def to_dict(self) -> dict: result: dict = {} @@ -6176,6 +6337,8 @@ def to_dict(self) -> dict: result["PermissionDecisionApproveForLocationApproval"] = to_class(PermissionDecisionApproveForLocationApproval, self.permission_decision_approve_for_location_approval) result["PermissionDecisionApproveForLocationApprovalCommands"] = to_class(PermissionDecisionApproveForLocationApprovalCommands, self.permission_decision_approve_for_location_approval_commands) result["PermissionDecisionApproveForLocationApprovalCustomTool"] = to_class(PermissionDecisionApproveForLocationApprovalCustomTool, self.permission_decision_approve_for_location_approval_custom_tool) + result["PermissionDecisionApproveForLocationApprovalExtensionManagement"] = to_class(PermissionDecisionApproveForLocationApprovalExtensionManagement, self.permission_decision_approve_for_location_approval_extension_management) + result["PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess"] = to_class(PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess, self.permission_decision_approve_for_location_approval_extension_permission_access) result["PermissionDecisionApproveForLocationApprovalMcp"] = to_class(PermissionDecisionApproveForLocationApprovalMCP, self.permission_decision_approve_for_location_approval_mcp) result["PermissionDecisionApproveForLocationApprovalMcpSampling"] = to_class(PermissionDecisionApproveForLocationApprovalMCPSampling, self.permission_decision_approve_for_location_approval_mcp_sampling) result["PermissionDecisionApproveForLocationApprovalMemory"] = to_class(PermissionDecisionApproveForLocationApprovalMemory, self.permission_decision_approve_for_location_approval_memory) @@ -6185,6 +6348,8 @@ def to_dict(self) -> dict: result["PermissionDecisionApproveForSessionApproval"] = to_class(PermissionDecisionApproveForSessionApproval, self.permission_decision_approve_for_session_approval) result["PermissionDecisionApproveForSessionApprovalCommands"] = to_class(PermissionDecisionApproveForSessionApprovalCommands, self.permission_decision_approve_for_session_approval_commands) result["PermissionDecisionApproveForSessionApprovalCustomTool"] = to_class(PermissionDecisionApproveForSessionApprovalCustomTool, self.permission_decision_approve_for_session_approval_custom_tool) + result["PermissionDecisionApproveForSessionApprovalExtensionManagement"] = to_class(PermissionDecisionApproveForSessionApprovalExtensionManagement, self.permission_decision_approve_for_session_approval_extension_management) + result["PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess"] = to_class(PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess, self.permission_decision_approve_for_session_approval_extension_permission_access) result["PermissionDecisionApproveForSessionApprovalMcp"] = to_class(PermissionDecisionApproveForSessionApprovalMCP, self.permission_decision_approve_for_session_approval_mcp) result["PermissionDecisionApproveForSessionApprovalMcpSampling"] = to_class(PermissionDecisionApproveForSessionApprovalMCPSampling, self.permission_decision_approve_for_session_approval_mcp_sampling) result["PermissionDecisionApproveForSessionApprovalMemory"] = to_class(PermissionDecisionApproveForSessionApprovalMemory, self.permission_decision_approve_for_session_approval_memory) @@ -6262,6 +6427,8 @@ def to_dict(self) -> dict: result["TasksPromoteToBackgroundResult"] = to_class(TasksPromoteToBackgroundResult, self.tasks_promote_to_background_result) result["TasksRemoveRequest"] = to_class(TasksRemoveRequest, self.tasks_remove_request) result["TasksRemoveResult"] = to_class(TasksRemoveResult, self.tasks_remove_result) + result["TasksSendMessageRequest"] = to_class(TasksSendMessageRequest, self.tasks_send_message_request) + result["TasksSendMessageResult"] = to_class(TasksSendMessageResult, self.tasks_send_message_result) result["TasksStartAgentRequest"] = to_class(TasksStartAgentRequest, self.tasks_start_agent_request) result["TasksStartAgentResult"] = to_class(TasksStartAgentResult, self.tasks_start_agent_result) result["Tool"] = to_class(Tool, self.tool) @@ -6633,6 +6800,11 @@ async def remove(self, params: TasksRemoveRequest, *, timeout: float | None = No params_dict["sessionId"] = self._session_id return TasksRemoveResult.from_dict(await self._client.request("session.tasks.remove", params_dict, **_timeout_kwargs(timeout))) + async def send_message(self, params: TasksSendMessageRequest, *, timeout: float | None = None) -> TasksSendMessageResult: + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return TasksSendMessageResult.from_dict(await self._client.request("session.tasks.sendMessage", params_dict, **_timeout_kwargs(timeout))) + # Experimental: this API group is experimental and may change or be removed. class SkillsApi: diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index c4dbb8158..b55bd921a 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -1747,16 +1747,19 @@ class PermissionPromptRequest: action: PermissionPromptRequestMemoryAction | None = None args: Any | None = None can_offer_session_approval: bool | None = None + capabilities: list[str] | None = None citations: str | None = None command_identifiers: list[str] | None = None diff: str | None = None direction: PermissionPromptRequestMemoryDirection | None = None + extension_name: str | None = None fact: str | None = None file_name: str | None = None full_command_text: str | None = None hook_message: str | None = None intention: str | None = None new_file_contents: str | None = None + operation: str | None = None path: str | None = None paths: list[str] | None = None reason: str | None = None @@ -1778,16 +1781,19 @@ def from_dict(obj: Any) -> "PermissionPromptRequest": action = from_union([from_none, lambda x: parse_enum(PermissionPromptRequestMemoryAction, x)], obj.get("action", "store")) args = from_union([from_none, lambda x: x], obj.get("args")) can_offer_session_approval = from_union([from_none, from_bool], obj.get("canOfferSessionApproval")) + capabilities = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("capabilities")) citations = from_union([from_none, from_str], obj.get("citations")) command_identifiers = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("commandIdentifiers")) diff = from_union([from_none, from_str], obj.get("diff")) direction = from_union([from_none, lambda x: parse_enum(PermissionPromptRequestMemoryDirection, x)], obj.get("direction")) + extension_name = from_union([from_none, from_str], obj.get("extensionName")) fact = from_union([from_none, from_str], obj.get("fact")) file_name = from_union([from_none, from_str], obj.get("fileName")) full_command_text = from_union([from_none, from_str], obj.get("fullCommandText")) hook_message = from_union([from_none, from_str], obj.get("hookMessage")) intention = from_union([from_none, from_str], obj.get("intention")) new_file_contents = from_union([from_none, from_str], obj.get("newFileContents")) + operation = from_union([from_none, from_str], obj.get("operation")) path = from_union([from_none, from_str], obj.get("path")) paths = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("paths")) reason = from_union([from_none, from_str], obj.get("reason")) @@ -1806,16 +1812,19 @@ def from_dict(obj: Any) -> "PermissionPromptRequest": action=action, args=args, can_offer_session_approval=can_offer_session_approval, + capabilities=capabilities, citations=citations, command_identifiers=command_identifiers, diff=diff, direction=direction, + extension_name=extension_name, fact=fact, file_name=file_name, full_command_text=full_command_text, hook_message=hook_message, intention=intention, new_file_contents=new_file_contents, + operation=operation, path=path, paths=paths, reason=reason, @@ -1841,6 +1850,8 @@ def to_dict(self) -> dict: result["args"] = from_union([from_none, lambda x: x], self.args) if self.can_offer_session_approval is not None: result["canOfferSessionApproval"] = from_union([from_none, from_bool], self.can_offer_session_approval) + if self.capabilities is not None: + result["capabilities"] = from_union([from_none, lambda x: from_list(from_str, x)], self.capabilities) if self.citations is not None: result["citations"] = from_union([from_none, from_str], self.citations) if self.command_identifiers is not None: @@ -1849,6 +1860,8 @@ def to_dict(self) -> dict: result["diff"] = from_union([from_none, from_str], self.diff) if self.direction is not None: result["direction"] = from_union([from_none, lambda x: to_enum(PermissionPromptRequestMemoryDirection, x)], self.direction) + if self.extension_name is not None: + result["extensionName"] = from_union([from_none, from_str], self.extension_name) if self.fact is not None: result["fact"] = from_union([from_none, from_str], self.fact) if self.file_name is not None: @@ -1861,6 +1874,8 @@ def to_dict(self) -> dict: result["intention"] = from_union([from_none, from_str], self.intention) if self.new_file_contents is not None: result["newFileContents"] = from_union([from_none, from_str], self.new_file_contents) + if self.operation is not None: + result["operation"] = from_union([from_none, from_str], self.operation) if self.path is not None: result["path"] = from_union([from_none, from_str], self.path) if self.paths is not None: @@ -1895,10 +1910,12 @@ class PermissionRequest: action: PermissionRequestMemoryAction | None = None args: Any = None can_offer_session_approval: bool | None = None + capabilities: list[str] | None = None citations: str | None = None commands: list[PermissionRequestShellCommand] | None = None diff: str | None = None direction: PermissionRequestMemoryDirection | None = None + extension_name: str | None = None fact: str | None = None file_name: str | None = None full_command_text: str | None = None @@ -1906,6 +1923,7 @@ class PermissionRequest: hook_message: str | None = None intention: str | None = None new_file_contents: str | None = None + operation: str | None = None path: str | None = None possible_paths: list[str] | None = None possible_urls: list[PermissionRequestShellPossibleUrl] | None = None @@ -1928,10 +1946,12 @@ def from_dict(obj: Any) -> "PermissionRequest": action = from_union([from_none, lambda x: parse_enum(PermissionRequestMemoryAction, x)], obj.get("action", "store")) args = obj.get("args") can_offer_session_approval = from_union([from_none, from_bool], obj.get("canOfferSessionApproval")) + capabilities = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("capabilities")) citations = from_union([from_none, from_str], obj.get("citations")) commands = from_union([from_none, lambda x: from_list(PermissionRequestShellCommand.from_dict, x)], obj.get("commands")) diff = from_union([from_none, from_str], obj.get("diff")) direction = from_union([from_none, lambda x: parse_enum(PermissionRequestMemoryDirection, x)], obj.get("direction")) + extension_name = from_union([from_none, from_str], obj.get("extensionName")) fact = from_union([from_none, from_str], obj.get("fact")) file_name = from_union([from_none, from_str], obj.get("fileName")) full_command_text = from_union([from_none, from_str], obj.get("fullCommandText")) @@ -1939,6 +1959,7 @@ def from_dict(obj: Any) -> "PermissionRequest": hook_message = from_union([from_none, from_str], obj.get("hookMessage")) intention = from_union([from_none, from_str], obj.get("intention")) new_file_contents = from_union([from_none, from_str], obj.get("newFileContents")) + operation = from_union([from_none, from_str], obj.get("operation")) path = from_union([from_none, from_str], obj.get("path")) possible_paths = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("possiblePaths")) possible_urls = from_union([from_none, lambda x: from_list(PermissionRequestShellPossibleUrl.from_dict, x)], obj.get("possibleUrls")) @@ -1958,10 +1979,12 @@ def from_dict(obj: Any) -> "PermissionRequest": action=action, args=args, can_offer_session_approval=can_offer_session_approval, + capabilities=capabilities, citations=citations, commands=commands, diff=diff, direction=direction, + extension_name=extension_name, fact=fact, file_name=file_name, full_command_text=full_command_text, @@ -1969,6 +1992,7 @@ def from_dict(obj: Any) -> "PermissionRequest": hook_message=hook_message, intention=intention, new_file_contents=new_file_contents, + operation=operation, path=path, possible_paths=possible_paths, possible_urls=possible_urls, @@ -1994,6 +2018,8 @@ def to_dict(self) -> dict: result["args"] = self.args if self.can_offer_session_approval is not None: result["canOfferSessionApproval"] = from_union([from_none, from_bool], self.can_offer_session_approval) + if self.capabilities is not None: + result["capabilities"] = from_union([from_none, lambda x: from_list(from_str, x)], self.capabilities) if self.citations is not None: result["citations"] = from_union([from_none, from_str], self.citations) if self.commands is not None: @@ -2002,6 +2028,8 @@ def to_dict(self) -> dict: result["diff"] = from_union([from_none, from_str], self.diff) if self.direction is not None: result["direction"] = from_union([from_none, lambda x: to_enum(PermissionRequestMemoryDirection, x)], self.direction) + if self.extension_name is not None: + result["extensionName"] = from_union([from_none, from_str], self.extension_name) if self.fact is not None: result["fact"] = from_union([from_none, from_str], self.fact) if self.file_name is not None: @@ -2016,6 +2044,8 @@ def to_dict(self) -> dict: result["intention"] = from_union([from_none, from_str], self.intention) if self.new_file_contents is not None: result["newFileContents"] = from_union([from_none, from_str], self.new_file_contents) + if self.operation is not None: + result["operation"] = from_union([from_none, from_str], self.operation) if self.path is not None: result["path"] = from_union([from_none, from_str], self.path) if self.possible_paths is not None: @@ -3008,6 +3038,7 @@ class SessionStartData: version: float already_in_use: bool | None = None context: WorkingDirectoryContext | None = None + detached_from_spawning_parent_session_id: str | None = None reasoning_effort: str | None = None remote_steerable: bool | None = None selected_model: str | None = None @@ -3022,6 +3053,7 @@ def from_dict(obj: Any) -> "SessionStartData": version = from_float(obj.get("version")) already_in_use = from_union([from_none, from_bool], obj.get("alreadyInUse")) context = from_union([from_none, WorkingDirectoryContext.from_dict], obj.get("context")) + detached_from_spawning_parent_session_id = from_union([from_none, from_str], obj.get("detachedFromSpawningParentSessionId")) reasoning_effort = from_union([from_none, from_str], obj.get("reasoningEffort")) remote_steerable = from_union([from_none, from_bool], obj.get("remoteSteerable")) selected_model = from_union([from_none, from_str], obj.get("selectedModel")) @@ -3033,6 +3065,7 @@ def from_dict(obj: Any) -> "SessionStartData": version=version, already_in_use=already_in_use, context=context, + detached_from_spawning_parent_session_id=detached_from_spawning_parent_session_id, reasoning_effort=reasoning_effort, remote_steerable=remote_steerable, selected_model=selected_model, @@ -3049,6 +3082,8 @@ def to_dict(self) -> dict: result["alreadyInUse"] = from_union([from_none, from_bool], self.already_in_use) if self.context is not None: result["context"] = from_union([from_none, lambda x: to_class(WorkingDirectoryContext, x)], self.context) + if self.detached_from_spawning_parent_session_id is not None: + result["detachedFromSpawningParentSessionId"] = from_union([from_none, from_str], self.detached_from_spawning_parent_session_id) if self.reasoning_effort is not None: result["reasoningEffort"] = from_union([from_none, from_str], self.reasoning_effort) if self.remote_steerable is not None: @@ -4674,6 +4709,8 @@ class PermissionPromptRequestKind(Enum): CUSTOM_TOOL = "custom-tool" PATH = "path" HOOK = "hook" + EXTENSION_MANAGEMENT = "extension-management" + EXTENSION_PERMISSION_ACCESS = "extension-permission-access" class PermissionPromptRequestMemoryAction(Enum): @@ -4705,6 +4742,8 @@ class PermissionRequestKind(Enum): MEMORY = "memory" CUSTOM_TOOL = "custom-tool" HOOK = "hook" + EXTENSION_MANAGEMENT = "extension-management" + EXTENSION_PERMISSION_ACCESS = "extension-permission-access" class PermissionRequestMemoryAction(Enum): diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index d0b7bf5b7..fc5deb6c5 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -96,6 +96,8 @@ pub mod rpc_methods { pub const SESSION_TASKS_CANCEL: &str = "session.tasks.cancel"; /// `session.tasks.remove` pub const SESSION_TASKS_REMOVE: &str = "session.tasks.remove"; + /// `session.tasks.sendMessage` + pub const SESSION_TASKS_SENDMESSAGE: &str = "session.tasks.sendMessage"; /// `session.skills.list` pub const SESSION_SKILLS_LIST: &str = "session.skills.list"; /// `session.skills.enable` @@ -823,7 +825,8 @@ pub struct McpServerList { #[serde(rename_all = "camelCase")] pub struct ModelBilling { /// Billing cost multiplier relative to the base rate - pub multiplier: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub multiplier: Option, } /// Vision-specific limits @@ -1081,6 +1084,21 @@ pub struct PermissionDecisionApproveForLocationApprovalCustomTool { pub tool_name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDecisionApproveForLocationApprovalExtensionManagement { + pub kind: PermissionDecisionApproveForLocationApprovalExtensionManagementKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess { + pub extension_name: String, + pub kind: PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PermissionDecisionApproveForLocation { @@ -1139,6 +1157,21 @@ pub struct PermissionDecisionApproveForSessionApprovalCustomTool { pub tool_name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDecisionApproveForSessionApprovalExtensionManagement { + pub kind: PermissionDecisionApproveForSessionApprovalExtensionManagementKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess { + pub extension_name: String, + pub kind: PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PermissionDecisionApproveForSession { @@ -1775,6 +1808,28 @@ pub struct TasksRemoveResult { pub removed: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TasksSendMessageRequest { + /// Agent ID of the sender, if sent on behalf of another agent + #[serde(skip_serializing_if = "Option::is_none")] + pub from_agent_id: Option, + /// Agent task identifier + pub id: String, + /// Message content to send to the agent + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TasksSendMessageResult { + /// Error message if delivery failed + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Whether the message was successfully delivered or steered + pub sent: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TasksStartAgentRequest { @@ -2154,8 +2209,6 @@ pub struct WorkspacesGetWorkspaceResultWorkspace { pub remote_steerable: Option, #[serde(skip_serializing_if = "Option::is_none")] pub repository: Option, - #[serde(rename = "session_sync_level", skip_serializing_if = "Option::is_none")] - pub session_sync_level: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, #[serde(rename = "summary_count", skip_serializing_if = "Option::is_none")] @@ -2365,8 +2418,6 @@ pub struct SessionWorkspacesGetWorkspaceResultWorkspace { pub remote_steerable: Option, #[serde(skip_serializing_if = "Option::is_none")] pub repository: Option, - #[serde(rename = "session_sync_level", skip_serializing_if = "Option::is_none")] - pub session_sync_level: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, #[serde(rename = "summary_count", skip_serializing_if = "Option::is_none")] @@ -2524,6 +2575,16 @@ pub struct SessionTasksRemoveResult { pub removed: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTasksSendMessageResult { + /// Error message if delivery failed + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Whether the message was successfully delivered or steered + pub sent: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionSkillsListParams { @@ -3108,6 +3169,18 @@ pub enum PermissionDecisionApproveForLocationApprovalCustomToolKind { CustomTool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionDecisionApproveForLocationApprovalExtensionManagementKind { + #[serde(rename = "extension-management")] + ExtensionManagement, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind { + #[serde(rename = "extension-permission-access")] + ExtensionPermissionAccess, +} + /// The approval to persist for this location #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -3119,6 +3192,10 @@ pub enum PermissionDecisionApproveForLocationApproval { McpSampling(PermissionDecisionApproveForLocationApprovalMcpSampling), Memory(PermissionDecisionApproveForLocationApprovalMemory), CustomTool(PermissionDecisionApproveForLocationApprovalCustomTool), + ExtensionManagement(PermissionDecisionApproveForLocationApprovalExtensionManagement), + ExtensionPermissionAccess( + PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess, + ), } /// Approved and persisted for this project location @@ -3170,6 +3247,18 @@ pub enum PermissionDecisionApproveForSessionApprovalCustomToolKind { CustomTool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionDecisionApproveForSessionApprovalExtensionManagementKind { + #[serde(rename = "extension-management")] + ExtensionManagement, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind { + #[serde(rename = "extension-permission-access")] + ExtensionPermissionAccess, +} + /// The approval to add as a session-scoped rule #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -3181,6 +3270,8 @@ pub enum PermissionDecisionApproveForSessionApproval { McpSampling(PermissionDecisionApproveForSessionApprovalMcpSampling), Memory(PermissionDecisionApproveForSessionApprovalMemory), CustomTool(PermissionDecisionApproveForSessionApprovalCustomTool), + ExtensionManagement(PermissionDecisionApproveForSessionApprovalExtensionManagement), + ExtensionPermissionAccess(PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess), } /// Approved and remembered for the rest of the session @@ -3460,19 +3551,6 @@ pub enum WorkspacesGetWorkspaceResultWorkspaceHostType { Unknown, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum WorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel { - #[serde(rename = "local")] - Local, - #[serde(rename = "user")] - User, - #[serde(rename = "repo_and_user")] - RepoAndUser, - /// Unknown variant for forward compatibility. - #[serde(other)] - Unknown, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionWorkspacesGetWorkspaceResultWorkspaceHostType { #[serde(rename = "github")] @@ -3483,16 +3561,3 @@ pub enum SessionWorkspacesGetWorkspaceResultWorkspaceHostType { #[serde(other)] Unknown, } - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum SessionWorkspacesGetWorkspaceResultWorkspaceSessionSyncLevel { - #[serde(rename = "local")] - Local, - #[serde(rename = "user")] - User, - #[serde(rename = "repo_and_user")] - RepoAndUser, - /// Unknown variant for forward compatibility. - #[serde(other)] - Unknown, -} diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index eed4aea2a..ec958708c 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -1476,6 +1476,29 @@ impl<'a> SessionRpcTasks<'a> { .await?; Ok(serde_json::from_value(_value)?) } + + /// Wire method: `session.tasks.sendMessage`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn send_message( + &self, + params: TasksSendMessageRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_TASKS_SENDMESSAGE, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } } /// `session.tools.*` RPCs. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index fdcf7a6b4..d27ac5a62 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -410,6 +410,9 @@ pub struct SessionStartData { pub context: Option, /// Version string of the Copilot application pub copilot_version: String, + /// When set, identifies a parent session whose context this session continues — e.g., a detached headless rem-agent run launched on the parent's interactive shutdown. Telemetry from this session is reported under the parent's session_id. + #[serde(skip_serializing_if = "Option::is_none")] + pub detached_from_spawning_parent_session_id: Option, /// Identifier of the software producing the events (e.g., "copilot-agent") pub producer: String, /// Reasoning effort level used for model calls, if applicable (e.g. "low", "medium", "high", "xhigh") @@ -1786,6 +1789,37 @@ pub struct PermissionRequestHook { pub tool_name: String, } +/// Extension management permission request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequestExtensionManagement { + /// Name of the extension being managed + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Permission kind discriminator + pub kind: PermissionRequestExtensionManagementKind, + /// The extension management operation (scaffold, reload) + pub operation: String, + /// Tool call ID that triggered this permission request + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +/// Extension permission access request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequestExtensionPermissionAccess { + /// Capabilities the extension is requesting + pub capabilities: Vec, + /// Name of the extension requesting permission access + pub extension_name: String, + /// Permission kind discriminator + pub kind: PermissionRequestExtensionPermissionAccessKind, + /// Tool call ID that triggered this permission request + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + /// Shell command permission prompt #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1960,6 +1994,37 @@ pub struct PermissionPromptRequestHook { pub tool_name: String, } +/// Extension management permission prompt +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionPromptRequestExtensionManagement { + /// Name of the extension being managed + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_name: Option, + /// Prompt kind discriminator + pub kind: PermissionPromptRequestExtensionManagementKind, + /// The extension management operation (scaffold, reload) + pub operation: String, + /// Tool call ID that triggered this permission request + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + +/// Extension permission access prompt +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionPromptRequestExtensionPermissionAccess { + /// Capabilities the extension is requesting + pub capabilities: Vec, + /// Name of the extension requesting permission access + pub extension_name: String, + /// Prompt kind discriminator + pub kind: PermissionPromptRequestExtensionPermissionAccessKind, + /// Tool call ID that triggered this permission request + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, +} + /// Permission request notification requiring client approval with request details #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2766,6 +2831,20 @@ pub enum PermissionRequestHookKind { Hook, } +/// Permission kind discriminator +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionRequestExtensionManagementKind { + #[serde(rename = "extension-management")] + ExtensionManagement, +} + +/// Permission kind discriminator +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionRequestExtensionPermissionAccessKind { + #[serde(rename = "extension-permission-access")] + ExtensionPermissionAccess, +} + /// Details of the permission being requested #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -2778,6 +2857,8 @@ pub enum PermissionRequest { Memory(PermissionRequestMemory), CustomTool(PermissionRequestCustomTool), Hook(PermissionRequestHook), + ExtensionManagement(PermissionRequestExtensionManagement), + ExtensionPermissionAccess(PermissionRequestExtensionPermissionAccess), } /// Prompt kind discriminator @@ -2881,6 +2962,20 @@ pub enum PermissionPromptRequestHookKind { Hook, } +/// Prompt kind discriminator +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionPromptRequestExtensionManagementKind { + #[serde(rename = "extension-management")] + ExtensionManagement, +} + +/// Prompt kind discriminator +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionPromptRequestExtensionPermissionAccessKind { + #[serde(rename = "extension-permission-access")] + ExtensionPermissionAccess, +} + /// Derived user-facing permission prompt details for UI consumers #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -2894,6 +2989,8 @@ pub enum PermissionPromptRequest { CustomTool(PermissionPromptRequestCustomTool), Path(PermissionPromptRequestPath), Hook(PermissionPromptRequestHook), + ExtensionManagement(PermissionPromptRequestExtensionManagement), + ExtensionPermissionAccess(PermissionPromptRequestExtensionPermissionAccess), } /// The permission request was approved diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index d5f77fef7..69a491670 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.44-2", + "@github/copilot": "^1.0.44-3", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -464,27 +464,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-2.tgz", - "integrity": "sha512-MUIR4w+oXjbg1jwUS8B86eMd/bV2gVKZ61a/aEUE4gUrFFpGXO0tNk9OkfLSH5cmlhJY6lzMzb+kKQWoeAbbNQ==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-3.tgz", + "integrity": "sha512-hTsNxnmtKDK3ymh+c6LrsXWc9TbbubUHSxPuAKc4CX0d1c9iI1R4ybzS5Ihe+GxlozHIyFANd58gAg3QH3uCkA==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.44-2", - "@github/copilot-darwin-x64": "1.0.44-2", - "@github/copilot-linux-arm64": "1.0.44-2", - "@github/copilot-linux-x64": "1.0.44-2", - "@github/copilot-win32-arm64": "1.0.44-2", - "@github/copilot-win32-x64": "1.0.44-2" + "@github/copilot-darwin-arm64": "1.0.44-3", + "@github/copilot-darwin-x64": "1.0.44-3", + "@github/copilot-linux-arm64": "1.0.44-3", + "@github/copilot-linux-x64": "1.0.44-3", + "@github/copilot-win32-arm64": "1.0.44-3", + "@github/copilot-win32-x64": "1.0.44-3" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-2.tgz", - "integrity": "sha512-6o/pvew0FZJG+8saG1K/L1pUIvpz4AWkZitiqH36tDfXdXKx/PUQ+zaFg/KPeHNnxtal5OdE/7iyrJwIqm2gPg==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-3.tgz", + "integrity": "sha512-59IXG1lGCf0Ni4TjNL6bqBul6G2FPFX2vh6pMnoRVtHvRrtFILIBMNRMNQFrYZo3eXYBqYXwVHu4R8zfELpK6A==", "cpu": [ "arm64" ], @@ -499,9 +499,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-2.tgz", - "integrity": "sha512-OMNoLNFYUynB4wiplSh4gtD5zVlvfWMKc0jKQ0oItJLGO8GRL9X0ZB2ONB+7JpVvPidz0Yy4+jU0zWNXEjMM5g==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-3.tgz", + "integrity": "sha512-I+aR9rBNzwn3OOd5oIDIpnUCkCtj3mL183Ml1LLUcJ3utxwxKVInckW/Jg36jSD2PhkbNX8gzq0l3dv0td6QYQ==", "cpu": [ "x64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-2.tgz", - "integrity": "sha512-5WGRADU08hqBTWmQ6JVOYMximzsXGuOdFF4GFRQqfsCR8k4RE8fdPWQJa92BpqMgGWwEVPemq0wB3D4hDM5eWw==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-3.tgz", + "integrity": "sha512-Agz4tMiM0hy9zIPPxKF0SSjMZSYuLYoGMe5KbvNEwTrAApLSrSW6k8yhlOTVCiRHEBsfh69We3LCOmc8hX8jVg==", "cpu": [ "arm64" ], @@ -533,9 +533,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-2.tgz", - "integrity": "sha512-4ZnA2QxEwgrdCePdS5OjuksEGFpJrXgofuELANCpDSHwR3eTV7PynVyqhG6Et7ktN2KzHk7zf8kvtiWVCOxvFg==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-3.tgz", + "integrity": "sha512-Ev5/uZKqSOr6l2tcy9Xqx354tuxo8qE42Cnnd6JynGrvVc1NpzF1Kt5eCzzjxdZiRtPo6AdDXS16oAN8CVxCrg==", "cpu": [ "x64" ], @@ -550,9 +550,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-2.tgz", - "integrity": "sha512-klgSdBZblz9O8BRnTh9uk9uO/INQwVeTBagXuJO7MrZ7JCfBVJyFUYky2tKIjFxlwefyhrRZuniqYeOI9fQc+A==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-3.tgz", + "integrity": "sha512-bV2JeRRNYTiTfqmCVeXdPpgYe8KY58diJFZdhYSQnQDowjKvRn59K0RBEYDGK8//AjN+NfaGPGikMq3CQm61cA==", "cpu": [ "arm64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.44-2", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-2.tgz", - "integrity": "sha512-ziq3abdbMCqtAqdiEWWf6cn0whlWss7rC9VMsO/Vx2gjSEVCeJkmIiRiQO45WikheyXyxEmCTAvOwZLQvs+I9g==", + "version": "1.0.44-3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-3.tgz", + "integrity": "sha512-qR6q16UDC6bIO8cde62z0wwVweH351RzN1KZgMjBqQYUBJw521K8VK7p64XK0tQWoTG8uyCuqqu5djQq/4Ek+g==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index f4e117606..72e06265e 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.44-2", + "@github/copilot": "^1.0.44-3", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From 30a76a535fcb094938e7920394865ba956c6172b Mon Sep 17 00:00:00 2001 From: Quim Muntal Date: Sat, 9 May 2026 16:01:52 +0200 Subject: [PATCH 19/33] Replace Go RPC quicktype generation (#1234) * Replace Go RPC quicktype generation * Address Go RPC codegen review feedback * Rerun CI * Sort generated Go fields and constants * Regenerate Go after rebase * Sort generated Go output consistently * fix race * Regenerate Go files for updated schemas --- go/client.go | 4 +- go/client_test.go | 4 +- go/generated_session_events.go | 1640 ++++++----- .../e2e/rpc_mcp_and_skills_e2e_test.go | 4 +- go/internal/e2e/rpc_mcp_config_e2e_test.go | 34 +- go/internal/e2e/rpc_server_e2e_test.go | 2 +- go/internal/e2e/rpc_session_state_e2e_test.go | 2 +- go/internal/e2e/session_fs_e2e_test.go | 37 +- go/rpc/generated_rpc.go | 2618 +++++++++-------- go/rpc/generated_rpc_union_test.go | 101 + go/rpc/result_union.go | 35 - go/session.go | 5 +- go/session_fs_provider.go | 52 +- go/types.go | 2 +- nodejs/package-lock.json | 8 + scripts/codegen/go.ts | 1152 +++++--- scripts/codegen/package-lock.json | 4 +- scripts/codegen/package.json | 3 +- scripts/codegen/types.d.ts | 3 + scripts/codegen/utils.ts | 10 +- 20 files changed, 3109 insertions(+), 2611 deletions(-) create mode 100644 go/rpc/generated_rpc_union_test.go delete mode 100644 go/rpc/result_union.go create mode 100644 scripts/codegen/types.d.ts diff --git a/go/client.go b/go/client.go index 6960fe9a2..8b6a70aed 100644 --- a/go/client.go +++ b/go/client.go @@ -63,7 +63,7 @@ func validateSessionFsConfig(config *SessionFsConfig) error { if config.SessionStatePath == "" { return errors.New("SessionFs.SessionStatePath is required") } - if config.Conventions != rpc.SessionFSSetProviderConventionsPosix && config.Conventions != rpc.SessionFSSetProviderConventionsWindows { + if config.Conventions != rpc.SessionFsSetProviderConventionsPosix && config.Conventions != rpc.SessionFsSetProviderConventionsWindows { return errors.New("SessionFs.Conventions must be either 'posix' or 'windows'") } return nil @@ -372,7 +372,7 @@ func (c *Client) Start(ctx context.Context) error { // If a session filesystem provider was configured, register it. if c.options.SessionFs != nil { - _, err := c.RPC.SessionFs.SetProvider(ctx, &rpc.SessionFSSetProviderRequest{ + _, err := c.RPC.SessionFs.SetProvider(ctx, &rpc.SessionFsSetProviderRequest{ InitialCwd: c.options.SessionFs.InitialCwd, SessionStatePath: c.options.SessionFs.SessionStatePath, Conventions: c.options.SessionFs.Conventions, diff --git a/go/client_test.go b/go/client_test.go index f9f47fc30..34e7b803d 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -241,7 +241,7 @@ func TestClient_SessionFsConfig(t *testing.T) { NewClient(&ClientOptions{ SessionFs: &SessionFsConfig{ SessionStatePath: "/session-state", - Conventions: rpc.SessionFSSetProviderConventionsPosix, + Conventions: rpc.SessionFsSetProviderConventionsPosix, }, }) }) @@ -261,7 +261,7 @@ func TestClient_SessionFsConfig(t *testing.T) { NewClient(&ClientOptions{ SessionFs: &SessionFsConfig{ InitialCwd: "/", - Conventions: rpc.SessionFSSetProviderConventionsPosix, + Conventions: rpc.SessionFsSetProviderConventionsPosix, }, }) }) diff --git a/go/generated_session_events.go b/go/generated_session_events.go index a507fa8ac..7dc1f3a32 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -5,6 +5,7 @@ package copilot import ( "encoding/json" + "errors" "time" ) @@ -27,6 +28,8 @@ func (r RawSessionEventData) MarshalJSON() ([]byte, error) { return r.Raw, nil } type SessionEvent struct { // Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. AgentID *string `json:"agentId,omitempty"` + // Typed event payload. Use a type switch to access per-event fields. + Data SessionEventData `json:"-"` // When true, the event is transient and not persisted to the session event log on disk Ephemeral *bool `json:"ephemeral,omitempty"` // Unique event identifier (UUID v4), generated when the event is emitted @@ -37,8 +40,6 @@ type SessionEvent struct { Timestamp time.Time `json:"timestamp"` // The event type discriminator. Type SessionEventType `json:"type"` - // Typed event payload. Use a type switch to access per-event fields. - Data SessionEventData `json:"-"` } // UnmarshalSessionEvent parses JSON bytes into a SessionEvent. @@ -56,12 +57,12 @@ func (r *SessionEvent) Marshal() ([]byte, error) { func (e *SessionEvent) UnmarshalJSON(data []byte) error { type rawEvent struct { AgentID *string `json:"agentId,omitempty"` + Data json.RawMessage `json:"data"` Ephemeral *bool `json:"ephemeral,omitempty"` ID string `json:"id"` ParentID *string `json:"parentId"` Timestamp time.Time `json:"timestamp"` Type SessionEventType `json:"type"` - Data json.RawMessage `json:"data"` } var raw rawEvent if err := json.Unmarshal(data, &raw); err != nil { @@ -75,482 +76,482 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { e.Type = raw.Type switch raw.Type { - case SessionEventTypeSessionStart: - var d SessionStartData + case SessionEventTypeAbort: + var d AbortData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionResume: - var d SessionResumeData + case SessionEventTypeAssistantIntent: + var d AssistantIntentData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionRemoteSteerableChanged: - var d SessionRemoteSteerableChangedData + case SessionEventTypeAssistantMessage: + var d AssistantMessageData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionError: - var d SessionErrorData + case SessionEventTypeAssistantMessageDelta: + var d AssistantMessageDeltaData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionIdle: - var d SessionIdleData + case SessionEventTypeAssistantMessageStart: + var d AssistantMessageStartData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionTitleChanged: - var d SessionTitleChangedData + case SessionEventTypeAssistantReasoning: + var d AssistantReasoningData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionScheduleCreated: - var d SessionScheduleCreatedData + case SessionEventTypeAssistantReasoningDelta: + var d AssistantReasoningDeltaData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionScheduleCancelled: - var d SessionScheduleCancelledData + case SessionEventTypeAssistantStreamingDelta: + var d AssistantStreamingDeltaData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionInfo: - var d SessionInfoData + case SessionEventTypeAssistantTurnEnd: + var d AssistantTurnEndData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionWarning: - var d SessionWarningData + case SessionEventTypeAssistantTurnStart: + var d AssistantTurnStartData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionModelChange: - var d SessionModelChangeData + case SessionEventTypeAssistantUsage: + var d AssistantUsageData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionModeChanged: - var d SessionModeChangedData + case SessionEventTypeAutoModeSwitchCompleted: + var d AutoModeSwitchCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionPlanChanged: - var d SessionPlanChangedData + case SessionEventTypeAutoModeSwitchRequested: + var d AutoModeSwitchRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionWorkspaceFileChanged: - var d SessionWorkspaceFileChangedData + case SessionEventTypeCapabilitiesChanged: + var d CapabilitiesChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionHandoff: - var d SessionHandoffData + case SessionEventTypeCommandCompleted: + var d CommandCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionTruncation: - var d SessionTruncationData + case SessionEventTypeCommandExecute: + var d CommandExecuteData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionSnapshotRewind: - var d SessionSnapshotRewindData + case SessionEventTypeCommandQueued: + var d CommandQueuedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionShutdown: - var d SessionShutdownData + case SessionEventTypeCommandsChanged: + var d CommandsChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionContextChanged: - var d SessionContextChangedData + case SessionEventTypeElicitationCompleted: + var d ElicitationCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionUsageInfo: - var d SessionUsageInfoData + case SessionEventTypeElicitationRequested: + var d ElicitationRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionCompactionStart: - var d SessionCompactionStartData + case SessionEventTypeExitPlanModeCompleted: + var d ExitPlanModeCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionCompactionComplete: - var d SessionCompactionCompleteData + case SessionEventTypeExitPlanModeRequested: + var d ExitPlanModeRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionTaskComplete: - var d SessionTaskCompleteData + case SessionEventTypeExternalToolCompleted: + var d ExternalToolCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeUserMessage: - var d UserMessageData + case SessionEventTypeExternalToolRequested: + var d ExternalToolRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypePendingMessagesModified: - var d PendingMessagesModifiedData + case SessionEventTypeHookEnd: + var d HookEndData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantTurnStart: - var d AssistantTurnStartData + case SessionEventTypeHookStart: + var d HookStartData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantIntent: - var d AssistantIntentData + case SessionEventTypeMcpOauthCompleted: + var d McpOauthCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantReasoning: - var d AssistantReasoningData + case SessionEventTypeMcpOauthRequired: + var d McpOauthRequiredData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantReasoningDelta: - var d AssistantReasoningDeltaData + case SessionEventTypeModelCallFailure: + var d ModelCallFailureData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantStreamingDelta: - var d AssistantStreamingDeltaData + case SessionEventTypePendingMessagesModified: + var d PendingMessagesModifiedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantMessage: - var d AssistantMessageData + case SessionEventTypePermissionCompleted: + var d PermissionCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantMessageStart: - var d AssistantMessageStartData + case SessionEventTypePermissionRequested: + var d PermissionRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantMessageDelta: - var d AssistantMessageDeltaData + case SessionEventTypeSamplingCompleted: + var d SamplingCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantTurnEnd: - var d AssistantTurnEndData + case SessionEventTypeSamplingRequested: + var d SamplingRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAssistantUsage: - var d AssistantUsageData + case SessionEventTypeSessionBackgroundTasksChanged: + var d SessionBackgroundTasksChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeModelCallFailure: - var d ModelCallFailureData + case SessionEventTypeSessionCompactionComplete: + var d SessionCompactionCompleteData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAbort: - var d AbortData + case SessionEventTypeSessionCompactionStart: + var d SessionCompactionStartData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeToolUserRequested: - var d ToolUserRequestedData + case SessionEventTypeSessionContextChanged: + var d SessionContextChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeToolExecutionStart: - var d ToolExecutionStartData + case SessionEventTypeSessionCustomAgentsUpdated: + var d SessionCustomAgentsUpdatedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeToolExecutionPartialResult: - var d ToolExecutionPartialResultData + case SessionEventTypeSessionError: + var d SessionErrorData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeToolExecutionProgress: - var d ToolExecutionProgressData + case SessionEventTypeSessionExtensionsLoaded: + var d SessionExtensionsLoadedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeToolExecutionComplete: - var d ToolExecutionCompleteData + case SessionEventTypeSessionHandoff: + var d SessionHandoffData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSkillInvoked: - var d SkillInvokedData + case SessionEventTypeSessionIdle: + var d SessionIdleData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSubagentStarted: - var d SubagentStartedData + case SessionEventTypeSessionInfo: + var d SessionInfoData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSubagentCompleted: - var d SubagentCompletedData + case SessionEventTypeSessionMcpServersLoaded: + var d SessionMcpServersLoadedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSubagentFailed: - var d SubagentFailedData + case SessionEventTypeSessionMcpServerStatusChanged: + var d SessionMcpServerStatusChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSubagentSelected: - var d SubagentSelectedData + case SessionEventTypeSessionModeChanged: + var d SessionModeChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSubagentDeselected: - var d SubagentDeselectedData + case SessionEventTypeSessionModelChange: + var d SessionModelChangeData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeHookStart: - var d HookStartData + case SessionEventTypeSessionPlanChanged: + var d SessionPlanChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeHookEnd: - var d HookEndData + case SessionEventTypeSessionRemoteSteerableChanged: + var d SessionRemoteSteerableChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSystemMessage: - var d SystemMessageData + case SessionEventTypeSessionResume: + var d SessionResumeData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSystemNotification: - var d SystemNotificationData + case SessionEventTypeSessionScheduleCancelled: + var d SessionScheduleCancelledData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypePermissionRequested: - var d PermissionRequestedData + case SessionEventTypeSessionScheduleCreated: + var d SessionScheduleCreatedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypePermissionCompleted: - var d PermissionCompletedData + case SessionEventTypeSessionShutdown: + var d SessionShutdownData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeUserInputRequested: - var d UserInputRequestedData + case SessionEventTypeSessionSkillsLoaded: + var d SessionSkillsLoadedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeUserInputCompleted: - var d UserInputCompletedData + case SessionEventTypeSessionSnapshotRewind: + var d SessionSnapshotRewindData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeElicitationRequested: - var d ElicitationRequestedData + case SessionEventTypeSessionStart: + var d SessionStartData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeElicitationCompleted: - var d ElicitationCompletedData + case SessionEventTypeSessionTaskComplete: + var d SessionTaskCompleteData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSamplingRequested: - var d SamplingRequestedData + case SessionEventTypeSessionTitleChanged: + var d SessionTitleChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSamplingCompleted: - var d SamplingCompletedData + case SessionEventTypeSessionToolsUpdated: + var d SessionToolsUpdatedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeMcpOauthRequired: - var d McpOauthRequiredData + case SessionEventTypeSessionTruncation: + var d SessionTruncationData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeMcpOauthCompleted: - var d McpOauthCompletedData + case SessionEventTypeSessionUsageInfo: + var d SessionUsageInfoData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeExternalToolRequested: - var d ExternalToolRequestedData + case SessionEventTypeSessionWarning: + var d SessionWarningData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeExternalToolCompleted: - var d ExternalToolCompletedData + case SessionEventTypeSessionWorkspaceFileChanged: + var d SessionWorkspaceFileChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeCommandQueued: - var d CommandQueuedData + case SessionEventTypeSkillInvoked: + var d SkillInvokedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeCommandExecute: - var d CommandExecuteData + case SessionEventTypeSubagentCompleted: + var d SubagentCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeCommandCompleted: - var d CommandCompletedData + case SessionEventTypeSubagentDeselected: + var d SubagentDeselectedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAutoModeSwitchRequested: - var d AutoModeSwitchRequestedData + case SessionEventTypeSubagentFailed: + var d SubagentFailedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeAutoModeSwitchCompleted: - var d AutoModeSwitchCompletedData + case SessionEventTypeSubagentSelected: + var d SubagentSelectedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeCommandsChanged: - var d CommandsChangedData + case SessionEventTypeSubagentStarted: + var d SubagentStartedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeCapabilitiesChanged: - var d CapabilitiesChangedData + case SessionEventTypeSystemMessage: + var d SystemMessageData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeExitPlanModeRequested: - var d ExitPlanModeRequestedData + case SessionEventTypeSystemNotification: + var d SystemNotificationData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeExitPlanModeCompleted: - var d ExitPlanModeCompletedData + case SessionEventTypeToolExecutionComplete: + var d ToolExecutionCompleteData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionToolsUpdated: - var d SessionToolsUpdatedData + case SessionEventTypeToolExecutionPartialResult: + var d ToolExecutionPartialResultData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionBackgroundTasksChanged: - var d SessionBackgroundTasksChangedData + case SessionEventTypeToolExecutionProgress: + var d ToolExecutionProgressData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionSkillsLoaded: - var d SessionSkillsLoadedData + case SessionEventTypeToolExecutionStart: + var d ToolExecutionStartData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionCustomAgentsUpdated: - var d SessionCustomAgentsUpdatedData + case SessionEventTypeToolUserRequested: + var d ToolUserRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionMcpServersLoaded: - var d SessionMcpServersLoadedData + case SessionEventTypeUserInputCompleted: + var d UserInputCompletedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionMcpServerStatusChanged: - var d SessionMcpServerStatusChangedData + case SessionEventTypeUserInputRequested: + var d UserInputRequestedData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } e.Data = &d - case SessionEventTypeSessionExtensionsLoaded: - var d SessionExtensionsLoadedData + case SessionEventTypeUserMessage: + var d UserMessageData if err := json.Unmarshal(raw.Data, &d); err != nil { return err } @@ -564,21 +565,21 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { func (e SessionEvent) MarshalJSON() ([]byte, error) { type rawEvent struct { AgentID *string `json:"agentId,omitempty"` + Data any `json:"data"` Ephemeral *bool `json:"ephemeral,omitempty"` ID string `json:"id"` ParentID *string `json:"parentId"` Timestamp time.Time `json:"timestamp"` Type SessionEventType `json:"type"` - Data any `json:"data"` } return json.Marshal(rawEvent{ AgentID: e.AgentID, + Data: e.Data, Ephemeral: e.Ephemeral, ID: e.ID, ParentID: e.ParentID, Timestamp: e.Timestamp, Type: e.Type, - Data: e.Data, }) } @@ -586,86 +587,86 @@ func (e SessionEvent) MarshalJSON() ([]byte, error) { type SessionEventType string const ( - SessionEventTypeSessionStart SessionEventType = "session.start" - SessionEventTypeSessionResume SessionEventType = "session.resume" - SessionEventTypeSessionRemoteSteerableChanged SessionEventType = "session.remote_steerable_changed" - SessionEventTypeSessionError SessionEventType = "session.error" - SessionEventTypeSessionIdle SessionEventType = "session.idle" - SessionEventTypeSessionTitleChanged SessionEventType = "session.title_changed" - SessionEventTypeSessionScheduleCreated SessionEventType = "session.schedule_created" - SessionEventTypeSessionScheduleCancelled SessionEventType = "session.schedule_cancelled" - SessionEventTypeSessionInfo SessionEventType = "session.info" - SessionEventTypeSessionWarning SessionEventType = "session.warning" - SessionEventTypeSessionModelChange SessionEventType = "session.model_change" - SessionEventTypeSessionModeChanged SessionEventType = "session.mode_changed" - SessionEventTypeSessionPlanChanged SessionEventType = "session.plan_changed" - SessionEventTypeSessionWorkspaceFileChanged SessionEventType = "session.workspace_file_changed" - SessionEventTypeSessionHandoff SessionEventType = "session.handoff" - SessionEventTypeSessionTruncation SessionEventType = "session.truncation" - SessionEventTypeSessionSnapshotRewind SessionEventType = "session.snapshot_rewind" - SessionEventTypeSessionShutdown SessionEventType = "session.shutdown" - SessionEventTypeSessionContextChanged SessionEventType = "session.context_changed" - SessionEventTypeSessionUsageInfo SessionEventType = "session.usage_info" - SessionEventTypeSessionCompactionStart SessionEventType = "session.compaction_start" - SessionEventTypeSessionCompactionComplete SessionEventType = "session.compaction_complete" - SessionEventTypeSessionTaskComplete SessionEventType = "session.task_complete" - SessionEventTypeUserMessage SessionEventType = "user.message" - SessionEventTypePendingMessagesModified SessionEventType = "pending_messages.modified" - SessionEventTypeAssistantTurnStart SessionEventType = "assistant.turn_start" + SessionEventTypeAbort SessionEventType = "abort" SessionEventTypeAssistantIntent SessionEventType = "assistant.intent" + SessionEventTypeAssistantMessage SessionEventType = "assistant.message" + SessionEventTypeAssistantMessageDelta SessionEventType = "assistant.message_delta" + SessionEventTypeAssistantMessageStart SessionEventType = "assistant.message_start" SessionEventTypeAssistantReasoning SessionEventType = "assistant.reasoning" SessionEventTypeAssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" SessionEventTypeAssistantStreamingDelta SessionEventType = "assistant.streaming_delta" - SessionEventTypeAssistantMessage SessionEventType = "assistant.message" - SessionEventTypeAssistantMessageStart SessionEventType = "assistant.message_start" - SessionEventTypeAssistantMessageDelta SessionEventType = "assistant.message_delta" SessionEventTypeAssistantTurnEnd SessionEventType = "assistant.turn_end" + SessionEventTypeAssistantTurnStart SessionEventType = "assistant.turn_start" SessionEventTypeAssistantUsage SessionEventType = "assistant.usage" + SessionEventTypeAutoModeSwitchCompleted SessionEventType = "auto_mode_switch.completed" + SessionEventTypeAutoModeSwitchRequested SessionEventType = "auto_mode_switch.requested" + SessionEventTypeCapabilitiesChanged SessionEventType = "capabilities.changed" + SessionEventTypeCommandCompleted SessionEventType = "command.completed" + SessionEventTypeCommandExecute SessionEventType = "command.execute" + SessionEventTypeCommandQueued SessionEventType = "command.queued" + SessionEventTypeCommandsChanged SessionEventType = "commands.changed" + SessionEventTypeElicitationCompleted SessionEventType = "elicitation.completed" + SessionEventTypeElicitationRequested SessionEventType = "elicitation.requested" + SessionEventTypeExitPlanModeCompleted SessionEventType = "exit_plan_mode.completed" + SessionEventTypeExitPlanModeRequested SessionEventType = "exit_plan_mode.requested" + SessionEventTypeExternalToolCompleted SessionEventType = "external_tool.completed" + SessionEventTypeExternalToolRequested SessionEventType = "external_tool.requested" + SessionEventTypeHookEnd SessionEventType = "hook.end" + SessionEventTypeHookStart SessionEventType = "hook.start" + SessionEventTypeMcpOauthCompleted SessionEventType = "mcp.oauth_completed" + SessionEventTypeMcpOauthRequired SessionEventType = "mcp.oauth_required" SessionEventTypeModelCallFailure SessionEventType = "model.call_failure" - SessionEventTypeAbort SessionEventType = "abort" - SessionEventTypeToolUserRequested SessionEventType = "tool.user_requested" - SessionEventTypeToolExecutionStart SessionEventType = "tool.execution_start" - SessionEventTypeToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" - SessionEventTypeToolExecutionProgress SessionEventType = "tool.execution_progress" - SessionEventTypeToolExecutionComplete SessionEventType = "tool.execution_complete" + SessionEventTypePendingMessagesModified SessionEventType = "pending_messages.modified" + SessionEventTypePermissionCompleted SessionEventType = "permission.completed" + SessionEventTypePermissionRequested SessionEventType = "permission.requested" + SessionEventTypeSamplingCompleted SessionEventType = "sampling.completed" + SessionEventTypeSamplingRequested SessionEventType = "sampling.requested" + SessionEventTypeSessionBackgroundTasksChanged SessionEventType = "session.background_tasks_changed" + SessionEventTypeSessionCompactionComplete SessionEventType = "session.compaction_complete" + SessionEventTypeSessionCompactionStart SessionEventType = "session.compaction_start" + SessionEventTypeSessionContextChanged SessionEventType = "session.context_changed" + SessionEventTypeSessionCustomAgentsUpdated SessionEventType = "session.custom_agents_updated" + SessionEventTypeSessionError SessionEventType = "session.error" + SessionEventTypeSessionExtensionsLoaded SessionEventType = "session.extensions_loaded" + SessionEventTypeSessionHandoff SessionEventType = "session.handoff" + SessionEventTypeSessionIdle SessionEventType = "session.idle" + SessionEventTypeSessionInfo SessionEventType = "session.info" + SessionEventTypeSessionMcpServersLoaded SessionEventType = "session.mcp_servers_loaded" + SessionEventTypeSessionMcpServerStatusChanged SessionEventType = "session.mcp_server_status_changed" + SessionEventTypeSessionModeChanged SessionEventType = "session.mode_changed" + SessionEventTypeSessionModelChange SessionEventType = "session.model_change" + SessionEventTypeSessionPlanChanged SessionEventType = "session.plan_changed" + SessionEventTypeSessionRemoteSteerableChanged SessionEventType = "session.remote_steerable_changed" + SessionEventTypeSessionResume SessionEventType = "session.resume" + SessionEventTypeSessionScheduleCancelled SessionEventType = "session.schedule_cancelled" + SessionEventTypeSessionScheduleCreated SessionEventType = "session.schedule_created" + SessionEventTypeSessionShutdown SessionEventType = "session.shutdown" + SessionEventTypeSessionSkillsLoaded SessionEventType = "session.skills_loaded" + SessionEventTypeSessionSnapshotRewind SessionEventType = "session.snapshot_rewind" + SessionEventTypeSessionStart SessionEventType = "session.start" + SessionEventTypeSessionTaskComplete SessionEventType = "session.task_complete" + SessionEventTypeSessionTitleChanged SessionEventType = "session.title_changed" + SessionEventTypeSessionToolsUpdated SessionEventType = "session.tools_updated" + SessionEventTypeSessionTruncation SessionEventType = "session.truncation" + SessionEventTypeSessionUsageInfo SessionEventType = "session.usage_info" + SessionEventTypeSessionWarning SessionEventType = "session.warning" + SessionEventTypeSessionWorkspaceFileChanged SessionEventType = "session.workspace_file_changed" SessionEventTypeSkillInvoked SessionEventType = "skill.invoked" - SessionEventTypeSubagentStarted SessionEventType = "subagent.started" SessionEventTypeSubagentCompleted SessionEventType = "subagent.completed" + SessionEventTypeSubagentDeselected SessionEventType = "subagent.deselected" SessionEventTypeSubagentFailed SessionEventType = "subagent.failed" SessionEventTypeSubagentSelected SessionEventType = "subagent.selected" - SessionEventTypeSubagentDeselected SessionEventType = "subagent.deselected" - SessionEventTypeHookStart SessionEventType = "hook.start" - SessionEventTypeHookEnd SessionEventType = "hook.end" + SessionEventTypeSubagentStarted SessionEventType = "subagent.started" SessionEventTypeSystemMessage SessionEventType = "system.message" SessionEventTypeSystemNotification SessionEventType = "system.notification" - SessionEventTypePermissionRequested SessionEventType = "permission.requested" - SessionEventTypePermissionCompleted SessionEventType = "permission.completed" - SessionEventTypeUserInputRequested SessionEventType = "user_input.requested" + SessionEventTypeToolExecutionComplete SessionEventType = "tool.execution_complete" + SessionEventTypeToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" + SessionEventTypeToolExecutionProgress SessionEventType = "tool.execution_progress" + SessionEventTypeToolExecutionStart SessionEventType = "tool.execution_start" + SessionEventTypeToolUserRequested SessionEventType = "tool.user_requested" SessionEventTypeUserInputCompleted SessionEventType = "user_input.completed" - SessionEventTypeElicitationRequested SessionEventType = "elicitation.requested" - SessionEventTypeElicitationCompleted SessionEventType = "elicitation.completed" - SessionEventTypeSamplingRequested SessionEventType = "sampling.requested" - SessionEventTypeSamplingCompleted SessionEventType = "sampling.completed" - SessionEventTypeMcpOauthRequired SessionEventType = "mcp.oauth_required" - SessionEventTypeMcpOauthCompleted SessionEventType = "mcp.oauth_completed" - SessionEventTypeExternalToolRequested SessionEventType = "external_tool.requested" - SessionEventTypeExternalToolCompleted SessionEventType = "external_tool.completed" - SessionEventTypeCommandQueued SessionEventType = "command.queued" - SessionEventTypeCommandExecute SessionEventType = "command.execute" - SessionEventTypeCommandCompleted SessionEventType = "command.completed" - SessionEventTypeAutoModeSwitchRequested SessionEventType = "auto_mode_switch.requested" - SessionEventTypeAutoModeSwitchCompleted SessionEventType = "auto_mode_switch.completed" - SessionEventTypeCommandsChanged SessionEventType = "commands.changed" - SessionEventTypeCapabilitiesChanged SessionEventType = "capabilities.changed" - SessionEventTypeExitPlanModeRequested SessionEventType = "exit_plan_mode.requested" - SessionEventTypeExitPlanModeCompleted SessionEventType = "exit_plan_mode.completed" - SessionEventTypeSessionToolsUpdated SessionEventType = "session.tools_updated" - SessionEventTypeSessionBackgroundTasksChanged SessionEventType = "session.background_tasks_changed" - SessionEventTypeSessionSkillsLoaded SessionEventType = "session.skills_loaded" - SessionEventTypeSessionCustomAgentsUpdated SessionEventType = "session.custom_agents_updated" - SessionEventTypeSessionMcpServersLoaded SessionEventType = "session.mcp_servers_loaded" - SessionEventTypeSessionMcpServerStatusChanged SessionEventType = "session.mcp_server_status_changed" - SessionEventTypeSessionExtensionsLoaded SessionEventType = "session.extensions_loaded" + SessionEventTypeUserInputRequested SessionEventType = "user_input.requested" + SessionEventTypeUserMessage SessionEventType = "user.message" ) // Agent intent description for current activity or plan @@ -856,7 +857,7 @@ type ElicitationCompletedData struct { // The user action: "accept" (submitted form), "decline" (explicitly refused), or "cancel" (dismissed) Action *ElicitationCompletedAction `json:"action,omitempty"` // The submitted form data when action is 'accept'; keys match the requested schema fields - Content map[string]any `json:"content,omitempty"` + Content map[string]*ElicitationCompletedContent `json:"content,omitempty"` // Request ID of the resolved elicitation request; clients should dismiss any UI for this request RequestID string `json:"requestId"` } @@ -1796,36 +1797,6 @@ type SessionWorkspaceFileChangedData struct { func (*SessionWorkspaceFileChangedData) sessionEventData() {} -// A content block within a tool result, which may be text, terminal output, image, audio, or a resource -type ToolExecutionCompleteContent struct { - // Type discriminator - Type ToolExecutionCompleteContentType `json:"type"` - // Working directory where the command was executed - Cwd *string `json:"cwd,omitempty"` - // Base64-encoded image data - Data *string `json:"data,omitempty"` - // Human-readable description of the resource - Description *string `json:"description,omitempty"` - // Process exit code, if the command has completed - ExitCode *float64 `json:"exitCode,omitempty"` - // Icons associated with this resource - Icons []ToolExecutionCompleteContentResourceLinkIcon `json:"icons,omitempty"` - // MIME type of the image (e.g., image/png, image/jpeg) - MIMEType *string `json:"mimeType,omitempty"` - // Resource name identifier - Name *string `json:"name,omitempty"` - // The embedded resource contents, either text or base64-encoded binary - Resource any `json:"resource,omitempty"` - // Size of the resource in bytes - Size *float64 `json:"size,omitempty"` - // The text content - Text *string `json:"text,omitempty"` - // Human-readable display title for the resource - Title *string `json:"title,omitempty"` - // URI identifying the resource - URI *string `json:"uri,omitempty"` -} - // A tool invocation request from the assistant type AssistantMessageToolRequest struct { // Arguments to pass to the tool, format depends on the tool @@ -1846,52 +1817,234 @@ type AssistantMessageToolRequest struct { Type *AssistantMessageToolRequestType `json:"type,omitempty"` } -// A user message attachment — a file, directory, code selection, blob, or GitHub reference -type UserMessageAttachment struct { - // Type discriminator - Type UserMessageAttachmentType `json:"type"` - // Base64-encoded content - Data *string `json:"data,omitempty"` - // User-facing display name for the attachment - DisplayName *string `json:"displayName,omitempty"` - // Absolute path to the file containing the selection - FilePath *string `json:"filePath,omitempty"` - // Optional line range to scope the attachment to a specific section of the file - LineRange *UserMessageAttachmentFileLineRange `json:"lineRange,omitempty"` - // MIME type of the inline data - MIMEType *string `json:"mimeType,omitempty"` - // Issue, pull request, or discussion number - Number *float64 `json:"number,omitempty"` - // Absolute file path - Path *string `json:"path,omitempty"` - // Type of GitHub reference - ReferenceType *UserMessageAttachmentGithubReferenceType `json:"referenceType,omitempty"` - // Position range of the selection within the file - Selection *UserMessageAttachmentSelectionDetails `json:"selection,omitempty"` - // Current state of the referenced item (e.g., open, closed, merged) - State *string `json:"state,omitempty"` - // The selected text content - Text *string `json:"text,omitempty"` - // Title of the referenced item - Title *string `json:"title,omitempty"` - // URL to the referenced item on GitHub - URL *string `json:"url,omitempty"` +// Per-request cost and usage data from the CAPI copilot_usage response field +type AssistantUsageCopilotUsage struct { + // Itemized token usage breakdown + TokenDetails []AssistantUsageCopilotUsageTokenDetail `json:"tokenDetails"` + // Total cost in nano-AI units for this request + TotalNanoAiu float64 `json:"totalNanoAiu"` } -// Aggregate code change metrics for the session -type ShutdownCodeChanges struct { - // List of file paths that were modified during the session - FilesModified []string `json:"filesModified"` - // Total number of lines added during the session - LinesAdded float64 `json:"linesAdded"` - // Total number of lines removed during the session - LinesRemoved float64 `json:"linesRemoved"` +// Token usage detail for a single billing category +type AssistantUsageCopilotUsageTokenDetail struct { + // Number of tokens in this billing batch + BatchSize float64 `json:"batchSize"` + // Cost per batch of tokens + CostPerBatch float64 `json:"costPerBatch"` + // Total token count for this entry + TokenCount float64 `json:"tokenCount"` + // Token category (e.g., "input", "output") + TokenType string `json:"tokenType"` +} + +type AssistantUsageQuotaSnapshot struct { + // Total requests allowed by the entitlement + EntitlementRequests float64 `json:"entitlementRequests"` + // Whether the user has an unlimited usage entitlement + IsUnlimitedEntitlement bool `json:"isUnlimitedEntitlement"` + // Number of requests over the entitlement limit + Overage float64 `json:"overage"` + // Whether overage is allowed when quota is exhausted + OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` + // Percentage of quota remaining (0.0 to 1.0) + RemainingPercentage float64 `json:"remainingPercentage"` + // Date when the quota resets + ResetDate *time.Time `json:"resetDate,omitempty"` + // Whether usage is still permitted after quota exhaustion + UsageAllowedWithExhaustedQuota bool `json:"usageAllowedWithExhaustedQuota"` + // Number of requests already consumed + UsedRequests float64 `json:"usedRequests"` +} + +// UI capability changes +type CapabilitiesChangedUI struct { + // Whether elicitation is now supported + Elicitation *bool `json:"elicitation,omitempty"` +} + +type CommandsChangedCommand struct { + Description *string `json:"description,omitempty"` + Name string `json:"name"` +} + +// Token usage breakdown for the compaction LLM call (aligned with assistant.usage format) +type CompactionCompleteCompactionTokensUsed struct { + // Cached input tokens reused in the compaction LLM call + CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` + // Tokens written to prompt cache in the compaction LLM call + CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` + // Per-request cost and usage data from the CAPI copilot_usage response field + CopilotUsage *CompactionCompleteCompactionTokensUsedCopilotUsage `json:"copilotUsage,omitempty"` + // Duration of the compaction LLM call in milliseconds + Duration *float64 `json:"duration,omitempty"` + // Input tokens consumed by the compaction LLM call + InputTokens *float64 `json:"inputTokens,omitempty"` + // Model identifier used for the compaction LLM call + Model *string `json:"model,omitempty"` + // Output tokens produced by the compaction LLM call + OutputTokens *float64 `json:"outputTokens,omitempty"` +} + +// Per-request cost and usage data from the CAPI copilot_usage response field +type CompactionCompleteCompactionTokensUsedCopilotUsage struct { + // Itemized token usage breakdown + TokenDetails []CompactionCompleteCompactionTokensUsedCopilotUsageTokenDetail `json:"tokenDetails"` + // Total cost in nano-AI units for this request + TotalNanoAiu float64 `json:"totalNanoAiu"` +} + +// Token usage detail for a single billing category +type CompactionCompleteCompactionTokensUsedCopilotUsageTokenDetail struct { + // Number of tokens in this billing batch + BatchSize float64 `json:"batchSize"` + // Cost per batch of tokens + CostPerBatch float64 `json:"costPerBatch"` + // Total token count for this entry + TokenCount float64 `json:"tokenCount"` + // Token category (e.g., "input", "output") + TokenType string `json:"tokenType"` +} + +type CustomAgentsUpdatedAgent struct { + // Description of what the agent does + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier for the agent + ID string `json:"id"` + // Model override for this agent, if set + Model *string `json:"model,omitempty"` + // Internal name of the agent + Name string `json:"name"` + // Source location: user, project, inherited, remote, or plugin + Source string `json:"source"` + // List of tool names available to this agent, or null when all tools are available + Tools []string `json:"tools"` + // Whether the agent can be selected by the user + UserInvocable bool `json:"userInvocable"` +} + +type ElicitationCompletedContent struct { + Bool *bool + Double *float64 + String *string + StringArray []string +} + +func (r ElicitationCompletedContent) MarshalJSON() ([]byte, error) { + if r.Bool != nil { + return json.Marshal(r.Bool) + } + if r.Double != nil { + return json.Marshal(r.Double) + } + if r.String != nil { + return json.Marshal(r.String) + } + if r.StringArray != nil { + return json.Marshal(r.StringArray) + } + return []byte("null"), nil +} + +func (r *ElicitationCompletedContent) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *r = ElicitationCompletedContent{} + return nil + } + { + var value bool + if err := json.Unmarshal(data, &value); err == nil { + *r = ElicitationCompletedContent{Bool: &value} + return nil + } + } + { + var value float64 + if err := json.Unmarshal(data, &value); err == nil { + *r = ElicitationCompletedContent{Double: &value} + return nil + } + } + { + var value string + if err := json.Unmarshal(data, &value); err == nil { + *r = ElicitationCompletedContent{String: &value} + return nil + } + } + { + var value []string + if err := json.Unmarshal(data, &value); err == nil { + *r = ElicitationCompletedContent{StringArray: value} + return nil + } + } + return errors.New("data did not match any union variant for ElicitationCompletedContent") +} + +// JSON Schema describing the form fields to present to the user (form mode only) +type ElicitationRequestedSchema struct { + // Form field definitions, keyed by field name + Properties map[string]any `json:"properties"` + // List of required field names + Required []string `json:"required,omitempty"` + // Schema type indicator (always 'object') + Type ElicitationRequestedSchemaType `json:"type"` +} + +type ExtensionsLoadedExtension struct { + // Source-qualified extension ID (e.g., 'project:my-ext', 'user:auth-helper') + ID string `json:"id"` + // Extension name (directory name) + Name string `json:"name"` + // Discovery source + Source ExtensionsLoadedExtensionSource `json:"source"` + // Current status: running, disabled, failed, or starting + Status ExtensionsLoadedExtensionStatus `json:"status"` +} + +// Repository context for the handed-off session +type HandoffRepository struct { + // Git branch name, if applicable + Branch *string `json:"branch,omitempty"` + // Repository name + Name string `json:"name"` + // Repository owner (user or organization) + Owner string `json:"owner"` +} + +// Error details when the hook failed +type HookEndError struct { + // Human-readable error message + Message string `json:"message"` + // Error stack trace, when available + Stack *string `json:"stack,omitempty"` +} + +// Static OAuth client configuration, if the server specifies one +type McpOauthRequiredStaticClientConfig struct { + // OAuth client ID for the server + ClientID string `json:"clientId"` + // Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). + GrantType *McpOauthRequiredStaticClientConfigGrantType `json:"grantType,omitempty"` + // Whether this is a public OAuth client + PublicClient *bool `json:"publicClient,omitempty"` +} + +type McpServersLoadedServer struct { + // Error message if the server failed to connect + Error *string `json:"error,omitempty"` + // Server name (config key) + Name string `json:"name"` + // Configuration source: user, workspace, plugin, or builtin + Source *string `json:"source,omitempty"` + // Connection status: connected, failed, needs-auth, pending, disabled, or not_configured + Status McpServersLoadedServerStatus `json:"status"` } // Derived user-facing permission prompt details for UI consumers type PermissionPromptRequest struct { - // Kind discriminator - Kind PermissionPromptRequestKind `json:"kind"` // Underlying permission kind that needs path approval AccessKind *PermissionPromptRequestPathAccessKind `json:"accessKind,omitempty"` // Whether this is a store or vote memory operation @@ -1922,6 +2075,8 @@ type PermissionPromptRequest struct { HookMessage *string `json:"hookMessage,omitempty"` // Human-readable description of what the command intends to do Intention *string `json:"intention,omitempty"` + // Kind discriminator + Kind PermissionPromptRequestKind `json:"kind"` // Complete new file contents for newly created files NewFileContents *string `json:"newFileContents,omitempty"` // The extension management operation (scaffold, reload) @@ -1954,8 +2109,6 @@ type PermissionPromptRequest struct { // Details of the permission being requested type PermissionRequest struct { - // Kind discriminator - Kind PermissionRequestKind `json:"kind"` // Whether this is a store or vote memory operation Action *PermissionRequestMemoryAction `json:"action,omitempty"` // Arguments to pass to the MCP tool @@ -1986,6 +2139,8 @@ type PermissionRequest struct { HookMessage *string `json:"hookMessage,omitempty"` // Human-readable description of what the command intends to do Intention *string `json:"intention,omitempty"` + // Kind discriminator + Kind PermissionRequestKind `json:"kind"` // Complete new file contents for newly created files NewFileContents *string `json:"newFileContents,omitempty"` // The extension management operation (scaffold, reload) @@ -2020,132 +2175,127 @@ type PermissionRequest struct { Warning *string `json:"warning,omitempty"` } -// End position of the selection -type UserMessageAttachmentSelectionDetailsEnd struct { - // End character offset within the line (0-based) - Character float64 `json:"character"` - // End line number (0-based) - Line float64 `json:"line"` +type PermissionRequestShellCommand struct { + // Command identifier (e.g., executable name) + Identifier string `json:"identifier"` + // Whether this command is read-only (no side effects) + ReadOnly bool `json:"readOnly"` } -// Error details when the hook failed -type HookEndError struct { - // Human-readable error message - Message string `json:"message"` - // Error stack trace, when available - Stack *string `json:"stack,omitempty"` +type PermissionRequestShellPossibleURL struct { + // URL that may be accessed by the command + URL string `json:"url"` } -// Error details when the tool execution failed -type ToolExecutionCompleteError struct { - // Machine-readable error code - Code *string `json:"code,omitempty"` - // Human-readable error message - Message string `json:"message"` +// The result of the permission request +type PermissionResult struct { + // The approval to add as a session-scoped rule + Approval *UserToolSessionApproval `json:"approval,omitempty"` + // Optional feedback from the user explaining the denial + Feedback *string `json:"feedback,omitempty"` + // Whether to force-reject the current agent turn + ForceReject *bool `json:"forceReject,omitempty"` + // Whether to interrupt the current agent turn + Interrupt *bool `json:"interrupt,omitempty"` + // Kind discriminator + Kind PermissionResultKind `json:"kind"` + // The location key (git root or cwd) to persist the approval to + LocationKey *string `json:"locationKey,omitempty"` + // Human-readable explanation of why the path was excluded + Message *string `json:"message,omitempty"` + // File path that triggered the exclusion + Path *string `json:"path,omitempty"` + // Optional explanation of why the request was cancelled + Reason *string `json:"reason,omitempty"` + // Rules that denied the request + Rules []PermissionRule `json:"rules,omitempty"` } -// Icon image for a resource -type ToolExecutionCompleteContentResourceLinkIcon struct { - // MIME type of the icon image - MIMEType *string `json:"mimeType,omitempty"` - // Available icon sizes (e.g., ['16x16', '32x32']) - Sizes []string `json:"sizes,omitempty"` - // URL or path to the icon image - Src string `json:"src"` - // Theme variant this icon is intended for - Theme *ToolExecutionCompleteContentResourceLinkIconTheme `json:"theme,omitempty"` +type PermissionRule struct { + // Optional rule argument matched against the request + Argument *string `json:"argument"` + // The rule kind, such as Shell or GitHubMCP + Kind string `json:"kind"` } -// JSON Schema describing the form fields to present to the user (form mode only) -type ElicitationRequestedSchema struct { - // Form field definitions, keyed by field name - Properties map[string]any `json:"properties"` - // List of required field names - Required []string `json:"required,omitempty"` - // Schema type indicator (always 'object') - Type string `json:"type"` +// Aggregate code change metrics for the session +type ShutdownCodeChanges struct { + // List of file paths that were modified during the session + FilesModified []string `json:"filesModified"` + // Total number of lines added during the session + LinesAdded float64 `json:"linesAdded"` + // Total number of lines removed during the session + LinesRemoved float64 `json:"linesRemoved"` } -// Metadata about the prompt template and its construction -type SystemMessageMetadata struct { - // Version identifier of the prompt template used - PromptVersion *string `json:"promptVersion,omitempty"` - // Template variables used when constructing the prompt - Variables map[string]any `json:"variables,omitempty"` +type ShutdownModelMetric struct { + // Request count and cost metrics + Requests ShutdownModelMetricRequests `json:"requests"` + // Token count details per type + TokenDetails map[string]ShutdownModelMetricTokenDetail `json:"tokenDetails,omitempty"` + // Accumulated nano-AI units cost for this model + TotalNanoAiu *float64 `json:"totalNanoAiu,omitempty"` + // Token usage breakdown + Usage ShutdownModelMetricUsage `json:"usage"` } -// Optional line range to scope the attachment to a specific section of the file -type UserMessageAttachmentFileLineRange struct { - // End line number (1-based, inclusive) - End float64 `json:"end"` - // Start line number (1-based) - Start float64 `json:"start"` +// Request count and cost metrics +type ShutdownModelMetricRequests struct { + // Cumulative cost multiplier for requests to this model + Cost float64 `json:"cost"` + // Total number of API requests made to this model + Count float64 `json:"count"` } -// Per-request cost and usage data from the CAPI copilot_usage response field -type AssistantUsageCopilotUsage struct { - // Itemized token usage breakdown - TokenDetails []AssistantUsageCopilotUsageTokenDetail `json:"tokenDetails"` - // Total cost in nano-AI units for this request - TotalNanoAiu float64 `json:"totalNanoAiu"` +type ShutdownModelMetricTokenDetail struct { + // Accumulated token count for this token type + TokenCount float64 `json:"tokenCount"` } -// Per-request cost and usage data from the CAPI copilot_usage response field -type CompactionCompleteCompactionTokensUsedCopilotUsage struct { - // Itemized token usage breakdown - TokenDetails []CompactionCompleteCompactionTokensUsedCopilotUsageTokenDetail `json:"tokenDetails"` - // Total cost in nano-AI units for this request - TotalNanoAiu float64 `json:"totalNanoAiu"` +// Token usage breakdown +type ShutdownModelMetricUsage struct { + // Total tokens read from prompt cache across all requests + CacheReadTokens float64 `json:"cacheReadTokens"` + // Total tokens written to prompt cache across all requests + CacheWriteTokens float64 `json:"cacheWriteTokens"` + // Total input tokens consumed across all requests to this model + InputTokens float64 `json:"inputTokens"` + // Total output tokens produced across all requests to this model + OutputTokens float64 `json:"outputTokens"` + // Total reasoning tokens produced across all requests to this model + ReasoningTokens *float64 `json:"reasoningTokens,omitempty"` } -// Position range of the selection within the file -type UserMessageAttachmentSelectionDetails struct { - // End position of the selection - End UserMessageAttachmentSelectionDetailsEnd `json:"end"` - // Start position of the selection - Start UserMessageAttachmentSelectionDetailsStart `json:"start"` +type ShutdownTokenDetail struct { + // Accumulated token count for this token type + TokenCount float64 `json:"tokenCount"` } -// Repository context for the handed-off session -type HandoffRepository struct { - // Git branch name, if applicable - Branch *string `json:"branch,omitempty"` - // Repository name +type SkillsLoadedSkill struct { + // Description of what the skill does + Description string `json:"description"` + // Whether the skill is currently enabled + Enabled bool `json:"enabled"` + // Unique identifier for the skill Name string `json:"name"` - // Repository owner (user or organization) - Owner string `json:"owner"` -} - -// Request count and cost metrics -type ShutdownModelMetricRequests struct { - // Cumulative cost multiplier for requests to this model - Cost float64 `json:"cost"` - // Total number of API requests made to this model - Count float64 `json:"count"` -} - -// Start position of the selection -type UserMessageAttachmentSelectionDetailsStart struct { - // Start character offset within the line (0-based) - Character float64 `json:"character"` - // Start line number (0-based) - Line float64 `json:"line"` + // Absolute path to the skill file, if available + Path *string `json:"path,omitempty"` + // Source location type of the skill (e.g., project, personal, plugin) + Source string `json:"source"` + // Whether the skill can be invoked by the user as a slash command + UserInvocable bool `json:"userInvocable"` } -// Static OAuth client configuration, if the server specifies one -type McpOauthRequiredStaticClientConfig struct { - // OAuth client ID for the server - ClientID string `json:"clientId"` - // Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). - GrantType *string `json:"grantType,omitempty"` - // Whether this is a public OAuth client - PublicClient *bool `json:"publicClient,omitempty"` +// Metadata about the prompt template and its construction +type SystemMessageMetadata struct { + // Version identifier of the prompt template used + PromptVersion *string `json:"promptVersion,omitempty"` + // Template variables used when constructing the prompt + Variables map[string]any `json:"variables,omitempty"` } // Structured metadata identifying what triggered this notification type SystemNotification struct { - // Type discriminator - Type SystemNotificationType `json:"type"` // Unique identifier of the background agent AgentID *string `json:"agentId,omitempty"` // Type of the agent (e.g., explore, task, general-purpose) @@ -2174,114 +2324,156 @@ type SystemNotification struct { TriggerFile *string `json:"triggerFile,omitempty"` // Tool command that triggered discovery (currently always 'view') TriggerTool *string `json:"triggerTool,omitempty"` + // Type discriminator + Type SystemNotificationType `json:"type"` } -// The approval to add as a session-scoped rule -type UserToolSessionApproval struct { - // Kind discriminator - Kind UserToolSessionApprovalKind `json:"kind"` - // Command identifiers approved by the user - CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` - // MCP server name - ServerName *string `json:"serverName,omitempty"` - // Optional MCP tool name, or null for all tools on the server - ToolName *string `json:"toolName,omitempty"` +// A content block within a tool result, which may be text, terminal output, image, audio, or a resource +type ToolExecutionCompleteContent struct { + // Working directory where the command was executed + Cwd *string `json:"cwd,omitempty"` + // Base64-encoded image data + Data *string `json:"data,omitempty"` + // Human-readable description of the resource + Description *string `json:"description,omitempty"` + // Process exit code, if the command has completed + ExitCode *float64 `json:"exitCode,omitempty"` + // Icons associated with this resource + Icons []ToolExecutionCompleteContentResourceLinkIcon `json:"icons,omitempty"` + // MIME type of the image (e.g., image/png, image/jpeg) + MIMEType *string `json:"mimeType,omitempty"` + // Resource name identifier + Name *string `json:"name,omitempty"` + // The embedded resource contents, either text or base64-encoded binary + Resource *ToolExecutionCompleteContentResourceDetails `json:"resource,omitempty"` + // Size of the resource in bytes + Size *float64 `json:"size,omitempty"` + // The text content + Text *string `json:"text,omitempty"` + // Human-readable display title for the resource + Title *string `json:"title,omitempty"` + // Type discriminator + Type ToolExecutionCompleteContentType `json:"type"` + // URI identifying the resource + URI *string `json:"uri,omitempty"` } -// The result of the permission request -type PermissionResult struct { - // Kind discriminator - Kind PermissionResultKind `json:"kind"` - // The approval to add as a session-scoped rule - Approval *UserToolSessionApproval `json:"approval,omitempty"` - // Optional feedback from the user explaining the denial - Feedback *string `json:"feedback,omitempty"` - // Whether to force-reject the current agent turn - ForceReject *bool `json:"forceReject,omitempty"` - // Whether to interrupt the current agent turn - Interrupt *bool `json:"interrupt,omitempty"` - // The location key (git root or cwd) to persist the approval to - LocationKey *string `json:"locationKey,omitempty"` - // Human-readable explanation of why the path was excluded - Message *string `json:"message,omitempty"` - // File path that triggered the exclusion - Path *string `json:"path,omitempty"` - // Optional explanation of why the request was cancelled - Reason *string `json:"reason,omitempty"` - // Rules that denied the request - Rules []PermissionRule `json:"rules,omitempty"` +// The embedded resource contents, either text or base64-encoded binary +type ToolExecutionCompleteContentResourceDetails struct { + // Base64-encoded binary content of the resource + Blob *string `json:"blob,omitempty"` + // MIME type of the text content + MIMEType *string `json:"mimeType,omitempty"` + // Text content of the resource + Text *string `json:"text,omitempty"` + // URI identifying the resource + URI string `json:"uri"` } -// Token usage breakdown -type ShutdownModelMetricUsage struct { - // Total tokens read from prompt cache across all requests - CacheReadTokens float64 `json:"cacheReadTokens"` - // Total tokens written to prompt cache across all requests - CacheWriteTokens float64 `json:"cacheWriteTokens"` - // Total input tokens consumed across all requests to this model - InputTokens float64 `json:"inputTokens"` - // Total output tokens produced across all requests to this model - OutputTokens float64 `json:"outputTokens"` - // Total reasoning tokens produced across all requests to this model - ReasoningTokens *float64 `json:"reasoningTokens,omitempty"` +// Icon image for a resource +type ToolExecutionCompleteContentResourceLinkIcon struct { + // MIME type of the icon image + MIMEType *string `json:"mimeType,omitempty"` + // Available icon sizes (e.g., ['16x16', '32x32']) + Sizes []string `json:"sizes,omitempty"` + // URL or path to the icon image + Src string `json:"src"` + // Theme variant this icon is intended for + Theme *ToolExecutionCompleteContentResourceLinkIconTheme `json:"theme,omitempty"` } -// Token usage breakdown for the compaction LLM call (aligned with assistant.usage format) -type CompactionCompleteCompactionTokensUsed struct { - // Cached input tokens reused in the compaction LLM call - CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` - // Tokens written to prompt cache in the compaction LLM call - CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` - // Per-request cost and usage data from the CAPI copilot_usage response field - CopilotUsage *CompactionCompleteCompactionTokensUsedCopilotUsage `json:"copilotUsage,omitempty"` - // Duration of the compaction LLM call in milliseconds - Duration *float64 `json:"duration,omitempty"` - // Input tokens consumed by the compaction LLM call - InputTokens *float64 `json:"inputTokens,omitempty"` - // Model identifier used for the compaction LLM call - Model *string `json:"model,omitempty"` - // Output tokens produced by the compaction LLM call - OutputTokens *float64 `json:"outputTokens,omitempty"` +// Error details when the tool execution failed +type ToolExecutionCompleteError struct { + // Machine-readable error code + Code *string `json:"code,omitempty"` + // Human-readable error message + Message string `json:"message"` +} + +// Tool execution result on success +type ToolExecutionCompleteResult struct { + // Concise tool result text sent to the LLM for chat completion, potentially truncated for token efficiency + Content string `json:"content"` + // Structured content blocks (text, images, audio, resources) returned by the tool in their native format + Contents []ToolExecutionCompleteContent `json:"contents,omitempty"` + // Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. + DetailedContent *string `json:"detailedContent,omitempty"` +} + +// A user message attachment — a file, directory, code selection, blob, or GitHub reference +type UserMessageAttachment struct { + // Base64-encoded content + Data *string `json:"data,omitempty"` + // User-facing display name for the attachment + DisplayName *string `json:"displayName,omitempty"` + // Absolute path to the file containing the selection + FilePath *string `json:"filePath,omitempty"` + // Optional line range to scope the attachment to a specific section of the file + LineRange *UserMessageAttachmentFileLineRange `json:"lineRange,omitempty"` + // MIME type of the inline data + MIMEType *string `json:"mimeType,omitempty"` + // Issue, pull request, or discussion number + Number *float64 `json:"number,omitempty"` + // Absolute file path + Path *string `json:"path,omitempty"` + // Type of GitHub reference + ReferenceType *UserMessageAttachmentGithubReferenceType `json:"referenceType,omitempty"` + // Position range of the selection within the file + Selection *UserMessageAttachmentSelectionDetails `json:"selection,omitempty"` + // Current state of the referenced item (e.g., open, closed, merged) + State *string `json:"state,omitempty"` + // The selected text content + Text *string `json:"text,omitempty"` + // Title of the referenced item + Title *string `json:"title,omitempty"` + // Type discriminator + Type UserMessageAttachmentType `json:"type"` + // URL to the referenced item on GitHub + URL *string `json:"url,omitempty"` +} + +// Optional line range to scope the attachment to a specific section of the file +type UserMessageAttachmentFileLineRange struct { + // End line number (1-based, inclusive) + End float64 `json:"end"` + // Start line number (1-based) + Start float64 `json:"start"` } -// Token usage detail for a single billing category -type AssistantUsageCopilotUsageTokenDetail struct { - // Number of tokens in this billing batch - BatchSize float64 `json:"batchSize"` - // Cost per batch of tokens - CostPerBatch float64 `json:"costPerBatch"` - // Total token count for this entry - TokenCount float64 `json:"tokenCount"` - // Token category (e.g., "input", "output") - TokenType string `json:"tokenType"` +// Position range of the selection within the file +type UserMessageAttachmentSelectionDetails struct { + // End position of the selection + End UserMessageAttachmentSelectionDetailsEnd `json:"end"` + // Start position of the selection + Start UserMessageAttachmentSelectionDetailsStart `json:"start"` } -// Token usage detail for a single billing category -type CompactionCompleteCompactionTokensUsedCopilotUsageTokenDetail struct { - // Number of tokens in this billing batch - BatchSize float64 `json:"batchSize"` - // Cost per batch of tokens - CostPerBatch float64 `json:"costPerBatch"` - // Total token count for this entry - TokenCount float64 `json:"tokenCount"` - // Token category (e.g., "input", "output") - TokenType string `json:"tokenType"` +// End position of the selection +type UserMessageAttachmentSelectionDetailsEnd struct { + // End character offset within the line (0-based) + Character float64 `json:"character"` + // End line number (0-based) + Line float64 `json:"line"` } -// Tool execution result on success -type ToolExecutionCompleteResult struct { - // Concise tool result text sent to the LLM for chat completion, potentially truncated for token efficiency - Content string `json:"content"` - // Structured content blocks (text, images, audio, resources) returned by the tool in their native format - Contents []ToolExecutionCompleteContent `json:"contents,omitempty"` - // Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. - DetailedContent *string `json:"detailedContent,omitempty"` +// Start position of the selection +type UserMessageAttachmentSelectionDetailsStart struct { + // Start character offset within the line (0-based) + Character float64 `json:"character"` + // Start line number (0-based) + Line float64 `json:"line"` } -// UI capability changes -type CapabilitiesChangedUI struct { - // Whether elicitation is now supported - Elicitation *bool `json:"elicitation,omitempty"` +// The approval to add as a session-scoped rule +type UserToolSessionApproval struct { + // Command identifiers approved by the user + CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` + // Kind discriminator + Kind UserToolSessionApprovalKind `json:"kind"` + // MCP server name + ServerName *string `json:"serverName,omitempty"` + // Optional MCP tool name, or null for all tools on the server + ToolName *string `json:"toolName,omitempty"` } // Working directory and git context at session start @@ -2304,179 +2496,111 @@ type WorkingDirectoryContext struct { RepositoryHost *string `json:"repositoryHost,omitempty"` } -type AssistantUsageQuotaSnapshot struct { - // Total requests allowed by the entitlement - EntitlementRequests float64 `json:"entitlementRequests"` - // Whether the user has an unlimited usage entitlement - IsUnlimitedEntitlement bool `json:"isUnlimitedEntitlement"` - // Number of requests over the entitlement limit - Overage float64 `json:"overage"` - // Whether overage is allowed when quota is exhausted - OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` - // Percentage of quota remaining (0.0 to 1.0) - RemainingPercentage float64 `json:"remainingPercentage"` - // Date when the quota resets - ResetDate *time.Time `json:"resetDate,omitempty"` - // Whether usage is still permitted after quota exhaustion - UsageAllowedWithExhaustedQuota bool `json:"usageAllowedWithExhaustedQuota"` - // Number of requests already consumed - UsedRequests float64 `json:"usedRequests"` -} - -type CommandsChangedCommand struct { - Description *string `json:"description,omitempty"` - Name string `json:"name"` -} - -type CustomAgentsUpdatedAgent struct { - // Description of what the agent does - Description string `json:"description"` - // Human-readable display name - DisplayName string `json:"displayName"` - // Unique identifier for the agent - ID string `json:"id"` - // Model override for this agent, if set - Model *string `json:"model,omitempty"` - // Internal name of the agent - Name string `json:"name"` - // Source location: user, project, inherited, remote, or plugin - Source string `json:"source"` - // List of tool names available to this agent, or null when all tools are available - Tools []string `json:"tools"` - // Whether the agent can be selected by the user - UserInvocable bool `json:"userInvocable"` -} +// Finite reason code describing why the current turn was aborted +type AbortReason string -type ExtensionsLoadedExtension struct { - // Source-qualified extension ID (e.g., 'project:my-ext', 'user:auth-helper') - ID string `json:"id"` - // Extension name (directory name) - Name string `json:"name"` - // Discovery source - Source ExtensionsLoadedExtensionSource `json:"source"` - // Current status: running, disabled, failed, or starting - Status ExtensionsLoadedExtensionStatus `json:"status"` -} +const ( + AbortReasonRemoteCommand AbortReason = "remote_command" + AbortReasonUserAbort AbortReason = "user_abort" + AbortReasonUserInitiated AbortReason = "user_initiated" +) -type McpServersLoadedServer struct { - // Error message if the server failed to connect - Error *string `json:"error,omitempty"` - // Server name (config key) - Name string `json:"name"` - // Configuration source: user, workspace, plugin, or builtin - Source *string `json:"source,omitempty"` - // Connection status: connected, failed, needs-auth, pending, disabled, or not_configured - Status McpServersLoadedServerStatus `json:"status"` -} +// Tool call type: "function" for standard tool calls, "custom" for grammar-based tool calls. Defaults to "function" when absent. +type AssistantMessageToolRequestType string -type PermissionRequestShellCommand struct { - // Command identifier (e.g., executable name) - Identifier string `json:"identifier"` - // Whether this command is read-only (no side effects) - ReadOnly bool `json:"readOnly"` -} +const ( + AssistantMessageToolRequestTypeCustom AssistantMessageToolRequestType = "custom" + AssistantMessageToolRequestTypeFunction AssistantMessageToolRequestType = "function" +) -type PermissionRequestShellPossibleURL struct { - // URL that may be accessed by the command - URL string `json:"url"` -} +// The user action: "accept" (submitted form), "decline" (explicitly refused), or "cancel" (dismissed) +type ElicitationCompletedAction string -type PermissionRule struct { - // Optional rule argument matched against the request - Argument *string `json:"argument"` - // The rule kind, such as Shell or GitHubMCP - Kind string `json:"kind"` -} +const ( + ElicitationCompletedActionAccept ElicitationCompletedAction = "accept" + ElicitationCompletedActionCancel ElicitationCompletedAction = "cancel" + ElicitationCompletedActionDecline ElicitationCompletedAction = "decline" +) -type ShutdownModelMetric struct { - // Request count and cost metrics - Requests ShutdownModelMetricRequests `json:"requests"` - // Token count details per type - TokenDetails map[string]ShutdownModelMetricTokenDetail `json:"tokenDetails,omitempty"` - // Accumulated nano-AI units cost for this model - TotalNanoAiu *float64 `json:"totalNanoAiu,omitempty"` - // Token usage breakdown - Usage ShutdownModelMetricUsage `json:"usage"` -} +// Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to "form" when absent. +type ElicitationRequestedMode string -type ShutdownModelMetricTokenDetail struct { - // Accumulated token count for this token type - TokenCount float64 `json:"tokenCount"` -} +const ( + ElicitationRequestedModeForm ElicitationRequestedMode = "form" + ElicitationRequestedModeURL ElicitationRequestedMode = "url" +) -type ShutdownTokenDetail struct { - // Accumulated token count for this token type - TokenCount float64 `json:"tokenCount"` -} +// Schema type indicator (always 'object') +type ElicitationRequestedSchemaType string -type SkillsLoadedSkill struct { - // Description of what the skill does - Description string `json:"description"` - // Whether the skill is currently enabled - Enabled bool `json:"enabled"` - // Unique identifier for the skill - Name string `json:"name"` - // Absolute path to the skill file, if available - Path *string `json:"path,omitempty"` - // Source location type of the skill (e.g., project, personal, plugin) - Source string `json:"source"` - // Whether the skill can be invoked by the user as a slash command - UserInvocable bool `json:"userInvocable"` -} +const ( + ElicitationRequestedSchemaTypeObject ElicitationRequestedSchemaType = "object" +) -// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured -type McpServersLoadedServerStatus string +// Discovery source +type ExtensionsLoadedExtensionSource string const ( - McpServersLoadedServerStatusConnected McpServersLoadedServerStatus = "connected" - McpServersLoadedServerStatusFailed McpServersLoadedServerStatus = "failed" - McpServersLoadedServerStatusNeedsAuth McpServersLoadedServerStatus = "needs-auth" - McpServersLoadedServerStatusPending McpServersLoadedServerStatus = "pending" - McpServersLoadedServerStatusDisabled McpServersLoadedServerStatus = "disabled" - McpServersLoadedServerStatusNotConfigured McpServersLoadedServerStatus = "not_configured" + ExtensionsLoadedExtensionSourceProject ExtensionsLoadedExtensionSource = "project" + ExtensionsLoadedExtensionSourceUser ExtensionsLoadedExtensionSource = "user" ) // Current status: running, disabled, failed, or starting type ExtensionsLoadedExtensionStatus string const ( - ExtensionsLoadedExtensionStatusRunning ExtensionsLoadedExtensionStatus = "running" ExtensionsLoadedExtensionStatusDisabled ExtensionsLoadedExtensionStatus = "disabled" ExtensionsLoadedExtensionStatusFailed ExtensionsLoadedExtensionStatus = "failed" + ExtensionsLoadedExtensionStatusRunning ExtensionsLoadedExtensionStatus = "running" ExtensionsLoadedExtensionStatusStarting ExtensionsLoadedExtensionStatus = "starting" ) -// Discovery source -type ExtensionsLoadedExtensionSource string +// Origin type of the session being handed off +type HandoffSourceType string const ( - ExtensionsLoadedExtensionSourceProject ExtensionsLoadedExtensionSource = "project" - ExtensionsLoadedExtensionSourceUser ExtensionsLoadedExtensionSource = "user" + HandoffSourceTypeLocal HandoffSourceType = "local" + HandoffSourceTypeRemote HandoffSourceType = "remote" ) -// Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to "form" when absent. -type ElicitationRequestedMode string +// Optional non-default OAuth grant type. When set to 'client_credentials', the OAuth flow runs headlessly using the client_id + keychain-stored secret (no browser, no callback server). +type McpOauthRequiredStaticClientConfigGrantType string const ( - ElicitationRequestedModeForm ElicitationRequestedMode = "form" - ElicitationRequestedModeURL ElicitationRequestedMode = "url" + McpOauthRequiredStaticClientConfigGrantTypeClientCredentials McpOauthRequiredStaticClientConfigGrantType = "client_credentials" ) -// Finite reason code describing why the current turn was aborted -type AbortReason string +// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured +type McpServersLoadedServerStatus string const ( - AbortReasonUserInitiated AbortReason = "user_initiated" - AbortReasonRemoteCommand AbortReason = "remote_command" - AbortReasonUserAbort AbortReason = "user_abort" + McpServersLoadedServerStatusConnected McpServersLoadedServerStatus = "connected" + McpServersLoadedServerStatusDisabled McpServersLoadedServerStatus = "disabled" + McpServersLoadedServerStatusFailed McpServersLoadedServerStatus = "failed" + McpServersLoadedServerStatusNeedsAuth McpServersLoadedServerStatus = "needs-auth" + McpServersLoadedServerStatusNotConfigured McpServersLoadedServerStatus = "not_configured" + McpServersLoadedServerStatusPending McpServersLoadedServerStatus = "pending" ) -// Hosting platform type of the repository (github or ado) -type WorkingDirectoryContextHostType string +// New connection status: connected, failed, needs-auth, pending, disabled, or not_configured +type McpServerStatusChangedStatus string const ( - WorkingDirectoryContextHostTypeGithub WorkingDirectoryContextHostType = "github" - WorkingDirectoryContextHostTypeAdo WorkingDirectoryContextHostType = "ado" + McpServerStatusChangedStatusConnected McpServerStatusChangedStatus = "connected" + McpServerStatusChangedStatusDisabled McpServerStatusChangedStatus = "disabled" + McpServerStatusChangedStatusFailed McpServerStatusChangedStatus = "failed" + McpServerStatusChangedStatusNeedsAuth McpServerStatusChangedStatus = "needs-auth" + McpServerStatusChangedStatusNotConfigured McpServerStatusChangedStatus = "not_configured" + McpServerStatusChangedStatusPending McpServerStatusChangedStatus = "pending" +) + +// Where the failed model call originated +type ModelCallFailureSource string + +const ( + ModelCallFailureSourceMcpSampling ModelCallFailureSource = "mcp_sampling" + ModelCallFailureSourceSubagent ModelCallFailureSource = "subagent" + ModelCallFailureSourceTopLevel ModelCallFailureSource = "top_level" ) // Kind discriminator for PermissionPromptRequest. @@ -2484,97 +2608,88 @@ type PermissionPromptRequestKind string const ( PermissionPromptRequestKindCommands PermissionPromptRequestKind = "commands" - PermissionPromptRequestKindWrite PermissionPromptRequestKind = "write" - PermissionPromptRequestKindRead PermissionPromptRequestKind = "read" - PermissionPromptRequestKindMcp PermissionPromptRequestKind = "mcp" - PermissionPromptRequestKindURL PermissionPromptRequestKind = "url" - PermissionPromptRequestKindMemory PermissionPromptRequestKind = "memory" PermissionPromptRequestKindCustomTool PermissionPromptRequestKind = "custom-tool" - PermissionPromptRequestKindPath PermissionPromptRequestKind = "path" - PermissionPromptRequestKindHook PermissionPromptRequestKind = "hook" PermissionPromptRequestKindExtensionManagement PermissionPromptRequestKind = "extension-management" PermissionPromptRequestKindExtensionPermissionAccess PermissionPromptRequestKind = "extension-permission-access" + PermissionPromptRequestKindHook PermissionPromptRequestKind = "hook" + PermissionPromptRequestKindMcp PermissionPromptRequestKind = "mcp" + PermissionPromptRequestKindMemory PermissionPromptRequestKind = "memory" + PermissionPromptRequestKindPath PermissionPromptRequestKind = "path" + PermissionPromptRequestKindRead PermissionPromptRequestKind = "read" + PermissionPromptRequestKindURL PermissionPromptRequestKind = "url" + PermissionPromptRequestKindWrite PermissionPromptRequestKind = "write" ) -// Kind discriminator for PermissionRequest. -type PermissionRequestKind string +// Whether this is a store or vote memory operation +type PermissionPromptRequestMemoryAction string const ( - PermissionRequestKindShell PermissionRequestKind = "shell" - PermissionRequestKindWrite PermissionRequestKind = "write" - PermissionRequestKindRead PermissionRequestKind = "read" - PermissionRequestKindMcp PermissionRequestKind = "mcp" - PermissionRequestKindURL PermissionRequestKind = "url" - PermissionRequestKindMemory PermissionRequestKind = "memory" - PermissionRequestKindCustomTool PermissionRequestKind = "custom-tool" - PermissionRequestKindHook PermissionRequestKind = "hook" - PermissionRequestKindExtensionManagement PermissionRequestKind = "extension-management" - PermissionRequestKindExtensionPermissionAccess PermissionRequestKind = "extension-permission-access" + PermissionPromptRequestMemoryActionStore PermissionPromptRequestMemoryAction = "store" + PermissionPromptRequestMemoryActionVote PermissionPromptRequestMemoryAction = "vote" ) -// Kind discriminator for PermissionResult. -type PermissionResultKind string +// Vote direction (vote only) +type PermissionPromptRequestMemoryDirection string const ( - PermissionResultKindApproved PermissionResultKind = "approved" - PermissionResultKindApprovedForSession PermissionResultKind = "approved-for-session" - PermissionResultKindApprovedForLocation PermissionResultKind = "approved-for-location" - PermissionResultKindCancelled PermissionResultKind = "cancelled" - PermissionResultKindDeniedByRules PermissionResultKind = "denied-by-rules" - PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser PermissionResultKind = "denied-no-approval-rule-and-could-not-request-from-user" - PermissionResultKindDeniedInteractivelyByUser PermissionResultKind = "denied-interactively-by-user" - PermissionResultKindDeniedByContentExclusionPolicy PermissionResultKind = "denied-by-content-exclusion-policy" - PermissionResultKindDeniedByPermissionRequestHook PermissionResultKind = "denied-by-permission-request-hook" + PermissionPromptRequestMemoryDirectionDownvote PermissionPromptRequestMemoryDirection = "downvote" + PermissionPromptRequestMemoryDirectionUpvote PermissionPromptRequestMemoryDirection = "upvote" ) -// Kind discriminator for UserToolSessionApproval. -type UserToolSessionApprovalKind string +// Underlying permission kind that needs path approval +type PermissionPromptRequestPathAccessKind string const ( - UserToolSessionApprovalKindCommands UserToolSessionApprovalKind = "commands" - UserToolSessionApprovalKindRead UserToolSessionApprovalKind = "read" - UserToolSessionApprovalKindWrite UserToolSessionApprovalKind = "write" - UserToolSessionApprovalKindMcp UserToolSessionApprovalKind = "mcp" - UserToolSessionApprovalKindMemory UserToolSessionApprovalKind = "memory" - UserToolSessionApprovalKindCustomTool UserToolSessionApprovalKind = "custom-tool" + PermissionPromptRequestPathAccessKindRead PermissionPromptRequestPathAccessKind = "read" + PermissionPromptRequestPathAccessKindShell PermissionPromptRequestPathAccessKind = "shell" + PermissionPromptRequestPathAccessKindWrite PermissionPromptRequestPathAccessKind = "write" ) -// Message role: "system" for system prompts, "developer" for developer-injected instructions -type SystemMessageRole string +// Kind discriminator for PermissionRequest. +type PermissionRequestKind string const ( - SystemMessageRoleSystem SystemMessageRole = "system" - SystemMessageRoleDeveloper SystemMessageRole = "developer" + PermissionRequestKindCustomTool PermissionRequestKind = "custom-tool" + PermissionRequestKindExtensionManagement PermissionRequestKind = "extension-management" + PermissionRequestKindExtensionPermissionAccess PermissionRequestKind = "extension-permission-access" + PermissionRequestKindHook PermissionRequestKind = "hook" + PermissionRequestKindMcp PermissionRequestKind = "mcp" + PermissionRequestKindMemory PermissionRequestKind = "memory" + PermissionRequestKindRead PermissionRequestKind = "read" + PermissionRequestKindShell PermissionRequestKind = "shell" + PermissionRequestKindURL PermissionRequestKind = "url" + PermissionRequestKindWrite PermissionRequestKind = "write" ) -// New connection status: connected, failed, needs-auth, pending, disabled, or not_configured -type McpServerStatusChangedStatus string +// Whether this is a store or vote memory operation +type PermissionRequestMemoryAction string const ( - McpServerStatusChangedStatusConnected McpServerStatusChangedStatus = "connected" - McpServerStatusChangedStatusFailed McpServerStatusChangedStatus = "failed" - McpServerStatusChangedStatusNeedsAuth McpServerStatusChangedStatus = "needs-auth" - McpServerStatusChangedStatusPending McpServerStatusChangedStatus = "pending" - McpServerStatusChangedStatusDisabled McpServerStatusChangedStatus = "disabled" - McpServerStatusChangedStatusNotConfigured McpServerStatusChangedStatus = "not_configured" + PermissionRequestMemoryActionStore PermissionRequestMemoryAction = "store" + PermissionRequestMemoryActionVote PermissionRequestMemoryAction = "vote" ) -// Origin type of the session being handed off -type HandoffSourceType string +// Vote direction (vote only) +type PermissionRequestMemoryDirection string const ( - HandoffSourceTypeRemote HandoffSourceType = "remote" - HandoffSourceTypeLocal HandoffSourceType = "local" + PermissionRequestMemoryDirectionDownvote PermissionRequestMemoryDirection = "downvote" + PermissionRequestMemoryDirectionUpvote PermissionRequestMemoryDirection = "upvote" ) -// The agent mode that was active when this message was sent -type UserMessageAgentMode string +// Kind discriminator for PermissionResult. +type PermissionResultKind string const ( - UserMessageAgentModeInteractive UserMessageAgentMode = "interactive" - UserMessageAgentModePlan UserMessageAgentMode = "plan" - UserMessageAgentModeAutopilot UserMessageAgentMode = "autopilot" - UserMessageAgentModeShell UserMessageAgentMode = "shell" + PermissionResultKindApproved PermissionResultKind = "approved" + PermissionResultKindApprovedForLocation PermissionResultKind = "approved-for-location" + PermissionResultKindApprovedForSession PermissionResultKind = "approved-for-session" + PermissionResultKindCancelled PermissionResultKind = "cancelled" + PermissionResultKindDeniedByContentExclusionPolicy PermissionResultKind = "denied-by-content-exclusion-policy" + PermissionResultKindDeniedByPermissionRequestHook PermissionResultKind = "denied-by-permission-request-hook" + PermissionResultKindDeniedByRules PermissionResultKind = "denied-by-rules" + PermissionResultKindDeniedInteractivelyByUser PermissionResultKind = "denied-interactively-by-user" + PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser PermissionResultKind = "denied-no-approval-rule-and-could-not-request-from-user" ) // The type of operation performed on the plan file @@ -2582,33 +2697,32 @@ type PlanChangedOperation string const ( PlanChangedOperationCreate PlanChangedOperation = "create" - PlanChangedOperationUpdate PlanChangedOperation = "update" PlanChangedOperationDelete PlanChangedOperation = "delete" + PlanChangedOperationUpdate PlanChangedOperation = "update" ) -// The user action: "accept" (submitted form), "decline" (explicitly refused), or "cancel" (dismissed) -type ElicitationCompletedAction string +// Whether the session ended normally ("routine") or due to a crash/fatal error ("error") +type ShutdownType string const ( - ElicitationCompletedActionAccept ElicitationCompletedAction = "accept" - ElicitationCompletedActionDecline ElicitationCompletedAction = "decline" - ElicitationCompletedActionCancel ElicitationCompletedAction = "cancel" + ShutdownTypeError ShutdownType = "error" + ShutdownTypeRoutine ShutdownType = "routine" ) -// Theme variant this icon is intended for -type ToolExecutionCompleteContentResourceLinkIconTheme string +// Message role: "system" for system prompts, "developer" for developer-injected instructions +type SystemMessageRole string const ( - ToolExecutionCompleteContentResourceLinkIconThemeLight ToolExecutionCompleteContentResourceLinkIconTheme = "light" - ToolExecutionCompleteContentResourceLinkIconThemeDark ToolExecutionCompleteContentResourceLinkIconTheme = "dark" + SystemMessageRoleDeveloper SystemMessageRole = "developer" + SystemMessageRoleSystem SystemMessageRole = "system" ) -// Tool call type: "function" for standard tool calls, "custom" for grammar-based tool calls. Defaults to "function" when absent. -type AssistantMessageToolRequestType string +// Whether the agent completed successfully or failed +type SystemNotificationAgentCompletedStatus string const ( - AssistantMessageToolRequestTypeFunction AssistantMessageToolRequestType = "function" - AssistantMessageToolRequestTypeCustom AssistantMessageToolRequestType = "custom" + SystemNotificationAgentCompletedStatusCompleted SystemNotificationAgentCompletedStatus = "completed" + SystemNotificationAgentCompletedStatusFailed SystemNotificationAgentCompletedStatus = "failed" ) // Type discriminator for SystemNotification. @@ -2617,84 +2731,80 @@ type SystemNotificationType string const ( SystemNotificationTypeAgentCompleted SystemNotificationType = "agent_completed" SystemNotificationTypeAgentIdle SystemNotificationType = "agent_idle" + SystemNotificationTypeInstructionDiscovered SystemNotificationType = "instruction_discovered" SystemNotificationTypeNewInboxMessage SystemNotificationType = "new_inbox_message" SystemNotificationTypeShellCompleted SystemNotificationType = "shell_completed" SystemNotificationTypeShellDetachedCompleted SystemNotificationType = "shell_detached_completed" - SystemNotificationTypeInstructionDiscovered SystemNotificationType = "instruction_discovered" +) + +// Theme variant this icon is intended for +type ToolExecutionCompleteContentResourceLinkIconTheme string + +const ( + ToolExecutionCompleteContentResourceLinkIconThemeDark ToolExecutionCompleteContentResourceLinkIconTheme = "dark" + ToolExecutionCompleteContentResourceLinkIconThemeLight ToolExecutionCompleteContentResourceLinkIconTheme = "light" ) // Type discriminator for ToolExecutionCompleteContent. type ToolExecutionCompleteContentType string const ( - ToolExecutionCompleteContentTypeText ToolExecutionCompleteContentType = "text" - ToolExecutionCompleteContentTypeTerminal ToolExecutionCompleteContentType = "terminal" - ToolExecutionCompleteContentTypeImage ToolExecutionCompleteContentType = "image" ToolExecutionCompleteContentTypeAudio ToolExecutionCompleteContentType = "audio" - ToolExecutionCompleteContentTypeResourceLink ToolExecutionCompleteContentType = "resource_link" + ToolExecutionCompleteContentTypeImage ToolExecutionCompleteContentType = "image" ToolExecutionCompleteContentTypeResource ToolExecutionCompleteContentType = "resource" + ToolExecutionCompleteContentTypeResourceLink ToolExecutionCompleteContentType = "resource_link" + ToolExecutionCompleteContentTypeTerminal ToolExecutionCompleteContentType = "terminal" + ToolExecutionCompleteContentTypeText ToolExecutionCompleteContentType = "text" ) -// Type discriminator for UserMessageAttachment. -type UserMessageAttachmentType string +// The agent mode that was active when this message was sent +type UserMessageAgentMode string const ( - UserMessageAttachmentTypeFile UserMessageAttachmentType = "file" - UserMessageAttachmentTypeDirectory UserMessageAttachmentType = "directory" - UserMessageAttachmentTypeSelection UserMessageAttachmentType = "selection" - UserMessageAttachmentTypeGithubReference UserMessageAttachmentType = "github_reference" - UserMessageAttachmentTypeBlob UserMessageAttachmentType = "blob" + UserMessageAgentModeAutopilot UserMessageAgentMode = "autopilot" + UserMessageAgentModeInteractive UserMessageAgentMode = "interactive" + UserMessageAgentModePlan UserMessageAgentMode = "plan" + UserMessageAgentModeShell UserMessageAgentMode = "shell" ) // Type of GitHub reference type UserMessageAttachmentGithubReferenceType string const ( + UserMessageAttachmentGithubReferenceTypeDiscussion UserMessageAttachmentGithubReferenceType = "discussion" UserMessageAttachmentGithubReferenceTypeIssue UserMessageAttachmentGithubReferenceType = "issue" UserMessageAttachmentGithubReferenceTypePr UserMessageAttachmentGithubReferenceType = "pr" - UserMessageAttachmentGithubReferenceTypeDiscussion UserMessageAttachmentGithubReferenceType = "discussion" -) - -// Underlying permission kind that needs path approval -type PermissionPromptRequestPathAccessKind string - -const ( - PermissionPromptRequestPathAccessKindRead PermissionPromptRequestPathAccessKind = "read" - PermissionPromptRequestPathAccessKindShell PermissionPromptRequestPathAccessKind = "shell" - PermissionPromptRequestPathAccessKindWrite PermissionPromptRequestPathAccessKind = "write" -) - -// Vote direction (vote only) -type PermissionPromptRequestMemoryDirection string - -const ( - PermissionPromptRequestMemoryDirectionUpvote PermissionPromptRequestMemoryDirection = "upvote" - PermissionPromptRequestMemoryDirectionDownvote PermissionPromptRequestMemoryDirection = "downvote" ) -// Vote direction (vote only) -type PermissionRequestMemoryDirection string +// Type discriminator for UserMessageAttachment. +type UserMessageAttachmentType string const ( - PermissionRequestMemoryDirectionUpvote PermissionRequestMemoryDirection = "upvote" - PermissionRequestMemoryDirectionDownvote PermissionRequestMemoryDirection = "downvote" + UserMessageAttachmentTypeBlob UserMessageAttachmentType = "blob" + UserMessageAttachmentTypeDirectory UserMessageAttachmentType = "directory" + UserMessageAttachmentTypeFile UserMessageAttachmentType = "file" + UserMessageAttachmentTypeGithubReference UserMessageAttachmentType = "github_reference" + UserMessageAttachmentTypeSelection UserMessageAttachmentType = "selection" ) -// Where the failed model call originated -type ModelCallFailureSource string +// Kind discriminator for UserToolSessionApproval. +type UserToolSessionApprovalKind string const ( - ModelCallFailureSourceTopLevel ModelCallFailureSource = "top_level" - ModelCallFailureSourceSubagent ModelCallFailureSource = "subagent" - ModelCallFailureSourceMcpSampling ModelCallFailureSource = "mcp_sampling" + UserToolSessionApprovalKindCommands UserToolSessionApprovalKind = "commands" + UserToolSessionApprovalKindCustomTool UserToolSessionApprovalKind = "custom-tool" + UserToolSessionApprovalKindMcp UserToolSessionApprovalKind = "mcp" + UserToolSessionApprovalKindMemory UserToolSessionApprovalKind = "memory" + UserToolSessionApprovalKindRead UserToolSessionApprovalKind = "read" + UserToolSessionApprovalKindWrite UserToolSessionApprovalKind = "write" ) -// Whether the agent completed successfully or failed -type SystemNotificationAgentCompletedStatus string +// Hosting platform type of the repository (github or ado) +type WorkingDirectoryContextHostType string const ( - SystemNotificationAgentCompletedStatusCompleted SystemNotificationAgentCompletedStatus = "completed" - SystemNotificationAgentCompletedStatusFailed SystemNotificationAgentCompletedStatus = "failed" + WorkingDirectoryContextHostTypeAdo WorkingDirectoryContextHostType = "ado" + WorkingDirectoryContextHostTypeGithub WorkingDirectoryContextHostType = "github" ) // Whether the file was newly created or updated @@ -2705,43 +2815,19 @@ const ( WorkspaceFileChangedOperationUpdate WorkspaceFileChangedOperation = "update" ) -// Whether the session ended normally ("routine") or due to a crash/fatal error ("error") -type ShutdownType string - -const ( - ShutdownTypeRoutine ShutdownType = "routine" - ShutdownTypeError ShutdownType = "error" -) - -// Whether this is a store or vote memory operation -type PermissionPromptRequestMemoryAction string - -const ( - PermissionPromptRequestMemoryActionStore PermissionPromptRequestMemoryAction = "store" - PermissionPromptRequestMemoryActionVote PermissionPromptRequestMemoryAction = "vote" -) - -// Whether this is a store or vote memory operation -type PermissionRequestMemoryAction string - -const ( - PermissionRequestMemoryActionStore PermissionRequestMemoryAction = "store" - PermissionRequestMemoryActionVote PermissionRequestMemoryAction = "vote" -) - // Type aliases for convenience. type ( - PermissionRequestCommand = PermissionRequestShellCommand - PossibleURL = PermissionRequestShellPossibleURL Attachment = UserMessageAttachment AttachmentType = UserMessageAttachmentType + PermissionRequestCommand = PermissionRequestShellCommand + PossibleURL = PermissionRequestShellPossibleURL ) // Constant aliases for convenience. const ( - AttachmentTypeFile = UserMessageAttachmentTypeFile + AttachmentTypeBlob = UserMessageAttachmentTypeBlob AttachmentTypeDirectory = UserMessageAttachmentTypeDirectory - AttachmentTypeSelection = UserMessageAttachmentTypeSelection + AttachmentTypeFile = UserMessageAttachmentTypeFile AttachmentTypeGithubReference = UserMessageAttachmentTypeGithubReference - AttachmentTypeBlob = UserMessageAttachmentTypeBlob + AttachmentTypeSelection = UserMessageAttachmentTypeSelection ) diff --git a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go index cff80e4ff..9a8ef8ebd 100644 --- a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go +++ b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go @@ -193,11 +193,11 @@ func TestRpcMcpAndSkillsE2E(t *testing.T) { } assertRpcError(t, "Mcp.Enable", func() error { - _, e := session.RPC.Mcp.Enable(t.Context(), &rpc.MCPEnableRequest{ServerName: "missing-server"}) + _, e := session.RPC.Mcp.Enable(t.Context(), &rpc.McpEnableRequest{ServerName: "missing-server"}) return e }, "no mcp host initialized") assertRpcError(t, "Mcp.Disable", func() error { - _, e := session.RPC.Mcp.Disable(t.Context(), &rpc.MCPDisableRequest{ServerName: "missing-server"}) + _, e := session.RPC.Mcp.Disable(t.Context(), &rpc.McpDisableRequest{ServerName: "missing-server"}) return e }, "no mcp host initialized") assertRpcError(t, "Mcp.Reload", func() error { diff --git a/go/internal/e2e/rpc_mcp_config_e2e_test.go b/go/internal/e2e/rpc_mcp_config_e2e_test.go index 187ee3802..34134c68a 100644 --- a/go/internal/e2e/rpc_mcp_config_e2e_test.go +++ b/go/internal/e2e/rpc_mcp_config_e2e_test.go @@ -22,11 +22,11 @@ func TestRpcMcpConfigE2E(t *testing.T) { serverName := fmt.Sprintf("sdk-test-%s", randomHex(t)) nodeCmd := "node" - baseConfig := rpc.MCPServerConfig{ + baseConfig := rpc.McpServerConfig{ Command: &nodeCmd, Args: []string{"-v"}, } - updatedConfig := rpc.MCPServerConfig{ + updatedConfig := rpc.McpServerConfig{ Command: &nodeCmd, Args: []string{"--version"}, } @@ -41,10 +41,10 @@ func TestRpcMcpConfigE2E(t *testing.T) { // Best-effort cleanup if a subtest assertion fails mid-flight. t.Cleanup(func() { - _, _ = client.RPC.Mcp.Config().Remove(t.Context(), &rpc.MCPConfigRemoveRequest{Name: serverName}) + _, _ = client.RPC.Mcp.Config().Remove(t.Context(), &rpc.McpConfigRemoveRequest{Name: serverName}) }) - if _, err := client.RPC.Mcp.Config().Add(t.Context(), &rpc.MCPConfigAddRequest{ + if _, err := client.RPC.Mcp.Config().Add(t.Context(), &rpc.McpConfigAddRequest{ Name: serverName, Config: baseConfig, }); err != nil { @@ -59,7 +59,7 @@ func TestRpcMcpConfigE2E(t *testing.T) { t.Fatalf("Expected %q to be present after Add", serverName) } - if _, err := client.RPC.Mcp.Config().Update(t.Context(), &rpc.MCPConfigUpdateRequest{ + if _, err := client.RPC.Mcp.Config().Update(t.Context(), &rpc.McpConfigUpdateRequest{ Name: serverName, Config: updatedConfig, }); err != nil { @@ -81,14 +81,14 @@ func TestRpcMcpConfigE2E(t *testing.T) { t.Errorf("Expected args[0]='--version', got %v", updated.Args) } - if _, err := client.RPC.Mcp.Config().Disable(t.Context(), &rpc.MCPConfigDisableRequest{Names: []string{serverName}}); err != nil { + if _, err := client.RPC.Mcp.Config().Disable(t.Context(), &rpc.McpConfigDisableRequest{Names: []string{serverName}}); err != nil { t.Fatalf("Mcp.Config.Disable failed: %v", err) } - if _, err := client.RPC.Mcp.Config().Enable(t.Context(), &rpc.MCPConfigEnableRequest{Names: []string{serverName}}); err != nil { + if _, err := client.RPC.Mcp.Config().Enable(t.Context(), &rpc.McpConfigEnableRequest{Names: []string{serverName}}); err != nil { t.Fatalf("Mcp.Config.Enable failed: %v", err) } - if _, err := client.RPC.Mcp.Config().Remove(t.Context(), &rpc.MCPConfigRemoveRequest{Name: serverName}); err != nil { + if _, err := client.RPC.Mcp.Config().Remove(t.Context(), &rpc.McpConfigRemoveRequest{Name: serverName}); err != nil { t.Fatalf("Mcp.Config.Remove failed: %v", err) } @@ -111,19 +111,19 @@ func TestRpcMcpConfigE2E(t *testing.T) { serverName := fmt.Sprintf("sdk-http-oauth-%s", randomHex(t)) - httpType := rpc.MCPServerConfigTypeHTTP + httpType := rpc.McpServerConfigTypeHTTP urlBase := "https://example.com/mcp" urlUpdated := "https://example.com/updated-mcp" clientID := "client-id" clientIDUpdated := "updated-client-id" - grantClientCreds := rpc.MCPServerConfigHTTPOauthGrantTypeClientCredentials - grantAuthCode := rpc.MCPServerConfigHTTPOauthGrantTypeAuthorizationCode + grantClientCreds := rpc.McpServerConfigHTTPOauthGrantTypeClientCredentials + grantAuthCode := rpc.McpServerConfigHTTPOauthGrantTypeAuthorizationCode var publicFalse = false var publicTrue = true var timeoutBase int64 = 3000 var timeoutUpdated int64 = 4000 - baseConfig := rpc.MCPServerConfig{ + baseConfig := rpc.McpServerConfig{ Type: &httpType, URL: &urlBase, Headers: map[string]string{"Authorization": "Bearer token"}, @@ -133,7 +133,7 @@ func TestRpcMcpConfigE2E(t *testing.T) { Tools: []string{"*"}, Timeout: &timeoutBase, } - updatedConfig := rpc.MCPServerConfig{ + updatedConfig := rpc.McpServerConfig{ Type: &httpType, URL: &urlUpdated, OauthClientID: &clientIDUpdated, @@ -144,10 +144,10 @@ func TestRpcMcpConfigE2E(t *testing.T) { } t.Cleanup(func() { - _, _ = client.RPC.Mcp.Config().Remove(t.Context(), &rpc.MCPConfigRemoveRequest{Name: serverName}) + _, _ = client.RPC.Mcp.Config().Remove(t.Context(), &rpc.McpConfigRemoveRequest{Name: serverName}) }) - if _, err := client.RPC.Mcp.Config().Add(t.Context(), &rpc.MCPConfigAddRequest{ + if _, err := client.RPC.Mcp.Config().Add(t.Context(), &rpc.McpConfigAddRequest{ Name: serverName, Config: baseConfig, }); err != nil { @@ -181,7 +181,7 @@ func TestRpcMcpConfigE2E(t *testing.T) { t.Errorf("Expected oauthGrantType='client_credentials', got %v", added.OauthGrantType) } - if _, err := client.RPC.Mcp.Config().Update(t.Context(), &rpc.MCPConfigUpdateRequest{ + if _, err := client.RPC.Mcp.Config().Update(t.Context(), &rpc.McpConfigUpdateRequest{ Name: serverName, Config: updatedConfig, }); err != nil { @@ -214,7 +214,7 @@ func TestRpcMcpConfigE2E(t *testing.T) { t.Errorf("Expected timeout=4000, got %v", updated.Timeout) } - if _, err := client.RPC.Mcp.Config().Remove(t.Context(), &rpc.MCPConfigRemoveRequest{Name: serverName}); err != nil { + if _, err := client.RPC.Mcp.Config().Remove(t.Context(), &rpc.McpConfigRemoveRequest{Name: serverName}); err != nil { t.Fatalf("Mcp.Config.Remove failed: %v", err) } diff --git a/go/internal/e2e/rpc_server_e2e_test.go b/go/internal/e2e/rpc_server_e2e_test.go index 1a22627ac..35a71e24e 100644 --- a/go/internal/e2e/rpc_server_e2e_test.go +++ b/go/internal/e2e/rpc_server_e2e_test.go @@ -160,7 +160,7 @@ func TestRpcServerE2E(t *testing.T) { skillsDir := createMcpSkillsRpcDirectory(t, ctx.WorkDir, "server-rpc-skills", skillName, "Skill discovered by server-scoped RPC tests.") workingDir := ctx.WorkDir - mcp, err := client.RPC.Mcp.Discover(t.Context(), &rpc.MCPDiscoverRequest{WorkingDirectory: &workingDir}) + mcp, err := client.RPC.Mcp.Discover(t.Context(), &rpc.McpDiscoverRequest{WorkingDirectory: &workingDir}) if err != nil { t.Fatalf("Mcp.Discover failed: %v", err) } diff --git a/go/internal/e2e/rpc_session_state_e2e_test.go b/go/internal/e2e/rpc_session_state_e2e_test.go index 885deb805..e9cf1110f 100644 --- a/go/internal/e2e/rpc_session_state_e2e_test.go +++ b/go/internal/e2e/rpc_session_state_e2e_test.go @@ -500,7 +500,7 @@ func TestRpcSessionStateE2E(t *testing.T) { t.Errorf("session.history.truncate should be implemented; error suggests it isn't: %v", err) } - _, err = session.RPC.Mcp.Oauth().Login(t.Context(), &rpc.MCPOauthLoginRequest{ServerName: "missing-server"}) + _, err = session.RPC.Mcp.Oauth().Login(t.Context(), &rpc.McpOauthLoginRequest{ServerName: "missing-server"}) if err == nil { t.Fatal("Expected Mcp.Oauth.Login with unknown server to fail") } diff --git a/go/internal/e2e/session_fs_e2e_test.go b/go/internal/e2e/session_fs_e2e_test.go index ffa1db98f..d56dc14a3 100644 --- a/go/internal/e2e/session_fs_e2e_test.go +++ b/go/internal/e2e/session_fs_e2e_test.go @@ -22,7 +22,7 @@ func TestSessionFsE2E(t *testing.T) { sessionFsConfig := &copilot.SessionFsConfig{ InitialCwd: "/", SessionStatePath: sessionStatePath, - Conventions: rpc.SessionFSSetProviderConventionsPosix, + Conventions: rpc.SessionFsSetProviderConventionsPosix, } createSessionFsHandler := func(session *copilot.Session) copilot.SessionFsProvider { return &testSessionFsHandler{ @@ -163,11 +163,11 @@ func TestSessionFsE2E(t *testing.T) { t.Cleanup(func() { client2.ForceStop() }) if err := client2.Start(t.Context()); err == nil { - t.Fatal("Expected Start to fail when sessionFs provider is set after sessions already exist") + t.Fatal("Expected Start to fail when SessionFs provider is set after sessions already exist") } }) - t.Run("should map large output handling into sessionFs", func(t *testing.T) { + t.Run("should map large output handling into SessionFs", func(t *testing.T) { ctx.ConfigureForTest(t) suppliedFileContent := strings.Repeat("x", 100_000) @@ -213,7 +213,7 @@ func TestSessionFsE2E(t *testing.T) { } }) - t.Run("should succeed with compaction while using sessionFs", func(t *testing.T) { + t.Run("should succeed with compaction while using SessionFs", func(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -252,7 +252,7 @@ func TestSessionFsE2E(t *testing.T) { t.Fatalf("Timed out waiting for checkpoint rewrite: %v", err) } }) - t.Run("should write workspace metadata via sessionFs", func(t *testing.T) { + t.Run("should write workspace metadata via SessionFs", func(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -277,17 +277,10 @@ func TestSessionFsE2E(t *testing.T) { t.Fatalf("Expected response to contain 56, got %q", content) } - // WorkspaceManager should have created workspace.yaml via sessionFs + // WorkspaceManager should have created workspace.yaml via SessionFs workspaceYamlPath := p(session.SessionID, sessionStatePath+"/workspace.yaml") - if err := waitForFile(workspaceYamlPath, 5*time.Second); err != nil { - t.Fatalf("Timed out waiting for workspace.yaml: %v", err) - } - yaml, err := os.ReadFile(workspaceYamlPath) - if err != nil { - t.Fatalf("Failed to read workspace.yaml: %v", err) - } - if !strings.Contains(string(yaml), "id:") { - t.Fatalf("Expected workspace.yaml to contain 'id:', got %q", string(yaml)) + if err := waitForFileContent(workspaceYamlPath, "id:", 5*time.Second); err != nil { + t.Fatalf("Timed out waiting for workspace.yaml content: %v", err) } // Checkpoint index should also exist @@ -301,7 +294,7 @@ func TestSessionFsE2E(t *testing.T) { } }) - t.Run("should persist plan.md via sessionFs", func(t *testing.T) { + t.Run("should persist plan.md via SessionFs", func(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -439,18 +432,18 @@ func (h *testSessionFsHandler) Readdir(path string) ([]string, error) { return names, nil } -func (h *testSessionFsHandler) ReaddirWithTypes(path string) ([]rpc.SessionFSReaddirWithTypesEntry, error) { +func (h *testSessionFsHandler) ReaddirWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) { entries, err := os.ReadDir(providerPath(h.root, h.sessionID, path)) if err != nil { return nil, err } - result := make([]rpc.SessionFSReaddirWithTypesEntry, 0, len(entries)) + result := make([]rpc.SessionFsReaddirWithTypesEntry, 0, len(entries)) for _, entry := range entries { - entryType := rpc.SessionFSReaddirWithTypesEntryTypeFile + entryType := rpc.SessionFsReaddirWithTypesEntryTypeFile if entry.IsDir() { - entryType = rpc.SessionFSReaddirWithTypesEntryTypeDirectory + entryType = rpc.SessionFsReaddirWithTypesEntryTypeDirectory } - result = append(result, rpc.SessionFSReaddirWithTypesEntry{ + result = append(result, rpc.SessionFsReaddirWithTypesEntry{ Name: entry.Name(), Type: entryType, }) @@ -596,7 +589,7 @@ func TestSessionFsHandlerOperationsE2E(t *testing.T) { } var found bool for _, entry := range typedEntries { - if entry.Name == "file.txt" && entry.Type == rpc.SessionFSReaddirWithTypesEntryTypeFile { + if entry.Name == "file.txt" && entry.Type == rpc.SessionFsReaddirWithTypesEntryTypeFile { found = true break } diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index dce096c3d..87f323814 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -12,263 +12,6 @@ import ( "time" ) -type RPCTypes struct { - AccountGetQuotaRequest AccountGetQuotaRequest `json:"AccountGetQuotaRequest"` - AccountGetQuotaResult AccountGetQuotaResult `json:"AccountGetQuotaResult"` - AccountQuotaSnapshot AccountQuotaSnapshot `json:"AccountQuotaSnapshot"` - AgentDeselectResult AgentDeselectResult `json:"AgentDeselectResult"` - AgentGetCurrentResult AgentGetCurrentResult `json:"AgentGetCurrentResult"` - AgentInfo AgentInfo `json:"AgentInfo"` - AgentList AgentList `json:"AgentList"` - AgentReloadResult AgentReloadResult `json:"AgentReloadResult"` - AgentSelectRequest AgentSelectRequest `json:"AgentSelectRequest"` - AgentSelectResult AgentSelectResult `json:"AgentSelectResult"` - AuthInfoType AuthInfoType `json:"AuthInfoType"` - CommandsHandlePendingCommandRequest CommandsHandlePendingCommandRequest `json:"CommandsHandlePendingCommandRequest"` - CommandsHandlePendingCommandResult CommandsHandlePendingCommandResult `json:"CommandsHandlePendingCommandResult"` - ConnectRequest ConnectRequest `json:"ConnectRequest"` - ConnectResult ConnectResult `json:"ConnectResult"` - CurrentModel CurrentModel `json:"CurrentModel"` - DiscoveredMCPServer DiscoveredMCPServer `json:"DiscoveredMcpServer"` - DiscoveredMCPServerSource MCPServerSource `json:"DiscoveredMcpServerSource"` - DiscoveredMCPServerType DiscoveredMCPServerType `json:"DiscoveredMcpServerType"` - EmbeddedBlobResourceContents EmbeddedBlobResourceContents `json:"EmbeddedBlobResourceContents"` - EmbeddedTextResourceContents EmbeddedTextResourceContents `json:"EmbeddedTextResourceContents"` - Extension Extension `json:"Extension"` - ExtensionList ExtensionList `json:"ExtensionList"` - ExtensionsDisableRequest ExtensionsDisableRequest `json:"ExtensionsDisableRequest"` - ExtensionsDisableResult ExtensionsDisableResult `json:"ExtensionsDisableResult"` - ExtensionsEnableRequest ExtensionsEnableRequest `json:"ExtensionsEnableRequest"` - ExtensionsEnableResult ExtensionsEnableResult `json:"ExtensionsEnableResult"` - ExtensionSource ExtensionSource `json:"ExtensionSource"` - ExtensionsReloadResult ExtensionsReloadResult `json:"ExtensionsReloadResult"` - ExtensionStatus ExtensionStatus `json:"ExtensionStatus"` - ExternalToolResult *ExternalToolResult `json:"ExternalToolResult"` - ExternalToolTextResultForLlm ExternalToolTextResultForLlm `json:"ExternalToolTextResultForLlm"` - ExternalToolTextResultForLlmContent ExternalToolTextResultForLlmContent `json:"ExternalToolTextResultForLlmContent"` - ExternalToolTextResultForLlmContentAudio ExternalToolTextResultForLlmContentAudio `json:"ExternalToolTextResultForLlmContentAudio"` - ExternalToolTextResultForLlmContentImage ExternalToolTextResultForLlmContentImage `json:"ExternalToolTextResultForLlmContentImage"` - ExternalToolTextResultForLlmContentResource ExternalToolTextResultForLlmContentResource `json:"ExternalToolTextResultForLlmContentResource"` - ExternalToolTextResultForLlmContentResourceDetails ExternalToolTextResultForLlmContentResourceDetails `json:"ExternalToolTextResultForLlmContentResourceDetails"` - ExternalToolTextResultForLlmContentResourceLink ExternalToolTextResultForLlmContentResourceLink `json:"ExternalToolTextResultForLlmContentResourceLink"` - ExternalToolTextResultForLlmContentResourceLinkIcon ExternalToolTextResultForLlmContentResourceLinkIcon `json:"ExternalToolTextResultForLlmContentResourceLinkIcon"` - ExternalToolTextResultForLlmContentResourceLinkIconTheme ExternalToolTextResultForLlmContentResourceLinkIconTheme `json:"ExternalToolTextResultForLlmContentResourceLinkIconTheme"` - ExternalToolTextResultForLlmContentTerminal ExternalToolTextResultForLlmContentTerminal `json:"ExternalToolTextResultForLlmContentTerminal"` - ExternalToolTextResultForLlmContentText ExternalToolTextResultForLlmContentText `json:"ExternalToolTextResultForLlmContentText"` - FilterMapping *FilterMapping `json:"FilterMapping"` - FilterMappingString FilterMappingString `json:"FilterMappingString"` - FilterMappingValue FilterMappingString `json:"FilterMappingValue"` - FleetStartRequest FleetStartRequest `json:"FleetStartRequest"` - FleetStartResult FleetStartResult `json:"FleetStartResult"` - HandlePendingToolCallRequest HandlePendingToolCallRequest `json:"HandlePendingToolCallRequest"` - HandlePendingToolCallResult HandlePendingToolCallResult `json:"HandlePendingToolCallResult"` - HistoryCompactContextWindow HistoryCompactContextWindow `json:"HistoryCompactContextWindow"` - HistoryCompactResult HistoryCompactResult `json:"HistoryCompactResult"` - HistoryTruncateRequest HistoryTruncateRequest `json:"HistoryTruncateRequest"` - HistoryTruncateResult HistoryTruncateResult `json:"HistoryTruncateResult"` - InstructionsGetSourcesResult InstructionsGetSourcesResult `json:"InstructionsGetSourcesResult"` - InstructionsSources InstructionsSources `json:"InstructionsSources"` - InstructionsSourcesLocation InstructionsSourcesLocation `json:"InstructionsSourcesLocation"` - InstructionsSourcesType InstructionsSourcesType `json:"InstructionsSourcesType"` - LogRequest LogRequest `json:"LogRequest"` - LogResult LogResult `json:"LogResult"` - MCPConfigAddRequest MCPConfigAddRequest `json:"McpConfigAddRequest"` - MCPConfigAddResult MCPConfigAddResult `json:"McpConfigAddResult"` - MCPConfigDisableRequest MCPConfigDisableRequest `json:"McpConfigDisableRequest"` - MCPConfigDisableResult MCPConfigDisableResult `json:"McpConfigDisableResult"` - MCPConfigEnableRequest MCPConfigEnableRequest `json:"McpConfigEnableRequest"` - MCPConfigEnableResult MCPConfigEnableResult `json:"McpConfigEnableResult"` - MCPConfigList MCPConfigList `json:"McpConfigList"` - MCPConfigRemoveRequest MCPConfigRemoveRequest `json:"McpConfigRemoveRequest"` - MCPConfigRemoveResult MCPConfigRemoveResult `json:"McpConfigRemoveResult"` - MCPConfigUpdateRequest MCPConfigUpdateRequest `json:"McpConfigUpdateRequest"` - MCPConfigUpdateResult MCPConfigUpdateResult `json:"McpConfigUpdateResult"` - MCPDisableRequest MCPDisableRequest `json:"McpDisableRequest"` - MCPDisableResult MCPDisableResult `json:"McpDisableResult"` - MCPDiscoverRequest MCPDiscoverRequest `json:"McpDiscoverRequest"` - MCPDiscoverResult MCPDiscoverResult `json:"McpDiscoverResult"` - MCPEnableRequest MCPEnableRequest `json:"McpEnableRequest"` - MCPEnableResult MCPEnableResult `json:"McpEnableResult"` - MCPOauthLoginRequest MCPOauthLoginRequest `json:"McpOauthLoginRequest"` - MCPOauthLoginResult MCPOauthLoginResult `json:"McpOauthLoginResult"` - MCPReloadResult MCPReloadResult `json:"McpReloadResult"` - MCPServer MCPServer `json:"McpServer"` - MCPServerConfig MCPServerConfig `json:"McpServerConfig"` - MCPServerConfigHTTP MCPServerConfigHTTP `json:"McpServerConfigHttp"` - MCPServerConfigHTTPOauthGrantType MCPServerConfigHTTPOauthGrantType `json:"McpServerConfigHttpOauthGrantType"` - MCPServerConfigHTTPType MCPServerConfigHTTPType `json:"McpServerConfigHttpType"` - MCPServerConfigLocal MCPServerConfigLocal `json:"McpServerConfigLocal"` - MCPServerConfigLocalType MCPServerConfigLocalType `json:"McpServerConfigLocalType"` - MCPServerList MCPServerList `json:"McpServerList"` - MCPServerSource MCPServerSource `json:"McpServerSource"` - MCPServerStatus MCPServerStatus `json:"McpServerStatus"` - Model ModelElement `json:"Model"` - ModelBilling ModelBilling `json:"ModelBilling"` - ModelCapabilities ModelCapabilities `json:"ModelCapabilities"` - ModelCapabilitiesLimits ModelCapabilitiesLimits `json:"ModelCapabilitiesLimits"` - ModelCapabilitiesLimitsVision ModelCapabilitiesLimitsVision `json:"ModelCapabilitiesLimitsVision"` - ModelCapabilitiesOverride ModelCapabilitiesOverride `json:"ModelCapabilitiesOverride"` - ModelCapabilitiesOverrideLimits ModelCapabilitiesOverrideLimits `json:"ModelCapabilitiesOverrideLimits"` - ModelCapabilitiesOverrideLimitsVision ModelCapabilitiesOverrideLimitsVision `json:"ModelCapabilitiesOverrideLimitsVision"` - ModelCapabilitiesOverrideSupports ModelCapabilitiesOverrideSupports `json:"ModelCapabilitiesOverrideSupports"` - ModelCapabilitiesSupports ModelCapabilitiesSupports `json:"ModelCapabilitiesSupports"` - ModelList ModelList `json:"ModelList"` - ModelPolicy ModelPolicy `json:"ModelPolicy"` - ModelsListRequest ModelsListRequest `json:"ModelsListRequest"` - ModelSwitchToRequest ModelSwitchToRequest `json:"ModelSwitchToRequest"` - ModelSwitchToResult ModelSwitchToResult `json:"ModelSwitchToResult"` - ModeSetRequest ModeSetRequest `json:"ModeSetRequest"` - ModeSetResult ModeSetResult `json:"ModeSetResult"` - NameGetResult NameGetResult `json:"NameGetResult"` - NameSetRequest NameSetRequest `json:"NameSetRequest"` - NameSetResult NameSetResult `json:"NameSetResult"` - PermissionDecision PermissionDecision `json:"PermissionDecision"` - PermissionDecisionApproveForLocation PermissionDecisionApproveForLocation `json:"PermissionDecisionApproveForLocation"` - PermissionDecisionApproveForLocationApproval PermissionDecisionApproveForLocationApproval `json:"PermissionDecisionApproveForLocationApproval"` - PermissionDecisionApproveForLocationApprovalCommands PermissionDecisionApproveForLocationApprovalCommands `json:"PermissionDecisionApproveForLocationApprovalCommands"` - PermissionDecisionApproveForLocationApprovalCustomTool PermissionDecisionApproveForLocationApprovalCustomTool `json:"PermissionDecisionApproveForLocationApprovalCustomTool"` - PermissionDecisionApproveForLocationApprovalExtensionManagement PermissionDecisionApproveForLocationApprovalExtensionManagement `json:"PermissionDecisionApproveForLocationApprovalExtensionManagement"` - PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess `json:"PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess"` - PermissionDecisionApproveForLocationApprovalMCP PermissionDecisionApproveForLocationApprovalMCP `json:"PermissionDecisionApproveForLocationApprovalMcp"` - PermissionDecisionApproveForLocationApprovalMCPSampling PermissionDecisionApproveForLocationApprovalMCPSampling `json:"PermissionDecisionApproveForLocationApprovalMcpSampling"` - PermissionDecisionApproveForLocationApprovalMemory PermissionDecisionApproveForLocationApprovalMemory `json:"PermissionDecisionApproveForLocationApprovalMemory"` - PermissionDecisionApproveForLocationApprovalRead PermissionDecisionApproveForLocationApprovalRead `json:"PermissionDecisionApproveForLocationApprovalRead"` - PermissionDecisionApproveForLocationApprovalWrite PermissionDecisionApproveForLocationApprovalWrite `json:"PermissionDecisionApproveForLocationApprovalWrite"` - PermissionDecisionApproveForSession PermissionDecisionApproveForSession `json:"PermissionDecisionApproveForSession"` - PermissionDecisionApproveForSessionApproval PermissionDecisionApproveForSessionApproval `json:"PermissionDecisionApproveForSessionApproval"` - PermissionDecisionApproveForSessionApprovalCommands PermissionDecisionApproveForSessionApprovalCommands `json:"PermissionDecisionApproveForSessionApprovalCommands"` - PermissionDecisionApproveForSessionApprovalCustomTool PermissionDecisionApproveForSessionApprovalCustomTool `json:"PermissionDecisionApproveForSessionApprovalCustomTool"` - PermissionDecisionApproveForSessionApprovalExtensionManagement PermissionDecisionApproveForSessionApprovalExtensionManagement `json:"PermissionDecisionApproveForSessionApprovalExtensionManagement"` - PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess `json:"PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess"` - PermissionDecisionApproveForSessionApprovalMCP PermissionDecisionApproveForSessionApprovalMCP `json:"PermissionDecisionApproveForSessionApprovalMcp"` - PermissionDecisionApproveForSessionApprovalMCPSampling PermissionDecisionApproveForSessionApprovalMCPSampling `json:"PermissionDecisionApproveForSessionApprovalMcpSampling"` - PermissionDecisionApproveForSessionApprovalMemory PermissionDecisionApproveForSessionApprovalMemory `json:"PermissionDecisionApproveForSessionApprovalMemory"` - PermissionDecisionApproveForSessionApprovalRead PermissionDecisionApproveForSessionApprovalRead `json:"PermissionDecisionApproveForSessionApprovalRead"` - PermissionDecisionApproveForSessionApprovalWrite PermissionDecisionApproveForSessionApprovalWrite `json:"PermissionDecisionApproveForSessionApprovalWrite"` - PermissionDecisionApproveOnce PermissionDecisionApproveOnce `json:"PermissionDecisionApproveOnce"` - PermissionDecisionApprovePermanently PermissionDecisionApprovePermanently `json:"PermissionDecisionApprovePermanently"` - PermissionDecisionReject PermissionDecisionReject `json:"PermissionDecisionReject"` - PermissionDecisionRequest PermissionDecisionRequest `json:"PermissionDecisionRequest"` - PermissionDecisionUserNotAvailable PermissionDecisionUserNotAvailable `json:"PermissionDecisionUserNotAvailable"` - PermissionRequestResult PermissionRequestResult `json:"PermissionRequestResult"` - PermissionsResetSessionApprovalsRequest PermissionsResetSessionApprovalsRequest `json:"PermissionsResetSessionApprovalsRequest"` - PermissionsResetSessionApprovalsResult PermissionsResetSessionApprovalsResult `json:"PermissionsResetSessionApprovalsResult"` - PermissionsSetApproveAllRequest PermissionsSetApproveAllRequest `json:"PermissionsSetApproveAllRequest"` - PermissionsSetApproveAllResult PermissionsSetApproveAllResult `json:"PermissionsSetApproveAllResult"` - PingRequest PingRequest `json:"PingRequest"` - PingResult PingResult `json:"PingResult"` - PlanDeleteResult PlanDeleteResult `json:"PlanDeleteResult"` - PlanReadResult PlanReadResult `json:"PlanReadResult"` - PlanUpdateRequest PlanUpdateRequest `json:"PlanUpdateRequest"` - PlanUpdateResult PlanUpdateResult `json:"PlanUpdateResult"` - Plugin PluginElement `json:"Plugin"` - PluginList PluginList `json:"PluginList"` - RemoteDisableResult RemoteDisableResult `json:"RemoteDisableResult"` - RemoteEnableResult RemoteEnableResult `json:"RemoteEnableResult"` - ServerSkill ServerSkill `json:"ServerSkill"` - ServerSkillList ServerSkillList `json:"ServerSkillList"` - SessionAuthStatus SessionAuthStatus `json:"SessionAuthStatus"` - SessionFSAppendFileRequest SessionFSAppendFileRequest `json:"SessionFsAppendFileRequest"` - SessionFSError SessionFSError `json:"SessionFsError"` - SessionFSErrorCode SessionFSErrorCode `json:"SessionFsErrorCode"` - SessionFSExistsRequest SessionFSExistsRequest `json:"SessionFsExistsRequest"` - SessionFSExistsResult SessionFSExistsResult `json:"SessionFsExistsResult"` - SessionFSMkdirRequest SessionFSMkdirRequest `json:"SessionFsMkdirRequest"` - SessionFSReaddirRequest SessionFSReaddirRequest `json:"SessionFsReaddirRequest"` - SessionFSReaddirResult SessionFSReaddirResult `json:"SessionFsReaddirResult"` - SessionFSReaddirWithTypesEntry SessionFSReaddirWithTypesEntry `json:"SessionFsReaddirWithTypesEntry"` - SessionFSReaddirWithTypesEntryType SessionFSReaddirWithTypesEntryType `json:"SessionFsReaddirWithTypesEntryType"` - SessionFSReaddirWithTypesRequest SessionFSReaddirWithTypesRequest `json:"SessionFsReaddirWithTypesRequest"` - SessionFSReaddirWithTypesResult SessionFSReaddirWithTypesResult `json:"SessionFsReaddirWithTypesResult"` - SessionFSReadFileRequest SessionFSReadFileRequest `json:"SessionFsReadFileRequest"` - SessionFSReadFileResult SessionFSReadFileResult `json:"SessionFsReadFileResult"` - SessionFSRenameRequest SessionFSRenameRequest `json:"SessionFsRenameRequest"` - SessionFSRmRequest SessionFSRmRequest `json:"SessionFsRmRequest"` - SessionFSSetProviderConventions SessionFSSetProviderConventions `json:"SessionFsSetProviderConventions"` - SessionFSSetProviderRequest SessionFSSetProviderRequest `json:"SessionFsSetProviderRequest"` - SessionFSSetProviderResult SessionFSSetProviderResult `json:"SessionFsSetProviderResult"` - SessionFSStatRequest SessionFSStatRequest `json:"SessionFsStatRequest"` - SessionFSStatResult SessionFSStatResult `json:"SessionFsStatResult"` - SessionFSWriteFileRequest SessionFSWriteFileRequest `json:"SessionFsWriteFileRequest"` - SessionLogLevel SessionLogLevel `json:"SessionLogLevel"` - SessionMode SessionMode `json:"SessionMode"` - SessionsForkRequest SessionsForkRequest `json:"SessionsForkRequest"` - SessionsForkResult SessionsForkResult `json:"SessionsForkResult"` - ShellExecRequest ShellExecRequest `json:"ShellExecRequest"` - ShellExecResult ShellExecResult `json:"ShellExecResult"` - ShellKillRequest ShellKillRequest `json:"ShellKillRequest"` - ShellKillResult ShellKillResult `json:"ShellKillResult"` - ShellKillSignal ShellKillSignal `json:"ShellKillSignal"` - Skill Skill `json:"Skill"` - SkillList SkillList `json:"SkillList"` - SkillsConfigSetDisabledSkillsRequest SkillsConfigSetDisabledSkillsRequest `json:"SkillsConfigSetDisabledSkillsRequest"` - SkillsConfigSetDisabledSkillsResult SkillsConfigSetDisabledSkillsResult `json:"SkillsConfigSetDisabledSkillsResult"` - SkillsDisableRequest SkillsDisableRequest `json:"SkillsDisableRequest"` - SkillsDisableResult SkillsDisableResult `json:"SkillsDisableResult"` - SkillsDiscoverRequest SkillsDiscoverRequest `json:"SkillsDiscoverRequest"` - SkillsEnableRequest SkillsEnableRequest `json:"SkillsEnableRequest"` - SkillsEnableResult SkillsEnableResult `json:"SkillsEnableResult"` - SkillsReloadResult SkillsReloadResult `json:"SkillsReloadResult"` - SuspendResult SuspendResult `json:"SuspendResult"` - TaskAgentInfo TaskAgentInfo `json:"TaskAgentInfo"` - TaskAgentInfoExecutionMode TaskInfoExecutionMode `json:"TaskAgentInfoExecutionMode"` - TaskAgentInfoStatus TaskInfoStatus `json:"TaskAgentInfoStatus"` - TaskInfo TaskInfo `json:"TaskInfo"` - TaskList TaskList `json:"TaskList"` - TasksCancelRequest TasksCancelRequest `json:"TasksCancelRequest"` - TasksCancelResult TasksCancelResult `json:"TasksCancelResult"` - TaskShellInfo TaskShellInfo `json:"TaskShellInfo"` - TaskShellInfoAttachmentMode TaskShellInfoAttachmentMode `json:"TaskShellInfoAttachmentMode"` - TaskShellInfoExecutionMode TaskInfoExecutionMode `json:"TaskShellInfoExecutionMode"` - TaskShellInfoStatus TaskInfoStatus `json:"TaskShellInfoStatus"` - TasksPromoteToBackgroundRequest TasksPromoteToBackgroundRequest `json:"TasksPromoteToBackgroundRequest"` - TasksPromoteToBackgroundResult TasksPromoteToBackgroundResult `json:"TasksPromoteToBackgroundResult"` - TasksRemoveRequest TasksRemoveRequest `json:"TasksRemoveRequest"` - TasksRemoveResult TasksRemoveResult `json:"TasksRemoveResult"` - TasksSendMessageRequest TasksSendMessageRequest `json:"TasksSendMessageRequest"` - TasksSendMessageResult TasksSendMessageResult `json:"TasksSendMessageResult"` - TasksStartAgentRequest TasksStartAgentRequest `json:"TasksStartAgentRequest"` - TasksStartAgentResult TasksStartAgentResult `json:"TasksStartAgentResult"` - Tool Tool `json:"Tool"` - ToolList ToolList `json:"ToolList"` - ToolsListRequest ToolsListRequest `json:"ToolsListRequest"` - UIElicitationArrayAnyOfField UIElicitationArrayAnyOfField `json:"UIElicitationArrayAnyOfField"` - UIElicitationArrayAnyOfFieldItems UIElicitationArrayAnyOfFieldItems `json:"UIElicitationArrayAnyOfFieldItems"` - UIElicitationArrayAnyOfFieldItemsAnyOf UIElicitationArrayAnyOfFieldItemsAnyOf `json:"UIElicitationArrayAnyOfFieldItemsAnyOf"` - UIElicitationArrayEnumField UIElicitationArrayEnumField `json:"UIElicitationArrayEnumField"` - UIElicitationArrayEnumFieldItems UIElicitationArrayEnumFieldItems `json:"UIElicitationArrayEnumFieldItems"` - UIElicitationFieldValue *UIElicitationFieldValue `json:"UIElicitationFieldValue"` - UIElicitationRequest UIElicitationRequest `json:"UIElicitationRequest"` - UIElicitationResponse UIElicitationResponse `json:"UIElicitationResponse"` - UIElicitationResponseAction UIElicitationResponseAction `json:"UIElicitationResponseAction"` - UIElicitationResponseContent map[string]*UIElicitationFieldValue `json:"UIElicitationResponseContent"` - UIElicitationResult UIElicitationResult `json:"UIElicitationResult"` - UIElicitationSchema UIElicitationSchema `json:"UIElicitationSchema"` - UIElicitationSchemaProperty UIElicitationSchemaProperty `json:"UIElicitationSchemaProperty"` - UIElicitationSchemaPropertyBoolean UIElicitationSchemaPropertyBoolean `json:"UIElicitationSchemaPropertyBoolean"` - UIElicitationSchemaPropertyNumber UIElicitationSchemaPropertyNumber `json:"UIElicitationSchemaPropertyNumber"` - UIElicitationSchemaPropertyNumberType UIElicitationSchemaPropertyNumberTypeEnum `json:"UIElicitationSchemaPropertyNumberType"` - UIElicitationSchemaPropertyString UIElicitationSchemaPropertyString `json:"UIElicitationSchemaPropertyString"` - UIElicitationSchemaPropertyStringFormat UIElicitationSchemaPropertyStringFormat `json:"UIElicitationSchemaPropertyStringFormat"` - UIElicitationStringEnumField UIElicitationStringEnumField `json:"UIElicitationStringEnumField"` - UIElicitationStringOneOfField UIElicitationStringOneOfField `json:"UIElicitationStringOneOfField"` - UIElicitationStringOneOfFieldOneOf UIElicitationStringOneOfFieldOneOf `json:"UIElicitationStringOneOfFieldOneOf"` - UIHandlePendingElicitationRequest UIHandlePendingElicitationRequest `json:"UIHandlePendingElicitationRequest"` - UsageGetMetricsResult UsageGetMetricsResult `json:"UsageGetMetricsResult"` - UsageMetricsCodeChanges UsageMetricsCodeChanges `json:"UsageMetricsCodeChanges"` - UsageMetricsModelMetric UsageMetricsModelMetric `json:"UsageMetricsModelMetric"` - UsageMetricsModelMetricRequests UsageMetricsModelMetricRequests `json:"UsageMetricsModelMetricRequests"` - UsageMetricsModelMetricTokenDetail UsageMetricsModelMetricTokenDetail `json:"UsageMetricsModelMetricTokenDetail"` - UsageMetricsModelMetricUsage UsageMetricsModelMetricUsage `json:"UsageMetricsModelMetricUsage"` - UsageMetricsTokenDetail UsageMetricsTokenDetail `json:"UsageMetricsTokenDetail"` - WorkspacesCreateFileRequest WorkspacesCreateFileRequest `json:"WorkspacesCreateFileRequest"` - WorkspacesCreateFileResult WorkspacesCreateFileResult `json:"WorkspacesCreateFileResult"` - WorkspacesGetWorkspaceResult WorkspacesGetWorkspaceResult `json:"WorkspacesGetWorkspaceResult"` - WorkspacesListFilesResult WorkspacesListFilesResult `json:"WorkspacesListFilesResult"` - WorkspacesReadFileRequest WorkspacesReadFileRequest `json:"WorkspacesReadFileRequest"` - WorkspacesReadFileResult WorkspacesReadFileResult `json:"WorkspacesReadFileResult"` -} - type AccountGetQuotaRequest struct { // GitHub token for per-user quota lookup. When provided, resolves this token to determine // the user's quota instead of using the global auth. @@ -299,17 +42,18 @@ type AccountQuotaSnapshot struct { UsedRequests int64 `json:"usedRequests"` } -// Experimental: AgentDeselectResult is part of an experimental API and may change or be removed. +// Experimental: AgentDeselectResult is part of an experimental API and may change or be +// removed. type AgentDeselectResult struct { } -// Experimental: AgentGetCurrentResult is part of an experimental API and may change or be removed. +// Experimental: AgentGetCurrentResult is part of an experimental API and may change or be +// removed. type AgentGetCurrentResult struct { // Currently selected custom agent, or null if using the default agent Agent *AgentInfo `json:"agent,omitempty"` } -// The newly selected custom agent type AgentInfo struct { // Description of the agent's purpose Description string `json:"description"` @@ -328,19 +72,22 @@ type AgentList struct { Agents []AgentInfo `json:"agents"` } -// Experimental: AgentReloadResult is part of an experimental API and may change or be removed. +// Experimental: AgentReloadResult is part of an experimental API and may change or be +// removed. type AgentReloadResult struct { // Reloaded custom agents Agents []AgentInfo `json:"agents"` } -// Experimental: AgentSelectRequest is part of an experimental API and may change or be removed. +// Experimental: AgentSelectRequest is part of an experimental API and may change or be +// removed. type AgentSelectRequest struct { // Name of the custom agent to select Name string `json:"name"` } -// Experimental: AgentSelectResult is part of an experimental API and may change or be removed. +// Experimental: AgentSelectResult is part of an experimental API and may change or be +// removed. type AgentSelectResult struct { // The newly selected custom agent Agent AgentInfo `json:"agent"` @@ -379,15 +126,15 @@ type CurrentModel struct { ModelID *string `json:"modelId,omitempty"` } -type DiscoveredMCPServer struct { +type DiscoveredMcpServer struct { // Whether the server is enabled (not in the disabled list) Enabled bool `json:"enabled"` // Server name (config key) Name string `json:"name"` // Configuration source - Source MCPServerSource `json:"source"` + Source DiscoveredMcpServerSource `json:"source"` // Server transport type: stdio, http, sse, or memory (local configs are normalized to stdio) - Type *DiscoveredMCPServerType `json:"type,omitempty"` + Type *DiscoveredMcpServerType `json:"type,omitempty"` } type EmbeddedBlobResourceContents struct { @@ -414,7 +161,7 @@ type Extension struct { // Extension name (directory name) Name string `json:"name"` // Process ID if the extension is running - PID *int64 `json:"pid,omitempty"` + Pid *int64 `json:"pid,omitempty"` // Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/) Source ExtensionSource `json:"source"` // Current status: running, disabled, failed, or starting @@ -427,30 +174,73 @@ type ExtensionList struct { Extensions []Extension `json:"extensions"` } -// Experimental: ExtensionsDisableRequest is part of an experimental API and may change or be removed. +// Experimental: ExtensionsDisableRequest is part of an experimental API and may change or +// be removed. type ExtensionsDisableRequest struct { // Source-qualified extension ID to disable ID string `json:"id"` } -// Experimental: ExtensionsDisableResult is part of an experimental API and may change or be removed. +// Experimental: ExtensionsDisableResult is part of an experimental API and may change or be +// removed. type ExtensionsDisableResult struct { } -// Experimental: ExtensionsEnableRequest is part of an experimental API and may change or be removed. +// Experimental: ExtensionsEnableRequest is part of an experimental API and may change or be +// removed. type ExtensionsEnableRequest struct { // Source-qualified extension ID to enable ID string `json:"id"` } -// Experimental: ExtensionsEnableResult is part of an experimental API and may change or be removed. +// Experimental: ExtensionsEnableResult is part of an experimental API and may change or be +// removed. type ExtensionsEnableResult struct { } -// Experimental: ExtensionsReloadResult is part of an experimental API and may change or be removed. +// Experimental: ExtensionsReloadResult is part of an experimental API and may change or be +// removed. type ExtensionsReloadResult struct { } +// Tool call result (string or expanded result object) +type ExternalToolResult struct { + ExternalToolTextResultForLlm *ExternalToolTextResultForLlm + String *string +} + +func (r ExternalToolResult) MarshalJSON() ([]byte, error) { + if r.ExternalToolTextResultForLlm != nil { + return json.Marshal(r.ExternalToolTextResultForLlm) + } + if r.String != nil { + return json.Marshal(r.String) + } + return []byte("null"), nil +} + +func (r *ExternalToolResult) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *r = ExternalToolResult{} + return nil + } + { + var value ExternalToolTextResultForLlm + if err := json.Unmarshal(data, &value); err == nil { + *r = ExternalToolResult{ExternalToolTextResultForLlm: &value} + return nil + } + } + { + var value string + if err := json.Unmarshal(data, &value); err == nil { + *r = ExternalToolResult{String: &value} + return nil + } + } + return errors.New("data did not match any union variant for ExternalToolResult") +} + // Expanded external tool result payload type ExternalToolTextResultForLlm struct { // Structured content blocks from the tool @@ -470,79 +260,33 @@ type ExternalToolTextResultForLlm struct { // A content block within a tool result, which may be text, terminal output, image, audio, // or a resource -// -// # Plain text content block -// -// Terminal/shell output content block with optional exit code and working directory -// -// # Image content block with base64-encoded data -// -// # Audio content block with base64-encoded data -// -// # Resource link content block referencing an external resource -// -// Embedded resource content block with inline text or binary data type ExternalToolTextResultForLlmContent struct { - // The text content - // - // Terminal/shell output text - Text *string `json:"text,omitempty"` - // Content block type discriminator - Type ExternalToolTextResultForLlmContentType `json:"type"` // Working directory where the command was executed Cwd *string `json:"cwd,omitempty"` - // Process exit code, if the command has completed - ExitCode *float64 `json:"exitCode,omitempty"` // Base64-encoded image data - // - // Base64-encoded audio data Data *string `json:"data,omitempty"` - // MIME type of the image (e.g., image/png, image/jpeg) - // - // MIME type of the audio (e.g., audio/wav, audio/mpeg) - // - // MIME type of the resource content - MIMEType *string `json:"mimeType,omitempty"` // Human-readable description of the resource Description *string `json:"description,omitempty"` + // Process exit code, if the command has completed + ExitCode *float64 `json:"exitCode,omitempty"` // Icons associated with this resource Icons []ExternalToolTextResultForLlmContentResourceLinkIcon `json:"icons,omitempty"` + // MIME type of the image (e.g., image/png, image/jpeg) + MIMEType *string `json:"mimeType,omitempty"` // Resource name identifier Name *string `json:"name,omitempty"` + // The embedded resource contents, either text or base64-encoded binary + Resource *ExternalToolTextResultForLlmContentResourceDetails `json:"resource,omitempty"` // Size of the resource in bytes Size *float64 `json:"size,omitempty"` + // The text content + Text *string `json:"text,omitempty"` // Human-readable display title for the resource Title *string `json:"title,omitempty"` + // Type discriminator + Type ExternalToolTextResultForLlmContentType `json:"type"` // URI identifying the resource URI *string `json:"uri,omitempty"` - // The embedded resource contents, either text or base64-encoded binary - Resource *ExternalToolTextResultForLlmContentResourceDetails `json:"resource,omitempty"` -} - -// Icon image for a resource -type ExternalToolTextResultForLlmContentResourceLinkIcon struct { - // MIME type of the icon image - MIMEType *string `json:"mimeType,omitempty"` - // Available icon sizes (e.g., ['16x16', '32x32']) - Sizes []string `json:"sizes,omitempty"` - // URL or path to the icon image - Src string `json:"src"` - // Theme variant this icon is intended for - Theme *ExternalToolTextResultForLlmContentResourceLinkIconTheme `json:"theme,omitempty"` -} - -// The embedded resource contents, either text or base64-encoded binary -type ExternalToolTextResultForLlmContentResourceDetails struct { - // MIME type of the text content - // - // MIME type of the blob content - MIMEType *string `json:"mimeType,omitempty"` - // Text content of the resource - Text *string `json:"text,omitempty"` - // URI identifying the resource - URI string `json:"uri"` - // Base64-encoded binary content of the resource - Blob *string `json:"blob,omitempty"` } // Audio content block with base64-encoded data @@ -573,6 +317,18 @@ type ExternalToolTextResultForLlmContentResource struct { Type ExternalToolTextResultForLlmContentResourceType `json:"type"` } +// The embedded resource contents, either text or base64-encoded binary +type ExternalToolTextResultForLlmContentResourceDetails struct { + // Base64-encoded binary content of the resource + Blob *string `json:"blob,omitempty"` + // MIME type of the text content + MIMEType *string `json:"mimeType,omitempty"` + // Text content of the resource + Text *string `json:"text,omitempty"` + // URI identifying the resource + URI string `json:"uri"` +} + // Resource link content block referencing an external resource type ExternalToolTextResultForLlmContentResourceLink struct { // Human-readable description of the resource @@ -593,6 +349,18 @@ type ExternalToolTextResultForLlmContentResourceLink struct { URI string `json:"uri"` } +// Icon image for a resource +type ExternalToolTextResultForLlmContentResourceLinkIcon struct { + // MIME type of the icon image + MIMEType *string `json:"mimeType,omitempty"` + // Available icon sizes (e.g., ['16x16', '32x32']) + Sizes []string `json:"sizes,omitempty"` + // URL or path to the icon image + Src string `json:"src"` + // Theme variant this icon is intended for + Theme *ExternalToolTextResultForLlmContentResourceLinkIconTheme `json:"theme,omitempty"` +} + // Terminal/shell output content block with optional exit code and working directory type ExternalToolTextResultForLlmContentTerminal struct { // Working directory where the command was executed @@ -613,13 +381,52 @@ type ExternalToolTextResultForLlmContentText struct { Type ExternalToolTextResultForLlmContentTextType `json:"type"` } -// Experimental: FleetStartRequest is part of an experimental API and may change or be removed. +type FilterMapping struct { + Enum *FilterMappingString + EnumMap map[string]FilterMappingValue +} + +func (r FilterMapping) MarshalJSON() ([]byte, error) { + if r.Enum != nil { + return json.Marshal(r.Enum) + } + if r.EnumMap != nil { + return json.Marshal(r.EnumMap) + } + return []byte("null"), nil +} + +func (r *FilterMapping) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *r = FilterMapping{} + return nil + } + { + var value FilterMappingString + if err := json.Unmarshal(data, &value); err == nil { + *r = FilterMapping{Enum: &value} + return nil + } + } + { + var value map[string]FilterMappingValue + if err := json.Unmarshal(data, &value); err == nil { + *r = FilterMapping{EnumMap: value} + return nil + } + } + return errors.New("data did not match any union variant for FilterMapping") +} + +// Experimental: FleetStartRequest is part of an experimental API and may change or be +// removed. type FleetStartRequest struct { // Optional user prompt to combine with fleet instructions Prompt *string `json:"prompt,omitempty"` } -// Experimental: FleetStartResult is part of an experimental API and may change or be removed. +// Experimental: FleetStartResult is part of an experimental API and may change or be +// removed. type FleetStartResult struct { // Whether fleet mode was successfully activated Started bool `json:"started"` @@ -655,7 +462,8 @@ type HistoryCompactContextWindow struct { ToolDefinitionsTokens *int64 `json:"toolDefinitionsTokens,omitempty"` } -// Experimental: HistoryCompactResult is part of an experimental API and may change or be removed. +// Experimental: HistoryCompactResult is part of an experimental API and may change or be +// removed. type HistoryCompactResult struct { // Post-compaction context window usage breakdown ContextWindow *HistoryCompactContextWindow `json:"contextWindow,omitempty"` @@ -667,13 +475,15 @@ type HistoryCompactResult struct { TokensRemoved int64 `json:"tokensRemoved"` } -// Experimental: HistoryTruncateRequest is part of an experimental API and may change or be removed. +// Experimental: HistoryTruncateRequest is part of an experimental API and may change or be +// removed. type HistoryTruncateRequest struct { // Event ID to truncate to. This event and all events after it are removed from the session. EventID string `json:"eventId"` } -// Experimental: HistoryTruncateResult is part of an experimental API and may change or be removed. +// Experimental: HistoryTruncateResult is part of an experimental API and may change or be +// removed. type HistoryTruncateResult struct { // Number of events that were removed EventsRemoved int64 `json:"eventsRemoved"` @@ -720,106 +530,94 @@ type LogResult struct { EventID string `json:"eventId"` } -type MCPConfigAddRequest struct { +type McpConfigAddRequest struct { // MCP server configuration (local/stdio or remote/http) - Config MCPServerConfig `json:"config"` + Config McpServerConfig `json:"config"` // Unique name for the MCP server Name string `json:"name"` } -// MCP server configuration (local/stdio or remote/http) -type MCPServerConfig struct { - Args []string `json:"args,omitempty"` - Command *string `json:"command,omitempty"` - Cwd *string `json:"cwd,omitempty"` - Env map[string]string `json:"env,omitempty"` - FilterMapping *FilterMapping `json:"filterMapping,omitempty"` - IsDefaultServer *bool `json:"isDefaultServer,omitempty"` - // Timeout in milliseconds for tool calls to this server. - Timeout *int64 `json:"timeout,omitempty"` - // Tools to include. Defaults to all tools if not specified. - Tools []string `json:"tools,omitempty"` - // Remote transport type. Defaults to "http" when omitted. - Type *MCPServerConfigType `json:"type,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - OauthClientID *string `json:"oauthClientId,omitempty"` - OauthGrantType *MCPServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` - OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` - URL *string `json:"url,omitempty"` -} - -type MCPConfigAddResult struct { +type McpConfigAddResult struct { } -type MCPConfigDisableRequest struct { +type McpConfigDisableRequest struct { // Names of MCP servers to disable. Each server is added to the persisted disabled list so // new sessions skip it. Already-disabled names are ignored. Active sessions keep their // current connections until they end. Names []string `json:"names"` } -type MCPConfigDisableResult struct { +type McpConfigDisableResult struct { } -type MCPConfigEnableRequest struct { +type McpConfigEnableRequest struct { // Names of MCP servers to enable. Each server is removed from the persisted disabled list // so new sessions spawn it. Unknown or already-enabled names are ignored. Names []string `json:"names"` } -type MCPConfigEnableResult struct { +type McpConfigEnableResult struct { } -type MCPConfigList struct { +type McpConfigList struct { // All MCP servers from user config, keyed by name - Servers map[string]MCPServerConfig `json:"servers"` + Servers map[string]McpServerConfig `json:"servers"` } -type MCPConfigRemoveRequest struct { +type McpConfigRemoveRequest struct { // Name of the MCP server to remove Name string `json:"name"` } -type MCPConfigRemoveResult struct { +type McpConfigRemoveResult struct { } -type MCPConfigUpdateRequest struct { +type McpConfigUpdateRequest struct { // MCP server configuration (local/stdio or remote/http) - Config MCPServerConfig `json:"config"` + Config McpServerConfig `json:"config"` // Name of the MCP server to update Name string `json:"name"` } -type MCPConfigUpdateResult struct { +type McpConfigUpdateResult struct { } -type MCPDisableRequest struct { +// Experimental: McpDisableRequest is part of an experimental API and may change or be +// removed. +type McpDisableRequest struct { // Name of the MCP server to disable ServerName string `json:"serverName"` } -type MCPDisableResult struct { +// Experimental: McpDisableResult is part of an experimental API and may change or be +// removed. +type McpDisableResult struct { } -type MCPDiscoverRequest struct { +type McpDiscoverRequest struct { // Working directory used as context for discovery (e.g., plugin resolution) WorkingDirectory *string `json:"workingDirectory,omitempty"` } -type MCPDiscoverResult struct { +type McpDiscoverResult struct { // MCP servers discovered from all sources - Servers []DiscoveredMCPServer `json:"servers"` + Servers []DiscoveredMcpServer `json:"servers"` } -type MCPEnableRequest struct { +// Experimental: McpEnableRequest is part of an experimental API and may change or be +// removed. +type McpEnableRequest struct { // Name of the MCP server to enable ServerName string `json:"serverName"` } -type MCPEnableResult struct { +// Experimental: McpEnableResult is part of an experimental API and may change or be removed. +type McpEnableResult struct { } -type MCPOauthLoginRequest struct { +// Experimental: McpOauthLoginRequest is part of an experimental API and may change or be +// removed. +type McpOauthLoginRequest struct { // Optional override for the body text shown on the OAuth loopback callback success page. // When omitted, the runtime applies a neutral fallback; callers driving interactive auth // should pass surface-specific copy telling the user where to return. @@ -838,7 +636,9 @@ type MCPOauthLoginRequest struct { ServerName string `json:"serverName"` } -type MCPOauthLoginResult struct { +// Experimental: McpOauthLoginResult is part of an experimental API and may change or be +// removed. +type McpOauthLoginResult struct { // URL the caller should open in a browser to complete OAuth. Omitted when cached tokens // were still valid and no browser interaction was needed — the server is already // reconnected in that case. When present, the runtime starts the callback listener before @@ -847,37 +647,59 @@ type MCPOauthLoginResult struct { AuthorizationURL *string `json:"authorizationUrl,omitempty"` } -type MCPReloadResult struct { +// Experimental: McpReloadResult is part of an experimental API and may change or be removed. +type McpReloadResult struct { } -type MCPServer struct { +type McpServer struct { // Error message if the server failed to connect Error *string `json:"error,omitempty"` // Server name (config key) Name string `json:"name"` // Configuration source: user, workspace, plugin, or builtin - Source *MCPServerSource `json:"source,omitempty"` + Source *McpServerSource `json:"source,omitempty"` // Connection status: connected, failed, needs-auth, pending, disabled, or not_configured - Status MCPServerStatus `json:"status"` + Status McpServerStatus `json:"status"` +} + +// MCP server configuration (local/stdio or remote/http) +type McpServerConfig struct { + Args []string `json:"args,omitempty"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping *FilterMapping `json:"filterMapping,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthGrantType *McpServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + // Timeout in milliseconds for tool calls to this server. + Timeout *int64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools if not specified. + Tools []string `json:"tools,omitempty"` + // Remote transport type. Defaults to "http" when omitted. + Type *McpServerConfigType `json:"type,omitempty"` + URL *string `json:"url,omitempty"` } -type MCPServerConfigHTTP struct { +type McpServerConfigHTTP struct { FilterMapping *FilterMapping `json:"filterMapping,omitempty"` Headers map[string]string `json:"headers,omitempty"` IsDefaultServer *bool `json:"isDefaultServer,omitempty"` OauthClientID *string `json:"oauthClientId,omitempty"` - OauthGrantType *MCPServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` + OauthGrantType *McpServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` // Timeout in milliseconds for tool calls to this server. Timeout *int64 `json:"timeout,omitempty"` // Tools to include. Defaults to all tools if not specified. Tools []string `json:"tools,omitempty"` // Remote transport type. Defaults to "http" when omitted. - Type *MCPServerConfigHTTPType `json:"type,omitempty"` + Type *McpServerConfigHTTPType `json:"type,omitempty"` URL string `json:"url"` } -type MCPServerConfigLocal struct { +type McpServerConfigLocal struct { Args []string `json:"args"` Command string `json:"command"` Cwd *string `json:"cwd,omitempty"` @@ -888,23 +710,16 @@ type MCPServerConfigLocal struct { Timeout *int64 `json:"timeout,omitempty"` // Tools to include. Defaults to all tools if not specified. Tools []string `json:"tools,omitempty"` - Type *MCPServerConfigLocalType `json:"type,omitempty"` + Type *McpServerConfigLocalType `json:"type,omitempty"` } -type MCPServerList struct { +// Experimental: McpServerList is part of an experimental API and may change or be removed. +type McpServerList struct { // Configured MCP servers - Servers []MCPServer `json:"servers"` -} - -type ModeSetRequest struct { - // The agent mode. Valid values: "interactive", "plan", "autopilot". - Mode SessionMode `json:"mode"` -} - -type ModeSetResult struct { + Servers []McpServer `json:"servers"` } -type ModelElement struct { +type Model struct { // Billing information Billing *ModelBilling `json:"billing,omitempty"` // Model capabilities and limits @@ -949,30 +764,14 @@ type ModelCapabilitiesLimits struct { // Vision-specific limits type ModelCapabilitiesLimitsVision struct { - // Maximum image size in bytes - MaxPromptImageSize int64 `json:"max_prompt_image_size"` // Maximum number of images per prompt MaxPromptImages int64 `json:"max_prompt_images"` + // Maximum image size in bytes + MaxPromptImageSize int64 `json:"max_prompt_image_size"` // MIME types the model accepts SupportedMediaTypes []string `json:"supported_media_types"` } -// Feature flags indicating what the model supports -type ModelCapabilitiesSupports struct { - // Whether this model supports reasoning effort configuration - ReasoningEffort *bool `json:"reasoningEffort,omitempty"` - // Whether this model supports vision/image input - Vision *bool `json:"vision,omitempty"` -} - -// Policy state (if applicable) -type ModelPolicy struct { - // Current policy state for this model - State string `json:"state"` - // Usage terms or conditions for this model - Terms *string `json:"terms,omitempty"` -} - // Override individual model capabilities resolved by the runtime type ModelCapabilitiesOverride struct { // Token limits for prompts, outputs, and context window @@ -991,10 +790,10 @@ type ModelCapabilitiesOverrideLimits struct { } type ModelCapabilitiesOverrideLimitsVision struct { - // Maximum image size in bytes - MaxPromptImageSize *int64 `json:"max_prompt_image_size,omitempty"` // Maximum number of images per prompt MaxPromptImages *int64 `json:"max_prompt_images,omitempty"` + // Maximum image size in bytes + MaxPromptImageSize *int64 `json:"max_prompt_image_size,omitempty"` // MIME types the model accepts SupportedMediaTypes []string `json:"supported_media_types,omitempty"` } @@ -1005,9 +804,31 @@ type ModelCapabilitiesOverrideSupports struct { Vision *bool `json:"vision,omitempty"` } +// Feature flags indicating what the model supports +type ModelCapabilitiesSupports struct { + // Whether this model supports reasoning effort configuration + ReasoningEffort *bool `json:"reasoningEffort,omitempty"` + // Whether this model supports vision/image input + Vision *bool `json:"vision,omitempty"` +} + type ModelList struct { // List of available models with full metadata - Models []ModelElement `json:"models"` + Models []Model `json:"models"` +} + +// Policy state (if applicable) +type ModelPolicy struct { + // Current policy state for this model + State string `json:"state"` + // Usage terms or conditions for this model + Terms *string `json:"terms,omitempty"` +} + +type ModelsListRequest struct { + // GitHub token for per-user model listing. When provided, resolves this token to determine + // the user's Copilot plan and available models instead of using the global auth. + GitHubToken *string `json:"gitHubToken,omitempty"` } type ModelSwitchToRequest struct { @@ -1024,10 +845,12 @@ type ModelSwitchToResult struct { ModelID *string `json:"modelId,omitempty"` } -type ModelsListRequest struct { - // GitHub token for per-user model listing. When provided, resolves this token to determine - // the user's Copilot plan and available models instead of using the global auth. - GitHubToken *string `json:"gitHubToken,omitempty"` +type ModeSetRequest struct { + // The agent mode. Valid values: "interactive", "plan", "autopilot". + Mode SessionMode `json:"mode"` +} + +type ModeSetResult struct { } type NameGetResult struct { @@ -1044,30 +867,16 @@ type NameSetResult struct { } type PermissionDecision struct { - // The permission request was approved for this one instance - // - // Approved and remembered for the rest of the session - // - // Approved and persisted for this project location - // - // Approved and persisted across sessions - // - // Denied by the user during an interactive prompt - // - // Denied because user confirmation was unavailable - Kind PermissionDecisionKind `json:"kind"` // The approval to add as a session-scoped rule - // - // The approval to persist for this location - Approval *PermissionDecisionApproveForLocationApproval `json:"approval,omitempty"` + Approval *PermissionDecisionApproveForSessionApproval `json:"approval,omitempty"` // The URL domain to approve for this session - // - // The URL domain to approve permanently Domain *string `json:"domain,omitempty"` - // The location key (git root or cwd) to persist the approval to - LocationKey *string `json:"locationKey,omitempty"` // Optional feedback from the user explaining the denial Feedback *string `json:"feedback,omitempty"` + // Kind discriminator + Kind PermissionDecisionKind `json:"kind"` + // The location key (git root or cwd) to persist the approval to + LocationKey *string `json:"locationKey,omitempty"` } type PermissionDecisionApproveForLocation struct { @@ -1081,12 +890,13 @@ type PermissionDecisionApproveForLocation struct { // The approval to persist for this location type PermissionDecisionApproveForLocationApproval struct { - CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` - Kind ApprovalKind `json:"kind"` - ServerName *string `json:"serverName,omitempty"` - ToolName *string `json:"toolName,omitempty"` - Operation *string `json:"operation,omitempty"` - ExtensionName *string `json:"extensionName,omitempty"` + CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` + ExtensionName *string `json:"extensionName,omitempty"` + // Kind discriminator + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + Operation *string `json:"operation,omitempty"` + ServerName *string `json:"serverName,omitempty"` + ToolName *string `json:"toolName,omitempty"` } type PermissionDecisionApproveForLocationApprovalCommands struct { @@ -1109,14 +919,14 @@ type PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess struc Kind PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind `json:"kind"` } -type PermissionDecisionApproveForLocationApprovalMCP struct { - Kind PermissionDecisionApproveForLocationApprovalMCPKind `json:"kind"` +type PermissionDecisionApproveForLocationApprovalMcp struct { + Kind PermissionDecisionApproveForLocationApprovalMcpKind `json:"kind"` ServerName string `json:"serverName"` ToolName *string `json:"toolName"` } -type PermissionDecisionApproveForLocationApprovalMCPSampling struct { - Kind PermissionDecisionApproveForLocationApprovalMCPSamplingKind `json:"kind"` +type PermissionDecisionApproveForLocationApprovalMcpSampling struct { + Kind PermissionDecisionApproveForLocationApprovalMcpSamplingKind `json:"kind"` ServerName string `json:"serverName"` } @@ -1143,55 +953,56 @@ type PermissionDecisionApproveForSession struct { // The approval to add as a session-scoped rule type PermissionDecisionApproveForSessionApproval struct { - CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` - Kind ApprovalKind `json:"kind"` - ServerName *string `json:"serverName,omitempty"` - ToolName *string `json:"toolName,omitempty"` - Operation *string `json:"operation,omitempty"` - ExtensionName *string `json:"extensionName,omitempty"` + CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` + ExtensionName *string `json:"extensionName,omitempty"` + // Kind discriminator + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + Operation *string `json:"operation,omitempty"` + ServerName *string `json:"serverName,omitempty"` + ToolName *string `json:"toolName,omitempty"` } type PermissionDecisionApproveForSessionApprovalCommands struct { - CommandIdentifiers []string `json:"commandIdentifiers"` - Kind PermissionDecisionApproveForLocationApprovalCommandsKind `json:"kind"` + CommandIdentifiers []string `json:"commandIdentifiers"` + Kind PermissionDecisionApproveForSessionApprovalCommandsKind `json:"kind"` } type PermissionDecisionApproveForSessionApprovalCustomTool struct { - Kind PermissionDecisionApproveForLocationApprovalCustomToolKind `json:"kind"` - ToolName string `json:"toolName"` + Kind PermissionDecisionApproveForSessionApprovalCustomToolKind `json:"kind"` + ToolName string `json:"toolName"` } type PermissionDecisionApproveForSessionApprovalExtensionManagement struct { - Kind PermissionDecisionApproveForLocationApprovalExtensionManagementKind `json:"kind"` - Operation *string `json:"operation,omitempty"` + Kind PermissionDecisionApproveForSessionApprovalExtensionManagementKind `json:"kind"` + Operation *string `json:"operation,omitempty"` } type PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess struct { - ExtensionName string `json:"extensionName"` - Kind PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind `json:"kind"` + ExtensionName string `json:"extensionName"` + Kind PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind `json:"kind"` } -type PermissionDecisionApproveForSessionApprovalMCP struct { - Kind PermissionDecisionApproveForLocationApprovalMCPKind `json:"kind"` - ServerName string `json:"serverName"` - ToolName *string `json:"toolName"` +type PermissionDecisionApproveForSessionApprovalMcp struct { + Kind PermissionDecisionApproveForSessionApprovalMcpKind `json:"kind"` + ServerName string `json:"serverName"` + ToolName *string `json:"toolName"` } -type PermissionDecisionApproveForSessionApprovalMCPSampling struct { - Kind PermissionDecisionApproveForLocationApprovalMCPSamplingKind `json:"kind"` - ServerName string `json:"serverName"` +type PermissionDecisionApproveForSessionApprovalMcpSampling struct { + Kind PermissionDecisionApproveForSessionApprovalMcpSamplingKind `json:"kind"` + ServerName string `json:"serverName"` } type PermissionDecisionApproveForSessionApprovalMemory struct { - Kind PermissionDecisionApproveForLocationApprovalMemoryKind `json:"kind"` + Kind PermissionDecisionApproveForSessionApprovalMemoryKind `json:"kind"` } type PermissionDecisionApproveForSessionApprovalRead struct { - Kind PermissionDecisionApproveForLocationApprovalReadKind `json:"kind"` + Kind PermissionDecisionApproveForSessionApprovalReadKind `json:"kind"` } type PermissionDecisionApproveForSessionApprovalWrite struct { - Kind PermissionDecisionApproveForLocationApprovalWriteKind `json:"kind"` + Kind PermissionDecisionApproveForSessionApprovalWriteKind `json:"kind"` } type PermissionDecisionApproveOnce struct { @@ -1281,7 +1092,7 @@ type PlanUpdateRequest struct { type PlanUpdateResult struct { } -type PluginElement struct { +type Plugin struct { // Whether the plugin is currently enabled Enabled bool `json:"enabled"` // Marketplace the plugin came from @@ -1295,14 +1106,16 @@ type PluginElement struct { // Experimental: PluginList is part of an experimental API and may change or be removed. type PluginList struct { // Installed plugins - Plugins []PluginElement `json:"plugins"` + Plugins []Plugin `json:"plugins"` } -// Experimental: RemoteDisableResult is part of an experimental API and may change or be removed. +// Experimental: RemoteDisableResult is part of an experimental API and may change or be +// removed. type RemoteDisableResult struct { } -// Experimental: RemoteEnableResult is part of an experimental API and may change or be removed. +// Experimental: RemoteEnableResult is part of an experimental API and may change or be +// removed. type RemoteEnableResult struct { // Whether remote steering is enabled RemoteSteerable bool `json:"remoteSteerable"` @@ -1347,7 +1160,7 @@ type SessionAuthStatus struct { StatusMessage *string `json:"statusMessage,omitempty"` } -type SessionFSAppendFileRequest struct { +type SessionFsAppendFileRequest struct { // Content to append Content string `json:"content"` // Optional POSIX-style mode for newly created files @@ -1359,26 +1172,26 @@ type SessionFSAppendFileRequest struct { } // Describes a filesystem error. -type SessionFSError struct { +type SessionFsError struct { // Error classification - Code SessionFSErrorCode `json:"code"` + Code SessionFsErrorCode `json:"code"` // Free-form detail about the error, for logging/diagnostics Message *string `json:"message,omitempty"` } -type SessionFSExistsRequest struct { +type SessionFsExistsRequest struct { // Path using SessionFs conventions Path string `json:"path"` // Target session identifier SessionID string `json:"sessionId"` } -type SessionFSExistsResult struct { +type SessionFsExistsResult struct { // Whether the path exists Exists bool `json:"exists"` } -type SessionFSMkdirRequest struct { +type SessionFsMkdirRequest struct { // Optional POSIX-style mode for newly created directories Mode *int64 `json:"mode,omitempty"` // Path using SessionFs conventions @@ -1389,56 +1202,56 @@ type SessionFSMkdirRequest struct { SessionID string `json:"sessionId"` } -type SessionFSReadFileRequest struct { +type SessionFsReaddirRequest struct { // Path using SessionFs conventions Path string `json:"path"` // Target session identifier SessionID string `json:"sessionId"` } -type SessionFSReadFileResult struct { - // File content as UTF-8 string - Content string `json:"content"` +type SessionFsReaddirResult struct { + // Entry names in the directory + Entries []string `json:"entries"` // Describes a filesystem error. - Error *SessionFSError `json:"error,omitempty"` + Error *SessionFsError `json:"error,omitempty"` +} + +type SessionFsReaddirWithTypesEntry struct { + // Entry name + Name string `json:"name"` + // Entry type + Type SessionFsReaddirWithTypesEntryType `json:"type"` } -type SessionFSReaddirRequest struct { +type SessionFsReaddirWithTypesRequest struct { // Path using SessionFs conventions Path string `json:"path"` // Target session identifier SessionID string `json:"sessionId"` } -type SessionFSReaddirResult struct { - // Entry names in the directory - Entries []string `json:"entries"` +type SessionFsReaddirWithTypesResult struct { + // Directory entries with type information + Entries []SessionFsReaddirWithTypesEntry `json:"entries"` // Describes a filesystem error. - Error *SessionFSError `json:"error,omitempty"` -} - -type SessionFSReaddirWithTypesEntry struct { - // Entry name - Name string `json:"name"` - // Entry type - Type SessionFSReaddirWithTypesEntryType `json:"type"` + Error *SessionFsError `json:"error,omitempty"` } -type SessionFSReaddirWithTypesRequest struct { +type SessionFsReadFileRequest struct { // Path using SessionFs conventions Path string `json:"path"` // Target session identifier SessionID string `json:"sessionId"` } -type SessionFSReaddirWithTypesResult struct { - // Directory entries with type information - Entries []SessionFSReaddirWithTypesEntry `json:"entries"` +type SessionFsReadFileResult struct { + // File content as UTF-8 string + Content string `json:"content"` // Describes a filesystem error. - Error *SessionFSError `json:"error,omitempty"` + Error *SessionFsError `json:"error,omitempty"` } -type SessionFSRenameRequest struct { +type SessionFsRenameRequest struct { // Destination path using SessionFs conventions Dest string `json:"dest"` // Target session identifier @@ -1447,7 +1260,7 @@ type SessionFSRenameRequest struct { Src string `json:"src"` } -type SessionFSRmRequest struct { +type SessionFsRmRequest struct { // Ignore errors if the path does not exist Force *bool `json:"force,omitempty"` // Path using SessionFs conventions @@ -1458,32 +1271,32 @@ type SessionFSRmRequest struct { SessionID string `json:"sessionId"` } -type SessionFSSetProviderRequest struct { +type SessionFsSetProviderRequest struct { // Path conventions used by this filesystem - Conventions SessionFSSetProviderConventions `json:"conventions"` + Conventions SessionFsSetProviderConventions `json:"conventions"` // Initial working directory for sessions InitialCwd string `json:"initialCwd"` // Path within each session's SessionFs where the runtime stores files for that session SessionStatePath string `json:"sessionStatePath"` } -type SessionFSSetProviderResult struct { +type SessionFsSetProviderResult struct { // Whether the provider was set successfully Success bool `json:"success"` } -type SessionFSStatRequest struct { +type SessionFsStatRequest struct { // Path using SessionFs conventions Path string `json:"path"` // Target session identifier SessionID string `json:"sessionId"` } -type SessionFSStatResult struct { +type SessionFsStatResult struct { // ISO 8601 timestamp of creation Birthtime time.Time `json:"birthtime"` // Describes a filesystem error. - Error *SessionFSError `json:"error,omitempty"` + Error *SessionFsError `json:"error,omitempty"` // Whether the path is a directory IsDirectory bool `json:"isDirectory"` // Whether the path is a file @@ -1494,7 +1307,7 @@ type SessionFSStatResult struct { Size int64 `json:"size"` } -type SessionFSWriteFileRequest struct { +type SessionFsWriteFileRequest struct { // Content to write Content string `json:"content"` // Optional POSIX-style mode for newly created files @@ -1505,7 +1318,8 @@ type SessionFSWriteFileRequest struct { SessionID string `json:"sessionId"` } -// Experimental: SessionsForkRequest is part of an experimental API and may change or be removed. +// Experimental: SessionsForkRequest is part of an experimental API and may change or be +// removed. type SessionsForkRequest struct { // Source session ID to fork from SessionID string `json:"sessionId"` @@ -1514,7 +1328,8 @@ type SessionsForkRequest struct { ToEventID *string `json:"toEventId,omitempty"` } -// Experimental: SessionsForkResult is part of an experimental API and may change or be removed. +// Experimental: SessionsForkResult is part of an experimental API and may change or be +// removed. type SessionsForkResult struct { // The new forked session's ID SessionID string `json:"sessionId"` @@ -1575,13 +1390,15 @@ type SkillsConfigSetDisabledSkillsRequest struct { type SkillsConfigSetDisabledSkillsResult struct { } -// Experimental: SkillsDisableRequest is part of an experimental API and may change or be removed. +// Experimental: SkillsDisableRequest is part of an experimental API and may change or be +// removed. type SkillsDisableRequest struct { // Name of the skill to disable Name string `json:"name"` } -// Experimental: SkillsDisableResult is part of an experimental API and may change or be removed. +// Experimental: SkillsDisableResult is part of an experimental API and may change or be +// removed. type SkillsDisableResult struct { } @@ -1592,17 +1409,20 @@ type SkillsDiscoverRequest struct { SkillDirectories []string `json:"skillDirectories,omitempty"` } -// Experimental: SkillsEnableRequest is part of an experimental API and may change or be removed. +// Experimental: SkillsEnableRequest is part of an experimental API and may change or be +// removed. type SkillsEnableRequest struct { // Name of the skill to enable Name string `json:"name"` } -// Experimental: SkillsEnableResult is part of an experimental API and may change or be removed. +// Experimental: SkillsEnableResult is part of an experimental API and may change or be +// removed. type SkillsEnableResult struct { } -// Experimental: SkillsReloadResult is part of an experimental API and may change or be removed. +// Experimental: SkillsReloadResult is part of an experimental API and may change or be +// removed. type SkillsReloadResult struct { } @@ -1613,7 +1433,7 @@ type TaskAgentInfo struct { // ISO 8601 timestamp when the current active period began ActiveStartedAt *time.Time `json:"activeStartedAt,omitempty"` // Accumulated active execution time in milliseconds - ActiveTimeMS *int64 `json:"activeTimeMs,omitempty"` + ActiveTimeMs *int64 `json:"activeTimeMs,omitempty"` // Type of agent running this task AgentType string `json:"agentType"` // Whether the task is currently in the original sync wait and can be moved to background @@ -1627,7 +1447,7 @@ type TaskAgentInfo struct { // Error message when the task failed Error *string `json:"error,omitempty"` // How the agent is currently being managed by the runtime - ExecutionMode *TaskInfoExecutionMode `json:"executionMode,omitempty"` + ExecutionMode *TaskAgentInfoExecutionMode `json:"executionMode,omitempty"` // Unique task identifier ID string `json:"id"` // ISO 8601 timestamp when the agent entered idle state @@ -1643,7 +1463,7 @@ type TaskAgentInfo struct { // ISO 8601 timestamp when the task was started StartedAt time.Time `json:"startedAt"` // Current lifecycle status of the task - Status TaskInfoStatus `json:"status"` + Status TaskAgentInfoStatus `json:"status"` // Tool call ID associated with this agent task ToolCallID string `json:"toolCallId"` // Task kind @@ -1654,33 +1474,38 @@ type TaskInfo struct { // ISO 8601 timestamp when the current active period began ActiveStartedAt *time.Time `json:"activeStartedAt,omitempty"` // Accumulated active execution time in milliseconds - ActiveTimeMS *int64 `json:"activeTimeMs,omitempty"` + ActiveTimeMs *int64 `json:"activeTimeMs,omitempty"` // Type of agent running this task AgentType *string `json:"agentType,omitempty"` + // Whether the shell runs inside a managed PTY session or as an independent background + // process + AttachmentMode *TaskShellInfoAttachmentMode `json:"attachmentMode,omitempty"` // Whether the task is currently in the original sync wait and can be moved to background // mode. False once it is already backgrounded, idle, finished, or no longer has a // promotable sync waiter. - // - // Whether this shell task can be promoted to background mode CanPromoteToBackground *bool `json:"canPromoteToBackground,omitempty"` - // ISO 8601 timestamp when the task finished + // Command being executed + Command *string `json:"command,omitempty"` + // ISO 8601 timestamp when the task finished CompletedAt *time.Time `json:"completedAt,omitempty"` // Short description of the task Description string `json:"description"` // Error message when the task failed Error *string `json:"error,omitempty"` // How the agent is currently being managed by the runtime - // - // Whether the shell command is currently sync-waited or background-managed - ExecutionMode *TaskInfoExecutionMode `json:"executionMode,omitempty"` + ExecutionMode *TaskAgentInfoExecutionMode `json:"executionMode,omitempty"` // Unique task identifier ID string `json:"id"` // ISO 8601 timestamp when the agent entered idle state IdleSince *time.Time `json:"idleSince,omitempty"` // Most recent response text from the agent LatestResponse *string `json:"latestResponse,omitempty"` + // Path to the detached shell log, when available + LogPath *string `json:"logPath,omitempty"` // Model used for the task when specified Model *string `json:"model,omitempty"` + // Process ID when available + Pid *int64 `json:"pid,omitempty"` // Prompt passed to the agent Prompt *string `json:"prompt,omitempty"` // Result text from the task when available @@ -1688,20 +1513,11 @@ type TaskInfo struct { // ISO 8601 timestamp when the task was started StartedAt time.Time `json:"startedAt"` // Current lifecycle status of the task - Status TaskInfoStatus `json:"status"` + Status TaskAgentInfoStatus `json:"status"` // Tool call ID associated with this agent task ToolCallID *string `json:"toolCallId,omitempty"` - // Task kind + // Type discriminator Type TaskInfoType `json:"type"` - // Whether the shell runs inside a managed PTY session or as an independent background - // process - AttachmentMode *TaskShellInfoAttachmentMode `json:"attachmentMode,omitempty"` - // Command being executed - Command *string `json:"command,omitempty"` - // Path to the detached shell log, when available - LogPath *string `json:"logPath,omitempty"` - // Process ID when available - PID *int64 `json:"pid,omitempty"` } // Experimental: TaskList is part of an experimental API and may change or be removed. @@ -1710,6 +1526,20 @@ type TaskList struct { Tasks []TaskInfo `json:"tasks"` } +// Experimental: TasksCancelRequest is part of an experimental API and may change or be +// removed. +type TasksCancelRequest struct { + // Task identifier + ID string `json:"id"` +} + +// Experimental: TasksCancelResult is part of an experimental API and may change or be +// removed. +type TasksCancelResult struct { + // Whether the task was successfully cancelled + Cancelled bool `json:"cancelled"` +} + type TaskShellInfo struct { // Whether the shell runs inside a managed PTY session or as an independent background // process @@ -1723,59 +1553,52 @@ type TaskShellInfo struct { // Short description of the task Description string `json:"description"` // Whether the shell command is currently sync-waited or background-managed - ExecutionMode *TaskInfoExecutionMode `json:"executionMode,omitempty"` + ExecutionMode *TaskShellInfoExecutionMode `json:"executionMode,omitempty"` // Unique task identifier ID string `json:"id"` // Path to the detached shell log, when available LogPath *string `json:"logPath,omitempty"` // Process ID when available - PID *int64 `json:"pid,omitempty"` + Pid *int64 `json:"pid,omitempty"` // ISO 8601 timestamp when the task was started StartedAt time.Time `json:"startedAt"` // Current lifecycle status of the task - Status TaskInfoStatus `json:"status"` + Status TaskShellInfoStatus `json:"status"` // Task kind Type TaskShellInfoType `json:"type"` } -// Experimental: TasksCancelRequest is part of an experimental API and may change or be removed. -type TasksCancelRequest struct { - // Task identifier - ID string `json:"id"` -} - -// Experimental: TasksCancelResult is part of an experimental API and may change or be removed. -type TasksCancelResult struct { - // Whether the task was successfully cancelled - Cancelled bool `json:"cancelled"` -} - -// Experimental: TasksPromoteToBackgroundRequest is part of an experimental API and may change or be removed. +// Experimental: TasksPromoteToBackgroundRequest is part of an experimental API and may +// change or be removed. type TasksPromoteToBackgroundRequest struct { // Task identifier ID string `json:"id"` } -// Experimental: TasksPromoteToBackgroundResult is part of an experimental API and may change or be removed. +// Experimental: TasksPromoteToBackgroundResult is part of an experimental API and may +// change or be removed. type TasksPromoteToBackgroundResult struct { // Whether the task was successfully promoted to background mode Promoted bool `json:"promoted"` } -// Experimental: TasksRemoveRequest is part of an experimental API and may change or be removed. +// Experimental: TasksRemoveRequest is part of an experimental API and may change or be +// removed. type TasksRemoveRequest struct { // Task identifier ID string `json:"id"` } -// Experimental: TasksRemoveResult is part of an experimental API and may change or be removed. +// Experimental: TasksRemoveResult is part of an experimental API and may change or be +// removed. type TasksRemoveResult struct { // Whether the task was removed. Returns false if the task does not exist or is still // running/idle (cancel it first). Removed bool `json:"removed"` } -// Experimental: TasksSendMessageRequest is part of an experimental API and may change or be removed. +// Experimental: TasksSendMessageRequest is part of an experimental API and may change or be +// removed. type TasksSendMessageRequest struct { // Agent ID of the sender, if sent on behalf of another agent FromAgentID *string `json:"fromAgentId,omitempty"` @@ -1785,7 +1608,8 @@ type TasksSendMessageRequest struct { Message string `json:"message"` } -// Experimental: TasksSendMessageResult is part of an experimental API and may change or be removed. +// Experimental: TasksSendMessageResult is part of an experimental API and may change or be +// removed. type TasksSendMessageResult struct { // Error message if delivery failed Error *string `json:"error,omitempty"` @@ -1793,7 +1617,8 @@ type TasksSendMessageResult struct { Sent bool `json:"sent"` } -// Experimental: TasksStartAgentRequest is part of an experimental API and may change or be removed. +// Experimental: TasksStartAgentRequest is part of an experimental API and may change or be +// removed. type TasksStartAgentRequest struct { // Type of agent to start (e.g., 'explore', 'task', 'general-purpose') AgentType string `json:"agentType"` @@ -1807,7 +1632,8 @@ type TasksStartAgentRequest struct { Prompt string `json:"prompt"` } -// Experimental: TasksStartAgentResult is part of an experimental API and may change or be removed. +// Experimental: TasksStartAgentResult is part of an experimental API and may change or be +// removed. type TasksStartAgentResult struct { // Generated agent ID for the background task AgentID string `json:"agentId"` @@ -1864,7 +1690,7 @@ type UIElicitationArrayEnumField struct { MaxItems *float64 `json:"maxItems,omitempty"` MinItems *float64 `json:"minItems,omitempty"` Title *string `json:"title,omitempty"` - Type UIElicitationArrayAnyOfFieldType `json:"type"` + Type UIElicitationArrayEnumFieldType `json:"type"` } type UIElicitationArrayEnumFieldItems struct { @@ -1872,6 +1698,65 @@ type UIElicitationArrayEnumFieldItems struct { Type UIElicitationArrayEnumFieldItemsType `json:"type"` } +type UIElicitationFieldValue struct { + Bool *bool + Double *float64 + String *string + StringArray []string +} + +func (r UIElicitationFieldValue) MarshalJSON() ([]byte, error) { + if r.Bool != nil { + return json.Marshal(r.Bool) + } + if r.Double != nil { + return json.Marshal(r.Double) + } + if r.String != nil { + return json.Marshal(r.String) + } + if r.StringArray != nil { + return json.Marshal(r.StringArray) + } + return []byte("null"), nil +} + +func (r *UIElicitationFieldValue) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *r = UIElicitationFieldValue{} + return nil + } + { + var value bool + if err := json.Unmarshal(data, &value); err == nil { + *r = UIElicitationFieldValue{Bool: &value} + return nil + } + } + { + var value float64 + if err := json.Unmarshal(data, &value); err == nil { + *r = UIElicitationFieldValue{Double: &value} + return nil + } + } + { + var value string + if err := json.Unmarshal(data, &value); err == nil { + *r = UIElicitationFieldValue{String: &value} + return nil + } + } + { + var value []string + if err := json.Unmarshal(data, &value); err == nil { + *r = UIElicitationFieldValue{StringArray: value} + return nil + } + } + return errors.New("data did not match any union variant for UIElicitationFieldValue") +} + type UIElicitationRequest struct { // Message describing what information is needed from the user Message string `json:"message"` @@ -1879,6 +1764,23 @@ type UIElicitationRequest struct { RequestedSchema UIElicitationSchema `json:"requestedSchema"` } +// The elicitation response (accept with form values, decline, or cancel) +type UIElicitationResponse struct { + // The user's response: accept (submitted), decline (rejected), or cancel (dismissed) + Action UIElicitationResponseAction `json:"action"` + // The form values submitted by the user (present when action is 'accept') + Content map[string]*UIElicitationFieldValue `json:"content,omitempty"` +} + +// The form values submitted by the user (present when action is 'accept') +type UIElicitationResponseContent map[string]*UIElicitationFieldValue + +type UIElicitationResult struct { + // Whether the response was accepted. False if the request was already resolved by another + // client. + Success bool `json:"success"` +} + // JSON Schema describing the form fields to present to the user type UIElicitationSchema struct { // Form field definitions, keyed by field name @@ -1894,42 +1796,17 @@ type UIElicitationSchemaProperty struct { Description *string `json:"description,omitempty"` Enum []string `json:"enum,omitempty"` EnumNames []string `json:"enumNames,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationSchemaPropertyType `json:"type"` - OneOf []UIElicitationStringOneOfFieldOneOf `json:"oneOf,omitempty"` - Items *UIElicitationArrayFieldItems `json:"items,omitempty"` - MaxItems *float64 `json:"maxItems,omitempty"` - MinItems *float64 `json:"minItems,omitempty"` Format *UIElicitationSchemaPropertyStringFormat `json:"format,omitempty"` - MaxLength *float64 `json:"maxLength,omitempty"` - MinLength *float64 `json:"minLength,omitempty"` + Items *UIElicitationSchemaPropertyItems `json:"items,omitempty"` Maximum *float64 `json:"maximum,omitempty"` + MaxItems *float64 `json:"maxItems,omitempty"` + MaxLength *float64 `json:"maxLength,omitempty"` Minimum *float64 `json:"minimum,omitempty"` -} - -type UIElicitationArrayFieldItems struct { - Enum []string `json:"enum,omitempty"` - Type *UIElicitationArrayEnumFieldItemsType `json:"type,omitempty"` - AnyOf []UIElicitationArrayAnyOfFieldItemsAnyOf `json:"anyOf,omitempty"` -} - -type UIElicitationStringOneOfFieldOneOf struct { - Const string `json:"const"` - Title string `json:"title"` -} - -// The elicitation response (accept with form values, decline, or cancel) -type UIElicitationResponse struct { - // The user's response: accept (submitted), decline (rejected), or cancel (dismissed) - Action UIElicitationResponseAction `json:"action"` - // The form values submitted by the user (present when action is 'accept') - Content map[string]*UIElicitationFieldValue `json:"content,omitempty"` -} - -type UIElicitationResult struct { - // Whether the response was accepted. False if the request was already resolved by another - // client. - Success bool `json:"success"` + MinItems *float64 `json:"minItems,omitempty"` + MinLength *float64 `json:"minLength,omitempty"` + OneOf []UIElicitationStringOneOfFieldOneOf `json:"oneOf,omitempty"` + Title *string `json:"title,omitempty"` + Type UIElicitationSchemaPropertyType `json:"type"` } type UIElicitationSchemaPropertyBoolean struct { @@ -1939,13 +1816,19 @@ type UIElicitationSchemaPropertyBoolean struct { Type UIElicitationSchemaPropertyBooleanType `json:"type"` } +type UIElicitationSchemaPropertyItems struct { + AnyOf []UIElicitationArrayAnyOfFieldItemsAnyOf `json:"anyOf,omitempty"` + Enum []string `json:"enum,omitempty"` + Type *UIElicitationSchemaPropertyItemsType `json:"type,omitempty"` +} + type UIElicitationSchemaPropertyNumber struct { - Default *float64 `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationSchemaPropertyNumberTypeEnum `json:"type"` + Default *float64 `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Title *string `json:"title,omitempty"` + Type UIElicitationSchemaPropertyNumberType `json:"type"` } type UIElicitationSchemaPropertyString struct { @@ -1955,16 +1838,16 @@ type UIElicitationSchemaPropertyString struct { MaxLength *float64 `json:"maxLength,omitempty"` MinLength *float64 `json:"minLength,omitempty"` Title *string `json:"title,omitempty"` - Type UIElicitationArrayEnumFieldItemsType `json:"type"` + Type UIElicitationSchemaPropertyStringType `json:"type"` } type UIElicitationStringEnumField struct { - Default *string `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Enum []string `json:"enum"` - EnumNames []string `json:"enumNames,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationArrayEnumFieldItemsType `json:"type"` + Default *string `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Enum []string `json:"enum"` + EnumNames []string `json:"enumNames,omitempty"` + Title *string `json:"title,omitempty"` + Type UIElicitationStringEnumFieldType `json:"type"` } type UIElicitationStringOneOfField struct { @@ -1972,7 +1855,12 @@ type UIElicitationStringOneOfField struct { Description *string `json:"description,omitempty"` OneOf []UIElicitationStringOneOfFieldOneOf `json:"oneOf"` Title *string `json:"title,omitempty"` - Type UIElicitationArrayEnumFieldItemsType `json:"type"` + Type UIElicitationStringOneOfFieldType `json:"type"` +} + +type UIElicitationStringOneOfFieldOneOf struct { + Const string `json:"const"` + Title string `json:"title"` } type UIHandlePendingElicitationRequest struct { @@ -1982,7 +1870,8 @@ type UIHandlePendingElicitationRequest struct { Result UIElicitationResponse `json:"result"` } -// Experimental: UsageGetMetricsResult is part of an experimental API and may change or be removed. +// Experimental: UsageGetMetricsResult is part of an experimental API and may change or be +// removed. type UsageGetMetricsResult struct { // Aggregated code change metrics CodeChanges UsageMetricsCodeChanges `json:"codeChanges"` @@ -1999,7 +1888,7 @@ type UsageGetMetricsResult struct { // Session-wide per-token-type accumulated token counts TokenDetails map[string]UsageMetricsTokenDetail `json:"tokenDetails,omitempty"` // Total time spent in model API calls (milliseconds) - TotalAPIDurationMS float64 `json:"totalApiDurationMs"` + TotalAPIDurationMs float64 `json:"totalApiDurationMs"` // Session-wide accumulated nano-AI units cost TotalNanoAiu *int64 `json:"totalNanoAiu,omitempty"` // Total user-initiated premium request cost across all models (may be fractional due to @@ -2074,27 +1963,27 @@ type WorkspacesCreateFileResult struct { type WorkspacesGetWorkspaceResult struct { // Current workspace metadata, or null if not available - Workspace *WorkspaceClass `json:"workspace"` -} - -type WorkspaceClass struct { - Branch *string `json:"branch,omitempty"` - ChronicleSyncDismissed *bool `json:"chronicle_sync_dismissed,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - Cwd *string `json:"cwd,omitempty"` - GitRoot *string `json:"git_root,omitempty"` - HostType *HostType `json:"host_type,omitempty"` - ID string `json:"id"` - McLastEventID *string `json:"mc_last_event_id,omitempty"` - McSessionID *string `json:"mc_session_id,omitempty"` - McTaskID *string `json:"mc_task_id,omitempty"` - Name *string `json:"name,omitempty"` - RemoteSteerable *bool `json:"remote_steerable,omitempty"` - Repository *string `json:"repository,omitempty"` - Summary *string `json:"summary,omitempty"` - SummaryCount *int64 `json:"summary_count,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - UserNamed *bool `json:"user_named,omitempty"` + Workspace *WorkspacesGetWorkspaceResultWorkspace `json:"workspace"` +} + +type WorkspacesGetWorkspaceResultWorkspace struct { + Branch *string `json:"branch,omitempty"` + ChronicleSyncDismissed *bool `json:"chronicle_sync_dismissed,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Cwd *string `json:"cwd,omitempty"` + GitRoot *string `json:"git_root,omitempty"` + HostType *WorkspacesGetWorkspaceResultWorkspaceHostType `json:"host_type,omitempty"` + ID string `json:"id"` + McLastEventID *string `json:"mc_last_event_id,omitempty"` + McSessionID *string `json:"mc_session_id,omitempty"` + McTaskID *string `json:"mc_task_id,omitempty"` + Name *string `json:"name,omitempty"` + RemoteSteerable *bool `json:"remote_steerable,omitempty"` + Repository *string `json:"repository,omitempty"` + Summary *string `json:"summary,omitempty"` + SummaryCount *int64 `json:"summary_count,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + UserNamed *bool `json:"user_named,omitempty"` } type WorkspacesListFilesResult struct { @@ -2117,42 +2006,40 @@ type AuthInfoType string const ( AuthInfoTypeAPIKey AuthInfoType = "api-key" - AuthInfoTypeUser AuthInfoType = "user" AuthInfoTypeCopilotAPIToken AuthInfoType = "copilot-api-token" AuthInfoTypeEnv AuthInfoType = "env" AuthInfoTypeGhCli AuthInfoType = "gh-cli" AuthInfoTypeHmac AuthInfoType = "hmac" AuthInfoTypeToken AuthInfoType = "token" + AuthInfoTypeUser AuthInfoType = "user" ) // Configuration source -// -// Configuration source: user, workspace, plugin, or builtin -type MCPServerSource string +type DiscoveredMcpServerSource string const ( - MCPServerSourceBuiltin MCPServerSource = "builtin" - MCPServerSourceUser MCPServerSource = "user" - MCPServerSourcePlugin MCPServerSource = "plugin" - MCPServerSourceWorkspace MCPServerSource = "workspace" + DiscoveredMcpServerSourceBuiltin DiscoveredMcpServerSource = "builtin" + DiscoveredMcpServerSourcePlugin DiscoveredMcpServerSource = "plugin" + DiscoveredMcpServerSourceUser DiscoveredMcpServerSource = "user" + DiscoveredMcpServerSourceWorkspace DiscoveredMcpServerSource = "workspace" ) // Server transport type: stdio, http, sse, or memory (local configs are normalized to stdio) -type DiscoveredMCPServerType string +type DiscoveredMcpServerType string const ( - DiscoveredMCPServerTypeHTTP DiscoveredMCPServerType = "http" - DiscoveredMCPServerTypeMemory DiscoveredMCPServerType = "memory" - DiscoveredMCPServerTypeSSE DiscoveredMCPServerType = "sse" - DiscoveredMCPServerTypeStdio DiscoveredMCPServerType = "stdio" + DiscoveredMcpServerTypeHTTP DiscoveredMcpServerType = "http" + DiscoveredMcpServerTypeMemory DiscoveredMcpServerType = "memory" + DiscoveredMcpServerTypeSse DiscoveredMcpServerType = "sse" + DiscoveredMcpServerTypeStdio DiscoveredMcpServerType = "stdio" ) // Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/) type ExtensionSource string const ( - ExtensionSourceUser ExtensionSource = "user" ExtensionSourceProject ExtensionSource = "project" + ExtensionSourceUser ExtensionSource = "user" ) // Current status: running, disabled, failed, or starting @@ -2165,61 +2052,68 @@ const ( ExtensionStatusStarting ExtensionStatus = "starting" ) -// Theme variant this icon is intended for -type ExternalToolTextResultForLlmContentResourceLinkIconTheme string - -const ( - ExternalToolTextResultForLlmContentResourceLinkIconThemeDark ExternalToolTextResultForLlmContentResourceLinkIconTheme = "dark" - ExternalToolTextResultForLlmContentResourceLinkIconThemeLight ExternalToolTextResultForLlmContentResourceLinkIconTheme = "light" -) - -type ExternalToolTextResultForLlmContentType string - -const ( - ExternalToolTextResultForLlmContentTypeAudio ExternalToolTextResultForLlmContentType = "audio" - ExternalToolTextResultForLlmContentTypeImage ExternalToolTextResultForLlmContentType = "image" - ExternalToolTextResultForLlmContentTypeResource ExternalToolTextResultForLlmContentType = "resource" - ExternalToolTextResultForLlmContentTypeResourceLink ExternalToolTextResultForLlmContentType = "resource_link" - ExternalToolTextResultForLlmContentTypeTerminal ExternalToolTextResultForLlmContentType = "terminal" - ExternalToolTextResultForLlmContentTypeText ExternalToolTextResultForLlmContentType = "text" -) - +// Content block type discriminator type ExternalToolTextResultForLlmContentAudioType string const ( ExternalToolTextResultForLlmContentAudioTypeAudio ExternalToolTextResultForLlmContentAudioType = "audio" ) +// Content block type discriminator type ExternalToolTextResultForLlmContentImageType string const ( ExternalToolTextResultForLlmContentImageTypeImage ExternalToolTextResultForLlmContentImageType = "image" ) -type ExternalToolTextResultForLlmContentResourceType string +// Theme variant this icon is intended for +type ExternalToolTextResultForLlmContentResourceLinkIconTheme string const ( - ExternalToolTextResultForLlmContentResourceTypeResource ExternalToolTextResultForLlmContentResourceType = "resource" + ExternalToolTextResultForLlmContentResourceLinkIconThemeDark ExternalToolTextResultForLlmContentResourceLinkIconTheme = "dark" + ExternalToolTextResultForLlmContentResourceLinkIconThemeLight ExternalToolTextResultForLlmContentResourceLinkIconTheme = "light" ) +// Content block type discriminator type ExternalToolTextResultForLlmContentResourceLinkType string const ( ExternalToolTextResultForLlmContentResourceLinkTypeResourceLink ExternalToolTextResultForLlmContentResourceLinkType = "resource_link" ) +// Content block type discriminator +type ExternalToolTextResultForLlmContentResourceType string + +const ( + ExternalToolTextResultForLlmContentResourceTypeResource ExternalToolTextResultForLlmContentResourceType = "resource" +) + +// Content block type discriminator type ExternalToolTextResultForLlmContentTerminalType string const ( ExternalToolTextResultForLlmContentTerminalTypeTerminal ExternalToolTextResultForLlmContentTerminalType = "terminal" ) +// Content block type discriminator type ExternalToolTextResultForLlmContentTextType string const ( ExternalToolTextResultForLlmContentTextTypeText ExternalToolTextResultForLlmContentTextType = "text" ) +// Type discriminator for ExternalToolTextResultForLlmContent. +type ExternalToolTextResultForLlmContentType string + +const ( + ExternalToolTextResultForLlmContentTypeAudio ExternalToolTextResultForLlmContentType = "audio" + ExternalToolTextResultForLlmContentTypeImage ExternalToolTextResultForLlmContentType = "image" + ExternalToolTextResultForLlmContentTypeResource ExternalToolTextResultForLlmContentType = "resource" + ExternalToolTextResultForLlmContentTypeResourceLink ExternalToolTextResultForLlmContentType = "resource_link" + ExternalToolTextResultForLlmContentTypeTerminal ExternalToolTextResultForLlmContentType = "terminal" + ExternalToolTextResultForLlmContentTypeText ExternalToolTextResultForLlmContentType = "text" +) + type FilterMappingString string const ( @@ -2228,12 +2122,20 @@ const ( FilterMappingStringNone FilterMappingString = "none" ) +type FilterMappingValue string + +const ( + FilterMappingValueHiddenCharacters FilterMappingValue = "hidden_characters" + FilterMappingValueMarkdown FilterMappingValue = "markdown" + FilterMappingValueNone FilterMappingValue = "none" +) + // Where this source lives — used for UI grouping type InstructionsSourcesLocation string const ( - InstructionsSourcesLocationUser InstructionsSourcesLocation = "user" InstructionsSourcesLocationRepository InstructionsSourcesLocation = "repository" + InstructionsSourcesLocationUser InstructionsSourcesLocation = "user" InstructionsSourcesLocationWorkingDirectory InstructionsSourcesLocation = "working-directory" ) @@ -2249,98 +2151,57 @@ const ( InstructionsSourcesTypeVscode InstructionsSourcesType = "vscode" ) -// Log severity level. Determines how the message is displayed in the timeline. Defaults to -// "info". -type SessionLogLevel string - -const ( - SessionLogLevelError SessionLogLevel = "error" - SessionLogLevelInfo SessionLogLevel = "info" - SessionLogLevelWarning SessionLogLevel = "warning" -) - -type MCPServerConfigHTTPOauthGrantType string - -const ( - MCPServerConfigHTTPOauthGrantTypeAuthorizationCode MCPServerConfigHTTPOauthGrantType = "authorization_code" - MCPServerConfigHTTPOauthGrantTypeClientCredentials MCPServerConfigHTTPOauthGrantType = "client_credentials" -) - -// Remote transport type. Defaults to "http" when omitted. -type MCPServerConfigType string - -const ( - MCPServerConfigTypeHTTP MCPServerConfigType = "http" - MCPServerConfigTypeLocal MCPServerConfigType = "local" - MCPServerConfigTypeSSE MCPServerConfigType = "sse" - MCPServerConfigTypeStdio MCPServerConfigType = "stdio" -) - -// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured -type MCPServerStatus string +type McpServerConfigHTTPOauthGrantType string const ( - MCPServerStatusConnected MCPServerStatus = "connected" - MCPServerStatusDisabled MCPServerStatus = "disabled" - MCPServerStatusFailed MCPServerStatus = "failed" - MCPServerStatusNeedsAuth MCPServerStatus = "needs-auth" - MCPServerStatusNotConfigured MCPServerStatus = "not_configured" - MCPServerStatusPending MCPServerStatus = "pending" + McpServerConfigHTTPOauthGrantTypeAuthorizationCode McpServerConfigHTTPOauthGrantType = "authorization_code" + McpServerConfigHTTPOauthGrantTypeClientCredentials McpServerConfigHTTPOauthGrantType = "client_credentials" ) // Remote transport type. Defaults to "http" when omitted. -type MCPServerConfigHTTPType string +type McpServerConfigHTTPType string const ( - MCPServerConfigHTTPTypeHTTP MCPServerConfigHTTPType = "http" - MCPServerConfigHTTPTypeSSE MCPServerConfigHTTPType = "sse" + McpServerConfigHTTPTypeHTTP McpServerConfigHTTPType = "http" + McpServerConfigHTTPTypeSse McpServerConfigHTTPType = "sse" ) -type MCPServerConfigLocalType string +type McpServerConfigLocalType string const ( - MCPServerConfigLocalTypeLocal MCPServerConfigLocalType = "local" - MCPServerConfigLocalTypeStdio MCPServerConfigLocalType = "stdio" -) - -// The agent mode. Valid values: "interactive", "plan", "autopilot". -type SessionMode string - -const ( - SessionModeAutopilot SessionMode = "autopilot" - SessionModeInteractive SessionMode = "interactive" - SessionModePlan SessionMode = "plan" + McpServerConfigLocalTypeLocal McpServerConfigLocalType = "local" + McpServerConfigLocalTypeStdio McpServerConfigLocalType = "stdio" ) -type ApprovalKind string +type McpServerConfigType string const ( - ApprovalKindCommands ApprovalKind = "commands" - ApprovalKindCustomTool ApprovalKind = "custom-tool" - ApprovalKindExtensionManagement ApprovalKind = "extension-management" - ApprovalKindExtensionPermissionAccess ApprovalKind = "extension-permission-access" - ApprovalKindMcp ApprovalKind = "mcp" - ApprovalKindMcpSampling ApprovalKind = "mcp-sampling" - ApprovalKindMemory ApprovalKind = "memory" - ApprovalKindRead ApprovalKind = "read" - ApprovalKindWrite ApprovalKind = "write" + McpServerConfigTypeHTTP McpServerConfigType = "http" + McpServerConfigTypeLocal McpServerConfigType = "local" + McpServerConfigTypeSse McpServerConfigType = "sse" + McpServerConfigTypeStdio McpServerConfigType = "stdio" ) -type PermissionDecisionKind string +// Configuration source: user, workspace, plugin, or builtin +type McpServerSource string const ( - PermissionDecisionKindApproveForLocation PermissionDecisionKind = "approve-for-location" - PermissionDecisionKindApproveForSession PermissionDecisionKind = "approve-for-session" - PermissionDecisionKindApproveOnce PermissionDecisionKind = "approve-once" - PermissionDecisionKindApprovePermanently PermissionDecisionKind = "approve-permanently" - PermissionDecisionKindReject PermissionDecisionKind = "reject" - PermissionDecisionKindUserNotAvailable PermissionDecisionKind = "user-not-available" + McpServerSourceBuiltin McpServerSource = "builtin" + McpServerSourcePlugin McpServerSource = "plugin" + McpServerSourceUser McpServerSource = "user" + McpServerSourceWorkspace McpServerSource = "workspace" ) -type PermissionDecisionApproveForLocationKind string +// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured +type McpServerStatus string const ( - PermissionDecisionApproveForLocationKindApproveForLocation PermissionDecisionApproveForLocationKind = "approve-for-location" + McpServerStatusConnected McpServerStatus = "connected" + McpServerStatusDisabled McpServerStatus = "disabled" + McpServerStatusFailed McpServerStatus = "failed" + McpServerStatusNeedsAuth McpServerStatus = "needs-auth" + McpServerStatusNotConfigured McpServerStatus = "not_configured" + McpServerStatusPending McpServerStatus = "pending" ) type PermissionDecisionApproveForLocationApprovalCommandsKind string @@ -2367,16 +2228,31 @@ const ( PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKindExtensionPermissionAccess PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind = "extension-permission-access" ) -type PermissionDecisionApproveForLocationApprovalMCPKind string +// Kind discriminator for PermissionDecisionApproveForLocationApproval. +type PermissionDecisionApproveForLocationApprovalKind string + +const ( + PermissionDecisionApproveForLocationApprovalKindCommands PermissionDecisionApproveForLocationApprovalKind = "commands" + PermissionDecisionApproveForLocationApprovalKindCustomTool PermissionDecisionApproveForLocationApprovalKind = "custom-tool" + PermissionDecisionApproveForLocationApprovalKindExtensionManagement PermissionDecisionApproveForLocationApprovalKind = "extension-management" + PermissionDecisionApproveForLocationApprovalKindExtensionPermissionAccess PermissionDecisionApproveForLocationApprovalKind = "extension-permission-access" + PermissionDecisionApproveForLocationApprovalKindMcp PermissionDecisionApproveForLocationApprovalKind = "mcp" + PermissionDecisionApproveForLocationApprovalKindMcpSampling PermissionDecisionApproveForLocationApprovalKind = "mcp-sampling" + PermissionDecisionApproveForLocationApprovalKindMemory PermissionDecisionApproveForLocationApprovalKind = "memory" + PermissionDecisionApproveForLocationApprovalKindRead PermissionDecisionApproveForLocationApprovalKind = "read" + PermissionDecisionApproveForLocationApprovalKindWrite PermissionDecisionApproveForLocationApprovalKind = "write" +) + +type PermissionDecisionApproveForLocationApprovalMcpKind string const ( - PermissionDecisionApproveForLocationApprovalMCPKindMcp PermissionDecisionApproveForLocationApprovalMCPKind = "mcp" + PermissionDecisionApproveForLocationApprovalMcpKindMcp PermissionDecisionApproveForLocationApprovalMcpKind = "mcp" ) -type PermissionDecisionApproveForLocationApprovalMCPSamplingKind string +type PermissionDecisionApproveForLocationApprovalMcpSamplingKind string const ( - PermissionDecisionApproveForLocationApprovalMCPSamplingKindMcpSampling PermissionDecisionApproveForLocationApprovalMCPSamplingKind = "mcp-sampling" + PermissionDecisionApproveForLocationApprovalMcpSamplingKindMcpSampling PermissionDecisionApproveForLocationApprovalMcpSamplingKind = "mcp-sampling" ) type PermissionDecisionApproveForLocationApprovalMemoryKind string @@ -2397,105 +2273,208 @@ const ( PermissionDecisionApproveForLocationApprovalWriteKindWrite PermissionDecisionApproveForLocationApprovalWriteKind = "write" ) -type PermissionDecisionApproveForSessionKind string +// Approved and persisted for this project location +type PermissionDecisionApproveForLocationKind string const ( - PermissionDecisionApproveForSessionKindApproveForSession PermissionDecisionApproveForSessionKind = "approve-for-session" + PermissionDecisionApproveForLocationKindApproveForLocation PermissionDecisionApproveForLocationKind = "approve-for-location" ) -type PermissionDecisionApproveOnceKind string +type PermissionDecisionApproveForSessionApprovalCommandsKind string const ( - PermissionDecisionApproveOnceKindApproveOnce PermissionDecisionApproveOnceKind = "approve-once" + PermissionDecisionApproveForSessionApprovalCommandsKindCommands PermissionDecisionApproveForSessionApprovalCommandsKind = "commands" ) -type PermissionDecisionApprovePermanentlyKind string +type PermissionDecisionApproveForSessionApprovalCustomToolKind string const ( - PermissionDecisionApprovePermanentlyKindApprovePermanently PermissionDecisionApprovePermanentlyKind = "approve-permanently" + PermissionDecisionApproveForSessionApprovalCustomToolKindCustomTool PermissionDecisionApproveForSessionApprovalCustomToolKind = "custom-tool" ) -type PermissionDecisionRejectKind string +type PermissionDecisionApproveForSessionApprovalExtensionManagementKind string const ( - PermissionDecisionRejectKindReject PermissionDecisionRejectKind = "reject" + PermissionDecisionApproveForSessionApprovalExtensionManagementKindExtensionManagement PermissionDecisionApproveForSessionApprovalExtensionManagementKind = "extension-management" ) -type PermissionDecisionUserNotAvailableKind string +type PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind string const ( - PermissionDecisionUserNotAvailableKindUserNotAvailable PermissionDecisionUserNotAvailableKind = "user-not-available" + PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKindExtensionPermissionAccess PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind = "extension-permission-access" ) -// Error classification -type SessionFSErrorCode string +// Kind discriminator for PermissionDecisionApproveForSessionApproval. +type PermissionDecisionApproveForSessionApprovalKind string const ( - SessionFSErrorCodeENOENT SessionFSErrorCode = "ENOENT" - SessionFSErrorCodeUNKNOWN SessionFSErrorCode = "UNKNOWN" + PermissionDecisionApproveForSessionApprovalKindCommands PermissionDecisionApproveForSessionApprovalKind = "commands" + PermissionDecisionApproveForSessionApprovalKindCustomTool PermissionDecisionApproveForSessionApprovalKind = "custom-tool" + PermissionDecisionApproveForSessionApprovalKindExtensionManagement PermissionDecisionApproveForSessionApprovalKind = "extension-management" + PermissionDecisionApproveForSessionApprovalKindExtensionPermissionAccess PermissionDecisionApproveForSessionApprovalKind = "extension-permission-access" + PermissionDecisionApproveForSessionApprovalKindMcp PermissionDecisionApproveForSessionApprovalKind = "mcp" + PermissionDecisionApproveForSessionApprovalKindMcpSampling PermissionDecisionApproveForSessionApprovalKind = "mcp-sampling" + PermissionDecisionApproveForSessionApprovalKindMemory PermissionDecisionApproveForSessionApprovalKind = "memory" + PermissionDecisionApproveForSessionApprovalKindRead PermissionDecisionApproveForSessionApprovalKind = "read" + PermissionDecisionApproveForSessionApprovalKindWrite PermissionDecisionApproveForSessionApprovalKind = "write" ) -// Entry type -type SessionFSReaddirWithTypesEntryType string +type PermissionDecisionApproveForSessionApprovalMcpKind string const ( - SessionFSReaddirWithTypesEntryTypeDirectory SessionFSReaddirWithTypesEntryType = "directory" - SessionFSReaddirWithTypesEntryTypeFile SessionFSReaddirWithTypesEntryType = "file" + PermissionDecisionApproveForSessionApprovalMcpKindMcp PermissionDecisionApproveForSessionApprovalMcpKind = "mcp" ) -// Path conventions used by this filesystem -type SessionFSSetProviderConventions string +type PermissionDecisionApproveForSessionApprovalMcpSamplingKind string const ( - SessionFSSetProviderConventionsPosix SessionFSSetProviderConventions = "posix" - SessionFSSetProviderConventionsWindows SessionFSSetProviderConventions = "windows" + PermissionDecisionApproveForSessionApprovalMcpSamplingKindMcpSampling PermissionDecisionApproveForSessionApprovalMcpSamplingKind = "mcp-sampling" ) -// Signal to send (default: SIGTERM) -type ShellKillSignal string +type PermissionDecisionApproveForSessionApprovalMemoryKind string const ( - ShellKillSignalSIGINT ShellKillSignal = "SIGINT" - ShellKillSignalSIGKILL ShellKillSignal = "SIGKILL" - ShellKillSignalSIGTERM ShellKillSignal = "SIGTERM" + PermissionDecisionApproveForSessionApprovalMemoryKindMemory PermissionDecisionApproveForSessionApprovalMemoryKind = "memory" ) -// How the agent is currently being managed by the runtime -// -// Whether the shell command is currently sync-waited or background-managed -type TaskInfoExecutionMode string +type PermissionDecisionApproveForSessionApprovalReadKind string const ( - TaskInfoExecutionModeBackground TaskInfoExecutionMode = "background" - TaskInfoExecutionModeSync TaskInfoExecutionMode = "sync" + PermissionDecisionApproveForSessionApprovalReadKindRead PermissionDecisionApproveForSessionApprovalReadKind = "read" ) -// Current lifecycle status of the task -type TaskInfoStatus string +type PermissionDecisionApproveForSessionApprovalWriteKind string const ( - TaskInfoStatusCancelled TaskInfoStatus = "cancelled" - TaskInfoStatusCompleted TaskInfoStatus = "completed" - TaskInfoStatusIdle TaskInfoStatus = "idle" - TaskInfoStatusFailed TaskInfoStatus = "failed" - TaskInfoStatusRunning TaskInfoStatus = "running" + PermissionDecisionApproveForSessionApprovalWriteKindWrite PermissionDecisionApproveForSessionApprovalWriteKind = "write" ) -type TaskAgentInfoType string +// Approved and remembered for the rest of the session +type PermissionDecisionApproveForSessionKind string const ( - TaskAgentInfoTypeAgent TaskAgentInfoType = "agent" + PermissionDecisionApproveForSessionKindApproveForSession PermissionDecisionApproveForSessionKind = "approve-for-session" ) -// Whether the shell runs inside a managed PTY session or as an independent background -// process -type TaskShellInfoAttachmentMode string +// The permission request was approved for this one instance +type PermissionDecisionApproveOnceKind string const ( - TaskShellInfoAttachmentModeAttached TaskShellInfoAttachmentMode = "attached" - TaskShellInfoAttachmentModeDetached TaskShellInfoAttachmentMode = "detached" + PermissionDecisionApproveOnceKindApproveOnce PermissionDecisionApproveOnceKind = "approve-once" +) + +// Approved and persisted across sessions +type PermissionDecisionApprovePermanentlyKind string + +const ( + PermissionDecisionApprovePermanentlyKindApprovePermanently PermissionDecisionApprovePermanentlyKind = "approve-permanently" +) + +// Kind discriminator for PermissionDecision. +type PermissionDecisionKind string + +const ( + PermissionDecisionKindApproveForLocation PermissionDecisionKind = "approve-for-location" + PermissionDecisionKindApproveForSession PermissionDecisionKind = "approve-for-session" + PermissionDecisionKindApproveOnce PermissionDecisionKind = "approve-once" + PermissionDecisionKindApprovePermanently PermissionDecisionKind = "approve-permanently" + PermissionDecisionKindReject PermissionDecisionKind = "reject" + PermissionDecisionKindUserNotAvailable PermissionDecisionKind = "user-not-available" +) + +// Denied by the user during an interactive prompt +type PermissionDecisionRejectKind string + +const ( + PermissionDecisionRejectKindReject PermissionDecisionRejectKind = "reject" +) + +// Denied because user confirmation was unavailable +type PermissionDecisionUserNotAvailableKind string + +const ( + PermissionDecisionUserNotAvailableKindUserNotAvailable PermissionDecisionUserNotAvailableKind = "user-not-available" +) + +// Error classification +type SessionFsErrorCode string + +const ( + SessionFsErrorCodeENOENT SessionFsErrorCode = "ENOENT" + SessionFsErrorCodeUNKNOWN SessionFsErrorCode = "UNKNOWN" +) + +// Entry type +type SessionFsReaddirWithTypesEntryType string + +const ( + SessionFsReaddirWithTypesEntryTypeDirectory SessionFsReaddirWithTypesEntryType = "directory" + SessionFsReaddirWithTypesEntryTypeFile SessionFsReaddirWithTypesEntryType = "file" +) + +// Path conventions used by this filesystem +type SessionFsSetProviderConventions string + +const ( + SessionFsSetProviderConventionsPosix SessionFsSetProviderConventions = "posix" + SessionFsSetProviderConventionsWindows SessionFsSetProviderConventions = "windows" +) + +// Log severity level. Determines how the message is displayed in the timeline. Defaults to +// "info". +type SessionLogLevel string + +const ( + SessionLogLevelError SessionLogLevel = "error" + SessionLogLevelInfo SessionLogLevel = "info" + SessionLogLevelWarning SessionLogLevel = "warning" +) + +// The agent mode. Valid values: "interactive", "plan", "autopilot". +type SessionMode string + +const ( + SessionModeAutopilot SessionMode = "autopilot" + SessionModeInteractive SessionMode = "interactive" + SessionModePlan SessionMode = "plan" +) + +// Signal to send (default: SIGTERM) +type ShellKillSignal string + +const ( + ShellKillSignalSIGINT ShellKillSignal = "SIGINT" + ShellKillSignalSIGKILL ShellKillSignal = "SIGKILL" + ShellKillSignalSIGTERM ShellKillSignal = "SIGTERM" ) +// How the agent is currently being managed by the runtime +type TaskAgentInfoExecutionMode string + +const ( + TaskAgentInfoExecutionModeBackground TaskAgentInfoExecutionMode = "background" + TaskAgentInfoExecutionModeSync TaskAgentInfoExecutionMode = "sync" +) + +// Current lifecycle status of the task +type TaskAgentInfoStatus string + +const ( + TaskAgentInfoStatusCancelled TaskAgentInfoStatus = "cancelled" + TaskAgentInfoStatusCompleted TaskAgentInfoStatus = "completed" + TaskAgentInfoStatusFailed TaskAgentInfoStatus = "failed" + TaskAgentInfoStatusIdle TaskAgentInfoStatus = "idle" + TaskAgentInfoStatusRunning TaskAgentInfoStatus = "running" +) + +// Task kind +type TaskAgentInfoType string + +const ( + TaskAgentInfoTypeAgent TaskAgentInfoType = "agent" +) + +// Type discriminator for TaskInfo. type TaskInfoType string const ( @@ -2503,6 +2482,35 @@ const ( TaskInfoTypeShell TaskInfoType = "shell" ) +// Whether the shell runs inside a managed PTY session or as an independent background +// process +type TaskShellInfoAttachmentMode string + +const ( + TaskShellInfoAttachmentModeAttached TaskShellInfoAttachmentMode = "attached" + TaskShellInfoAttachmentModeDetached TaskShellInfoAttachmentMode = "detached" +) + +// Whether the shell command is currently sync-waited or background-managed +type TaskShellInfoExecutionMode string + +const ( + TaskShellInfoExecutionModeBackground TaskShellInfoExecutionMode = "background" + TaskShellInfoExecutionModeSync TaskShellInfoExecutionMode = "sync" +) + +// Current lifecycle status of the task +type TaskShellInfoStatus string + +const ( + TaskShellInfoStatusCancelled TaskShellInfoStatus = "cancelled" + TaskShellInfoStatusCompleted TaskShellInfoStatus = "completed" + TaskShellInfoStatusFailed TaskShellInfoStatus = "failed" + TaskShellInfoStatusIdle TaskShellInfoStatus = "idle" + TaskShellInfoStatusRunning TaskShellInfoStatus = "running" +) + +// Task kind type TaskShellInfoType string const ( @@ -2521,6 +2529,40 @@ const ( UIElicitationArrayEnumFieldItemsTypeString UIElicitationArrayEnumFieldItemsType = "string" ) +type UIElicitationArrayEnumFieldType string + +const ( + UIElicitationArrayEnumFieldTypeArray UIElicitationArrayEnumFieldType = "array" +) + +// The user's response: accept (submitted), decline (rejected), or cancel (dismissed) +type UIElicitationResponseAction string + +const ( + UIElicitationResponseActionAccept UIElicitationResponseAction = "accept" + UIElicitationResponseActionCancel UIElicitationResponseAction = "cancel" + UIElicitationResponseActionDecline UIElicitationResponseAction = "decline" +) + +type UIElicitationSchemaPropertyBooleanType string + +const ( + UIElicitationSchemaPropertyBooleanTypeBoolean UIElicitationSchemaPropertyBooleanType = "boolean" +) + +type UIElicitationSchemaPropertyItemsType string + +const ( + UIElicitationSchemaPropertyItemsTypeString UIElicitationSchemaPropertyItemsType = "string" +) + +type UIElicitationSchemaPropertyNumberType string + +const ( + UIElicitationSchemaPropertyNumberTypeInteger UIElicitationSchemaPropertyNumberType = "integer" + UIElicitationSchemaPropertyNumberTypeNumber UIElicitationSchemaPropertyNumberType = "number" +) + type UIElicitationSchemaPropertyStringFormat string const ( @@ -2530,207 +2572,201 @@ const ( UIElicitationSchemaPropertyStringFormatURI UIElicitationSchemaPropertyStringFormat = "uri" ) +type UIElicitationSchemaPropertyStringType string + +const ( + UIElicitationSchemaPropertyStringTypeString UIElicitationSchemaPropertyStringType = "string" +) + type UIElicitationSchemaPropertyType string const ( - UIElicitationSchemaPropertyTypeInteger UIElicitationSchemaPropertyType = "integer" - UIElicitationSchemaPropertyTypeNumber UIElicitationSchemaPropertyType = "number" UIElicitationSchemaPropertyTypeArray UIElicitationSchemaPropertyType = "array" UIElicitationSchemaPropertyTypeBoolean UIElicitationSchemaPropertyType = "boolean" + UIElicitationSchemaPropertyTypeInteger UIElicitationSchemaPropertyType = "integer" + UIElicitationSchemaPropertyTypeNumber UIElicitationSchemaPropertyType = "number" UIElicitationSchemaPropertyTypeString UIElicitationSchemaPropertyType = "string" ) +// Schema type indicator (always 'object') type UIElicitationSchemaType string const ( UIElicitationSchemaTypeObject UIElicitationSchemaType = "object" ) -// The user's response: accept (submitted), decline (rejected), or cancel (dismissed) -type UIElicitationResponseAction string +type UIElicitationStringEnumFieldType string const ( - UIElicitationResponseActionAccept UIElicitationResponseAction = "accept" - UIElicitationResponseActionCancel UIElicitationResponseAction = "cancel" - UIElicitationResponseActionDecline UIElicitationResponseAction = "decline" + UIElicitationStringEnumFieldTypeString UIElicitationStringEnumFieldType = "string" ) -type UIElicitationSchemaPropertyBooleanType string +type UIElicitationStringOneOfFieldType string const ( - UIElicitationSchemaPropertyBooleanTypeBoolean UIElicitationSchemaPropertyBooleanType = "boolean" + UIElicitationStringOneOfFieldTypeString UIElicitationStringOneOfFieldType = "string" ) -type UIElicitationSchemaPropertyNumberTypeEnum string +type WorkspacesGetWorkspaceResultWorkspaceHostType string const ( - UIElicitationSchemaPropertyNumberTypeEnumInteger UIElicitationSchemaPropertyNumberTypeEnum = "integer" - UIElicitationSchemaPropertyNumberTypeEnumNumber UIElicitationSchemaPropertyNumberTypeEnum = "number" + WorkspacesGetWorkspaceResultWorkspaceHostTypeAdo WorkspacesGetWorkspaceResultWorkspaceHostType = "ado" + WorkspacesGetWorkspaceResultWorkspaceHostTypeGithub WorkspacesGetWorkspaceResultWorkspaceHostType = "github" ) -type HostType string - -const ( - HostTypeAdo HostType = "ado" - HostTypeGithub HostType = "github" -) - -// Tool call result (string or expanded result object) -type ExternalToolResult struct { - ExternalToolTextResultForLlm *ExternalToolTextResultForLlm - String *string -} - -type FilterMapping struct { - Enum *FilterMappingString - EnumMap map[string]FilterMappingString -} - -type UIElicitationFieldValue struct { - Bool *bool - Double *float64 - String *string - StringArray []string -} - type serverApi struct { client *jsonrpc2.Client } -type ServerModelsApi serverApi +type ServerAccountApi serverApi -func (a *ServerModelsApi) List(ctx context.Context, params *ModelsListRequest) (*ModelList, error) { - raw, err := a.client.Request("models.list", params) +func (a *ServerAccountApi) GetQuota(ctx context.Context, params *AccountGetQuotaRequest) (*AccountGetQuotaResult, error) { + raw, err := a.client.Request("account.getQuota", params) if err != nil { return nil, err } - var result ModelList + var result AccountGetQuotaResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ServerToolsApi serverApi +type ServerMcpApi serverApi -func (a *ServerToolsApi) List(ctx context.Context, params *ToolsListRequest) (*ToolList, error) { - raw, err := a.client.Request("tools.list", params) +func (a *ServerMcpApi) Discover(ctx context.Context, params *McpDiscoverRequest) (*McpDiscoverResult, error) { + raw, err := a.client.Request("mcp.discover", params) if err != nil { return nil, err } - var result ToolList + var result McpDiscoverResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ServerAccountApi serverApi +type ServerMcpConfigApi serverApi -func (a *ServerAccountApi) GetQuota(ctx context.Context, params *AccountGetQuotaRequest) (*AccountGetQuotaResult, error) { - raw, err := a.client.Request("account.getQuota", params) +func (a *ServerMcpConfigApi) Add(ctx context.Context, params *McpConfigAddRequest) (*McpConfigAddResult, error) { + raw, err := a.client.Request("mcp.config.add", params) if err != nil { return nil, err } - var result AccountGetQuotaResult + var result McpConfigAddResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ServerMcpApi serverApi - -func (a *ServerMcpApi) Discover(ctx context.Context, params *MCPDiscoverRequest) (*MCPDiscoverResult, error) { - raw, err := a.client.Request("mcp.discover", params) +func (a *ServerMcpConfigApi) Disable(ctx context.Context, params *McpConfigDisableRequest) (*McpConfigDisableResult, error) { + raw, err := a.client.Request("mcp.config.disable", params) if err != nil { return nil, err } - var result MCPDiscoverResult + var result McpConfigDisableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ServerMcpConfigApi serverApi +func (a *ServerMcpConfigApi) Enable(ctx context.Context, params *McpConfigEnableRequest) (*McpConfigEnableResult, error) { + raw, err := a.client.Request("mcp.config.enable", params) + if err != nil { + return nil, err + } + var result McpConfigEnableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} -func (a *ServerMcpConfigApi) List(ctx context.Context) (*MCPConfigList, error) { +func (a *ServerMcpConfigApi) List(ctx context.Context) (*McpConfigList, error) { raw, err := a.client.Request("mcp.config.list", nil) if err != nil { return nil, err } - var result MCPConfigList + var result McpConfigList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ServerMcpConfigApi) Add(ctx context.Context, params *MCPConfigAddRequest) (*MCPConfigAddResult, error) { - raw, err := a.client.Request("mcp.config.add", params) +func (a *ServerMcpConfigApi) Remove(ctx context.Context, params *McpConfigRemoveRequest) (*McpConfigRemoveResult, error) { + raw, err := a.client.Request("mcp.config.remove", params) if err != nil { return nil, err } - var result MCPConfigAddResult + var result McpConfigRemoveResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ServerMcpConfigApi) Update(ctx context.Context, params *MCPConfigUpdateRequest) (*MCPConfigUpdateResult, error) { +func (a *ServerMcpConfigApi) Update(ctx context.Context, params *McpConfigUpdateRequest) (*McpConfigUpdateResult, error) { raw, err := a.client.Request("mcp.config.update", params) if err != nil { return nil, err } - var result MCPConfigUpdateResult + var result McpConfigUpdateResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ServerMcpConfigApi) Remove(ctx context.Context, params *MCPConfigRemoveRequest) (*MCPConfigRemoveResult, error) { - raw, err := a.client.Request("mcp.config.remove", params) +func (s *ServerMcpApi) Config() *ServerMcpConfigApi { + return (*ServerMcpConfigApi)(s) +} + +type ServerModelsApi serverApi + +func (a *ServerModelsApi) List(ctx context.Context, params *ModelsListRequest) (*ModelList, error) { + raw, err := a.client.Request("models.list", params) if err != nil { return nil, err } - var result MCPConfigRemoveResult + var result ModelList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ServerMcpConfigApi) Enable(ctx context.Context, params *MCPConfigEnableRequest) (*MCPConfigEnableResult, error) { - raw, err := a.client.Request("mcp.config.enable", params) +type ServerSessionFsApi serverApi + +func (a *ServerSessionFsApi) SetProvider(ctx context.Context, params *SessionFsSetProviderRequest) (*SessionFsSetProviderResult, error) { + raw, err := a.client.Request("sessionFs.setProvider", params) if err != nil { return nil, err } - var result MCPConfigEnableResult + var result SessionFsSetProviderResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ServerMcpConfigApi) Disable(ctx context.Context, params *MCPConfigDisableRequest) (*MCPConfigDisableResult, error) { - raw, err := a.client.Request("mcp.config.disable", params) +// Experimental: ServerSessionsApi contains experimental APIs that may change or be removed. +type ServerSessionsApi serverApi + +func (a *ServerSessionsApi) Fork(ctx context.Context, params *SessionsForkRequest) (*SessionsForkResult, error) { + raw, err := a.client.Request("sessions.fork", params) if err != nil { return nil, err } - var result MCPConfigDisableResult + var result SessionsForkResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (s *ServerMcpApi) Config() *ServerMcpConfigApi { - return (*ServerMcpConfigApi)(s) -} - type ServerSkillsApi serverApi func (a *ServerSkillsApi) Discover(ctx context.Context, params *SkillsDiscoverRequest) (*ServerSkillList, error) { @@ -2763,29 +2799,14 @@ func (s *ServerSkillsApi) Config() *ServerSkillsConfigApi { return (*ServerSkillsConfigApi)(s) } -type ServerSessionFsApi serverApi - -func (a *ServerSessionFsApi) SetProvider(ctx context.Context, params *SessionFSSetProviderRequest) (*SessionFSSetProviderResult, error) { - raw, err := a.client.Request("sessionFs.setProvider", params) - if err != nil { - return nil, err - } - var result SessionFSSetProviderResult - if err := json.Unmarshal(raw, &result); err != nil { - return nil, err - } - return &result, nil -} - -// Experimental: ServerSessionsApi contains experimental APIs that may change or be removed. -type ServerSessionsApi serverApi +type ServerToolsApi serverApi -func (a *ServerSessionsApi) Fork(ctx context.Context, params *SessionsForkRequest) (*SessionsForkResult, error) { - raw, err := a.client.Request("sessions.fork", params) +func (a *ServerToolsApi) List(ctx context.Context, params *ToolsListRequest) (*ToolList, error) { + raw, err := a.client.Request("tools.list", params) if err != nil { return nil, err } - var result SessionsForkResult + var result ToolList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } @@ -2794,15 +2815,16 @@ func (a *ServerSessionsApi) Fork(ctx context.Context, params *SessionsForkReques // ServerRpc provides typed server-scoped RPC methods. type ServerRpc struct { - common serverApi // Reuse a single struct instead of allocating one for each service on the heap. + // Reuse a single struct instead of allocating one for each service on the heap. + common serverApi - Models *ServerModelsApi - Tools *ServerToolsApi Account *ServerAccountApi Mcp *ServerMcpApi - Skills *ServerSkillsApi + Models *ServerModelsApi SessionFs *ServerSessionFsApi Sessions *ServerSessionsApi + Skills *ServerSkillsApi + Tools *ServerToolsApi } func (a *ServerRpc) Ping(ctx context.Context, params *PingRequest) (*PingResult, error) { @@ -2820,13 +2842,13 @@ func (a *ServerRpc) Ping(ctx context.Context, params *PingRequest) (*PingResult, func NewServerRpc(client *jsonrpc2.Client) *ServerRpc { r := &ServerRpc{} r.common = serverApi{client: client} - r.Models = (*ServerModelsApi)(&r.common) - r.Tools = (*ServerToolsApi)(&r.common) r.Account = (*ServerAccountApi)(&r.common) r.Mcp = (*ServerMcpApi)(&r.common) - r.Skills = (*ServerSkillsApi)(&r.common) + r.Models = (*ServerModelsApi)(&r.common) r.SessionFs = (*ServerSessionFsApi)(&r.common) r.Sessions = (*ServerSessionsApi)(&r.common) + r.Skills = (*ServerSkillsApi)(&r.common) + r.Tools = (*ServerToolsApi)(&r.common) return r } @@ -2834,13 +2856,15 @@ type internalServerApi struct { client *jsonrpc2.Client } -// InternalServerRpc provides internal SDK server-scoped RPC methods (handshake helpers etc.). Not part of the public API. +// InternalServerRpc provides internal SDK server-scoped RPC methods (handshake helpers +// etc.). Not part of the public API. type InternalServerRpc struct { - common internalServerApi // Reuse a single struct instead of allocating one for each service on the heap. - + // Reuse a single struct instead of allocating one for each service on the heap. + common internalServerApi } -// Internal: Connect is part of the SDK's internal handshake/plumbing; external callers should not use it. +// Internal: Connect is part of the SDK's internal handshake/plumbing; external callers +// should not use it. func (a *InternalServerRpc) Connect(ctx context.Context, params *ConnectRequest) (*ConnectResult, error) { raw, err := a.common.client.Request("connect", params) if err != nil { @@ -2864,219 +2888,221 @@ type sessionApi struct { sessionID string } -type AuthApi sessionApi +// Experimental: AgentApi contains experimental APIs that may change or be removed. +type AgentApi sessionApi -func (a *AuthApi) GetStatus(ctx context.Context) (*SessionAuthStatus, error) { +func (a *AgentApi) Deselect(ctx context.Context) (*AgentDeselectResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.auth.getStatus", req) + raw, err := a.client.Request("session.agent.deselect", req) if err != nil { return nil, err } - var result SessionAuthStatus + var result AgentDeselectResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ModelApi sessionApi - -func (a *ModelApi) GetCurrent(ctx context.Context) (*CurrentModel, error) { +func (a *AgentApi) GetCurrent(ctx context.Context) (*AgentGetCurrentResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.model.getCurrent", req) + raw, err := a.client.Request("session.agent.getCurrent", req) if err != nil { return nil, err } - var result CurrentModel + var result AgentGetCurrentResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ModelApi) SwitchTo(ctx context.Context, params *ModelSwitchToRequest) (*ModelSwitchToResult, error) { +func (a *AgentApi) List(ctx context.Context) (*AgentList, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["modelId"] = params.ModelID - if params.ReasoningEffort != nil { - req["reasoningEffort"] = *params.ReasoningEffort - } - if params.ModelCapabilities != nil { - req["modelCapabilities"] = *params.ModelCapabilities - } - } - raw, err := a.client.Request("session.model.switchTo", req) + raw, err := a.client.Request("session.agent.list", req) if err != nil { return nil, err } - var result ModelSwitchToResult + var result AgentList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ModeApi sessionApi - -func (a *ModeApi) Get(ctx context.Context) (*SessionMode, error) { +func (a *AgentApi) Reload(ctx context.Context) (*AgentReloadResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.mode.get", req) + raw, err := a.client.Request("session.agent.reload", req) if err != nil { return nil, err } - var result SessionMode + var result AgentReloadResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ModeApi) Set(ctx context.Context, params *ModeSetRequest) (*ModeSetResult, error) { +func (a *AgentApi) Select(ctx context.Context, params *AgentSelectRequest) (*AgentSelectResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["mode"] = params.Mode + req["name"] = params.Name } - raw, err := a.client.Request("session.mode.set", req) + raw, err := a.client.Request("session.agent.select", req) if err != nil { return nil, err } - var result ModeSetResult + var result AgentSelectResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type NameApi sessionApi +type AuthApi sessionApi -func (a *NameApi) Get(ctx context.Context) (*NameGetResult, error) { +func (a *AuthApi) GetStatus(ctx context.Context) (*SessionAuthStatus, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.name.get", req) + raw, err := a.client.Request("session.auth.getStatus", req) if err != nil { return nil, err } - var result NameGetResult + var result SessionAuthStatus if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *NameApi) Set(ctx context.Context, params *NameSetRequest) (*NameSetResult, error) { +type CommandsApi sessionApi + +func (a *CommandsApi) HandlePendingCommand(ctx context.Context, params *CommandsHandlePendingCommandRequest) (*CommandsHandlePendingCommandResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["name"] = params.Name + if params.Error != nil { + req["error"] = *params.Error + } + req["requestId"] = params.RequestID } - raw, err := a.client.Request("session.name.set", req) + raw, err := a.client.Request("session.commands.handlePendingCommand", req) if err != nil { return nil, err } - var result NameSetResult + var result CommandsHandlePendingCommandResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type PlanApi sessionApi +// Experimental: ExtensionsApi contains experimental APIs that may change or be removed. +type ExtensionsApi sessionApi -func (a *PlanApi) Read(ctx context.Context) (*PlanReadResult, error) { +func (a *ExtensionsApi) Disable(ctx context.Context, params *ExtensionsDisableRequest) (*ExtensionsDisableResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.plan.read", req) + if params != nil { + req["id"] = params.ID + } + raw, err := a.client.Request("session.extensions.disable", req) if err != nil { return nil, err } - var result PlanReadResult + var result ExtensionsDisableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *PlanApi) Update(ctx context.Context, params *PlanUpdateRequest) (*PlanUpdateResult, error) { +func (a *ExtensionsApi) Enable(ctx context.Context, params *ExtensionsEnableRequest) (*ExtensionsEnableResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["content"] = params.Content + req["id"] = params.ID } - raw, err := a.client.Request("session.plan.update", req) + raw, err := a.client.Request("session.extensions.enable", req) if err != nil { return nil, err } - var result PlanUpdateResult + var result ExtensionsEnableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *PlanApi) Delete(ctx context.Context) (*PlanDeleteResult, error) { +func (a *ExtensionsApi) List(ctx context.Context) (*ExtensionList, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.plan.delete", req) + raw, err := a.client.Request("session.extensions.list", req) if err != nil { return nil, err } - var result PlanDeleteResult + var result ExtensionList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type WorkspacesApi sessionApi - -func (a *WorkspacesApi) GetWorkspace(ctx context.Context) (*WorkspacesGetWorkspaceResult, error) { +func (a *ExtensionsApi) Reload(ctx context.Context) (*ExtensionsReloadResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.workspaces.getWorkspace", req) + raw, err := a.client.Request("session.extensions.reload", req) if err != nil { return nil, err } - var result WorkspacesGetWorkspaceResult + var result ExtensionsReloadResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *WorkspacesApi) ListFiles(ctx context.Context) (*WorkspacesListFilesResult, error) { +// Experimental: FleetApi contains experimental APIs that may change or be removed. +type FleetApi sessionApi + +func (a *FleetApi) Start(ctx context.Context, params *FleetStartRequest) (*FleetStartResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.workspaces.listFiles", req) + if params != nil { + if params.Prompt != nil { + req["prompt"] = *params.Prompt + } + } + raw, err := a.client.Request("session.fleet.start", req) if err != nil { return nil, err } - var result WorkspacesListFilesResult + var result FleetStartResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *WorkspacesApi) ReadFile(ctx context.Context, params *WorkspacesReadFileRequest) (*WorkspacesReadFileResult, error) { +// Experimental: HistoryApi contains experimental APIs that may change or be removed. +type HistoryApi sessionApi + +func (a *HistoryApi) Compact(ctx context.Context) (*HistoryCompactResult, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["path"] = params.Path - } - raw, err := a.client.Request("session.workspaces.readFile", req) + raw, err := a.client.Request("session.history.compact", req) if err != nil { return nil, err } - var result WorkspacesReadFileResult + var result HistoryCompactResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *WorkspacesApi) CreateFile(ctx context.Context, params *WorkspacesCreateFileRequest) (*WorkspacesCreateFileResult, error) { +func (a *HistoryApi) Truncate(ctx context.Context, params *HistoryTruncateRequest) (*HistoryTruncateResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["path"] = params.Path - req["content"] = params.Content + req["eventId"] = params.EventID } - raw, err := a.client.Request("session.workspaces.createFile", req) + raw, err := a.client.Request("session.history.truncate", req) if err != nil { return nil, err } - var result WorkspacesCreateFileResult + var result HistoryTruncateResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } @@ -3098,681 +3124,679 @@ func (a *InstructionsApi) GetSources(ctx context.Context) (*InstructionsGetSourc return &result, nil } -// Experimental: FleetApi contains experimental APIs that may change or be removed. -type FleetApi sessionApi +// Experimental: McpApi contains experimental APIs that may change or be removed. +type McpApi sessionApi -func (a *FleetApi) Start(ctx context.Context, params *FleetStartRequest) (*FleetStartResult, error) { +func (a *McpApi) Disable(ctx context.Context, params *McpDisableRequest) (*McpDisableResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - if params.Prompt != nil { - req["prompt"] = *params.Prompt - } + req["serverName"] = params.ServerName } - raw, err := a.client.Request("session.fleet.start", req) + raw, err := a.client.Request("session.mcp.disable", req) if err != nil { return nil, err } - var result FleetStartResult + var result McpDisableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: AgentApi contains experimental APIs that may change or be removed. -type AgentApi sessionApi - -func (a *AgentApi) List(ctx context.Context) (*AgentList, error) { +func (a *McpApi) Enable(ctx context.Context, params *McpEnableRequest) (*McpEnableResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.agent.list", req) + if params != nil { + req["serverName"] = params.ServerName + } + raw, err := a.client.Request("session.mcp.enable", req) if err != nil { return nil, err } - var result AgentList + var result McpEnableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *AgentApi) GetCurrent(ctx context.Context) (*AgentGetCurrentResult, error) { +func (a *McpApi) List(ctx context.Context) (*McpServerList, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.agent.getCurrent", req) + raw, err := a.client.Request("session.mcp.list", req) if err != nil { return nil, err } - var result AgentGetCurrentResult + var result McpServerList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *AgentApi) Select(ctx context.Context, params *AgentSelectRequest) (*AgentSelectResult, error) { +func (a *McpApi) Reload(ctx context.Context) (*McpReloadResult, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["name"] = params.Name - } - raw, err := a.client.Request("session.agent.select", req) + raw, err := a.client.Request("session.mcp.reload", req) if err != nil { return nil, err } - var result AgentSelectResult + var result McpReloadResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *AgentApi) Deselect(ctx context.Context) (*AgentDeselectResult, error) { +// Experimental: McpOauthApi contains experimental APIs that may change or be removed. +type McpOauthApi sessionApi + +func (a *McpOauthApi) Login(ctx context.Context, params *McpOauthLoginRequest) (*McpOauthLoginResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.agent.deselect", req) + if params != nil { + if params.CallbackSuccessMessage != nil { + req["callbackSuccessMessage"] = *params.CallbackSuccessMessage + } + if params.ClientName != nil { + req["clientName"] = *params.ClientName + } + if params.ForceReauth != nil { + req["forceReauth"] = *params.ForceReauth + } + req["serverName"] = params.ServerName + } + raw, err := a.client.Request("session.mcp.oauth.login", req) if err != nil { return nil, err } - var result AgentDeselectResult + var result McpOauthLoginResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *AgentApi) Reload(ctx context.Context) (*AgentReloadResult, error) { +// Experimental: Oauth returns experimental APIs that may change or be removed. +func (s *McpApi) Oauth() *McpOauthApi { + return (*McpOauthApi)(s) +} + +type ModeApi sessionApi + +func (a *ModeApi) Get(ctx context.Context) (*SessionMode, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.agent.reload", req) + raw, err := a.client.Request("session.mode.get", req) if err != nil { return nil, err } - var result AgentReloadResult + var result SessionMode if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: TasksApi contains experimental APIs that may change or be removed. -type TasksApi sessionApi - -func (a *TasksApi) StartAgent(ctx context.Context, params *TasksStartAgentRequest) (*TasksStartAgentResult, error) { +func (a *ModeApi) Set(ctx context.Context, params *ModeSetRequest) (*ModeSetResult, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["agentType"] = params.AgentType - req["prompt"] = params.Prompt - req["name"] = params.Name - if params.Description != nil { - req["description"] = *params.Description - } - if params.Model != nil { - req["model"] = *params.Model - } + if params != nil { + req["mode"] = params.Mode } - raw, err := a.client.Request("session.tasks.startAgent", req) + raw, err := a.client.Request("session.mode.set", req) if err != nil { return nil, err } - var result TasksStartAgentResult + var result ModeSetResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *TasksApi) List(ctx context.Context) (*TaskList, error) { +type ModelApi sessionApi + +func (a *ModelApi) GetCurrent(ctx context.Context) (*CurrentModel, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.tasks.list", req) + raw, err := a.client.Request("session.model.getCurrent", req) if err != nil { return nil, err } - var result TaskList + var result CurrentModel if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *TasksApi) PromoteToBackground(ctx context.Context, params *TasksPromoteToBackgroundRequest) (*TasksPromoteToBackgroundResult, error) { +func (a *ModelApi) SwitchTo(ctx context.Context, params *ModelSwitchToRequest) (*ModelSwitchToResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["id"] = params.ID + if params.ModelCapabilities != nil { + req["modelCapabilities"] = *params.ModelCapabilities + } + req["modelId"] = params.ModelID + if params.ReasoningEffort != nil { + req["reasoningEffort"] = *params.ReasoningEffort + } } - raw, err := a.client.Request("session.tasks.promoteToBackground", req) + raw, err := a.client.Request("session.model.switchTo", req) if err != nil { return nil, err } - var result TasksPromoteToBackgroundResult + var result ModelSwitchToResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *TasksApi) Cancel(ctx context.Context, params *TasksCancelRequest) (*TasksCancelResult, error) { +type NameApi sessionApi + +func (a *NameApi) Get(ctx context.Context) (*NameGetResult, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["id"] = params.ID - } - raw, err := a.client.Request("session.tasks.cancel", req) + raw, err := a.client.Request("session.name.get", req) if err != nil { return nil, err } - var result TasksCancelResult + var result NameGetResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *TasksApi) Remove(ctx context.Context, params *TasksRemoveRequest) (*TasksRemoveResult, error) { +func (a *NameApi) Set(ctx context.Context, params *NameSetRequest) (*NameSetResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["id"] = params.ID + req["name"] = params.Name } - raw, err := a.client.Request("session.tasks.remove", req) + raw, err := a.client.Request("session.name.set", req) if err != nil { return nil, err } - var result TasksRemoveResult + var result NameSetResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *TasksApi) SendMessage(ctx context.Context, params *TasksSendMessageRequest) (*TasksSendMessageResult, error) { +type PermissionsApi sessionApi + +func (a *PermissionsApi) HandlePendingPermissionRequest(ctx context.Context, params *PermissionDecisionRequest) (*PermissionRequestResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["id"] = params.ID - req["message"] = params.Message - if params.FromAgentID != nil { - req["fromAgentId"] = *params.FromAgentID - } + req["requestId"] = params.RequestID + req["result"] = params.Result } - raw, err := a.client.Request("session.tasks.sendMessage", req) + raw, err := a.client.Request("session.permissions.handlePendingPermissionRequest", req) if err != nil { return nil, err } - var result TasksSendMessageResult + var result PermissionRequestResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: SkillsApi contains experimental APIs that may change or be removed. -type SkillsApi sessionApi - -func (a *SkillsApi) List(ctx context.Context) (*SkillList, error) { +func (a *PermissionsApi) ResetSessionApprovals(ctx context.Context) (*PermissionsResetSessionApprovalsResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.skills.list", req) + raw, err := a.client.Request("session.permissions.resetSessionApprovals", req) if err != nil { return nil, err } - var result SkillList + var result PermissionsResetSessionApprovalsResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *SkillsApi) Enable(ctx context.Context, params *SkillsEnableRequest) (*SkillsEnableResult, error) { +func (a *PermissionsApi) SetApproveAll(ctx context.Context, params *PermissionsSetApproveAllRequest) (*PermissionsSetApproveAllResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["name"] = params.Name + req["enabled"] = params.Enabled } - raw, err := a.client.Request("session.skills.enable", req) + raw, err := a.client.Request("session.permissions.setApproveAll", req) if err != nil { return nil, err } - var result SkillsEnableResult + var result PermissionsSetApproveAllResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *SkillsApi) Disable(ctx context.Context, params *SkillsDisableRequest) (*SkillsDisableResult, error) { +type PlanApi sessionApi + +func (a *PlanApi) Delete(ctx context.Context) (*PlanDeleteResult, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["name"] = params.Name - } - raw, err := a.client.Request("session.skills.disable", req) + raw, err := a.client.Request("session.plan.delete", req) if err != nil { return nil, err } - var result SkillsDisableResult + var result PlanDeleteResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *SkillsApi) Reload(ctx context.Context) (*SkillsReloadResult, error) { +func (a *PlanApi) Read(ctx context.Context) (*PlanReadResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.skills.reload", req) + raw, err := a.client.Request("session.plan.read", req) if err != nil { return nil, err } - var result SkillsReloadResult + var result PlanReadResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: McpApi contains experimental APIs that may change or be removed. -type McpApi sessionApi - -func (a *McpApi) List(ctx context.Context) (*MCPServerList, error) { +func (a *PlanApi) Update(ctx context.Context, params *PlanUpdateRequest) (*PlanUpdateResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.mcp.list", req) + if params != nil { + req["content"] = params.Content + } + raw, err := a.client.Request("session.plan.update", req) if err != nil { return nil, err } - var result MCPServerList + var result PlanUpdateResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *McpApi) Enable(ctx context.Context, params *MCPEnableRequest) (*MCPEnableResult, error) { +// Experimental: PluginsApi contains experimental APIs that may change or be removed. +type PluginsApi sessionApi + +func (a *PluginsApi) List(ctx context.Context) (*PluginList, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["serverName"] = params.ServerName - } - raw, err := a.client.Request("session.mcp.enable", req) + raw, err := a.client.Request("session.plugins.list", req) if err != nil { return nil, err } - var result MCPEnableResult + var result PluginList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *McpApi) Disable(ctx context.Context, params *MCPDisableRequest) (*MCPDisableResult, error) { +// Experimental: RemoteApi contains experimental APIs that may change or be removed. +type RemoteApi sessionApi + +func (a *RemoteApi) Disable(ctx context.Context) (*RemoteDisableResult, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["serverName"] = params.ServerName - } - raw, err := a.client.Request("session.mcp.disable", req) + raw, err := a.client.Request("session.remote.disable", req) if err != nil { return nil, err } - var result MCPDisableResult + var result RemoteDisableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *McpApi) Reload(ctx context.Context) (*MCPReloadResult, error) { +func (a *RemoteApi) Enable(ctx context.Context) (*RemoteEnableResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.mcp.reload", req) + raw, err := a.client.Request("session.remote.enable", req) if err != nil { return nil, err } - var result MCPReloadResult + var result RemoteEnableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: McpOauthApi contains experimental APIs that may change or be removed. -type McpOauthApi sessionApi +type ShellApi sessionApi -func (a *McpOauthApi) Login(ctx context.Context, params *MCPOauthLoginRequest) (*MCPOauthLoginResult, error) { +func (a *ShellApi) Exec(ctx context.Context, params *ShellExecRequest) (*ShellExecResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["serverName"] = params.ServerName - if params.ForceReauth != nil { - req["forceReauth"] = *params.ForceReauth - } - if params.ClientName != nil { - req["clientName"] = *params.ClientName + req["command"] = params.Command + if params.Cwd != nil { + req["cwd"] = *params.Cwd } - if params.CallbackSuccessMessage != nil { - req["callbackSuccessMessage"] = *params.CallbackSuccessMessage + if params.Timeout != nil { + req["timeout"] = *params.Timeout } } - raw, err := a.client.Request("session.mcp.oauth.login", req) + raw, err := a.client.Request("session.shell.exec", req) if err != nil { return nil, err } - var result MCPOauthLoginResult + var result ShellExecResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: Oauth returns experimental APIs that may change or be removed. -func (s *McpApi) Oauth() *McpOauthApi { - return (*McpOauthApi)(s) -} - -// Experimental: PluginsApi contains experimental APIs that may change or be removed. -type PluginsApi sessionApi - -func (a *PluginsApi) List(ctx context.Context) (*PluginList, error) { +func (a *ShellApi) Kill(ctx context.Context, params *ShellKillRequest) (*ShellKillResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.plugins.list", req) + if params != nil { + req["processId"] = params.ProcessID + if params.Signal != nil { + req["signal"] = *params.Signal + } + } + raw, err := a.client.Request("session.shell.kill", req) if err != nil { return nil, err } - var result PluginList + var result ShellKillResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: ExtensionsApi contains experimental APIs that may change or be removed. -type ExtensionsApi sessionApi +// Experimental: SkillsApi contains experimental APIs that may change or be removed. +type SkillsApi sessionApi -func (a *ExtensionsApi) List(ctx context.Context) (*ExtensionList, error) { +func (a *SkillsApi) Disable(ctx context.Context, params *SkillsDisableRequest) (*SkillsDisableResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.extensions.list", req) + if params != nil { + req["name"] = params.Name + } + raw, err := a.client.Request("session.skills.disable", req) if err != nil { return nil, err } - var result ExtensionList + var result SkillsDisableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ExtensionsApi) Enable(ctx context.Context, params *ExtensionsEnableRequest) (*ExtensionsEnableResult, error) { +func (a *SkillsApi) Enable(ctx context.Context, params *SkillsEnableRequest) (*SkillsEnableResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["id"] = params.ID + req["name"] = params.Name } - raw, err := a.client.Request("session.extensions.enable", req) + raw, err := a.client.Request("session.skills.enable", req) if err != nil { return nil, err } - var result ExtensionsEnableResult + var result SkillsEnableResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ExtensionsApi) Disable(ctx context.Context, params *ExtensionsDisableRequest) (*ExtensionsDisableResult, error) { +func (a *SkillsApi) List(ctx context.Context) (*SkillList, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["id"] = params.ID - } - raw, err := a.client.Request("session.extensions.disable", req) + raw, err := a.client.Request("session.skills.list", req) if err != nil { return nil, err } - var result ExtensionsDisableResult + var result SkillList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ExtensionsApi) Reload(ctx context.Context) (*ExtensionsReloadResult, error) { +func (a *SkillsApi) Reload(ctx context.Context) (*SkillsReloadResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.extensions.reload", req) + raw, err := a.client.Request("session.skills.reload", req) if err != nil { return nil, err } - var result ExtensionsReloadResult + var result SkillsReloadResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ToolsApi sessionApi +// Experimental: TasksApi contains experimental APIs that may change or be removed. +type TasksApi sessionApi -func (a *ToolsApi) HandlePendingToolCall(ctx context.Context, params *HandlePendingToolCallRequest) (*HandlePendingToolCallResult, error) { +func (a *TasksApi) Cancel(ctx context.Context, params *TasksCancelRequest) (*TasksCancelResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["requestId"] = params.RequestID - if params.Result != nil { - req["result"] = *params.Result - } - if params.Error != nil { - req["error"] = *params.Error - } + req["id"] = params.ID } - raw, err := a.client.Request("session.tools.handlePendingToolCall", req) + raw, err := a.client.Request("session.tasks.cancel", req) if err != nil { return nil, err } - var result HandlePendingToolCallResult + var result TasksCancelResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type CommandsApi sessionApi - -func (a *CommandsApi) HandlePendingCommand(ctx context.Context, params *CommandsHandlePendingCommandRequest) (*CommandsHandlePendingCommandResult, error) { +func (a *TasksApi) List(ctx context.Context) (*TaskList, error) { req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["requestId"] = params.RequestID - if params.Error != nil { - req["error"] = *params.Error - } - } - raw, err := a.client.Request("session.commands.handlePendingCommand", req) + raw, err := a.client.Request("session.tasks.list", req) if err != nil { return nil, err } - var result CommandsHandlePendingCommandResult + var result TaskList if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type UIApi sessionApi - -func (a *UIApi) Elicitation(ctx context.Context, params *UIElicitationRequest) (*UIElicitationResponse, error) { +func (a *TasksApi) PromoteToBackground(ctx context.Context, params *TasksPromoteToBackgroundRequest) (*TasksPromoteToBackgroundResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["message"] = params.Message - req["requestedSchema"] = params.RequestedSchema + req["id"] = params.ID } - raw, err := a.client.Request("session.ui.elicitation", req) + raw, err := a.client.Request("session.tasks.promoteToBackground", req) if err != nil { return nil, err } - var result UIElicitationResponse + var result TasksPromoteToBackgroundResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *UIApi) HandlePendingElicitation(ctx context.Context, params *UIHandlePendingElicitationRequest) (*UIElicitationResult, error) { +func (a *TasksApi) Remove(ctx context.Context, params *TasksRemoveRequest) (*TasksRemoveResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["requestId"] = params.RequestID - req["result"] = params.Result + req["id"] = params.ID } - raw, err := a.client.Request("session.ui.handlePendingElicitation", req) + raw, err := a.client.Request("session.tasks.remove", req) if err != nil { return nil, err } - var result UIElicitationResult + var result TasksRemoveResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type PermissionsApi sessionApi - -func (a *PermissionsApi) HandlePendingPermissionRequest(ctx context.Context, params *PermissionDecisionRequest) (*PermissionRequestResult, error) { +func (a *TasksApi) SendMessage(ctx context.Context, params *TasksSendMessageRequest) (*TasksSendMessageResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["requestId"] = params.RequestID - req["result"] = params.Result + if params.FromAgentID != nil { + req["fromAgentId"] = *params.FromAgentID + } + req["id"] = params.ID + req["message"] = params.Message } - raw, err := a.client.Request("session.permissions.handlePendingPermissionRequest", req) + raw, err := a.client.Request("session.tasks.sendMessage", req) if err != nil { return nil, err } - var result PermissionRequestResult + var result TasksSendMessageResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *PermissionsApi) SetApproveAll(ctx context.Context, params *PermissionsSetApproveAllRequest) (*PermissionsSetApproveAllResult, error) { +func (a *TasksApi) StartAgent(ctx context.Context, params *TasksStartAgentRequest) (*TasksStartAgentResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["enabled"] = params.Enabled + req["agentType"] = params.AgentType + if params.Description != nil { + req["description"] = *params.Description + } + if params.Model != nil { + req["model"] = *params.Model + } + req["name"] = params.Name + req["prompt"] = params.Prompt } - raw, err := a.client.Request("session.permissions.setApproveAll", req) + raw, err := a.client.Request("session.tasks.startAgent", req) if err != nil { return nil, err } - var result PermissionsSetApproveAllResult + var result TasksStartAgentResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *PermissionsApi) ResetSessionApprovals(ctx context.Context) (*PermissionsResetSessionApprovalsResult, error) { +type ToolsApi sessionApi + +func (a *ToolsApi) HandlePendingToolCall(ctx context.Context, params *HandlePendingToolCallRequest) (*HandlePendingToolCallResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.permissions.resetSessionApprovals", req) + if params != nil { + if params.Error != nil { + req["error"] = *params.Error + } + req["requestId"] = params.RequestID + if params.Result != nil { + req["result"] = *params.Result + } + } + raw, err := a.client.Request("session.tools.handlePendingToolCall", req) if err != nil { return nil, err } - var result PermissionsResetSessionApprovalsResult + var result HandlePendingToolCallResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -type ShellApi sessionApi +type UIApi sessionApi -func (a *ShellApi) Exec(ctx context.Context, params *ShellExecRequest) (*ShellExecResult, error) { +func (a *UIApi) Elicitation(ctx context.Context, params *UIElicitationRequest) (*UIElicitationResponse, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["command"] = params.Command - if params.Cwd != nil { - req["cwd"] = *params.Cwd - } - if params.Timeout != nil { - req["timeout"] = *params.Timeout - } + req["message"] = params.Message + req["requestedSchema"] = params.RequestedSchema } - raw, err := a.client.Request("session.shell.exec", req) + raw, err := a.client.Request("session.ui.elicitation", req) if err != nil { return nil, err } - var result ShellExecResult + var result UIElicitationResponse if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *ShellApi) Kill(ctx context.Context, params *ShellKillRequest) (*ShellKillResult, error) { +func (a *UIApi) HandlePendingElicitation(ctx context.Context, params *UIHandlePendingElicitationRequest) (*UIElicitationResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["processId"] = params.ProcessID - if params.Signal != nil { - req["signal"] = *params.Signal - } + req["requestId"] = params.RequestID + req["result"] = params.Result } - raw, err := a.client.Request("session.shell.kill", req) + raw, err := a.client.Request("session.ui.handlePendingElicitation", req) if err != nil { return nil, err } - var result ShellKillResult + var result UIElicitationResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: HistoryApi contains experimental APIs that may change or be removed. -type HistoryApi sessionApi +// Experimental: UsageApi contains experimental APIs that may change or be removed. +type UsageApi sessionApi -func (a *HistoryApi) Compact(ctx context.Context) (*HistoryCompactResult, error) { +func (a *UsageApi) GetMetrics(ctx context.Context) (*UsageGetMetricsResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.history.compact", req) + raw, err := a.client.Request("session.usage.getMetrics", req) if err != nil { return nil, err } - var result HistoryCompactResult + var result UsageGetMetricsResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *HistoryApi) Truncate(ctx context.Context, params *HistoryTruncateRequest) (*HistoryTruncateResult, error) { +type WorkspacesApi sessionApi + +func (a *WorkspacesApi) CreateFile(ctx context.Context, params *WorkspacesCreateFileRequest) (*WorkspacesCreateFileResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { - req["eventId"] = params.EventID + req["content"] = params.Content + req["path"] = params.Path } - raw, err := a.client.Request("session.history.truncate", req) + raw, err := a.client.Request("session.workspaces.createFile", req) if err != nil { return nil, err } - var result HistoryTruncateResult + var result WorkspacesCreateFileResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: UsageApi contains experimental APIs that may change or be removed. -type UsageApi sessionApi - -func (a *UsageApi) GetMetrics(ctx context.Context) (*UsageGetMetricsResult, error) { +func (a *WorkspacesApi) GetWorkspace(ctx context.Context) (*WorkspacesGetWorkspaceResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.usage.getMetrics", req) + raw, err := a.client.Request("session.workspaces.getWorkspace", req) if err != nil { return nil, err } - var result UsageGetMetricsResult + var result WorkspacesGetWorkspaceResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -// Experimental: RemoteApi contains experimental APIs that may change or be removed. -type RemoteApi sessionApi - -func (a *RemoteApi) Enable(ctx context.Context) (*RemoteEnableResult, error) { +func (a *WorkspacesApi) ListFiles(ctx context.Context) (*WorkspacesListFilesResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.remote.enable", req) + raw, err := a.client.Request("session.workspaces.listFiles", req) if err != nil { return nil, err } - var result RemoteEnableResult + var result WorkspacesListFilesResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } return &result, nil } -func (a *RemoteApi) Disable(ctx context.Context) (*RemoteDisableResult, error) { +func (a *WorkspacesApi) ReadFile(ctx context.Context, params *WorkspacesReadFileRequest) (*WorkspacesReadFileResult, error) { req := map[string]any{"sessionId": a.sessionID} - raw, err := a.client.Request("session.remote.disable", req) + if params != nil { + req["path"] = params.Path + } + raw, err := a.client.Request("session.workspaces.readFile", req) if err != nil { return nil, err } - var result RemoteDisableResult + var result WorkspacesReadFileResult if err := json.Unmarshal(raw, &result); err != nil { return nil, err } @@ -3781,55 +3805,43 @@ func (a *RemoteApi) Disable(ctx context.Context) (*RemoteDisableResult, error) { // SessionRpc provides typed session-scoped RPC methods. type SessionRpc struct { - common sessionApi // Reuse a single struct instead of allocating one for each service on the heap. + // Reuse a single struct instead of allocating one for each service on the heap. + common sessionApi + Agent *AgentApi Auth *AuthApi - Model *ModelApi + Commands *CommandsApi + Extensions *ExtensionsApi + Fleet *FleetApi + History *HistoryApi + Instructions *InstructionsApi + Mcp *McpApi Mode *ModeApi + Model *ModelApi Name *NameApi + Permissions *PermissionsApi Plan *PlanApi - Workspaces *WorkspacesApi - Instructions *InstructionsApi - Fleet *FleetApi - Agent *AgentApi - Tasks *TasksApi - Skills *SkillsApi - Mcp *McpApi Plugins *PluginsApi - Extensions *ExtensionsApi + Remote *RemoteApi + Shell *ShellApi + Skills *SkillsApi + Tasks *TasksApi Tools *ToolsApi - Commands *CommandsApi UI *UIApi - Permissions *PermissionsApi - Shell *ShellApi - History *HistoryApi Usage *UsageApi - Remote *RemoteApi -} - -func (a *SessionRpc) Suspend(ctx context.Context) (*SuspendResult, error) { - req := map[string]any{"sessionId": a.common.sessionID} - raw, err := a.common.client.Request("session.suspend", req) - if err != nil { - return nil, err - } - var result SuspendResult - if err := json.Unmarshal(raw, &result); err != nil { - return nil, err - } - return &result, nil + Workspaces *WorkspacesApi } func (a *SessionRpc) Log(ctx context.Context, params *LogRequest) (*LogResult, error) { req := map[string]any{"sessionId": a.common.sessionID} if params != nil { - req["message"] = params.Message - if params.Level != nil { - req["level"] = *params.Level - } if params.Ephemeral != nil { req["ephemeral"] = *params.Ephemeral } + if params.Level != nil { + req["level"] = *params.Level + } + req["message"] = params.Message if params.URL != nil { req["url"] = *params.URL } @@ -3845,45 +3857,58 @@ func (a *SessionRpc) Log(ctx context.Context, params *LogRequest) (*LogResult, e return &result, nil } +func (a *SessionRpc) Suspend(ctx context.Context) (*SuspendResult, error) { + req := map[string]any{"sessionId": a.common.sessionID} + raw, err := a.common.client.Request("session.suspend", req) + if err != nil { + return nil, err + } + var result SuspendResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { r := &SessionRpc{} r.common = sessionApi{client: client, sessionID: sessionID} + r.Agent = (*AgentApi)(&r.common) r.Auth = (*AuthApi)(&r.common) - r.Model = (*ModelApi)(&r.common) + r.Commands = (*CommandsApi)(&r.common) + r.Extensions = (*ExtensionsApi)(&r.common) + r.Fleet = (*FleetApi)(&r.common) + r.History = (*HistoryApi)(&r.common) + r.Instructions = (*InstructionsApi)(&r.common) + r.Mcp = (*McpApi)(&r.common) r.Mode = (*ModeApi)(&r.common) + r.Model = (*ModelApi)(&r.common) r.Name = (*NameApi)(&r.common) + r.Permissions = (*PermissionsApi)(&r.common) r.Plan = (*PlanApi)(&r.common) - r.Workspaces = (*WorkspacesApi)(&r.common) - r.Instructions = (*InstructionsApi)(&r.common) - r.Fleet = (*FleetApi)(&r.common) - r.Agent = (*AgentApi)(&r.common) - r.Tasks = (*TasksApi)(&r.common) - r.Skills = (*SkillsApi)(&r.common) - r.Mcp = (*McpApi)(&r.common) r.Plugins = (*PluginsApi)(&r.common) - r.Extensions = (*ExtensionsApi)(&r.common) + r.Remote = (*RemoteApi)(&r.common) + r.Shell = (*ShellApi)(&r.common) + r.Skills = (*SkillsApi)(&r.common) + r.Tasks = (*TasksApi)(&r.common) r.Tools = (*ToolsApi)(&r.common) - r.Commands = (*CommandsApi)(&r.common) r.UI = (*UIApi)(&r.common) - r.Permissions = (*PermissionsApi)(&r.common) - r.Shell = (*ShellApi)(&r.common) - r.History = (*HistoryApi)(&r.common) r.Usage = (*UsageApi)(&r.common) - r.Remote = (*RemoteApi)(&r.common) + r.Workspaces = (*WorkspacesApi)(&r.common) return r } type SessionFsHandler interface { - ReadFile(request *SessionFSReadFileRequest) (*SessionFSReadFileResult, error) - WriteFile(request *SessionFSWriteFileRequest) (*SessionFSError, error) - AppendFile(request *SessionFSAppendFileRequest) (*SessionFSError, error) - Exists(request *SessionFSExistsRequest) (*SessionFSExistsResult, error) - Stat(request *SessionFSStatRequest) (*SessionFSStatResult, error) - Mkdir(request *SessionFSMkdirRequest) (*SessionFSError, error) - Readdir(request *SessionFSReaddirRequest) (*SessionFSReaddirResult, error) - ReaddirWithTypes(request *SessionFSReaddirWithTypesRequest) (*SessionFSReaddirWithTypesResult, error) - Rm(request *SessionFSRmRequest) (*SessionFSError, error) - Rename(request *SessionFSRenameRequest) (*SessionFSError, error) + AppendFile(request *SessionFsAppendFileRequest) (*SessionFsError, error) + Exists(request *SessionFsExistsRequest) (*SessionFsExistsResult, error) + Mkdir(request *SessionFsMkdirRequest) (*SessionFsError, error) + Readdir(request *SessionFsReaddirRequest) (*SessionFsReaddirResult, error) + ReaddirWithTypes(request *SessionFsReaddirWithTypesRequest) (*SessionFsReaddirWithTypesResult, error) + ReadFile(request *SessionFsReadFileRequest) (*SessionFsReadFileResult, error) + Rename(request *SessionFsRenameRequest) (*SessionFsError, error) + Rm(request *SessionFsRmRequest) (*SessionFsError, error) + Stat(request *SessionFsStatRequest) (*SessionFsStatResult, error) + WriteFile(request *SessionFsWriteFileRequest) (*SessionFsError, error) } // ClientSessionApiHandlers provides all client session API handler groups for a session. @@ -3902,10 +3927,11 @@ func clientSessionHandlerError(err error) *jsonrpc2.Error { return &jsonrpc2.Error{Code: -32603, Message: err.Error()} } -// RegisterClientSessionApiHandlers registers handlers for server-to-client session API calls. +// RegisterClientSessionApiHandlers registers handlers for server-to-client session API +// calls. func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func(sessionID string) *ClientSessionApiHandlers) { - client.SetRequestHandler("sessionFs.readFile", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSReadFileRequest + client.SetRequestHandler("sessionFs.appendFile", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsAppendFileRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -3913,7 +3939,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.ReadFile(&request) + result, err := handlers.SessionFs.AppendFile(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -3923,8 +3949,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.writeFile", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSWriteFileRequest + client.SetRequestHandler("sessionFs.exists", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsExistsRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -3932,7 +3958,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.WriteFile(&request) + result, err := handlers.SessionFs.Exists(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -3942,8 +3968,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.appendFile", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSAppendFileRequest + client.SetRequestHandler("sessionFs.mkdir", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsMkdirRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -3951,7 +3977,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.AppendFile(&request) + result, err := handlers.SessionFs.Mkdir(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -3961,8 +3987,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.exists", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSExistsRequest + client.SetRequestHandler("sessionFs.readdir", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsReaddirRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -3970,7 +3996,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.Exists(&request) + result, err := handlers.SessionFs.Readdir(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -3980,8 +4006,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.stat", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSStatRequest + client.SetRequestHandler("sessionFs.readdirWithTypes", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsReaddirWithTypesRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -3989,7 +4015,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.Stat(&request) + result, err := handlers.SessionFs.ReaddirWithTypes(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -3999,8 +4025,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.mkdir", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSMkdirRequest + client.SetRequestHandler("sessionFs.readFile", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsReadFileRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -4008,7 +4034,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.Mkdir(&request) + result, err := handlers.SessionFs.ReadFile(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -4018,8 +4044,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.readdir", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSReaddirRequest + client.SetRequestHandler("sessionFs.rename", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsRenameRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -4027,7 +4053,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.Readdir(&request) + result, err := handlers.SessionFs.Rename(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -4037,8 +4063,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.readdirWithTypes", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSReaddirWithTypesRequest + client.SetRequestHandler("sessionFs.rm", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsRmRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -4046,7 +4072,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.ReaddirWithTypes(&request) + result, err := handlers.SessionFs.Rm(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -4056,8 +4082,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.rm", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSRmRequest + client.SetRequestHandler("sessionFs.stat", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsStatRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -4065,7 +4091,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.Rm(&request) + result, err := handlers.SessionFs.Stat(&request) if err != nil { return nil, clientSessionHandlerError(err) } @@ -4075,8 +4101,8 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( } return raw, nil }) - client.SetRequestHandler("sessionFs.rename", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { - var request SessionFSRenameRequest + client.SetRequestHandler("sessionFs.writeFile", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request SessionFsWriteFileRequest if err := json.Unmarshal(params, &request); err != nil { return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} } @@ -4084,7 +4110,7 @@ func RegisterClientSessionApiHandlers(client *jsonrpc2.Client, getHandlers func( if handlers == nil || handlers.SessionFs == nil { return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("No sessionFs handler registered for session: %s", request.SessionID)} } - result, err := handlers.SessionFs.Rename(&request) + result, err := handlers.SessionFs.WriteFile(&request) if err != nil { return nil, clientSessionHandlerError(err) } diff --git a/go/rpc/generated_rpc_union_test.go b/go/rpc/generated_rpc_union_test.go new file mode 100644 index 000000000..c0afbe911 --- /dev/null +++ b/go/rpc/generated_rpc_union_test.go @@ -0,0 +1,101 @@ +package rpc + +import ( + "encoding/json" + "testing" +) + +func TestExternalToolResultJSONUnion(t *testing.T) { + stringResult := ExternalToolResult{String: stringPtr("tool result")} + raw, err := json.Marshal(stringResult) + if err != nil { + t.Fatalf("marshal string result: %v", err) + } + if string(raw) != `"tool result"` { + t.Fatalf("marshal string result = %s", raw) + } + + var decodedString ExternalToolResult + if err := json.Unmarshal([]byte(`"tool result"`), &decodedString); err != nil { + t.Fatalf("unmarshal string result: %v", err) + } + if decodedString.String == nil || *decodedString.String != "tool result" { + t.Fatalf("unmarshal string result = %#v", decodedString) + } + + objectResult := ExternalToolResult{ExternalToolTextResultForLlm: &ExternalToolTextResultForLlm{TextResultForLlm: "expanded"}} + raw, err = json.Marshal(objectResult) + if err != nil { + t.Fatalf("marshal object result: %v", err) + } + if string(raw) != `{"textResultForLlm":"expanded"}` { + t.Fatalf("marshal object result = %s", raw) + } + + var decodedObject ExternalToolResult + if err := json.Unmarshal([]byte(`{"textResultForLlm":"expanded"}`), &decodedObject); err != nil { + t.Fatalf("unmarshal object result: %v", err) + } + if decodedObject.ExternalToolTextResultForLlm == nil || decodedObject.ExternalToolTextResultForLlm.TextResultForLlm != "expanded" { + t.Fatalf("unmarshal object result = %#v", decodedObject) + } +} + +func TestFilterMappingJSONUnion(t *testing.T) { + mapping := FilterMapping{EnumMap: map[string]FilterMappingValue{"secret": FilterMappingValueHiddenCharacters}} + raw, err := json.Marshal(mapping) + if err != nil { + t.Fatalf("marshal filter mapping map: %v", err) + } + if string(raw) != `{"secret":"hidden_characters"}` { + t.Fatalf("marshal filter mapping map = %s", raw) + } + + var decodedMap FilterMapping + if err := json.Unmarshal([]byte(`{"secret":"hidden_characters"}`), &decodedMap); err != nil { + t.Fatalf("unmarshal filter mapping map: %v", err) + } + if decodedMap.EnumMap["secret"] != FilterMappingValueHiddenCharacters { + t.Fatalf("unmarshal filter mapping map = %#v", decodedMap) + } + + enumValue := FilterMappingStringMarkdown + raw, err = json.Marshal(FilterMapping{Enum: &enumValue}) + if err != nil { + t.Fatalf("marshal filter mapping enum: %v", err) + } + if string(raw) != `"markdown"` { + t.Fatalf("marshal filter mapping enum = %s", raw) + } + + var decodedEnum FilterMapping + if err := json.Unmarshal([]byte(`"markdown"`), &decodedEnum); err != nil { + t.Fatalf("unmarshal filter mapping enum: %v", err) + } + if decodedEnum.Enum == nil || *decodedEnum.Enum != FilterMappingStringMarkdown { + t.Fatalf("unmarshal filter mapping enum = %#v", decodedEnum) + } +} + +func TestUIElicitationFieldValueJSONUnion(t *testing.T) { + boolValue := true + raw, err := json.Marshal(UIElicitationFieldValue{Bool: &boolValue}) + if err != nil { + t.Fatalf("marshal bool value: %v", err) + } + if string(raw) != `true` { + t.Fatalf("marshal bool value = %s", raw) + } + + var decodedArray UIElicitationFieldValue + if err := json.Unmarshal([]byte(`["a","b"]`), &decodedArray); err != nil { + t.Fatalf("unmarshal string array value: %v", err) + } + if len(decodedArray.StringArray) != 2 || decodedArray.StringArray[0] != "a" || decodedArray.StringArray[1] != "b" { + t.Fatalf("unmarshal string array value = %#v", decodedArray) + } +} + +func stringPtr(value string) *string { + return &value +} diff --git a/go/rpc/result_union.go b/go/rpc/result_union.go deleted file mode 100644 index 3387dce1b..000000000 --- a/go/rpc/result_union.go +++ /dev/null @@ -1,35 +0,0 @@ -package rpc - -import "encoding/json" - -// MarshalJSON serializes ExternalToolResult as the appropriate JSON variant: -// a plain string when String is set, or the ExternalToolTextResultForLlm object otherwise. -// The generated struct has no custom marshaler, so without this the Go -// struct fields would serialize as {"ExternalToolTextResultForLlm":...,"String":...} -// instead of the union the server expects. -func (r ExternalToolResult) MarshalJSON() ([]byte, error) { - if r.String != nil { - return json.Marshal(*r.String) - } - if r.ExternalToolTextResultForLlm != nil { - return json.Marshal(*r.ExternalToolTextResultForLlm) - } - return []byte("null"), nil -} - -// UnmarshalJSON deserializes a JSON value into the appropriate ExternalToolResult variant. -func (r *ExternalToolResult) UnmarshalJSON(data []byte) error { - // Try string first - var s string - if err := json.Unmarshal(data, &s); err == nil { - r.String = &s - return nil - } - // Try ExternalToolTextResultForLlm object - var rr ExternalToolTextResultForLlm - if err := json.Unmarshal(data, &rr); err == nil { - r.ExternalToolTextResultForLlm = &rr - return nil - } - return nil -} diff --git a/go/session.go b/go/session.go index 884a3773d..0b70950c4 100644 --- a/go/session.go +++ b/go/session.go @@ -746,7 +746,6 @@ func (ui *SessionUI) Confirm(ctx context.Context, message string) (bool, error) if err := ui.session.assertElicitation(); err != nil { return false, err } - defaultTrue := &rpc.UIElicitationFieldValue{Bool: Bool(true)} rpcResult, err := ui.session.RPC.UI.Elicitation(ctx, &rpc.UIElicitationRequest{ Message: message, RequestedSchema: rpc.UIElicitationSchema{ @@ -754,7 +753,7 @@ func (ui *SessionUI) Confirm(ctx context.Context, message string) (bool, error) Properties: map[string]rpc.UIElicitationSchemaProperty{ "confirmed": { Type: rpc.UIElicitationSchemaPropertyTypeBoolean, - Default: defaultTrue, + Default: toRPCContent(true), }, }, Required: []string{"confirmed"}, @@ -828,7 +827,7 @@ func (ui *SessionUI) Input(ctx context.Context, message string, opts *InputOptio prop.Format = &format } if opts.Default != "" { - prop.Default = &rpc.UIElicitationFieldValue{String: &opts.Default} + prop.Default = toRPCContent(opts.Default) } } rpcResult, err := ui.session.RPC.UI.Elicitation(ctx, &rpc.UIElicitationRequest{ diff --git a/go/session_fs_provider.go b/go/session_fs_provider.go index eb7107581..197b3fc20 100644 --- a/go/session_fs_provider.go +++ b/go/session_fs_provider.go @@ -38,7 +38,7 @@ type SessionFsProvider interface { Readdir(path string) ([]string, error) // ReaddirWithTypes lists entries with type information. // Return os.ErrNotExist if the directory does not exist. - ReaddirWithTypes(path string) ([]rpc.SessionFSReaddirWithTypesEntry, error) + ReaddirWithTypes(path string) ([]rpc.SessionFsReaddirWithTypesEntry, error) // Rm removes a file or directory. If recursive is true, remove contents too. // If force is true, do not return an error when the path does not exist. Rm(path string, recursive bool, force bool) error @@ -56,7 +56,7 @@ type SessionFsFileInfo struct { } // sessionFsAdapter wraps a SessionFsProvider to implement rpc.SessionFsHandler, -// converting idiomatic Go errors into SessionFSError results. +// converting idiomatic Go errors into SessionFsError results. type sessionFsAdapter struct { provider SessionFsProvider } @@ -65,15 +65,15 @@ func newSessionFsAdapter(provider SessionFsProvider) rpc.SessionFsHandler { return &sessionFsAdapter{provider: provider} } -func (a *sessionFsAdapter) ReadFile(request *rpc.SessionFSReadFileRequest) (*rpc.SessionFSReadFileResult, error) { +func (a *sessionFsAdapter) ReadFile(request *rpc.SessionFsReadFileRequest) (*rpc.SessionFsReadFileResult, error) { content, err := a.provider.ReadFile(request.Path) if err != nil { - return &rpc.SessionFSReadFileResult{Error: toSessionFsError(err)}, nil + return &rpc.SessionFsReadFileResult{Error: toSessionFsError(err)}, nil } - return &rpc.SessionFSReadFileResult{Content: content}, nil + return &rpc.SessionFsReadFileResult{Content: content}, nil } -func (a *sessionFsAdapter) WriteFile(request *rpc.SessionFSWriteFileRequest) (*rpc.SessionFSError, error) { +func (a *sessionFsAdapter) WriteFile(request *rpc.SessionFsWriteFileRequest) (*rpc.SessionFsError, error) { var mode *int if request.Mode != nil { m := int(*request.Mode) @@ -85,7 +85,7 @@ func (a *sessionFsAdapter) WriteFile(request *rpc.SessionFSWriteFileRequest) (*r return nil, nil } -func (a *sessionFsAdapter) AppendFile(request *rpc.SessionFSAppendFileRequest) (*rpc.SessionFSError, error) { +func (a *sessionFsAdapter) AppendFile(request *rpc.SessionFsAppendFileRequest) (*rpc.SessionFsError, error) { var mode *int if request.Mode != nil { m := int(*request.Mode) @@ -97,20 +97,20 @@ func (a *sessionFsAdapter) AppendFile(request *rpc.SessionFSAppendFileRequest) ( return nil, nil } -func (a *sessionFsAdapter) Exists(request *rpc.SessionFSExistsRequest) (*rpc.SessionFSExistsResult, error) { +func (a *sessionFsAdapter) Exists(request *rpc.SessionFsExistsRequest) (*rpc.SessionFsExistsResult, error) { exists, err := a.provider.Exists(request.Path) if err != nil { - return &rpc.SessionFSExistsResult{Exists: false}, nil + return &rpc.SessionFsExistsResult{Exists: false}, nil } - return &rpc.SessionFSExistsResult{Exists: exists}, nil + return &rpc.SessionFsExistsResult{Exists: exists}, nil } -func (a *sessionFsAdapter) Stat(request *rpc.SessionFSStatRequest) (*rpc.SessionFSStatResult, error) { +func (a *sessionFsAdapter) Stat(request *rpc.SessionFsStatRequest) (*rpc.SessionFsStatResult, error) { info, err := a.provider.Stat(request.Path) if err != nil { - return &rpc.SessionFSStatResult{Error: toSessionFsError(err)}, nil + return &rpc.SessionFsStatResult{Error: toSessionFsError(err)}, nil } - return &rpc.SessionFSStatResult{ + return &rpc.SessionFsStatResult{ IsFile: info.IsFile, IsDirectory: info.IsDirectory, Size: info.Size, @@ -119,7 +119,7 @@ func (a *sessionFsAdapter) Stat(request *rpc.SessionFSStatRequest) (*rpc.Session }, nil } -func (a *sessionFsAdapter) Mkdir(request *rpc.SessionFSMkdirRequest) (*rpc.SessionFSError, error) { +func (a *sessionFsAdapter) Mkdir(request *rpc.SessionFsMkdirRequest) (*rpc.SessionFsError, error) { recursive := request.Recursive != nil && *request.Recursive var mode *int if request.Mode != nil { @@ -132,23 +132,23 @@ func (a *sessionFsAdapter) Mkdir(request *rpc.SessionFSMkdirRequest) (*rpc.Sessi return nil, nil } -func (a *sessionFsAdapter) Readdir(request *rpc.SessionFSReaddirRequest) (*rpc.SessionFSReaddirResult, error) { +func (a *sessionFsAdapter) Readdir(request *rpc.SessionFsReaddirRequest) (*rpc.SessionFsReaddirResult, error) { entries, err := a.provider.Readdir(request.Path) if err != nil { - return &rpc.SessionFSReaddirResult{Error: toSessionFsError(err)}, nil + return &rpc.SessionFsReaddirResult{Error: toSessionFsError(err)}, nil } - return &rpc.SessionFSReaddirResult{Entries: entries}, nil + return &rpc.SessionFsReaddirResult{Entries: entries}, nil } -func (a *sessionFsAdapter) ReaddirWithTypes(request *rpc.SessionFSReaddirWithTypesRequest) (*rpc.SessionFSReaddirWithTypesResult, error) { +func (a *sessionFsAdapter) ReaddirWithTypes(request *rpc.SessionFsReaddirWithTypesRequest) (*rpc.SessionFsReaddirWithTypesResult, error) { entries, err := a.provider.ReaddirWithTypes(request.Path) if err != nil { - return &rpc.SessionFSReaddirWithTypesResult{Error: toSessionFsError(err)}, nil + return &rpc.SessionFsReaddirWithTypesResult{Error: toSessionFsError(err)}, nil } - return &rpc.SessionFSReaddirWithTypesResult{Entries: entries}, nil + return &rpc.SessionFsReaddirWithTypesResult{Entries: entries}, nil } -func (a *sessionFsAdapter) Rm(request *rpc.SessionFSRmRequest) (*rpc.SessionFSError, error) { +func (a *sessionFsAdapter) Rm(request *rpc.SessionFsRmRequest) (*rpc.SessionFsError, error) { recursive := request.Recursive != nil && *request.Recursive force := request.Force != nil && *request.Force if err := a.provider.Rm(request.Path, recursive, force); err != nil { @@ -157,18 +157,18 @@ func (a *sessionFsAdapter) Rm(request *rpc.SessionFSRmRequest) (*rpc.SessionFSEr return nil, nil } -func (a *sessionFsAdapter) Rename(request *rpc.SessionFSRenameRequest) (*rpc.SessionFSError, error) { +func (a *sessionFsAdapter) Rename(request *rpc.SessionFsRenameRequest) (*rpc.SessionFsError, error) { if err := a.provider.Rename(request.Src, request.Dest); err != nil { return toSessionFsError(err), nil } return nil, nil } -func toSessionFsError(err error) *rpc.SessionFSError { - code := rpc.SessionFSErrorCodeUNKNOWN +func toSessionFsError(err error) *rpc.SessionFsError { + code := rpc.SessionFsErrorCodeUNKNOWN if errors.Is(err, os.ErrNotExist) { - code = rpc.SessionFSErrorCodeENOENT + code = rpc.SessionFsErrorCodeENOENT } msg := err.Error() - return &rpc.SessionFSError{Code: code, Message: &msg} + return &rpc.SessionFsError{Code: code, Message: &msg} } diff --git a/go/types.go b/go/types.go index ee973a069..74ef3a2c3 100644 --- a/go/types.go +++ b/go/types.go @@ -565,7 +565,7 @@ type SessionFsConfig struct { // session-scoped files such as events, checkpoints, and temp files. SessionStatePath string // Conventions identifies the path conventions used by this filesystem provider. - Conventions rpc.SessionFSSetProviderConventions + Conventions rpc.SessionFsSetProviderConventions } // SessionConfig configures a new session diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 81fe4ba21..ef3bb3fcf 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1260,6 +1260,7 @@ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1299,6 +1300,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -1627,6 +1629,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1954,6 +1957,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2862,6 +2866,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3280,6 +3285,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3313,6 +3319,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3380,6 +3387,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index d75c568df..c72d91e27 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -9,33 +9,31 @@ import { execFile } from "child_process"; import fs from "fs/promises"; import type { JSONSchema7 } from "json-schema"; -import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; import { promisify } from "util"; +import wordwrap from "wordwrap"; import { cloneSchemaForCodegen, + collectDefinitionCollections, filterNodeByVisibility, fixNullableRequiredRefsInApiSchema, getApiSchemaPath, + getNullableInner, getRpcSchemaTypeName, getSessionEventsSchemaPath, + getSessionEventVariantSchemas, + getSharedSessionEventEnvelopeProperties, hasSchemaPayload, - isNodeFullyExperimental, isNodeFullyDeprecated, + isNodeFullyExperimental, + isRpcMethod, isSchemaDeprecated, isVoidSchema, - getNullableInner, - isRpcMethod, postProcessSchema, - stripBooleanLiterals, - writeGeneratedFile, - collectDefinitionCollections, - resolveObjectSchema, - resolveSchema, - withSharedDefinitions, refTypeName, + resolveObjectSchema, resolveRef, - getSessionEventVariantSchemas, - getSharedSessionEventEnvelopeProperties, + resolveSchema, + writeGeneratedFile, type ApiSchema, type DefinitionCollections, type RpcMethod, @@ -48,6 +46,8 @@ const execFileAsync = promisify(execFile); // Go initialisms that should be all-caps const goInitialisms = new Set(["id", "ui", "uri", "url", "api", "http", "https", "json", "xml", "html", "css", "sql", "ssh", "tcp", "udp", "ip", "rpc", "mime"]); +const goCommentTextWrapLength = 90; +const wrapGoCommentText = wordwrap(goCommentTextWrapLength); function toPascalCase(s: string): string { return s @@ -56,135 +56,96 @@ function toPascalCase(s: string): string { .join(""); } +function toGoSchemaTypeName(s: string): string { + return toPascalCase(splitGoIdentifierWords(s).join("_")); +} + function toGoFieldName(jsonName: string): string { // Handle camelCase field names like "modelId" -> "ModelID" - return jsonName - .replace(/([a-z])([A-Z])/g, "$1_$2") - .split("_") + return splitGoIdentifierWords(jsonName) .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(""); } -/** - * Post-process Go enum constants so every constant follows the canonical - * Go `TypeNameValue` convention. quicktype disambiguates collisions with - * whimsical prefixes (Purple, Fluffy, …) that we replace. - */ -function postProcessEnumConstants(code: string): string { - const renames = new Map(); - - // Match constant declarations inside const ( … ) blocks. - const constLineRe = /^\s+(\w+)\s+(\w+)\s*=\s*"([^"]+)"/gm; - let m; - while ((m = constLineRe.exec(code)) !== null) { - const [, constName, typeName, value] = m; - if (constName.startsWith(typeName)) continue; - - // Use the same initialism logic as toPascalCase so "url" → "URL", "mcp" → "MCP", etc. - const valuePascal = value - .split(/[._-]/) - .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)) - .join(""); - const desired = typeName + valuePascal; - if (constName !== desired) { - renames.set(constName, desired); - } - } +function compareGoFieldNames(left: string, right: string): number { + return left.localeCompare(right); +} - // Replace each const block in place, then fix switch-case references - // in marshal/unmarshal functions. This avoids renaming struct fields. +function sortByGoFieldName(entries: [string, T][]): [string, T][] { + return entries.sort(([left], [right]) => compareGoFieldNames(toGoFieldName(left), toGoFieldName(right))); +} - // Phase 1: Rename inside const ( … ) blocks - code = code.replace(/^(const \([\s\S]*?\n\))/gm, (block) => { - let b = block; - for (const [oldName, newName] of renames) { - b = b.replace(new RegExp(`\\b${oldName}\\b`, "g"), newName); - } - return b; - }); - - // Phase 2: Rename inside func bodies (marshal/unmarshal helpers use case statements) - code = code.replace(/^(func \([\s\S]*?\n\})/gm, (funcBlock) => { - let b = funcBlock; - for (const [oldName, newName] of renames) { - b = b.replace(new RegExp(`\\b${oldName}\\b`, "g"), newName); - } - return b; - }); +function sortByPascalName(entries: [string, T][]): [string, T][] { + return entries.sort(([left], [right]) => toPascalCase(left).localeCompare(toPascalCase(right))); +} - return code; +function compareRpcMethodsByGoName(left: RpcMethod, right: RpcMethod): number { + return clientHandlerMethodName(left.rpcMethod).localeCompare(clientHandlerMethodName(right.rpcMethod)); } -function collapsePlaceholderGoStructs(code: string, knownDefinitionNames?: Set): string { - const structBlockRe = /((?:\/\/.*\r?\n)*)type\s+(\w+)\s+struct\s*\{[\s\S]*?^\}/gm; - const matches = [...code.matchAll(structBlockRe)].map((match) => ({ - fullBlock: match[0], - name: match[2], - normalizedBody: normalizeGoStructBlock(match[0], match[2]), - })); - const groups = new Map(); +function splitGoIdentifierWords(name: string): string[] { + return name + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .split(/[._-]/) + .filter((word) => word.length > 0); +} - for (const match of matches) { - const group = groups.get(match.normalizedBody) ?? []; - group.push(match); - groups.set(match.normalizedBody, group); - } +function isStringEnumDefinition(definition: JSONSchema7): definition is JSONSchema7 & { enum: string[] } { + return Array.isArray(definition.enum) && definition.enum.every((value) => typeof value === "string"); +} - for (const group of groups.values()) { - if (group.length < 2) continue; +function pushGoComment(lines: string[], text: string, indent = "", wrap = true): void { + lines.push(...goCommentLines(text, indent, wrap)); +} - const canonical = chooseCanonicalPlaceholderDuplicate(group.map(({ name }) => name), knownDefinitionNames); - if (!canonical) continue; +function pushGoCommentForContext(lines: string[], text: string, ctx: GoCodegenCtx, indent = ""): void { + pushGoComment(lines, text, indent, ctx.wrapComments !== false); +} - for (const duplicate of group) { - if (duplicate.name === canonical) continue; - // Only collapse types that quicktype invented (Class suffix or not - // in the schema's named definitions). Preserve intentionally-named types. - if (!isPlaceholderTypeName(duplicate.name) && knownDefinitionNames?.has(duplicate.name.toLowerCase())) continue; +function goCommentLines(text: string, indent = "", wrap = true): string[] { + const prefix = `${indent}//`; + const lines: string[] = []; - code = code.replace(duplicate.fullBlock, ""); - code = code.replace(new RegExp(`\\b${duplicate.name}\\b`, "g"), canonical); + for (const paragraph of text.split(/\r?\n/)) { + const trimmed = paragraph.trim(); + if (trimmed.length === 0) { + lines.push(prefix); + continue; + } + const commentLines = wrap + ? wrapGoCommentText(trimmed).split("\n").map((wrappedLine: string) => wrappedLine.trim()) + : [trimmed]; + for (const line of commentLines) { + lines.push(`${prefix} ${line}`); } } - return code.replace(/\n{3,}/g, "\n\n"); + return lines; } -function normalizeGoStructBlock(block: string, name: string): string { - return block - .replace(/^\s*\/\/.*\r?\n/gm, "") - .replace(new RegExp(`^type\\s+${name}\\s+struct\\s*\\{`, "m"), "type struct {") +function wrapGeneratedGoComments(code: string): string { + return code .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0) + .flatMap((line) => { + const match = /^(\s*)\/\/\s?(.*)$/.exec(line); + if (!match) return [line]; + const [, indent, text] = match; + if (text.length <= goCommentTextWrapLength) return [line]; + return goCommentLines(text, indent); + }) .join("\n"); } -function chooseCanonicalPlaceholderDuplicate(names: string[], knownDefinitionNames?: Set): string | undefined { - // Prefer the name that matches a schema definition — it's intentionally named. - if (knownDefinitionNames) { - const definedName = names.find((name) => knownDefinitionNames.has(name.toLowerCase())); - if (definedName) return definedName; - } - // Fallback for Class-suffix placeholders: pick the non-placeholder name. - const specificNames = names.filter((name) => !isPlaceholderTypeName(name)); - if (specificNames.length === 0) return undefined; - return specificNames[0]; -} - -function isPlaceholderTypeName(name: string): boolean { - return name.endsWith("Class"); -} - /** * Extract a mapping from (structName, jsonFieldName) → goFieldName - * so the wrapper code references the actual quicktype-generated field names. + * so the wrapper code references the generated Go field names. */ -function extractFieldNames(qtCode: string): Map> { +function extractFieldNames(generatedTypeCode: string): Map> { const result = new Map>(); const structRe = /^type\s+(\w+)\s+struct\s*\{([^}]*)\}/gm; let sm; - while ((sm = structRe.exec(qtCode)) !== null) { + while ((sm = structRe.exec(generatedTypeCode)) !== null) { const [, structName, body] = sm; const fields = new Map(); const fieldRe = /^\s+(\w+)\s+[^`\n]+`json:"([^",]+)/gm; @@ -197,133 +158,6 @@ function extractFieldNames(qtCode: string): Map> { return result; } -/** - * Add `,omitempty` to JSON tags for optional fields in quicktype-generated structs. - * - * Quicktype's Go renderer emits `omitempty` for most optional fields, but it can miss - * some — notably fields whose type is `*Foo` where `Foo` is a `$ref` to an `anyOf` union - * (e.g., `FilterMapping`). When such a pointer field is left without `omitempty`, the Go - * struct serializes the nil pointer as `"foo": null`, which the runtime's Zod schema - * rejects with a validation error. - * - * This pass walks each known struct (whose schema is in `definitions`) and rewrites any - * `json:"propName"` tag (no comma, no modifier) to `json:"propName,omitempty"` when - * `propName` is not listed in the schema's `required` array. - */ -function addMissingOmitemptyToQuicktypeStructs( - qtCode: string, - definitions: Record -): string { - // Build a case-insensitive lookup from emitted Go type name → schema definition. - const defByLower = new Map(); - for (const [name, def] of Object.entries(definitions)) { - defByLower.set(name.toLowerCase(), def); - } - - return qtCode.replace( - /^(type\s+(\w+)\s+struct\s*\{)([\s\S]*?)^\}/gm, - (match, header: string, typeName: string, body: string) => { - const def = defByLower.get(typeName.toLowerCase()); - if (!def || typeof def !== "object") return match; - - // Build the union of (properties, required) across the schema. For a regular - // object schema this is just (properties, required). For a discriminated union - // (anyOf with $ref variants), quicktype emits a flat struct merging all variant - // fields — we need to consider a property required only if it is required in - // every variant and present in every variant. - const merged = mergeSchemaPropertiesForOmitempty(def, defByLower); - if (!merged) return match; - const { properties, required } = merged; - - const newBody = body.replace( - /(`json:")([a-zA-Z0-9_]+)("`)/g, - (tagMatch: string, open: string, propName: string, close: string) => { - if (required.has(propName)) return tagMatch; - if (!(propName in properties)) return tagMatch; - return `${open}${propName},omitempty${close}`; - } - ); - return `${header}${newBody}}`; - } - ); -} - -function mergeSchemaPropertiesForOmitempty( - def: JSONSchema7, - defByLower: Map -): { properties: Record; required: Set } | undefined { - if (def.properties) { - return { - properties: def.properties as Record, - required: new Set(def.required || []), - }; - } - if (Array.isArray(def.anyOf)) { - const variantSchemas: JSONSchema7[] = []; - for (const v of def.anyOf as JSONSchema7[]) { - if (typeof v !== "object" || v === null) continue; - if (v.$ref) { - const refName = v.$ref.split("/").pop(); - if (!refName) continue; - const resolved = defByLower.get(refName.toLowerCase()); - if (resolved && resolved.properties) variantSchemas.push(resolved); - } else if (v.properties) { - variantSchemas.push(v); - } - } - if (variantSchemas.length === 0) return undefined; - - const properties: Record = {}; - const presenceCount = new Map(); - const requiredEverywhere = new Set(); - let firstVariant = true; - for (const variant of variantSchemas) { - const variantRequired = new Set(variant.required || []); - const propNames = Object.keys(variant.properties || {}); - if (firstVariant) { - for (const name of variantRequired) requiredEverywhere.add(name); - firstVariant = false; - } else { - for (const name of [...requiredEverywhere]) { - if (!variantRequired.has(name)) requiredEverywhere.delete(name); - } - } - for (const name of propNames) { - presenceCount.set(name, (presenceCount.get(name) ?? 0) + 1); - if (!(name in properties)) { - properties[name] = (variant.properties as Record)[name]; - } - } - } - const required = new Set(); - for (const name of requiredEverywhere) { - if ((presenceCount.get(name) ?? 0) === variantSchemas.length) required.add(name); - } - return { properties, required }; - } - return undefined; -} - -function extractQuicktypeImports(qtCode: string): { code: string; imports: string[] } { - const collectedImports: string[] = []; - let code = qtCode.replace(/^import \(\n([\s\S]*?)^\)\n+/m, (_match, block: string) => { - for (const line of block.split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed.length > 0) { - collectedImports.push(trimmed); - } - } - return ""; - }); - - code = code.replace(/^import ("[^"]+")\n+/m, (_match, singleImport: string) => { - collectedImports.push(singleImport.trim()); - return ""; - }); - - return { code, imports: collectedImports }; -} - async function formatGoFile(filePath: string): Promise { try { await execFileAsync("go", ["fmt", filePath]); @@ -335,7 +169,7 @@ async function formatGoFile(filePath: string): Promise { function collectRpcMethods(node: Record): RpcMethod[] { const results: RpcMethod[] = []; - for (const value of Object.values(node)) { + for (const [, value] of sortByPascalName(Object.entries(node))) { if (isRpcMethod(value)) { results.push(value); } else if (typeof value === "object" && value !== null) { @@ -362,9 +196,9 @@ function schemaSourceForNamedDefinition( if (schema?.$ref && resolvedSchema) { return resolvedSchema; } - // When the schema is an anyOf/oneOf wrapper (e.g., Zod optional params producing - // `anyOf: [{ not: {} }, { $ref }]`), use the resolved object schema to avoid - // generating self-referential type aliases that crash quicktype. + // When a method wrapper is named the same as the referenced schema inside an + // anyOf/oneOf, store the resolved object shape so the definition map does not + // create a self-referential alias. if ((schema?.anyOf || schema?.oneOf) && resolvedSchema?.properties) { return resolvedSchema; } @@ -430,6 +264,7 @@ interface GoCodegenCtx { enumsByName: Map; // enumName → enumName (dedup by type name, not values) generatedNames: Set; definitions?: DefinitionCollections; + wrapComments?: boolean; } function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { @@ -471,17 +306,19 @@ function getGoSharedEventEnvelopeProperties(schema: JSONSchema7, ctx: GoCodegenC }); } -function emitGoEnvelopeStructField(property: GoEventEnvelopeProperty, includeComment: boolean): string[] { +function emitGoEnvelopeStructField(property: GoEventEnvelopeProperty, includeComment: boolean, wrapComments = true): string[] { const lines: string[] = []; if (includeComment && property.description) { - for (const line of property.description.split(/\r?\n/)) { - lines.push(`\t// ${line}`); - } + pushGoComment(lines, property.description, "\t", wrapComments); } lines.push(`\t${property.fieldName} ${property.typeName} \`${property.jsonTag}\``); return lines; } +function sortedGoEventEnvelopeProperties(properties: GoEventEnvelopeProperty[]): GoEventEnvelopeProperty[] { + return [...properties].sort((left, right) => compareGoFieldNames(left.fieldName, right.fieldName)); +} + /** * Find a const-valued discriminator property shared by all anyOf variants. */ @@ -527,25 +364,18 @@ function getOrCreateGoEnum( const lines: string[] = []; if (description) { - for (const line of description.split(/\r?\n/)) { - lines.push(`// ${line}`); - } + pushGoCommentForContext(lines, description, ctx); } if (deprecated) { - lines.push(`// Deprecated: ${enumName} is deprecated and will be removed in a future version.`); + pushGoCommentForContext(lines, `Deprecated: ${enumName} is deprecated and will be removed in a future version.`, ctx); } lines.push(`type ${enumName} string`); lines.push(``); lines.push(`const (`); - for (const value of values) { - const constSuffix = value - .split(/[-_.]/) - .map((w) => - goInitialisms.has(w.toLowerCase()) - ? w.toUpperCase() - : w.charAt(0).toUpperCase() + w.slice(1) - ) - .join(""); + const consts = values + .map((value) => ({ value, constSuffix: goEnumConstSuffix(value) })) + .sort((left, right) => `${enumName}${left.constSuffix}`.localeCompare(`${enumName}${right.constSuffix}`)); + for (const { value, constSuffix } of consts) { lines.push(`\t${enumName}${constSuffix} ${enumName} = "${value}"`); } lines.push(`)`); @@ -555,6 +385,35 @@ function getOrCreateGoEnum( return enumName; } +function goEnumConstSuffix(value: string): string { + return value + .split(/[-_.]/) + .map((word) => + goInitialisms.has(word.toLowerCase()) + ? word.toUpperCase() + : word.charAt(0).toUpperCase() + word.slice(1) + ) + .join(""); +} + +function schemaForConstValue(value: unknown): JSONSchema7 { + if (value === null) return { type: "null" }; + if (Array.isArray(value)) return { type: "array", items: {} }; + + switch (typeof value) { + case "boolean": + return { type: "boolean" }; + case "number": + return { type: Number.isInteger(value) ? "integer" : "number" }; + case "string": + return { type: "string" }; + case "object": + return { type: "object", additionalProperties: true }; + default: + return {}; + } +} + /** * Resolve a JSON Schema property to a Go type string. * Emits nested struct/enum definitions into ctx as a side effect. @@ -581,6 +440,10 @@ function resolveGoPropertyType( emitGoStruct(typeName, resolved, ctx); return isRequired ? typeName : `*${typeName}`; } + if (resolved.anyOf || resolved.oneOf) { + emitGoRpcDefinition(refTypeName(propSchema.$ref, ctx.definitions), resolved, ctx); + return isRequired ? typeName : `*${typeName}`; + } return resolveGoPropertyType(resolved, parentTypeName, jsonPropName, isRequired, ctx); } // Fallback: use the type name directly @@ -593,7 +456,6 @@ function resolveGoPropertyType( if (nullableInnerSchema) { // anyOf [T, null/{not:{}}] → nullable T const innerType = resolveGoPropertyType(nullableInnerSchema, parentTypeName, jsonPropName, true, ctx); - if (isRequired) return innerType; // Pointer-wrap if not already a pointer, slice, or map if (innerType.startsWith("*") || innerType.startsWith("[]") || innerType.startsWith("map[")) { return innerType; @@ -628,6 +490,11 @@ function resolveGoPropertyType( emitGoFlatDiscriminatedUnion(unionName, disc.property, disc.mapping, ctx, propSchema.description); return isRequired && !hasNull ? unionName : `*${unionName}`; } + if (canFlattenGoObjectUnion(resolvedVariants, ctx)) { + const unionName = (propSchema.title as string) || nestedName; + emitGoFlattenedObjectUnion(unionName, resolvedVariants, ctx, propSchema.description); + return isRequired && !hasNull ? unionName : `*${unionName}`; + } // Non-discriminated multi-type union → any return "any"; } @@ -639,9 +506,14 @@ function resolveGoPropertyType( return isRequired ? enumType : `*${enumType}`; } - // Handle const (discriminator markers) — just use string + // Handle const values. String consts stay enum-like to preserve generated names for + // discriminators; other const values use their underlying JSON type. if (propSchema.const !== undefined) { - return isRequired ? "string" : "*string"; + if (typeof propSchema.const !== "string") { + return resolveGoPropertyType(schemaForConstValue(propSchema.const), parentTypeName, jsonPropName, isRequired, ctx); + } + const enumType = getOrCreateGoEnum((propSchema.title as string) || nestedName, [propSchema.const], ctx, propSchema.description, isSchemaDeprecated(propSchema)); + return isRequired ? enumType : `*${enumType}`; } const type = propSchema.type; @@ -712,7 +584,15 @@ function resolveGoPropertyType( emitGoStruct(valueName, ap, ctx); return `map[string]${valueName}`; } - const valueType = resolveGoPropertyType(ap, parentTypeName, jsonPropName + "Value", true, ctx); + let valueType = resolveGoPropertyType(ap, parentTypeName, jsonPropName + "Value", true, ctx); + const resolvedValueType = ap.$ref ? resolveRef(ap.$ref, ctx.definitions) : undefined; + if (resolvedValueType?.anyOf || resolvedValueType?.oneOf) { + const unionMembers = goNonNullUnionMembers(resolvedValueType) + .map((member) => resolveGoUnionMember(member, ctx.definitions)); + if (!canFlattenGoObjectUnion(unionMembers, ctx) && !valueType.startsWith("*") && !valueType.startsWith("[]") && !valueType.startsWith("map[")) { + valueType = `*${valueType}`; + } + } return `map[string]${valueType}`; } return "map[string]any"; @@ -740,16 +620,14 @@ function emitGoStruct( const lines: string[] = []; const desc = description || schema.description; if (desc) { - for (const line of desc.split(/\r?\n/)) { - lines.push(`// ${line}`); - } + pushGoCommentForContext(lines, desc, ctx); } if (isSchemaDeprecated(schema)) { - lines.push(`// Deprecated: ${typeName} is deprecated and will be removed in a future version.`); + pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); } lines.push(`type ${typeName} struct {`); - for (const [propName, propSchema] of Object.entries(schema.properties || {}).sort(([a], [b]) => a.localeCompare(b))) { + for (const [propName, propSchema] of sortByGoFieldName(Object.entries(schema.properties || {}))) { if (typeof propSchema !== "object") continue; const prop = propSchema as JSONSchema7; const isReq = required.has(propName); @@ -758,10 +636,10 @@ function emitGoStruct( const omit = isReq ? "" : ",omitempty"; if (prop.description) { - lines.push(`\t// ${prop.description}`); + pushGoCommentForContext(lines, prop.description, ctx, "\t"); } if (isSchemaDeprecated(prop)) { - lines.push(`\t// Deprecated: ${goName} is deprecated.`); + pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); } lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); } @@ -834,27 +712,238 @@ function emitGoFlatDiscriminatedUnion( const lines: string[] = []; if (description) { - for (const line of description.split(/\r?\n/)) { - lines.push(`// ${line}`); - } + pushGoCommentForContext(lines, description, ctx); } lines.push(`type ${typeName} struct {`); - // Emit discriminator field first - lines.push(`\t// ${discGoName} discriminator`); - lines.push(`\t${discGoName} ${discEnumName} \`json:"${discriminatorProp}"\``); - - // Emit remaining fields - for (const [propName, info] of [...allProps.entries()].sort(([a], [b]) => a.localeCompare(b))) { - if (propName === discriminatorProp) continue; + for (const [propName, info] of sortByGoFieldName([...allProps.entries()])) { const goName = toGoFieldName(propName); - const goType = resolveGoPropertyType(info.schema, typeName, propName, info.requiredInAll, ctx); + const goType = propName === discriminatorProp + ? discEnumName + : resolveGoPropertyType(info.schema, typeName, propName, info.requiredInAll, ctx); const omit = info.requiredInAll ? "" : ",omitempty"; - if (info.schema.description) { - lines.push(`\t// ${info.schema.description}`); + if (propName === discriminatorProp) { + lines.push(`\t// ${discGoName} discriminator`); + } else if (info.schema.description) { + pushGoCommentForContext(lines, info.schema.description, ctx, "\t"); } if (isSchemaDeprecated(info.schema)) { - lines.push(`\t// Deprecated: ${goName} is deprecated.`); + pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); + } + lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); + } + + lines.push(`}`); + ctx.structs.push(lines.join("\n")); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)); + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function normalizeSchemaForMatch(schema: JSONSchema7, ctx: GoCodegenCtx): unknown { + const resolved = resolveSchema(schema, ctx.definitions) ?? schema; + if (Array.isArray(resolved)) { + return resolved.map((item) => typeof item === "object" && item !== null + ? normalizeSchemaForMatch(item as JSONSchema7, ctx) + : item); + } + if (!resolved || typeof resolved !== "object") return resolved; + + const entries = Object.entries(resolved) + .filter(([key]) => !["title", "description", "default"].includes(key)) + .map(([key, value]) => { + if ((key === "anyOf" || key === "oneOf") && Array.isArray(value)) { + const members = value + .map((member) => normalizeSchemaForMatch(member as JSONSchema7, ctx)) + .sort((left, right) => stableStringify(left).localeCompare(stableStringify(right))); + return [key, members] as const; + } + if (key === "enum" && Array.isArray(value)) { + return [key, [...value].sort()] as const; + } + if (key === "type" && Array.isArray(value)) { + return [key, [...value].sort()] as const; + } + if (value && typeof value === "object") { + return [key, normalizeSchemaForMatch(value as JSONSchema7, ctx)] as const; + } + return [key, value] as const; + }); + + return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); +} + +function dedupeGoSchemasForMatch(schemas: JSONSchema7[], ctx: GoCodegenCtx): JSONSchema7[] { + const seen = new Set(); + const result: JSONSchema7[] = []; + for (const schema of schemas) { + const key = stableStringify(normalizeSchemaForMatch(schema, ctx)); + if (seen.has(key)) continue; + seen.add(key); + result.push(schema); + } + return result; +} + +function goDefinitionRefForEquivalentSchema(schema: JSONSchema7, ctx: GoCodegenCtx): string | undefined { + const schemaKey = stableStringify(normalizeSchemaForMatch(schema, ctx)); + const definitions = { + ...(ctx.definitions?.definitions ?? {}), + ...(ctx.definitions?.$defs ?? {}), + }; + for (const [definitionName, definition] of Object.entries(definitions)) { + if (!definition || typeof definition !== "object") continue; + const definitionKey = stableStringify(normalizeSchemaForMatch(definition as JSONSchema7, ctx)); + if (definitionKey === schemaKey) { + return `#/definitions/${definitionName}`; + } + } + return undefined; +} + +function goDefinitionName(definitionName: string): string { + return toGoSchemaTypeName(definitionName); +} + +function goNonNullUnionMembers(schema: JSONSchema7): JSONSchema7[] { + return ((schema.anyOf ?? schema.oneOf) as JSONSchema7[] | undefined) + ?.filter((member) => { + if (!member || typeof member !== "object") return false; + if (member.type === "null") return false; + if (member.not && typeof member.not === "object" && Object.keys(member.not).length === 0) return false; + return true; + }) ?? []; +} + +function resolveGoUnionMember(member: JSONSchema7, definitions: DefinitionCollections | undefined): JSONSchema7 { + if (member.$ref) { + return resolveRef(member.$ref, definitions) ?? member; + } + return member; +} + +function goObjectUnionMemberSchema(member: JSONSchema7, ctx: GoCodegenCtx): JSONSchema7 | undefined { + const resolved = resolveGoUnionMember(member, ctx.definitions); + const objectSchema = resolveObjectSchema(resolved, ctx.definitions) ?? resolveSchema(resolved, ctx.definitions) ?? resolved; + if (objectSchema?.properties && (objectSchema.type === "object" || objectSchema.type === undefined)) { + return objectSchema; + } + return undefined; +} + +function canFlattenGoObjectUnion(members: JSONSchema7[], ctx: GoCodegenCtx): boolean { + return members.length > 0 && members.every((member) => goObjectUnionMemberSchema(member, ctx) !== undefined); +} + +function goStringEnumValues(schema: JSONSchema7, ctx: GoCodegenCtx): string[] | undefined { + const resolved = resolveSchema(schema, ctx.definitions) ?? schema; + if (typeof resolved.const === "string") return [resolved.const]; + if (isStringEnumDefinition(resolved)) return resolved.enum; + + const unionMembers = goNonNullUnionMembers(resolved); + if (unionMembers.length > 0) { + const values: string[] = []; + for (const member of unionMembers) { + const memberValues = goStringEnumValues(member, ctx); + if (!memberValues) return undefined; + values.push(...memberValues); + } + return [...new Set(values)]; + } + + return undefined; +} + +function mergeGoFlattenedPropertySchema( + typeName: string, + propName: string, + schemas: JSONSchema7[], + ctx: GoCodegenCtx +): JSONSchema7 { + if (schemas.length === 1) return schemas[0]; + + const enumValues = schemas.map((schema) => goStringEnumValues(schema, ctx)); + if (enumValues.every((values): values is string[] => values !== undefined)) { + return { + type: "string", + enum: [...new Set(enumValues.flat())], + title: typeName + toGoFieldName(propName), + }; + } + + const firstSchemaKey = stableStringify(resolveSchema(schemas[0], ctx.definitions) ?? schemas[0]); + if (schemas.every((schema) => stableStringify(resolveSchema(schema, ctx.definitions) ?? schema) === firstSchemaKey)) { + return schemas[0]; + } + + const unionSchema = { anyOf: dedupeGoSchemasForMatch(schemas, ctx) }; + const definitionRef = goDefinitionRefForEquivalentSchema(unionSchema, ctx); + if (definitionRef) return { $ref: definitionRef }; + + return unionSchema; +} + +function emitGoFlattenedObjectUnion( + typeName: string, + variants: JSONSchema7[], + ctx: GoCodegenCtx, + description?: string +): void { + if (ctx.generatedNames.has(typeName)) return; + ctx.generatedNames.add(typeName); + + const objectVariants = variants + .map((variant) => goObjectUnionMemberSchema(variant, ctx)) + .filter((variant): variant is JSONSchema7 => variant !== undefined); + const allProps = new Map(); + + for (const variant of objectVariants) { + const required = new Set(variant.required || []); + for (const [propName, propSchema] of Object.entries(variant.properties || {})) { + if (typeof propSchema !== "object") continue; + const existing = allProps.get(propName); + if (existing) { + existing.schemas.push(propSchema as JSONSchema7); + existing.presentCount++; + if (!required.has(propName)) { + existing.requiredInAll = false; + } + } else { + allProps.set(propName, { + schemas: [propSchema as JSONSchema7], + requiredInAll: required.has(propName), + presentCount: 1, + }); + } + } + } + + const lines: string[] = []; + if (description) { + pushGoCommentForContext(lines, description, ctx); + } + lines.push(`type ${typeName} struct {`); + + for (const [propName, info] of sortByGoFieldName([...allProps.entries()])) { + const goName = toGoFieldName(propName); + const mergedSchema = mergeGoFlattenedPropertySchema(typeName, propName, info.schemas, ctx); + const requiredInAll = info.requiredInAll && info.presentCount === objectVariants.length; + const goType = resolveGoPropertyType(mergedSchema, typeName, propName, requiredInAll, ctx); + const omit = requiredInAll ? "" : ",omitempty"; + const description = info.schemas.find((schema) => schema.description)?.description; + if (description) { + pushGoCommentForContext(lines, description, ctx, "\t"); + } + if (info.schemas.some((schema) => isSchemaDeprecated(schema))) { + pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); } lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); } @@ -863,6 +952,225 @@ function emitGoFlatDiscriminatedUnion( ctx.structs.push(lines.join("\n")); } +function goUnionFieldName(member: JSONSchema7, ctx: GoCodegenCtx): string { + if (member.$ref) { + const resolved = resolveRef(member.$ref, ctx.definitions); + if (resolved?.enum) return "Enum"; + return goDefinitionName(refTypeName(member.$ref, ctx.definitions)); + } + + if (member.enum) return "Enum"; + + if (member.type === "object" && member.additionalProperties && typeof member.additionalProperties === "object") { + const valueSchema = member.additionalProperties as JSONSchema7; + if (valueSchema.$ref) { + const resolved = resolveRef(valueSchema.$ref, ctx.definitions); + if (resolved?.enum) return "EnumMap"; + return `${goDefinitionName(refTypeName(valueSchema.$ref, ctx.definitions))}Map`; + } + return `${goPrimitiveUnionFieldName(valueSchema)}Map`; + } + + if (member.type === "array") { + const items = member.items && typeof member.items === "object" && !Array.isArray(member.items) + ? member.items as JSONSchema7 + : undefined; + return `${items ? goUnionFieldName(items, ctx) : "Any"}Array`; + } + + return goPrimitiveUnionFieldName(member); +} + +function goPrimitiveUnionFieldName(schema: JSONSchema7): string { + switch (schema.type) { + case "boolean": return "Bool"; + case "integer": return "Integer"; + case "number": return "Double"; + case "string": return "String"; + case "object": return "Object"; + default: return "Any"; + } +} + +function goUnionFieldType(member: JSONSchema7, fieldName: string, parentTypeName: string, ctx: GoCodegenCtx): string { + const memberType = resolveGoPropertyType(member, parentTypeName, fieldName, true, ctx); + if (memberType.startsWith("*") || memberType.startsWith("[]") || memberType.startsWith("map[")) { + return memberType; + } + return `*${memberType}`; +} + +function goUnionFieldMarshalIsSet(fieldName: string, fieldType: string): string { + if (fieldType.startsWith("*") || fieldType.startsWith("[]") || fieldType.startsWith("map[")) { + return `r.${fieldName} != nil`; + } + return "true"; +} + +function goUnionFieldUnmarshalType(fieldType: string): string { + if (fieldType.startsWith("*")) { + return fieldType.slice(1); + } + return fieldType; +} + +function goUnionFieldUnmarshalAssignment(typeName: string, fieldName: string, fieldType: string): string { + if (fieldType.startsWith("*")) { + return `*r = ${typeName}{${fieldName}: &value}`; + } + return `*r = ${typeName}{${fieldName}: value}`; +} + +function emitGoUnionStruct(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): void { + if (ctx.generatedNames.has(typeName)) return; + ctx.generatedNames.add(typeName); + + const members = goNonNullUnionMembers(schema); + const lines: string[] = []; + if (schema.description) { + pushGoCommentForContext(lines, schema.description, ctx); + } + if (isSchemaDeprecated(schema)) { + pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); + } + lines.push(`type ${typeName} struct {`); + + const emittedFields = new Set(); + const fields: { name: string; type: string }[] = []; + for (const member of members) { + const fieldNameBase = goUnionFieldName(member, ctx); + let fieldName = fieldNameBase; + let suffix = 2; + while (emittedFields.has(fieldName)) { + fieldName = `${fieldNameBase}${suffix++}`; + } + emittedFields.add(fieldName); + const fieldType = goUnionFieldType(member, fieldName, typeName, ctx); + fields.push({ name: fieldName, type: fieldType }); + } + + fields.sort((left, right) => compareGoFieldNames(left.name, right.name)); + for (const field of fields) { + lines.push(`\t${field.name} ${field.type}`); + } + + lines.push(`}`); + lines.push(``); + lines.push(`func (r ${typeName}) MarshalJSON() ([]byte, error) {`); + for (const field of fields) { + lines.push(`\tif ${goUnionFieldMarshalIsSet(field.name, field.type)} {`); + lines.push(`\t\treturn json.Marshal(r.${field.name})`); + lines.push(`\t}`); + } + lines.push(`\treturn []byte("null"), nil`); + lines.push(`}`); + lines.push(``); + lines.push(`func (r *${typeName}) UnmarshalJSON(data []byte) error {`); + lines.push(`\tif string(data) == "null" {`); + lines.push(`\t\t*r = ${typeName}{}`); + lines.push(`\t\treturn nil`); + lines.push(`\t}`); + for (const field of fields) { + lines.push(`\t{`); + lines.push(`\t\tvar value ${goUnionFieldUnmarshalType(field.type)}`); + lines.push(`\t\tif err := json.Unmarshal(data, &value); err == nil {`); + lines.push(`\t\t\t${goUnionFieldUnmarshalAssignment(typeName, field.name, field.type)}`); + lines.push(`\t\t\treturn nil`); + lines.push(`\t\t}`); + lines.push(`\t}`); + } + lines.push(`\treturn errors.New("data did not match any union variant for ${typeName}")`); + lines.push(`}`); + ctx.structs.push(lines.join("\n")); +} + +function emitGoAlias(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): void { + if (ctx.generatedNames.has(typeName)) return; + ctx.generatedNames.add(typeName); + + const lines: string[] = []; + if (schema.description) { + pushGoCommentForContext(lines, schema.description, ctx); + } + if (isSchemaDeprecated(schema)) { + pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); + } + lines.push(`type ${typeName} ${resolveGoPropertyType(schema, typeName, "Value", true, ctx)}`); + ctx.structs.push(lines.join("\n")); +} + +function emitGoRpcDefinition(definitionName: string, schema: JSONSchema7, ctx: GoCodegenCtx): string { + const typeName = goDefinitionName(definitionName); + const effectiveSchema = resolveObjectSchema(schema, ctx.definitions) ?? resolveSchema(schema, ctx.definitions) ?? schema; + + if (isStringEnumDefinition(effectiveSchema)) { + getOrCreateGoEnum(typeName, effectiveSchema.enum, ctx, effectiveSchema.description, isSchemaDeprecated(effectiveSchema)); + return typeName; + } + + if (isNamedGoObjectSchema(effectiveSchema)) { + emitGoStruct(typeName, effectiveSchema, ctx); + return typeName; + } + + const unionMembers = goNonNullUnionMembers(effectiveSchema); + if (unionMembers.length > 0) { + const resolvedVariants = unionMembers.map((member) => resolveGoUnionMember(member, ctx.definitions)); + const discriminator = findGoDiscriminator(resolvedVariants); + if (discriminator) { + emitGoFlatDiscriminatedUnion(typeName, discriminator.property, discriminator.mapping, ctx, (effectiveSchema as JSONSchema7).description); + } else if (canFlattenGoObjectUnion(resolvedVariants, ctx)) { + emitGoFlattenedObjectUnion(typeName, resolvedVariants, ctx, (effectiveSchema as JSONSchema7).description); + } else { + emitGoUnionStruct(typeName, effectiveSchema, ctx); + } + return typeName; + } + + emitGoAlias(typeName, effectiveSchema, ctx); + return typeName; +} + +function generateGoRpcTypeCode(definitions: Record, definitionCollections: DefinitionCollections): string { + const ctx: GoCodegenCtx = { + structs: [], + enums: [], + enumsByName: new Map(), + generatedNames: new Set(), + definitions: definitionCollections, + }; + const schemaKeysByTypeName = new Map(); + const entries = Object.entries(definitions) + .sort(([left], [right]) => goDefinitionName(left).localeCompare(goDefinitionName(right))); + + for (const [definitionName, definition] of entries) { + const typeName = goDefinitionName(definitionName); + const schemaKey = stableStringify(resolveSchema(definition, definitionCollections) ?? definition); + const existingSchemaKey = schemaKeysByTypeName.get(typeName); + if (existingSchemaKey && existingSchemaKey !== schemaKey) { + throw new Error(`Conflicting Go RPC type name "${typeName}" for different schemas. Add a schema title/withTypeName to disambiguate.`); + } + schemaKeysByTypeName.set(typeName, schemaKey); + emitGoRpcDefinition(definitionName, definition, ctx); + } + + const lines: string[] = []; + for (const typeCode of ctx.structs.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { + lines.push(typeCode); + lines.push(``); + } + for (const typeCode of ctx.enums.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { + lines.push(typeCode); + lines.push(``); + } + + return lines.join("\n").replace(/\n+$/, ""); +} + +function goDeclaredTypeName(code: string): string { + return /^type\s+(\w+)\b/m.exec(code)?.[1] ?? code; +} + /** * Generate the complete Go session-events file content. */ @@ -874,8 +1182,45 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { enumsByName: new Map(), generatedNames: new Set(), definitions: collectDefinitionCollections(schema as Record), + wrapComments: false, }; const envelopeProperties = getGoSharedEventEnvelopeProperties(schema, ctx); + const sessionEventStructFields = [ + ...envelopeProperties.map((property) => ({ + fieldName: property.fieldName, + lines: emitGoEnvelopeStructField(property, true, ctx.wrapComments !== false), + })), + { + fieldName: "Data", + lines: [ + ...goCommentLines("Typed event payload. Use a type switch to access per-event fields.", "\t", ctx.wrapComments !== false), + `\tData SessionEventData \`json:"-"\``, + ], + }, + { + fieldName: "Type", + lines: [ + ...goCommentLines("The event type discriminator.", "\t", ctx.wrapComments !== false), + `\tType SessionEventType \`json:"type"\``, + ], + }, + ].sort((left, right) => compareGoFieldNames(left.fieldName, right.fieldName)); + const rawEventUnmarshalFields = [ + ...envelopeProperties.map((property) => ({ + fieldName: property.fieldName, + lines: emitGoEnvelopeStructField(property, false, ctx.wrapComments !== false), + })), + { fieldName: "Data", lines: [`\tData json.RawMessage \`json:"data"\``] }, + { fieldName: "Type", lines: [`\tType SessionEventType \`json:"type"\``] }, + ].sort((left, right) => compareGoFieldNames(left.fieldName, right.fieldName)); + const rawEventMarshalFields = [ + ...envelopeProperties.map((property) => ({ + fieldName: property.fieldName, + lines: emitGoEnvelopeStructField(property, false, ctx.wrapComments !== false), + })), + { fieldName: "Data", lines: [`\tData any \`json:"data"\``] }, + { fieldName: "Type", lines: [`\tType SessionEventType \`json:"type"\``] }, + ].sort((left, right) => compareGoFieldNames(left.fieldName, right.fieldName)); // Generate per-event data structs const dataStructs: string[] = []; @@ -884,15 +1229,13 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { const lines: string[] = []; if (variant.dataDescription) { - for (const line of variant.dataDescription.split(/\r?\n/)) { - lines.push(`// ${line}`); - } + pushGoCommentForContext(lines, variant.dataDescription, ctx); } else { - lines.push(`// ${variant.dataClassName} holds the payload for ${variant.typeName} events.`); + pushGoCommentForContext(lines, `${variant.dataClassName} holds the payload for ${variant.typeName} events.`, ctx); } lines.push(`type ${variant.dataClassName} struct {`); - for (const [propName, propSchema] of Object.entries(variant.dataSchema.properties || {}).sort(([a], [b]) => a.localeCompare(b))) { + for (const [propName, propSchema] of sortByGoFieldName(Object.entries(variant.dataSchema.properties || {}))) { if (typeof propSchema !== "object") continue; const prop = propSchema as JSONSchema7; const isReq = required.has(propName); @@ -901,10 +1244,10 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { const omit = isReq ? "" : ",omitempty"; if (prop.description) { - lines.push(`\t// ${prop.description}`); + pushGoCommentForContext(lines, prop.description, ctx, "\t"); } if (isSchemaDeprecated(prop)) { - lines.push(`\t// Deprecated: ${goName} is deprecated.`); + pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); } lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); } @@ -922,18 +1265,21 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { eventTypeEnum.push(`type SessionEventType string`); eventTypeEnum.push(``); eventTypeEnum.push(`const (`); - for (const variant of variants) { - const constName = - "SessionEventType" + - variant.typeName + const eventTypeConsts = variants + .map((variant) => ({ + constName: "SessionEventType" + variant.typeName .split(/[._]/) .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1) ) - .join(""); - eventTypeEnum.push(`\t${constName} SessionEventType = "${variant.typeName}"`); + .join(""), + typeName: variant.typeName, + })) + .sort((left, right) => left.constName.localeCompare(right.constName)); + for (const { constName, typeName } of eventTypeConsts) { + eventTypeEnum.push(`\t${constName} SessionEventType = "${typeName}"`); } eventTypeEnum.push(`)`); @@ -947,6 +1293,7 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // Imports — time is always needed for SessionEvent.Timestamp out.push(`import (`); + out.push(`\t"errors"`); out.push(`\t"encoding/json"`); out.push(`\t"time"`); out.push(`)`); @@ -974,13 +1321,9 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // SessionEvent struct out.push(`// SessionEvent represents a single session event with a typed data payload.`); out.push(`type SessionEvent struct {`); - for (const property of envelopeProperties) { - out.push(...emitGoEnvelopeStructField(property, true)); + for (const field of sessionEventStructFields) { + out.push(...field.lines); } - out.push(`\t// The event type discriminator.`); - out.push(`\tType SessionEventType \`json:"type"\``); - out.push(`\t// Typed event payload. Use a type switch to access per-event fields.`); - out.push(`\tData SessionEventData \`json:"-"\``); out.push(`}`); out.push(``); @@ -1003,37 +1346,38 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // Custom UnmarshalJSON out.push(`func (e *SessionEvent) UnmarshalJSON(data []byte) error {`); out.push(`\ttype rawEvent struct {`); - for (const property of envelopeProperties) { - for (const line of emitGoEnvelopeStructField(property, false)) { + for (const field of rawEventUnmarshalFields) { + for (const line of field.lines) { out.push(`\t${line}`); } } - out.push(`\t\tType SessionEventType \`json:"type"\``); - out.push(`\t\tData json.RawMessage \`json:"data"\``); out.push(`\t}`); out.push(`\tvar raw rawEvent`); out.push(`\tif err := json.Unmarshal(data, &raw); err != nil {`); out.push(`\t\treturn err`); out.push(`\t}`); - for (const property of envelopeProperties) { + for (const property of sortedGoEventEnvelopeProperties(envelopeProperties)) { out.push(`\te.${property.fieldName} = raw.${property.fieldName}`); } out.push(`\te.Type = raw.Type`); out.push(``); out.push(`\tswitch raw.Type {`); - for (const variant of variants) { - const constName = - "SessionEventType" + - variant.typeName + const eventCases = variants + .map((variant) => ({ + constName: "SessionEventType" + variant.typeName .split(/[._]/) .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1) ) - .join(""); + .join(""), + dataClassName: variant.dataClassName, + })) + .sort((left, right) => left.constName.localeCompare(right.constName)); + for (const { constName, dataClassName } of eventCases) { out.push(`\tcase ${constName}:`); - out.push(`\t\tvar d ${variant.dataClassName}`); + out.push(`\t\tvar d ${dataClassName}`); out.push(`\t\tif err := json.Unmarshal(raw.Data, &d); err != nil {`); out.push(`\t\t\treturn err`); out.push(`\t\t}`); @@ -1049,20 +1393,21 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // Custom MarshalJSON out.push(`func (e SessionEvent) MarshalJSON() ([]byte, error) {`); out.push(`\ttype rawEvent struct {`); - for (const property of envelopeProperties) { - for (const line of emitGoEnvelopeStructField(property, false)) { + for (const field of rawEventMarshalFields) { + for (const line of field.lines) { out.push(`\t${line}`); } } - out.push(`\t\tType SessionEventType \`json:"type"\``); - out.push(`\t\tData any \`json:"data"\``); out.push(`\t}`); out.push(`\treturn json.Marshal(rawEvent{`); - for (const property of envelopeProperties) { - out.push(`\t\t${property.fieldName}: e.${property.fieldName},`); + const rawEventValues = [ + ...envelopeProperties.map((property) => property.fieldName), + "Data", + "Type", + ].sort(compareGoFieldNames); + for (const fieldName of rawEventValues) { + out.push(`\t\t${fieldName}: e.${fieldName},`); } - out.push(`\t\tType: e.Type,`); - out.push(`\t\tData: e.Data,`); out.push(`\t})`); out.push(`}`); out.push(``); @@ -1078,13 +1423,13 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { } // Nested structs - for (const s of ctx.structs.sort()) { + for (const s of ctx.structs.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { out.push(s); out.push(``); } // Enums - for (const e of ctx.enums.sort()) { + for (const e of ctx.enums.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { out.push(e); out.push(``); } @@ -1105,14 +1450,14 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { }; out.push(`// Type aliases for convenience.`); out.push(`type (`); - for (const [alias, target] of Object.entries(TYPE_ALIASES)) { + for (const [alias, target] of Object.entries(TYPE_ALIASES).sort(([left], [right]) => left.localeCompare(right))) { out.push(`\t${alias} = ${target}`); } out.push(`)`); out.push(``); out.push(`// Constant aliases for convenience.`); out.push(`const (`); - for (const [alias, target] of Object.entries(CONST_ALIASES)) { + for (const [alias, target] of Object.entries(CONST_ALIASES).sort(([left], [right]) => left.localeCompare(right))) { out.push(`\t${alias} = ${target}`); } out.push(`)`); @@ -1148,17 +1493,19 @@ async function generateRpc(schemaPath?: string): Promise { ...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {}), ...collectRpcMethods(schema.clientSession || {}), - ]; + ].sort((left, right) => left.rpcMethod.localeCompare(right.rpcMethod)); - // Build a combined schema for quicktype — prefix types to avoid conflicts. - // Include shared definitions from the API schema for $ref resolution. + // Build a combined definition map, including shared API definitions plus + // method-specific request/result wrapper types. rpcDefinitions = collectDefinitionCollections(schema as Record); - const combinedSchema = withSharedDefinitions( - { - $schema: "http://json-schema.org/draft-07/schema#", - }, - rpcDefinitions - ); + const allDefinitions: Record = { + ...Object.fromEntries( + Object.entries(rpcDefinitions.$defs ?? {}).filter(([, value]) => typeof value === "object" && value !== null) + ) as Record, + ...Object.fromEntries( + Object.entries(rpcDefinitions.definitions ?? {}).filter(([, value]) => typeof value === "object" && value !== null) + ) as Record, + }; for (const method of allMethods) { const resultSchema = getMethodResultSchema(method); @@ -1168,14 +1515,14 @@ async function generateRpc(schemaPath?: string): Promise { // the inner type is already in definitions via shared hoisting. } else if (isVoidSchema(resultSchema)) { // Emit an empty struct for void results (forward-compatible with adding fields later) - combinedSchema.definitions![goResultTypeName(method)] = { + allDefinitions[goResultTypeName(method)] = { title: goResultTypeName(method), type: "object", properties: {}, additionalProperties: false, }; } else if (method.result) { - combinedSchema.definitions![goResultTypeName(method)] = withRootTitle( + allDefinitions[goResultTypeName(method)] = withRootTitle( schemaSourceForNamedDefinition(method.result, resultSchema), goResultTypeName(method) ); @@ -1192,13 +1539,13 @@ async function generateRpc(schemaPath?: string): Promise { required: resolvedParams.required?.filter((r) => r !== "sessionId"), }; if (hasSchemaPayload(filtered)) { - combinedSchema.definitions![goParamsTypeName(method)] = withRootTitle( + allDefinitions[goParamsTypeName(method)] = withRootTitle( filtered, goParamsTypeName(method) ); } } else { - combinedSchema.definitions![goParamsTypeName(method)] = withRootTitle( + allDefinitions[goParamsTypeName(method)] = withRootTitle( schemaSourceForNamedDefinition(method.params, resolvedParams), goParamsTypeName(method) ); @@ -1206,56 +1553,27 @@ async function generateRpc(schemaPath?: string): Promise { } } - const allDefinitions = combinedSchema.definitions! as Record; const allDefinitionCollections: DefinitionCollections = { - definitions: { ...(combinedSchema.$defs ?? {}), ...allDefinitions }, - $defs: { ...allDefinitions, ...(combinedSchema.$defs ?? {}) }, + definitions: { ...(rpcDefinitions.$defs ?? {}), ...allDefinitions }, + $defs: { ...allDefinitions, ...(rpcDefinitions.$defs ?? {}) }, }; + rpcDefinitions = allDefinitionCollections; - // Generate types via quicktype — use a single combined schema source so quicktype - // sees each definition exactly once, preventing whimsical prefix disambiguation. - const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); - const singleSchema: JSONSchema7 = { - $schema: "http://json-schema.org/draft-07/schema#", - type: "object", - definitions: stripBooleanLiterals(allDefinitions) as Record, - properties: Object.fromEntries( - Object.keys(allDefinitions).map((name) => [name, { $ref: `#/definitions/${name}` }]) - ), - required: Object.keys(allDefinitions), - }; - await schemaInput.addSource({ name: "RpcTypes", schema: JSON.stringify(singleSchema) }); - - const inputData = new InputData(); - inputData.addInput(schemaInput); - - const qtResult = await quicktype({ - inputData, - lang: "go", - rendererOptions: { package: "copilot", "just-types": "true" }, - }); - - // Post-process quicktype output: hoist quicktype's imports into the file-level import block - let qtCode = qtResult.lines.filter((l) => !l.startsWith("package ")).join("\n"); - const quicktypeImports = extractQuicktypeImports(qtCode); - qtCode = quicktypeImports.code; - qtCode = postProcessEnumConstants(qtCode); - const knownDefNames = new Set(Object.keys(allDefinitions).map((n) => n.toLowerCase())); - qtCode = collapsePlaceholderGoStructs(qtCode, knownDefNames); - // Strip trailing whitespace from quicktype output (gofmt requirement) - qtCode = qtCode.replace(/[ \t]+$/gm, ""); - - // Extract actual type names generated by quicktype (may differ from toPascalCase) + let generatedTypeCode = generateGoRpcTypeCode(allDefinitions, allDefinitionCollections); + // Strip trailing whitespace from generated output (gofmt requirement) + generatedTypeCode = generatedTypeCode.replace(/[ \t]+$/gm, ""); + + // Extract generated type names. Some may differ from toPascalCase due explicit schema titles. const actualTypeNames = new Map(); const typeRe = /^type\s+(\w+)\b/gm; let sm; - while ((sm = typeRe.exec(qtCode)) !== null) { + while ((sm = typeRe.exec(generatedTypeCode)) !== null) { actualTypeNames.set(sm[1].toLowerCase(), sm[1]); } const resolveType = (name: string): string => actualTypeNames.get(name.toLowerCase()) ?? name; - // Extract field name mappings (quicktype may rename fields to avoid Go keyword conflicts) - const fieldNames = extractFieldNames(qtCode); + // Extract field name mappings so wrappers use the emitted Go field names. + const fieldNames = extractFieldNames(generatedTypeCode); // Annotate experimental data types const experimentalTypeNames = new Set(); @@ -1268,7 +1586,7 @@ async function generateRpc(schemaPath?: string): Promise { } } for (const typeName of experimentalTypeNames) { - qtCode = qtCode.replace( + generatedTypeCode = generatedTypeCode.replace( new RegExp(`^(type ${typeName} struct)`, "m"), `// Experimental: ${typeName} is part of an experimental API and may change or be removed.\n$1` ); @@ -1289,7 +1607,7 @@ async function generateRpc(schemaPath?: string): Promise { } } for (const typeName of deprecatedTypeNames) { - qtCode = qtCode.replace( + generatedTypeCode = generatedTypeCode.replace( new RegExp(`^(type ${typeName} struct)`, "m"), `// Deprecated: ${typeName} is deprecated and will be removed in a future version.\n$1` ); @@ -1304,22 +1622,13 @@ async function generateRpc(schemaPath?: string): Promise { } } for (const typeName of internalTypeNames) { - qtCode = qtCode.replace( + generatedTypeCode = generatedTypeCode.replace( new RegExp(`^(type ${typeName} struct)`, "m"), `// Internal: ${typeName} is an internal SDK API and is not part of the public surface.\n$1` ); } - // Remove trailing blank lines from quicktype output before appending - qtCode = qtCode.replace(/\n+$/, ""); - // Replace interface{} with any (quicktype emits the pre-1.18 form) - qtCode = qtCode.replace(/\binterface\{\}/g, "any"); - - // Post-process: add ,omitempty to optional fields that quicktype emitted without it. - // Quicktype's Go renderer correctly emits omitempty for most optional fields, but it - // misses some (notably $ref-to-anyOf union types like FilterMapping). For each struct - // type we know from the schema, walk its fields and add omitempty if the field is not - // listed in `required` and the tag does not already include any modifier. - qtCode = addMissingOmitemptyToQuicktypeStructs(qtCode, allDefinitions); + // Remove trailing blank lines before appending. + generatedTypeCode = generatedTypeCode.replace(/\n+$/, ""); // Build method wrappers const lines: string[] = []; @@ -1329,10 +1638,8 @@ async function generateRpc(schemaPath?: string): Promise { lines.push(`package rpc`); lines.push(``); const imports = [`"context"`, `"encoding/json"`]; - for (const imp of quicktypeImports.imports) { - if (!imports.includes(imp)) { - imports.push(imp); - } + if (generatedTypeCode.includes("time.Time")) { + imports.push(`"time"`); } if (schema.clientSession) { imports.push(`"errors"`, `"fmt"`); @@ -1346,7 +1653,7 @@ async function generateRpc(schemaPath?: string): Promise { lines.push(`)`); lines.push(``); - lines.push(qtCode); + lines.push(generatedTypeCode); lines.push(``); // Emit ServerRpc @@ -1369,7 +1676,7 @@ async function generateRpc(schemaPath?: string): Promise { emitClientSessionApiRegistration(lines, schema.clientSession, resolveType); } - const outPath = await writeGeneratedFile("go/rpc/generated_rpc.go", lines.join("\n")); + const outPath = await writeGeneratedFile("go/rpc/generated_rpc.go", wrapGeneratedGoComments(lines.join("\n"))); console.log(` ✓ ${outPath}`); await formatGoFile(outPath); @@ -1386,18 +1693,19 @@ function emitApiGroup( groupExperimental: boolean, groupDeprecated: boolean = false ): void { - const subGroups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + const subGroups = sortByPascalName(Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v))); + const methods = sortByPascalName(Object.entries(node).filter(([, v]) => isRpcMethod(v))); if (groupDeprecated) { - lines.push(`// Deprecated: ${apiName} contains deprecated APIs that will be removed in a future version.`); + pushGoComment(lines, `Deprecated: ${apiName} contains deprecated APIs that will be removed in a future version.`); } if (groupExperimental) { - lines.push(`// Experimental: ${apiName} contains experimental APIs that may change or be removed.`); + pushGoComment(lines, `Experimental: ${apiName} contains experimental APIs that may change or be removed.`); } lines.push(`type ${apiName} ${serviceName}`); lines.push(``); - for (const [key, value] of Object.entries(node)) { + for (const [key, value] of methods) { if (!isRpcMethod(value)) continue; emitMethod(lines, apiName, key, value, isSession, resolveType, fieldNames, groupExperimental, false, groupDeprecated); } @@ -1409,7 +1717,7 @@ function emitApiGroup( emitApiGroup(lines, subApiName, subGroupNode as Record, isSession, serviceName, resolveType, fieldNames, subGroupExperimental, subGroupDeprecated); if (subGroupExperimental) { - lines.push(`// Experimental: ${toPascalCase(subGroupName)} returns experimental APIs that may change or be removed.`); + pushGoComment(lines, `Experimental: ${toPascalCase(subGroupName)} returns experimental APIs that may change or be removed.`); } lines.push(`func (s *${apiName}) ${toPascalCase(subGroupName)}() *${subApiName} {`); lines.push(`\treturn (*${subApiName})(s)`); @@ -1419,8 +1727,8 @@ function emitApiGroup( } function emitRpcWrapper(lines: string[], node: Record, isSession: boolean, resolveType: (name: string) => string, fieldNames: Map>, classPrefix: string = ""): void { - const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); - const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); + const groups = sortByPascalName(Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v))); + const topLevelMethods = sortByPascalName(Object.entries(node).filter(([, v]) => isRpcMethod(v))); const wrapperName = classPrefix + (isSession ? "SessionRpc" : "ServerRpc"); const apiSuffix = "Api"; @@ -1453,11 +1761,15 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio const pad = (name: string) => name.padEnd(maxFieldLen); // Emit wrapper struct - lines.push(classPrefix === "Internal" - ? `// ${wrapperName} provides internal SDK ${isSession ? "session" : "server"}-scoped RPC methods (handshake helpers etc.). Not part of the public API.` - : `// ${wrapperName} provides typed ${isSession ? "session" : "server"}-scoped RPC methods.`); + pushGoComment( + lines, + classPrefix === "Internal" + ? `${wrapperName} provides internal SDK ${isSession ? "session" : "server"}-scoped RPC methods (handshake helpers etc.). Not part of the public API.` + : `${wrapperName} provides typed ${isSession ? "session" : "server"}-scoped RPC methods.` + ); lines.push(`type ${wrapperName} struct {`); - lines.push(`\t${pad("common")} ${serviceName} // Reuse a single struct instead of allocating one for each service on the heap.`); + pushGoComment(lines, `Reuse a single struct instead of allocating one for each service on the heap.`, "\t"); + lines.push(`\t${pad("common")} ${serviceName}`); lines.push(``); for (const [groupName] of groups) { const prefix = classPrefix + (isSession ? "" : "Server"); @@ -1501,7 +1813,9 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc const effectiveParams = getMethodParamsSchema(method); const paramProps = effectiveParams?.properties || {}; const requiredParams = new Set(effectiveParams?.required || []); - const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); + const nonSessionParams = Object.keys(paramProps) + .filter((k) => k !== "sessionId") + .sort((left, right) => compareGoFieldNames(toGoFieldName(left), toGoFieldName(right))); const hasParams = isSession ? nonSessionParams.length > 0 : hasSchemaPayload(effectiveParams); const paramsType = hasParams ? resolveType(goParamsTypeName(method)) : ""; @@ -1510,13 +1824,13 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc const sessionIDRef = isWrapper ? "a.common.sessionID" : "a.sessionID"; if (method.deprecated && !groupDeprecated) { - lines.push(`// Deprecated: ${methodName} is deprecated and will be removed in a future version.`); + pushGoComment(lines, `Deprecated: ${methodName} is deprecated and will be removed in a future version.`); } if (method.stability === "experimental" && !groupExperimental) { - lines.push(`// Experimental: ${methodName} is an experimental API and may change or be removed in future versions.`); + pushGoComment(lines, `Experimental: ${methodName} is an experimental API and may change or be removed in future versions.`); } if (method.visibility === "internal") { - lines.push(`// Internal: ${methodName} is part of the SDK's internal handshake/plumbing; external callers should not use it.`); + pushGoComment(lines, `Internal: ${methodName} is part of the SDK's internal handshake/plumbing; external callers should not use it.`); } const sig = hasParams ? `func (a *${receiver}) ${methodName}(ctx context.Context, params *${paramsType}) (*${resultType}, error)` @@ -1568,12 +1882,12 @@ interface ClientGroup { function collectClientGroups(node: Record): ClientGroup[] { const groups: ClientGroup[] = []; - for (const [groupName, groupNode] of Object.entries(node)) { + for (const [groupName, groupNode] of sortByPascalName(Object.entries(node))) { if (typeof groupNode === "object" && groupNode !== null) { groups.push({ groupName, groupNode: groupNode as Record, - methods: collectRpcMethods(groupNode as Record), + methods: collectRpcMethods(groupNode as Record).sort(compareRpcMethodsByGoName), }); } } @@ -1596,18 +1910,18 @@ function emitClientSessionApiRegistration(lines: string[], clientSchema: Record< const groupExperimental = isNodeFullyExperimental(groupNode); const groupDeprecated = isNodeFullyDeprecated(groupNode); if (groupDeprecated) { - lines.push(`// Deprecated: ${interfaceName} contains deprecated APIs that will be removed in a future version.`); + pushGoComment(lines, `Deprecated: ${interfaceName} contains deprecated APIs that will be removed in a future version.`); } if (groupExperimental) { - lines.push(`// Experimental: ${interfaceName} contains experimental APIs that may change or be removed.`); + pushGoComment(lines, `Experimental: ${interfaceName} contains experimental APIs that may change or be removed.`); } lines.push(`type ${interfaceName} interface {`); for (const method of methods) { if (method.deprecated && !groupDeprecated) { - lines.push(`\t// Deprecated: ${clientHandlerMethodName(method.rpcMethod)} is deprecated and will be removed in a future version.`); + pushGoComment(lines, `Deprecated: ${clientHandlerMethodName(method.rpcMethod)} is deprecated and will be removed in a future version.`, "\t"); } if (method.stability === "experimental" && !groupExperimental) { - lines.push(`\t// Experimental: ${clientHandlerMethodName(method.rpcMethod)} is an experimental API and may change or be removed in future versions.`); + pushGoComment(lines, `Experimental: ${clientHandlerMethodName(method.rpcMethod)} is an experimental API and may change or be removed in future versions.`, "\t"); } const paramsType = resolveType(goParamsTypeName(method)); const resultSchema = getMethodResultSchema(method); diff --git a/scripts/codegen/package-lock.json b/scripts/codegen/package-lock.json index 46804c886..ff7c16f93 100644 --- a/scripts/codegen/package-lock.json +++ b/scripts/codegen/package-lock.json @@ -9,7 +9,8 @@ "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.4", "quicktype-core": "^23.2.6", - "tsx": "^4.20.6" + "tsx": "^4.20.6", + "wordwrap": "^1.0.0" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -794,6 +795,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/scripts/codegen/package.json b/scripts/codegen/package.json index c42713d84..c942b1c93 100644 --- a/scripts/codegen/package.json +++ b/scripts/codegen/package.json @@ -14,6 +14,7 @@ "json-schema": "^0.4.0", "json-schema-to-typescript": "^15.0.4", "quicktype-core": "^23.2.6", - "tsx": "^4.20.6" + "tsx": "^4.20.6", + "wordwrap": "^1.0.0" } } diff --git a/scripts/codegen/types.d.ts b/scripts/codegen/types.d.ts new file mode 100644 index 000000000..bc3b2f657 --- /dev/null +++ b/scripts/codegen/types.d.ts @@ -0,0 +1,3 @@ +declare module "wordwrap" { + export default function wordwrap(width: number): (text: string) => string; +} diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index bbbeb877c..a071dc6ae 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -8,10 +8,10 @@ import { execFile } from "child_process"; import fs from "fs/promises"; +import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; -import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; export const execFileAsync = promisify(execFile); @@ -65,7 +65,7 @@ export async function getApiSchemaPath(cliArg?: string): Promise { // ── Schema processing ─────────────────────────────────────────────────────── /** - * Post-process JSON Schema for quicktype compatibility. + * Post-process JSON Schema for code generators that expect enum-style literals. * Converts boolean const values to enum. */ export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { @@ -136,12 +136,12 @@ export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { /** * Strip boolean literal constraints (`const: true/false`, `enum: [true]`, `enum: [false]`) - * from a schema, recursively. quicktype's Python and Go renderers attempt to derive + * from a schema, recursively. quicktype's Python renderer attempts to derive * identifier names from enum values; deriving a name from a boolean throws inside * `snakeNameStyle` (TypeError: s.codePointAt is not a function). * - * The literal narrowing isn't expressible in Python/Go anyway, so we drop it and - * keep just `type: "boolean"`. TypeScript/C# codegen runs on the original schema. + * The literal narrowing isn't expressible in Python anyway, so we drop it and + * keep just `type: "boolean"`. Other codegen runs on the original schema. */ export function stripBooleanLiterals(schema: T): T { if (typeof schema !== "object" || schema === null) return schema; From d5c8db4738e3560d7e6c8ae7cc3327382c140b39 Mon Sep 17 00:00:00 2001 From: Claudio Godoy <40471021+claudiogodoy99@users.noreply.github.com> Date: Sat, 9 May 2026 23:22:03 -0300 Subject: [PATCH 20/33] fix(go): capture CLI stderr and fix SetProcessDone race (#863) * test(go): validate stderr not captured and SetProcessDone race on process exit * fix(go): capture CLI stderr and fix SetProcessDone race * test(go): avoid breaking portability Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(tests): read stderr msg and exit code from env vars in TestHelperProcess to match newStderrTestCommand * fix: use bounded ring buffer for CLI stderr capture to prevent unbounded memory growth * fix: add new package truncbuffer --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- go/client.go | 43 ++++++- go/client_test.go | 128 ++++++++++++++++++++ go/internal/jsonrpc2/jsonrpc2.go | 29 ++--- go/internal/jsonrpc2/jsonrpc2_test.go | 118 ++++++++++++++++++ go/internal/truncbuffer/truncbuffer.go | 69 +++++++++++ go/internal/truncbuffer/truncbuffer_test.go | 68 +++++++++++ 6 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 go/internal/truncbuffer/truncbuffer.go create mode 100644 go/internal/truncbuffer/truncbuffer_test.go diff --git a/go/client.go b/go/client.go index 8b6a70aed..5c99d8294 100644 --- a/go/client.go +++ b/go/client.go @@ -48,6 +48,7 @@ import ( "github.com/github/copilot-sdk/go/internal/embeddedcli" "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/internal/truncbuffer" "github.com/github/copilot-sdk/go/rpc" ) @@ -1442,6 +1443,11 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { return nil } +// stderrBufferSize is the maximum number of bytes kept from the CLI process's +// stderr. Only the tail is retained so that memory stays bounded even when the +// process produces a large amount of diagnostic output. +const stderrBufferSize = 64 * 1024 + // startCLIServer starts the CLI server process. // // This spawns the CLI server as a subprocess using the configured transport @@ -1558,6 +1564,8 @@ func (c *Client) startCLIServer(ctx context.Context) error { return fmt.Errorf("failed to create stdout pipe: %w", err) } + c.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize) + if err := c.process.Start(); err != nil { return fmt.Errorf("failed to start CLI server: %w", err) } @@ -1589,12 +1597,15 @@ func (c *Client) startCLIServer(ctx context.Context) error { return fmt.Errorf("failed to create stdout pipe: %w", err) } + c.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize) + if err := c.process.Start(); err != nil { return fmt.Errorf("failed to start CLI server: %w", err) } c.monitorProcess() + proc := c.process scanner := bufio.NewScanner(stdout) portRegex := regexp.MustCompile(`listening on port (\d+)`) @@ -1605,10 +1616,22 @@ func (c *Client) startCLIServer(ctx context.Context) error { select { case <-ctx.Done(): killErr := c.killProcess() - return errors.Join(fmt.Errorf("failed waiting for CLI server to start: %w", ctx.Err()), killErr) + baseErr := fmt.Errorf("failed waiting for CLI server to start: %w", ctx.Err()) + if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok { + if stderr := strings.TrimSpace(buf.String()); stderr != "" { + baseErr = fmt.Errorf("%w; stderr: %s", baseErr, stderr) + } + } + return errors.Join(baseErr, killErr) case <-c.processDone: killErr := c.killProcess() - return errors.Join(errors.New("CLI server process exited before reporting port"), killErr) + baseErr := errors.New("CLI server process exited before reporting port") + if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok { + if stderr := strings.TrimSpace(buf.String()); stderr != "" { + baseErr = fmt.Errorf("%w; stderr: %s", baseErr, stderr) + } + } + return errors.Join(baseErr, killErr) default: if scanner.Scan() { line := scanner.Text() @@ -1651,10 +1674,22 @@ func (c *Client) monitorProcess() { c.processErrorPtr = &processError go func() { waitErr := proc.Wait() + var stderrOutput string + if buf, ok := proc.Stderr.(*truncbuffer.TruncBuffer); ok { + stderrOutput = strings.TrimSpace(buf.String()) + } if waitErr != nil { - processError = fmt.Errorf("CLI process exited: %w", waitErr) + if stderrOutput != "" { + processError = fmt.Errorf("CLI process exited: %w\nstderr: %s", waitErr, stderrOutput) + } else { + processError = fmt.Errorf("CLI process exited: %w", waitErr) + } } else { - processError = errors.New("CLI process exited unexpectedly") + if stderrOutput != "" { + processError = fmt.Errorf("CLI process exited unexpectedly\nstderr: %s", stderrOutput) + } else { + processError = errors.New("CLI process exited unexpectedly") + } } close(done) }() diff --git a/go/client_test.go b/go/client_test.go index 34e7b803d..c0335a8a9 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -4,12 +4,16 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "reflect" "regexp" + "strconv" + "strings" "sync" "testing" + "github.com/github/copilot-sdk/go/internal/truncbuffer" "github.com/github/copilot-sdk/go/rpc" ) @@ -1297,3 +1301,127 @@ func TestCreateSessionResponse_Capabilities(t *testing.T) { } }) } + +// TestHelperProcess is a helper used by tests that need to spawn a process +// which writes to stderr and exits with a given status. It is invoked +// via "go test" by running the test binary itself with -test.run. +// The stderr message and exit code are passed via environment variables +// HELPER_STDERR_MSG and HELPER_EXIT_CODE (defaulting to "" and 1). +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + // Not in helper process mode; let the test run normally. + return + } + + msg := os.Getenv("HELPER_STDERR_MSG") + if msg == "" { + // Fall back to command-line args after "--" for backwards compat. + for i, arg := range os.Args { + if arg == "--" && i+1 < len(os.Args) { + msg = os.Args[i+1] + break + } + } + } + if msg != "" { + _, _ = os.Stderr.WriteString(msg + "\n") + } + + exitCode := 1 + if ec := os.Getenv("HELPER_EXIT_CODE"); ec != "" { + if v, err := strconv.Atoi(ec); err == nil { + exitCode = v + } + } + os.Exit(exitCode) +} + +// newStderrTestCommand constructs a command that re-invokes the current test +// binary to run TestHelperProcess with the provided stderr message and exit +// code. This avoids any dependency on a shell like "sh" and is portable. +func newStderrTestCommand(stderrMsg string, exitCode int) *exec.Cmd { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess") + cmd.Env = append(os.Environ(), + "GO_WANT_HELPER_PROCESS=1", + "HELPER_STDERR_MSG="+stderrMsg, + "HELPER_EXIT_CODE="+strconv.Itoa(exitCode), + ) + return cmd +} + +// TestMonitorProcess_StderrCaptured validates that when the CLI process +// writes an error to stderr and exits, the stderr content IS included +// in the process error (now that startCLIServer sets Stderr). +func TestMonitorProcess_StderrCaptured(t *testing.T) { + client := &Client{ + sessions: make(map[string]*Session), + } + + stderrMsg := "error: authentication failed: invalid token" + client.process = exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", stderrMsg) + client.process.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + + // Replicate what startCLIServer now does: capture stderr. + client.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize) + + if err := client.process.Start(); err != nil { + t.Fatalf("failed to start test process: %v", err) + } + + client.monitorProcess() + + // Wait for the process to exit. + <-client.processDone + + processError := *client.processErrorPtr + if processError == nil { + t.Fatal("expected a process error after non-zero exit, got nil") + } + + if !strings.Contains(processError.Error(), stderrMsg) { + t.Errorf("stderr output not included in process error.\n"+ + " got: %q\n"+ + " want: error containing %q", processError.Error(), stderrMsg) + } +} + +// TestMonitorProcess_StderrCapturedOnZeroExit validates that even when the +// CLI process exits with code 0, stderr content is included in the error. +func TestMonitorProcess_StderrCapturedOnZeroExit(t *testing.T) { + client := &Client{ + sessions: make(map[string]*Session), + } + + stderrMsg := "warning: version mismatch, shutting down" + client.process = newStderrTestCommand(stderrMsg, 0) + client.process.Stderr = truncbuffer.NewTruncBuffer(stderrBufferSize) + + if err := client.process.Start(); err != nil { + t.Fatalf("failed to start test process: %v", err) + } + + client.monitorProcess() + <-client.processDone + + processError := *client.processErrorPtr + if processError == nil { + t.Fatal("expected a process error for unexpected exit, got nil") + } + + if !strings.Contains(processError.Error(), stderrMsg) { + t.Errorf("stderr output not included in process error for exit code 0.\n"+ + " got: %q\n"+ + " want: error containing %q", processError.Error(), stderrMsg) + } +} + +// TestStartCLIServer_StderrFieldSet verifies that startCLIServer sets +// exec.Cmd.Stderr to a *truncbuffer.TruncBuffer so CLI diagnostic output is captured. +func TestStartCLIServer_StderrFieldSet(t *testing.T) { + cmd := exec.Command(os.Args[0]) + buf := truncbuffer.NewTruncBuffer(stderrBufferSize) + cmd.Stderr = buf + if _, ok := cmd.Stderr.(*truncbuffer.TruncBuffer); !ok { + t.Error("expected Stderr to be *truncbuffer.TruncBuffer after assignment") + } +} diff --git a/go/internal/jsonrpc2/jsonrpc2.go b/go/internal/jsonrpc2/jsonrpc2.go index 1c6862c23..f306a02c7 100644 --- a/go/internal/jsonrpc2/jsonrpc2.go +++ b/go/internal/jsonrpc2/jsonrpc2.go @@ -72,8 +72,8 @@ type Client struct { stopChan chan struct{} wg sync.WaitGroup processDone chan struct{} // closed when the underlying process exits - processError error // set before processDone is closed - processErrorMu sync.RWMutex // protects processError + processErrorPtr *error // points to the process error + processErrorMu sync.RWMutex // protects processErrorPtr onClose func() // called when the read loop exits unexpectedly } @@ -92,25 +92,26 @@ func NewClient(stdin io.WriteCloser, stdout io.ReadCloser) *Client { } // SetProcessDone sets a channel that will be closed when the process exits, -// and stores the error that should be returned to pending/future requests. +// and stores the error pointer that should be returned to pending/future requests. +// The error is read directly from the pointer after the channel closes, avoiding +// a race between an async goroutine and callers checking the error. func (c *Client) SetProcessDone(done chan struct{}, errPtr *error) { c.processDone = done - // Monitor the channel and copy the error when it closes - go func() { - <-done - if errPtr != nil { - c.processErrorMu.Lock() - c.processError = *errPtr - c.processErrorMu.Unlock() - } - }() + c.processErrorMu.Lock() + c.processErrorPtr = errPtr + c.processErrorMu.Unlock() } -// getProcessError returns the process exit error if the process has exited +// getProcessError returns the process exit error if the process has exited. +// It reads directly from the stored error pointer, which is guaranteed to be +// set before the processDone channel is closed. func (c *Client) getProcessError() error { c.processErrorMu.RLock() defer c.processErrorMu.RUnlock() - return c.processError + if c.processErrorPtr != nil { + return *c.processErrorPtr + } + return nil } // Start begins listening for messages in a background goroutine diff --git a/go/internal/jsonrpc2/jsonrpc2_test.go b/go/internal/jsonrpc2/jsonrpc2_test.go index 9f542049d..26aa5a472 100644 --- a/go/internal/jsonrpc2/jsonrpc2_test.go +++ b/go/internal/jsonrpc2/jsonrpc2_test.go @@ -1,6 +1,7 @@ package jsonrpc2 import ( + "errors" "io" "sync" "testing" @@ -67,3 +68,120 @@ func TestOnCloseNotCalledOnIntentionalStop(t *testing.T) { t.Error("onClose should not be called on intentional Stop()") } } + +// TestSetProcessDone_ErrorAvailableImmediately validates that getProcessError() +// returns the correct error immediately after processDone is closed. +// The current implementation stores a pointer to the process error +// synchronously when the processDone channel is closed, so callers should +// never observe a nil error after the channel has been closed. +func TestSetProcessDone_ErrorAvailableImmediately(t *testing.T) { + misses := 0 + const iterations = 1000 + + for i := 0; i < iterations; i++ { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + + client := NewClient(stdinW, stdoutR) + + done := make(chan struct{}) + processErr := errors.New("CLI process exited: exit status 1") + + client.SetProcessDone(done, &processErr) + + // Simulate process exit: error is already set, close the channel. + close(done) + + // Do NOT yield to the scheduler — check immediately. + // In the current code the goroutine inside SetProcessDone may not + // have copied the error to client.processError yet. + if err := client.getProcessError(); err == nil { + misses++ + } + + stdinR.Close() + stdinW.Close() + stdoutR.Close() + stdoutW.Close() + } + + if misses > 0 { + t.Errorf("SetProcessDone regression: getProcessError() returned nil %d/%d times "+ + "immediately after processDone was closed, even though the error pointer "+ + "should be stored synchronously.", misses, iterations) + } +} + +// TestSetProcessDone_RequestMissesProcessError validates that the Request() +// method returns the specific process error instead of the generic +// "process exited unexpectedly" message once processDone has been closed. +func TestSetProcessDone_RequestMissesProcessError(t *testing.T) { + misses := 0 + const iterations = 100 + + for i := 0; i < iterations; i++ { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + + client := NewClient(stdinW, stdoutR) + client.Start() + + done := make(chan struct{}) + processErr := errors.New("CLI process exited: authentication failed") + + client.SetProcessDone(done, &processErr) + + // Simulate process exit. + close(done) + // Close the writer so the readLoop can exit. + stdoutW.Close() + + // Make a request — should get the specific process error. + _, err := client.Request("test.method", nil) + if err != nil && err.Error() == "process exited unexpectedly" { + misses++ + } + + client.Stop() + stdinR.Close() + stdinW.Close() + stdoutR.Close() + } + + if misses > 0 { + t.Errorf("Request() bug: returned generic 'process exited unexpectedly' %d/%d times "+ + "instead of the actual process error after process exit; the process "+ + "error was not correctly propagated from SetProcessDone.", misses, iterations) + } +} + +// TestSetProcessDone_ErrorAvailableImmediately verifies that the process error +// is available as soon as the done channel is closed, matching the +// pointer-based implementation where no asynchronous copy is required. +func TestSetProcessDone_ErrorCopiedEventually(t *testing.T) { + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + defer stdinR.Close() + defer stdinW.Close() + defer stdoutR.Close() + defer stdoutW.Close() + + client := NewClient(stdinW, stdoutR) + + done := make(chan struct{}) + processErr := errors.New("CLI process exited: version mismatch") + + client.SetProcessDone(done, &processErr) + + // Close the channel: the process error should now be observable immediately, + // without needing to yield to another goroutine. + close(done) + + err := client.getProcessError() + if err == nil { + t.Fatal("expected process error to be available immediately after done is closed, got nil") + } + if err.Error() != processErr.Error() { + t.Errorf("expected %q, got %q", processErr.Error(), err.Error()) + } +} diff --git a/go/internal/truncbuffer/truncbuffer.go b/go/internal/truncbuffer/truncbuffer.go new file mode 100644 index 000000000..4034817bb --- /dev/null +++ b/go/internal/truncbuffer/truncbuffer.go @@ -0,0 +1,69 @@ +package truncbuffer + +import "sync" + +// TruncBuffer is a ring buffer that retains only the last max bytes, +// discarding older data. This is useful for capturing stderr output in a +// memory-bounded way when the full output may be arbitrarily large. +// All methods are safe for concurrent use. +type TruncBuffer struct { + mu sync.RWMutex + buf []byte + head int + size int + full bool +} + +// NewTruncBuffer creates a TruncBuffer that keeps at most n bytes. +func NewTruncBuffer(n int) *TruncBuffer { + return &TruncBuffer{ + buf: make([]byte, n), + size: n, + } +} + +// Write appends p to the buffer, keeping only the last size bytes. +// The return value n is the length of p; +func (t *TruncBuffer) Write(p []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + + // If input is larger than the buffer, only keep the tail. + if len(p) >= t.size { + copy(t.buf, p[len(p)-t.size:]) + t.head = 0 + t.full = true + return len(p), nil + } + + for _, b := range p { + t.buf[t.head] = b + t.head++ + if t.head == t.size { + t.head = 0 + t.full = true + } + } + + return len(p), nil +} + +// Bytes returns a copy of the current buffer contents in order. +func (t *TruncBuffer) Bytes() []byte { + t.mu.RLock() + defer t.mu.RUnlock() + + if !t.full { + return append([]byte(nil), t.buf[:t.head]...) + } + + out := make([]byte, t.size) + n := copy(out, t.buf[t.head:]) + copy(out[n:], t.buf[:t.head]) + return out +} + +// String returns the buffer contents as a string. +func (t *TruncBuffer) String() string { + return string(t.Bytes()) +} diff --git a/go/internal/truncbuffer/truncbuffer_test.go b/go/internal/truncbuffer/truncbuffer_test.go new file mode 100644 index 000000000..8dad41b23 --- /dev/null +++ b/go/internal/truncbuffer/truncbuffer_test.go @@ -0,0 +1,68 @@ +package truncbuffer + +import ( + "io" + "sync" + "testing" +) + +var _ io.Writer = (*TruncBuffer)(nil) + +func TestTruncBuffer_SmallWrites(t *testing.T) { + tb := NewTruncBuffer(10) + tb.Write([]byte("hello")) + if got := string(tb.Bytes()); got != "hello" { + t.Fatalf("got %q, want %q", got, "hello") + } +} + +func TestTruncBuffer_ExactMax(t *testing.T) { + tb := NewTruncBuffer(5) + tb.Write([]byte("abcde")) + if got := string(tb.Bytes()); got != "abcde" { + t.Fatalf("got %q, want %q", got, "abcde") + } +} + +func TestTruncBuffer_OverflowSingleWrite(t *testing.T) { + tb := NewTruncBuffer(5) + tb.Write([]byte("abcdefgh")) + if got := string(tb.Bytes()); got != "defgh" { + t.Fatalf("got %q, want %q", got, "defgh") + } +} + +func TestTruncBuffer_OverflowMultipleWrites(t *testing.T) { + tb := NewTruncBuffer(6) + tb.Write([]byte("abc")) + tb.Write([]byte("defgh")) + if got := string(tb.Bytes()); got != "cdefgh" { + t.Fatalf("got %q, want %q", got, "cdefgh") + } +} + +func TestTruncBuffer_ManySmallWrites(t *testing.T) { + tb := NewTruncBuffer(4) + for _, b := range []byte("abcdefg") { + tb.Write([]byte{b}) + } + if got := string(tb.Bytes()); got != "defg" { + t.Fatalf("got %q, want %q", got, "defg") + } +} + +func TestTruncBuffer_ConcurrentWrites(t *testing.T) { + tb := NewTruncBuffer(64) + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + tb.Write([]byte("abcdefgh")) + }() + } + wg.Wait() + if got := len(tb.Bytes()); got > 64 { + t.Fatalf("buffer exceeded max: got %d bytes", got) + } +} From f6257791b9dab133358456caa4ba86f20f833101 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 9 May 2026 23:37:55 -0400 Subject: [PATCH 21/33] Handle empty session fork behavior in E2E tests (#1247) Allow the empty-session fork tests to accept either the older runtime error or a successful empty fork, and apply the expectation across C#, Node, Python, and Go. Also mark the C# test project explicitly as a test project so dotnet test discovers the xUnit tests with newer SDKs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/E2E/RpcSessionStateE2ETests.cs | 23 ++++++++-- dotnet/test/GitHub.Copilot.SDK.Test.csproj | 1 + go/internal/e2e/rpc_session_state_e2e_test.go | 43 +++++++++++++++---- nodejs/test/e2e/rpc_session_state.e2e.test.ts | 28 +++++++++--- python/e2e/test_rpc_session_state_e2e.py | 26 ++++++++--- 5 files changed, 96 insertions(+), 25 deletions(-) diff --git a/dotnet/test/E2E/RpcSessionStateE2ETests.cs b/dotnet/test/E2E/RpcSessionStateE2ETests.cs index 53f3af7b8..56821e90f 100644 --- a/dotnet/test/E2E/RpcSessionStateE2ETests.cs +++ b/dotnet/test/E2E/RpcSessionStateE2ETests.cs @@ -276,14 +276,29 @@ public async Task Should_Fork_Session_With_Persisted_Messages() } [Fact] - public async Task Should_Report_Error_When_Forking_Session_Without_Persisted_Events() + public async Task Should_Handle_Forking_Session_Without_Persisted_Events() { await using var session = await CreateSessionAsync(); - var ex = await Assert.ThrowsAnyAsync(() => Client.Rpc.Sessions.ForkAsync(session.SessionId)); + SessionsForkResult? fork = null; + var ex = await Record.ExceptionAsync(async () => + { + fork = await Client.Rpc.Sessions.ForkAsync(session.SessionId); + }); - Assert.Contains("not found or has no persisted events", ex.ToString(), StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("Unhandled method sessions.fork", ex.ToString(), StringComparison.OrdinalIgnoreCase); + if (ex is not null) + { + Assert.Contains("not found or has no persisted events", ex.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Unhandled method sessions.fork", ex.ToString(), StringComparison.OrdinalIgnoreCase); + return; + } + + var forkSessionId = Assert.IsType(fork).SessionId; + Assert.False(string.IsNullOrWhiteSpace(forkSessionId)); + Assert.NotEqual(session.SessionId, forkSessionId); + + await using var forkedSession = await ResumeSessionAsync(forkSessionId); + Assert.Empty(GetConversationMessages(await forkedSession.GetMessagesAsync())); } [Fact] diff --git a/dotnet/test/GitHub.Copilot.SDK.Test.csproj b/dotnet/test/GitHub.Copilot.SDK.Test.csproj index e42dc8e4c..5d7e3dd16 100644 --- a/dotnet/test/GitHub.Copilot.SDK.Test.csproj +++ b/dotnet/test/GitHub.Copilot.SDK.Test.csproj @@ -2,6 +2,7 @@ false + true $(NoWarn);GHCP001 diff --git a/go/internal/e2e/rpc_session_state_e2e_test.go b/go/internal/e2e/rpc_session_state_e2e_test.go index e9cf1110f..623a26188 100644 --- a/go/internal/e2e/rpc_session_state_e2e_test.go +++ b/go/internal/e2e/rpc_session_state_e2e_test.go @@ -297,23 +297,50 @@ func TestRpcSessionStateE2E(t *testing.T) { forkedSession.Disconnect() }) - t.Run("should report error when forking session without persisted events", func(t *testing.T) { + t.Run("should handle forking session without persisted events", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) if err != nil { t.Fatalf("Failed to create session: %v", err) } + defer session.Disconnect() - _, err = client.RPC.Sessions.Fork(t.Context(), &rpc.SessionsForkRequest{SessionID: session.SessionID}) - if err == nil { - t.Fatal("Expected fork on empty session to fail") + fork, err := client.RPC.Sessions.Fork(t.Context(), &rpc.SessionsForkRequest{SessionID: session.SessionID}) + if err != nil { + errText := strings.ToLower(err.Error()) + if !strings.Contains(errText, "not found or has no persisted events") { + t.Errorf("Expected error mentioning 'not found or has no persisted events', got %v", err) + } + if strings.Contains(errText, "unhandled method sessions.fork") { + t.Errorf("sessions.fork should be implemented; error suggests it isn't: %v", err) + } + return } - if !strings.Contains(strings.ToLower(err.Error()), "not found or has no persisted events") { - t.Errorf("Expected error mentioning 'not found or has no persisted events', got %v", err) + if fork == nil { + t.Fatal("Expected non-nil fork result") } - if strings.Contains(strings.ToLower(err.Error()), "unhandled method sessions.fork") { - t.Errorf("sessions.fork should be implemented; error suggests it isn't: %v", err) + if strings.TrimSpace(fork.SessionID) == "" { + t.Fatal("Expected non-empty fork session id") + } + if fork.SessionID == session.SessionID { + t.Errorf("Expected fork session id to differ from source %q", session.SessionID) + } + + forkedSession, err := client.ResumeSession(t.Context(), fork.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to resume forked session: %v", err) + } + defer forkedSession.Disconnect() + + forkedMessages, err := forkedSession.GetMessages(t.Context()) + if err != nil { + t.Fatalf("Failed to read forked messages: %v", err) + } + if forkedConversation := conversationMessages(forkedMessages); len(forkedConversation) != 0 { + t.Errorf("Expected empty forked conversation, got %v", forkedConversation) } }) diff --git a/nodejs/test/e2e/rpc_session_state.e2e.test.ts b/nodejs/test/e2e/rpc_session_state.e2e.test.ts index 8adda8ab1..706c116e4 100644 --- a/nodejs/test/e2e/rpc_session_state.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state.e2e.test.ts @@ -191,20 +191,34 @@ describe("Session-scoped RPC", async () => { await session.disconnect(); }); - it("should report error when forking session without persisted events", async () => { + it("should handle forking session without persisted events", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); - - await expect(client.rpc.sessions.fork({ sessionId: session.sessionId })).rejects.toSatisfy( - (err: unknown) => { + try { + let fork: Awaited>; + try { + fork = await client.rpc.sessions.fork({ sessionId: session.sessionId }); + } catch (err: unknown) { const text = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); expect(text.toLowerCase()).toContain("not found or has no persisted events"); expect(text.toLowerCase()).not.toContain("unhandled method sessions.fork"); - return true; + return; } - ); - await session.disconnect(); + expect(fork.sessionId.trim()).toBeTruthy(); + expect(fork.sessionId).not.toBe(session.sessionId); + + const forkedSession = await client.resumeSession(fork.sessionId, { + onPermissionRequest: approveAll, + }); + try { + expect(getConversationMessages(await forkedSession.getMessages())).toEqual([]); + } finally { + await forkedSession.disconnect(); + } + } finally { + await session.disconnect(); + } }); it("should fork session to event id excluding boundary event", async () => { diff --git a/python/e2e/test_rpc_session_state_e2e.py b/python/e2e/test_rpc_session_state_e2e.py index ffeec1cf3..0c841465a 100644 --- a/python/e2e/test_rpc_session_state_e2e.py +++ b/python/e2e/test_rpc_session_state_e2e.py @@ -216,20 +216,34 @@ async def test_should_fork_session_with_persisted_messages(self, ctx: E2ETestCon finally: await session.disconnect() - async def test_should_report_error_when_forking_session_without_persisted_events( + async def test_should_handle_forking_session_without_persisted_events( self, ctx: E2ETestContext ): session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, ) try: - with pytest.raises(Exception) as excinfo: - await ctx.client.rpc.sessions.fork( + try: + fork = await ctx.client.rpc.sessions.fork( SessionsForkRequest(session_id=session.session_id) ) - text = str(excinfo.value).lower() - assert "not found or has no persisted events" in text - assert "unhandled method sessions.fork" not in text + except Exception as exc: + text = str(exc).lower() + assert "not found or has no persisted events" in text + assert "unhandled method sessions.fork" not in text + return + + assert fork.session_id.strip() + assert fork.session_id != session.session_id + + forked_session = await ctx.client.resume_session( + fork.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + try: + assert _conversation_messages(await forked_session.get_messages()) == [] + finally: + await forked_session.disconnect() finally: await session.disconnect() From 4901dff0128db56c748ecd1b6d7489a10e346718 Mon Sep 17 00:00:00 2001 From: Quim Muntal Date: Mon, 11 May 2026 15:03:46 +0200 Subject: [PATCH 22/33] Add Go reference badge to README (#1253) Added Go reference badge to README. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 186cf0b98..7ae2b0972 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![NPM Downloads](https://img.shields.io/npm/dm/%40github%2Fcopilot-sdk?label=npm)](https://www.npmjs.com/package/@github/copilot-sdk) [![PyPI - Downloads](https://img.shields.io/pypi/dm/github-copilot-sdk?label=PyPI)](https://pypi.org/project/github-copilot-sdk/) [![NuGet Downloads](https://img.shields.io/nuget/dt/GitHub.Copilot.SDK?label=NuGet)](https://www.nuget.org/packages/GitHub.Copilot.SDK) +[![Go Reference](https://pkg.go.dev/badge/github.com/github/copilot-sdk/go.svg)](https://pkg.go.dev/github.com/github/copilot-sdk/go) Agents for every app. From 87945942978f7b45719db5870fc6c60cadc33e2c Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 11 May 2026 10:30:56 -0400 Subject: [PATCH 23/33] Expand Rust E2E coverage (#1250) * Expand Rust E2E coverage Add a replay-backed Rust E2E suite matching the .NET coverage, update Rust SDK session lifecycle support for session filesystem and multi-client scenarios, and add reliability fixes for model caching and cancellation-safe pending session registration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Rust E2E review feedback Move struct-only E2E placeholders into unit tests, exercise invalid external-auth client options, avoid logging session IDs from test assertions, and harden failing Rust E2Es on CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Rust CI after review updates Allow inert use_logged_in_user(false) on external transports so shared E2E client options continue to work, while still rejecting true external auth requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stabilize Rust selection attachment E2E Use a workspace-relative selection file path so the replayed prompt is stable across platforms instead of containing platform-specific temp directory relatives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make Rust E2E CI fail fast Add a per-test E2E timeout and run Rust CI tests serially with uncaptured output so stuck replay-backed tests expose the active test instead of hanging silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Rust E2E suite runtime Replace the global Rust E2E lock with bounded replay-test concurrency so the suite no longer serializes every replay-backed case. Wait for disconnected-client tool removal before sending the follow-up multi-client prompt to keep the concurrent run deterministic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use bounded Rust E2E concurrency in CI Set libtest to four worker threads so GitHub-hosted runners actually exercise the harness concurrency limit instead of defaulting to the runner CPU count. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Rust session tests for generated IDs Update session_test fake servers to echo the session id requested by the SDK instead of returning arbitrary ids. This keeps the tests aligned with the SDK's session-id validation and prevents cargo test from failing before the E2E suite runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Rust E2E review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Rust multi-client clippy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust-sdk-tests.yml | 4 +- rust/Cargo.lock | 10 + rust/Cargo.toml | 1 + rust/src/jsonrpc.rs | 22 +- rust/src/lib.rs | 199 ++- rust/src/session.rs | 177 +- rust/src/types.rs | 11 + rust/tests/api_types_test.rs | 99 ++ rust/tests/e2e.rs | 91 + rust/tests/e2e/abort.rs | 173 ++ rust/tests/e2e/ask_user.rs | 195 ++ rust/tests/e2e/builtin_tools.rs | 242 +++ rust/tests/e2e/client.rs | 277 +++ rust/tests/e2e/client_api.rs | 177 ++ rust/tests/e2e/client_lifecycle.rs | 228 +++ rust/tests/e2e/client_options.rs | 286 +++ rust/tests/e2e/commands.rs | 165 ++ rust/tests/e2e/compaction.rs | 145 ++ rust/tests/e2e/elicitation.rs | 589 ++++++ rust/tests/e2e/error_resilience.rs | 101 ++ rust/tests/e2e/event_fidelity.rs | 368 ++++ rust/tests/e2e/hooks.rs | 215 +++ rust/tests/e2e/hooks_extended.rs | 563 ++++++ rust/tests/e2e/mcp_and_agents.rs | 389 ++++ rust/tests/e2e/mode_handlers.rs | 279 +++ rust/tests/e2e/multi_client.rs | 593 +++++++ .../e2e/multi_client_commands_elicitation.rs | 265 +++ rust/tests/e2e/multi_turn.rs | 156 ++ rust/tests/e2e/pending_work_resume.rs | 342 ++++ rust/tests/e2e/per_session_auth.rs | 151 ++ rust/tests/e2e/permissions.rs | 672 +++++++ rust/tests/e2e/rpc_additional_edge_cases.rs | 535 ++++++ rust/tests/e2e/rpc_agent.rs | 324 ++++ rust/tests/e2e/rpc_event_side_effects.rs | 354 ++++ rust/tests/e2e/rpc_mcp_and_skills.rs | 483 +++++ rust/tests/e2e/rpc_mcp_config.rs | 211 +++ rust/tests/e2e/rpc_server.rs | 244 +++ rust/tests/e2e/rpc_session_state.rs | 990 +++++++++++ rust/tests/e2e/rpc_shell_and_fleet.rs | 115 ++ rust/tests/e2e/rpc_shell_edge_cases.rs | 392 ++++ rust/tests/e2e/rpc_tasks_and_handlers.rs | 293 +++ rust/tests/e2e/session.rs | 1575 +++++++++++++++++ rust/tests/e2e/session_config.rs | 955 ++++++++++ rust/tests/e2e/session_fs.rs | 630 +++++++ rust/tests/e2e/session_lifecycle.rs | 257 +++ rust/tests/e2e/skills.rs | 178 ++ rust/tests/e2e/streaming_fidelity.rs | 363 ++++ rust/tests/e2e/support.rs | 764 ++++++++ rust/tests/e2e/suspend.rs | 88 + rust/tests/e2e/system_message_transform.rs | 187 ++ rust/tests/e2e/telemetry.rs | 233 +++ rust/tests/e2e/tool_results.rs | 361 ++++ rust/tests/e2e/tools.rs | 756 ++++++++ rust/tests/mode_handlers_e2e_test.rs | 663 ------- rust/tests/session_test.rs | 85 +- ...models_withcustomhandler_callshandler.yaml | 3 + .../client/should_force_stop_client.yaml | 3 + .../should_get_authenticated_status.yaml | 3 + test/snapshots/client/should_get_status.yaml | 3 + ...should_list_models_when_authenticated.yaml | 3 + ...ould_start_ping_and_stop_stdio_client.yaml | 3 + ...should_start_ping_and_stop_tcp_client.yaml | 3 + ...hould_stop_client_with_active_session.yaml | 3 + .../should_listen_on_configured_tcp_port.yaml | 3 + ...on_with_commands_creates_successfully.yaml | 3 + ...on_with_commands_resumes_successfully.yaml | 10 + ...with_no_commands_creates_successfully.yaml | 3 + ...m_returns_false_when_handler_declines.yaml | 3 + ...irm_returns_true_when_handler_accepts.yaml | 3 + ...faults_capabilities_when_not_provided.yaml | 3 + ...elicitation_returns_all_action_shapes.yaml | 3 + ...ion_throws_when_capability_is_missing.yaml | 3 + .../input_returns_freeform_value.yaml | 3 + .../select_returns_selected_option.yaml | 3 + ...uestelicitation_when_handler_provided.yaml | 3 + ...icitationhandler_creates_successfully.yaml | 3 + ..._capability_based_on_handler_presence.yaml | 3 + ..._handle_custom_agent_with_mcp_servers.yaml | 3 + ...custom_agent_with_tools_configuration.yaml | 3 + .../should_handle_multiple_custom_agents.yaml | 3 + .../should_handle_multiple_mcp_servers.yaml | 3 + ...atus_is_unauthenticated_without_token.yaml | 3 + .../session_fails_with_invalid_token.yaml | 3 + .../session_token_overrides_client_token.yaml | 3 + ...ken_when_no_session_token_is_supplied.yaml | 3 + ...ame_value_multiple_times_stays_stable.yaml | 3 + .../name_set_with_unicode_round_trips.yaml | 3 + ...on_approvals_on_fresh_session_is_noop.yaml | 3 + ...ns_set_approve_all_toggle_round_trips.yaml | 3 + ...delete_when_none_exists_is_idempotent.yaml | 3 + ...empty_content_then_read_returns_empty.yaml | 3 + ...ut_does_not_kill_long_running_command.yaml | 3 + ..._on_fresh_session_returns_zero_tokens.yaml | 3 + ...e_file_with_empty_content_round_trips.yaml | 3 + ...e_file_with_large_content_round_trips.yaml | 3 + ...file_with_unicode_content_round_trips.yaml | 3 + ...tfiles_returns_sorted_or_stable_order.yaml | 3 + ...ce_returns_stable_result_across_calls.yaml | 3 + .../rpc_agents/should_call_agent_reload.yaml | 3 + .../should_deselect_current_agent.yaml | 3 + ...bagent_selected_and_deselected_events.yaml | 3 + .../should_list_available_custom_agents.yaml | 3 + ...list_when_no_custom_agents_configured.yaml | 3 + ...return_null_when_no_agent_is_selected.yaml | 3 + .../should_select_and_get_current_agent.yaml | 3 + ...emit_mode_changed_event_when_mode_set.yaml | 3 + ...n_changed_event_for_update_and_delete.yaml | 3 + ...ged_update_operation_on_second_update.yaml | 3 + ...mit_title_changed_event_when_name_set.yaml | 3 + ..._file_changed_event_when_file_created.yaml | 3 + ...should_list_and_toggle_session_skills.yaml | 3 + .../should_list_extensions.yaml | 3 + ...st_mcp_servers_with_configured_server.yaml | 3 + .../should_list_plugins.yaml | 3 + .../should_reload_session_skills.yaml | 3 + ...ror_when_extensions_are_not_available.yaml | 3 + ...rror_when_mcp_host_is_not_initialized.yaml | 3 + ...en_mcp_oauth_server_is_not_configured.yaml | 3 + ...r_when_mcp_oauth_server_is_not_remote.yaml | 3 + .../should_call_server_mcp_config_rpcs.yaml | 3 + ..._round_trip_http_mcp_oauth_config_rpc.yaml | 3 + ..._account_get_quota_when_authenticated.yaml | 3 + ...all_rpc_models_list_with_typed_result.yaml | 3 + ...rpc_ping_with_typed_params_and_result.yaml | 3 + ...call_rpc_tools_list_with_typed_result.yaml | 3 + ...should_discover_server_mcp_and_skills.yaml | 3 + ...uld_call_session_rpc_model_getcurrent.yaml | 3 + ...hould_call_session_rpc_model_switchto.yaml | 3 + ...all_session_usage_and_permission_rpcs.yaml | 3 + ...hould_call_workspace_file_rpc_methods.yaml | 3 + ...e_with_nested_path_auto_creating_dirs.yaml | 3 + ...ed_event_each_time_name_set_is_called.yaml | 3 + .../should_get_and_set_session_metadata.yaml | 3 + .../should_get_and_set_session_mode.yaml | 3 + ...king_session_without_persisted_events.yaml | 3 + .../should_read_update_and_delete_plan.yaml | 3 + ...ject_empty_or_whitespace_session_name.yaml | 3 + ..._reject_workspace_file_path_traversal.yaml | 3 + ...or_reading_nonexistent_workspace_file.yaml | 3 + ...ors_for_unsupported_session_rpc_paths.yaml | 3 + ...d_set_and_get_each_session_mode_value.yaml | 3 + ..._workspace_file_with_update_operation.yaml | 3 + .../should_execute_shell_command.yaml | 3 + .../should_kill_shell_process.yaml | 3 + ..._exec_with_custom_cwd_honors_override.yaml | 3 + ...hell_exec_with_large_stdout_cleans_up.yaml | 3 + ...mmand_returns_processid_and_cleans_up.yaml | 3 + ...ell_exec_with_stderr_output_cleans_up.yaml | 3 + ...th_timeout_kills_long_running_command.yaml | 3 + ...ll_cleans_up_after_terminating_signal.yaml | 3 + ..._kill_unknown_processid_returns_false.yaml | 3 + ...urn_false_for_missing_task_operations.yaml | 3 + ...ed_error_for_invalid_task_agent_model.yaml | 3 + ...ted_error_for_missing_task_agent_type.yaml | 3 + ...or_missing_pending_handler_requestids.yaml | 3 + ...ee_tool_request_and_completion_events.yaml | 21 + ...isconnecting_client_removes_its_tools.yaml | 69 + ...es_permission_and_both_see_the_result.yaml | 50 + ...ts_permission_and_both_see_the_result.yaml | 25 + ...r_different_tools_and_agent_uses_both.yaml | 36 + ...oning_effort_values_on_session_create.yaml | 3 + ...ly_reasoning_effort_on_session_create.yaml | 3 + ...e_session_with_custom_provider_config.yaml | 3 + .../should_use_custom_session_id.yaml | 3 + 164 files changed, 17958 insertions(+), 783 deletions(-) create mode 100644 rust/tests/api_types_test.rs create mode 100644 rust/tests/e2e.rs create mode 100644 rust/tests/e2e/abort.rs create mode 100644 rust/tests/e2e/ask_user.rs create mode 100644 rust/tests/e2e/builtin_tools.rs create mode 100644 rust/tests/e2e/client.rs create mode 100644 rust/tests/e2e/client_api.rs create mode 100644 rust/tests/e2e/client_lifecycle.rs create mode 100644 rust/tests/e2e/client_options.rs create mode 100644 rust/tests/e2e/commands.rs create mode 100644 rust/tests/e2e/compaction.rs create mode 100644 rust/tests/e2e/elicitation.rs create mode 100644 rust/tests/e2e/error_resilience.rs create mode 100644 rust/tests/e2e/event_fidelity.rs create mode 100644 rust/tests/e2e/hooks.rs create mode 100644 rust/tests/e2e/hooks_extended.rs create mode 100644 rust/tests/e2e/mcp_and_agents.rs create mode 100644 rust/tests/e2e/mode_handlers.rs create mode 100644 rust/tests/e2e/multi_client.rs create mode 100644 rust/tests/e2e/multi_client_commands_elicitation.rs create mode 100644 rust/tests/e2e/multi_turn.rs create mode 100644 rust/tests/e2e/pending_work_resume.rs create mode 100644 rust/tests/e2e/per_session_auth.rs create mode 100644 rust/tests/e2e/permissions.rs create mode 100644 rust/tests/e2e/rpc_additional_edge_cases.rs create mode 100644 rust/tests/e2e/rpc_agent.rs create mode 100644 rust/tests/e2e/rpc_event_side_effects.rs create mode 100644 rust/tests/e2e/rpc_mcp_and_skills.rs create mode 100644 rust/tests/e2e/rpc_mcp_config.rs create mode 100644 rust/tests/e2e/rpc_server.rs create mode 100644 rust/tests/e2e/rpc_session_state.rs create mode 100644 rust/tests/e2e/rpc_shell_and_fleet.rs create mode 100644 rust/tests/e2e/rpc_shell_edge_cases.rs create mode 100644 rust/tests/e2e/rpc_tasks_and_handlers.rs create mode 100644 rust/tests/e2e/session.rs create mode 100644 rust/tests/e2e/session_config.rs create mode 100644 rust/tests/e2e/session_fs.rs create mode 100644 rust/tests/e2e/session_lifecycle.rs create mode 100644 rust/tests/e2e/skills.rs create mode 100644 rust/tests/e2e/streaming_fidelity.rs create mode 100644 rust/tests/e2e/support.rs create mode 100644 rust/tests/e2e/suspend.rs create mode 100644 rust/tests/e2e/system_message_transform.rs create mode 100644 rust/tests/e2e/telemetry.rs create mode 100644 rust/tests/e2e/tool_results.rs create mode 100644 rust/tests/e2e/tools.rs delete mode 100644 rust/tests/mode_handlers_e2e_test.rs create mode 100644 test/snapshots/client/listmodels_withcustomhandler_callshandler.yaml create mode 100644 test/snapshots/client/should_force_stop_client.yaml create mode 100644 test/snapshots/client/should_get_authenticated_status.yaml create mode 100644 test/snapshots/client/should_get_status.yaml create mode 100644 test/snapshots/client/should_list_models_when_authenticated.yaml create mode 100644 test/snapshots/client/should_start_ping_and_stop_stdio_client.yaml create mode 100644 test/snapshots/client/should_start_ping_and_stop_tcp_client.yaml create mode 100644 test/snapshots/client/should_stop_client_with_active_session.yaml create mode 100644 test/snapshots/client_options/should_listen_on_configured_tcp_port.yaml create mode 100644 test/snapshots/commands/session_with_commands_creates_successfully.yaml create mode 100644 test/snapshots/commands/session_with_commands_resumes_successfully.yaml create mode 100644 test/snapshots/commands/session_with_no_commands_creates_successfully.yaml create mode 100644 test/snapshots/elicitation/confirm_returns_false_when_handler_declines.yaml create mode 100644 test/snapshots/elicitation/confirm_returns_true_when_handler_accepts.yaml create mode 100644 test/snapshots/elicitation/defaults_capabilities_when_not_provided.yaml create mode 100644 test/snapshots/elicitation/elicitation_returns_all_action_shapes.yaml create mode 100644 test/snapshots/elicitation/elicitation_throws_when_capability_is_missing.yaml create mode 100644 test/snapshots/elicitation/input_returns_freeform_value.yaml create mode 100644 test/snapshots/elicitation/select_returns_selected_option.yaml create mode 100644 test/snapshots/elicitation/sends_requestelicitation_when_handler_provided.yaml create mode 100644 test/snapshots/elicitation/session_without_elicitationhandler_creates_successfully.yaml create mode 100644 test/snapshots/elicitation/should_report_elicitation_capability_based_on_handler_presence.yaml create mode 100644 test/snapshots/mcp_and_agents/should_handle_custom_agent_with_mcp_servers.yaml create mode 100644 test/snapshots/mcp_and_agents/should_handle_custom_agent_with_tools_configuration.yaml create mode 100644 test/snapshots/mcp_and_agents/should_handle_multiple_custom_agents.yaml create mode 100644 test/snapshots/mcp_and_agents/should_handle_multiple_mcp_servers.yaml create mode 100644 test/snapshots/per-session-auth/session_auth_status_is_unauthenticated_without_token.yaml create mode 100644 test/snapshots/per-session-auth/session_fails_with_invalid_token.yaml create mode 100644 test/snapshots/per-session-auth/session_token_overrides_client_token.yaml create mode 100644 test/snapshots/per-session-auth/session_uses_client_token_when_no_session_token_is_supplied.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/mode_set_to_same_value_multiple_times_stays_stable.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/name_set_with_unicode_round_trips.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/permissions_reset_session_approvals_on_fresh_session_is_noop.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/permissions_set_approve_all_toggle_round_trips.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/plan_delete_when_none_exists_is_idempotent.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/plan_update_with_empty_content_then_read_returns_empty.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/shell_exec_with_zero_timeout_does_not_kill_long_running_command.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/usage_get_metrics_on_fresh_session_returns_zero_tokens.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_empty_content_round_trips.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_large_content_round_trips.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_unicode_content_round_trips.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/workspaces_createfile_then_listfiles_returns_sorted_or_stable_order.yaml create mode 100644 test/snapshots/rpc_additional_edge_cases/workspaces_getworkspace_returns_stable_result_across_calls.yaml create mode 100644 test/snapshots/rpc_agents/should_call_agent_reload.yaml create mode 100644 test/snapshots/rpc_agents/should_deselect_current_agent.yaml create mode 100644 test/snapshots/rpc_agents/should_emit_subagent_selected_and_deselected_events.yaml create mode 100644 test/snapshots/rpc_agents/should_list_available_custom_agents.yaml create mode 100644 test/snapshots/rpc_agents/should_return_empty_list_when_no_custom_agents_configured.yaml create mode 100644 test/snapshots/rpc_agents/should_return_null_when_no_agent_is_selected.yaml create mode 100644 test/snapshots/rpc_agents/should_select_and_get_current_agent.yaml create mode 100644 test/snapshots/rpc_event_side_effects/should_emit_mode_changed_event_when_mode_set.yaml create mode 100644 test/snapshots/rpc_event_side_effects/should_emit_plan_changed_event_for_update_and_delete.yaml create mode 100644 test/snapshots/rpc_event_side_effects/should_emit_plan_changed_update_operation_on_second_update.yaml create mode 100644 test/snapshots/rpc_event_side_effects/should_emit_title_changed_event_when_name_set.yaml create mode 100644 test/snapshots/rpc_event_side_effects/should_emit_workspace_file_changed_event_when_file_created.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_list_and_toggle_session_skills.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_list_extensions.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_list_mcp_servers_with_configured_server.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_list_plugins.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_reload_session_skills.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_report_error_when_extensions_are_not_available.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_host_is_not_initialized.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_configured.yaml create mode 100644 test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_remote.yaml create mode 100644 test/snapshots/rpc_mcp_config/should_call_server_mcp_config_rpcs.yaml create mode 100644 test/snapshots/rpc_mcp_config/should_round_trip_http_mcp_oauth_config_rpc.yaml create mode 100644 test/snapshots/rpc_server/should_call_rpc_account_get_quota_when_authenticated.yaml create mode 100644 test/snapshots/rpc_server/should_call_rpc_models_list_with_typed_result.yaml create mode 100644 test/snapshots/rpc_server/should_call_rpc_ping_with_typed_params_and_result.yaml create mode 100644 test/snapshots/rpc_server/should_call_rpc_tools_list_with_typed_result.yaml create mode 100644 test/snapshots/rpc_server/should_discover_server_mcp_and_skills.yaml create mode 100644 test/snapshots/rpc_session_state/should_call_session_rpc_model_getcurrent.yaml create mode 100644 test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml create mode 100644 test/snapshots/rpc_session_state/should_call_session_usage_and_permission_rpcs.yaml create mode 100644 test/snapshots/rpc_session_state/should_call_workspace_file_rpc_methods.yaml create mode 100644 test/snapshots/rpc_session_state/should_create_workspace_file_with_nested_path_auto_creating_dirs.yaml create mode 100644 test/snapshots/rpc_session_state/should_emit_title_changed_event_each_time_name_set_is_called.yaml create mode 100644 test/snapshots/rpc_session_state/should_get_and_set_session_metadata.yaml create mode 100644 test/snapshots/rpc_session_state/should_get_and_set_session_mode.yaml create mode 100644 test/snapshots/rpc_session_state/should_handle_forking_session_without_persisted_events.yaml create mode 100644 test/snapshots/rpc_session_state/should_read_update_and_delete_plan.yaml create mode 100644 test/snapshots/rpc_session_state/should_reject_empty_or_whitespace_session_name.yaml create mode 100644 test/snapshots/rpc_session_state/should_reject_workspace_file_path_traversal.yaml create mode 100644 test/snapshots/rpc_session_state/should_report_error_reading_nonexistent_workspace_file.yaml create mode 100644 test/snapshots/rpc_session_state/should_report_implemented_errors_for_unsupported_session_rpc_paths.yaml create mode 100644 test/snapshots/rpc_session_state/should_set_and_get_each_session_mode_value.yaml create mode 100644 test/snapshots/rpc_session_state/should_update_existing_workspace_file_with_update_operation.yaml create mode 100644 test/snapshots/rpc_shell_and_fleet/should_execute_shell_command.yaml create mode 100644 test/snapshots/rpc_shell_and_fleet/should_kill_shell_process.yaml create mode 100644 test/snapshots/rpc_shell_edge_cases/shell_exec_with_custom_cwd_honors_override.yaml create mode 100644 test/snapshots/rpc_shell_edge_cases/shell_exec_with_large_stdout_cleans_up.yaml create mode 100644 test/snapshots/rpc_shell_edge_cases/shell_exec_with_nonexistent_command_returns_processid_and_cleans_up.yaml create mode 100644 test/snapshots/rpc_shell_edge_cases/shell_exec_with_stderr_output_cleans_up.yaml create mode 100644 test/snapshots/rpc_shell_edge_cases/shell_exec_with_timeout_kills_long_running_command.yaml create mode 100644 test/snapshots/rpc_shell_edge_cases/shell_kill_cleans_up_after_terminating_signal.yaml create mode 100644 test/snapshots/rpc_shell_edge_cases/shell_kill_unknown_processid_returns_false.yaml create mode 100644 test/snapshots/rpc_tasks_and_handlers/should_list_task_state_and_return_false_for_missing_task_operations.yaml create mode 100644 test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_invalid_task_agent_model.yaml create mode 100644 test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_missing_task_agent_type.yaml create mode 100644 test/snapshots/rpc_tasks_and_handlers/should_return_expected_results_for_missing_pending_handler_requestids.yaml create mode 100644 test/snapshots/rust_multi_client/both_clients_see_tool_request_and_completion_events.yaml create mode 100644 test/snapshots/rust_multi_client/disconnecting_client_removes_its_tools.yaml create mode 100644 test/snapshots/rust_multi_client/one_client_approves_permission_and_both_see_the_result.yaml create mode 100644 test/snapshots/rust_multi_client/one_client_rejects_permission_and_both_see_the_result.yaml create mode 100644 test/snapshots/rust_multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml create mode 100644 test/snapshots/session_config/should_apply_all_reasoning_effort_values_on_session_create.yaml create mode 100644 test/snapshots/session_config/should_apply_reasoning_effort_on_session_create.yaml create mode 100644 test/snapshots/session_config/should_create_session_with_custom_provider_config.yaml create mode 100644 test/snapshots/session_config/should_use_custom_session_id.yaml diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 207ed6de9..f542307be 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -94,10 +94,12 @@ jobs: run: pwsh.exe -Command "Write-Host 'PowerShell ready'" - name: cargo test + timeout-minutes: 90 env: + RUST_E2E_CONCURRENCY: 4 COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} - run: cargo test --features test-support + run: cargo test --features test-support -- --test-threads=4 --nocapture # Validates the `embedded-cli` build path on all three supported # platforms. This is the only place `build.rs` actually runs (the diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8b130628e..3065822e7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -361,6 +361,7 @@ dependencies = [ "tokio-util", "tracing", "ureq", + "uuid", "zip", "zstd", ] @@ -1284,6 +1285,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4d8831f7e..182707bf1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -51,6 +51,7 @@ parking_lot = "0.12" regex = "1" sha2 = { version = "0.10", optional = true } getrandom = "0.2" +uuid = { version = "1", default-features = false, features = ["v4"] } zstd = { version = "0.13", optional = true } [dev-dependencies] diff --git a/rust/src/jsonrpc.rs b/rust/src/jsonrpc.rs index f0b0d6cc0..88a9670cd 100644 --- a/rust/src/jsonrpc.rs +++ b/rust/src/jsonrpc.rs @@ -3,11 +3,12 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; -use parking_lot::RwLock; +use parking_lot::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio::task::JoinHandle; use tracing::{Instrument, debug, error, warn}; use crate::{Error, ProtocolError}; @@ -184,6 +185,8 @@ pub struct JsonRpcClient { pending_requests: Arc>>>, notification_tx: broadcast::Sender, request_tx: mpsc::UnboundedSender, + read_task: Mutex>>, + write_task: Mutex>>, } impl JsonRpcClient { @@ -202,7 +205,7 @@ impl JsonRpcClient { let (write_tx, write_rx) = mpsc::unbounded_channel::(); let writer_span = tracing::error_span!("jsonrpc_write_loop"); - tokio::spawn(Self::write_loop(writer, write_rx).instrument(writer_span)); + let write_task = tokio::spawn(Self::write_loop(writer, write_rx).instrument(writer_span)); let client = Self { request_id: AtomicU64::new(1), @@ -210,6 +213,8 @@ impl JsonRpcClient { pending_requests: Arc::new(RwLock::new(HashMap::new())), notification_tx, request_tx, + read_task: Mutex::new(None), + write_task: Mutex::new(Some(write_task)), }; let pending_requests = client.pending_requests.clone(); @@ -217,7 +222,7 @@ impl JsonRpcClient { let request_tx_clone = client.request_tx.clone(); let reader_span = tracing::error_span!("jsonrpc_read_loop"); - tokio::spawn( + let read_task = tokio::spawn( async move { Self::read_loop( reader, @@ -229,10 +234,21 @@ impl JsonRpcClient { } .instrument(reader_span), ); + *client.read_task.lock() = Some(read_task); client } + pub(crate) fn force_close(&self) { + if let Some(task) = self.read_task.lock().take() { + task.abort(); + } + if let Some(task) = self.write_task.lock().take() { + task.abort(); + } + self.pending_requests.write().clear(); + } + /// Writer-actor task. Owns the `AsyncWrite`, drains the command queue, /// and writes each frame atomically (header + body + flush) before /// signaling the ack. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1af468182..e0a724fd1 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -267,6 +267,15 @@ pub enum SessionError { /// non-empty. #[error("invalid SessionFsConfig: {0}")] InvalidSessionFsConfig(String), + + /// The CLI returned a different session ID than the one the SDK registered. + #[error("CLI returned session ID {returned} after SDK registered {requested}")] + SessionIdMismatch { + /// Session ID registered by the SDK before the RPC was sent. + requested: SessionId, + /// Session ID returned by the CLI. + returned: SessionId, + }, } /// How the SDK communicates with the CLI server. @@ -873,6 +882,7 @@ struct ClientInner { state: parking_lot::Mutex, lifecycle_tx: broadcast::Sender, on_list_models: Option>, + models_cache: parking_lot::Mutex>>>, session_fs_configured: bool, on_get_trace_context: Option>, /// Token sent in the `connect` handshake. Auto-generated when the @@ -900,6 +910,24 @@ impl Client { if let Some(cfg) = &options.session_fs { validate_session_fs_config(cfg)?; } + // Auth options only make sense when the SDK spawns the CLI; with an + // external server, the server manages its own auth. + if matches!(options.transport, Transport::External { .. }) { + if options.github_token.is_some() { + return Err(Error::InvalidConfig( + "github_token cannot be used with Transport::External \ + (external server manages its own auth)" + .to_string(), + )); + } + if options.use_logged_in_user == Some(true) { + return Err(Error::InvalidConfig( + "use_logged_in_user cannot be used with Transport::External \ + (external server manages its own auth)" + .to_string(), + )); + } + } // Validate token + transport combination. Stdio cannot use a // connection token; auto-generate a UUID when the SDK spawns // its own CLI in TCP mode and no explicit token was set. @@ -1138,6 +1166,7 @@ impl Client { state: parking_lot::Mutex::new(ConnectionState::Connected), lifecycle_tx: broadcast::channel(256).0, on_list_models, + models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())), session_fs_configured, on_get_trace_context, effective_connection_token, @@ -1752,10 +1781,17 @@ impl Client { /// When [`ClientOptions::on_list_models`] is set, returns the handler's /// result without making a `models.list` RPC. Otherwise queries the CLI. pub async fn list_models(&self) -> Result, Error> { - if let Some(handler) = &self.inner.on_list_models { - return handler.list_models().await; - } - Ok(self.rpc().models().list().await?.models) + let cache = self.inner.models_cache.lock().clone(); + let models = cache + .get_or_try_init(|| async { + if let Some(handler) = &self.inner.on_list_models { + handler.list_models().await + } else { + Ok(self.rpc().models().list().await?.models) + } + }) + .await?; + Ok(models.clone()) } /// Invoke [`ClientOptions::on_get_trace_context`] when configured, @@ -1828,6 +1864,7 @@ impl Client { let child = self.inner.child.lock().take(); *self.inner.state.lock() = ConnectionState::Disconnected; + *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new()); if let Some(mut child) = child && let Err(e) = child.kill().await { @@ -1879,10 +1916,12 @@ impl Client { { error!(pid = ?pid, error = %e, "failed to send kill signal"); } + self.inner.rpc.force_close(); // Drop all session channels so any awaiters see a closed channel // instead of waiting for responses that will never arrive. self.inner.router.clear(); *self.inner.state.lock() = ConnectionState::Disconnected; + *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new()); } /// Subscribe to lifecycle events. @@ -2405,43 +2444,137 @@ mod tests { policy: None, supported_reasoning_efforts: Vec::new(), }; - let handler = Arc::new(CountingHandler { + let handler: Arc = Arc::new(CountingHandler { calls: Arc::clone(&calls), models: vec![model.clone()], }); - // We can't call list_models() through Client::start without a CLI, but we - // can exercise the override path by directly constructing a Client whose - // inner has the handler set. This is the same dispatch path as the real - // call; from_streams's None default is replaced via inner construction. - let inner = ClientInner { - child: parking_lot::Mutex::new(None), - rpc: { - let (req_tx, _req_rx) = mpsc::unbounded_channel(); - let (notif_tx, _notif_rx) = broadcast::channel(16); - let (read_pipe, _write_pipe) = tokio::io::duplex(64); - let (_unused_read, write_pipe) = tokio::io::duplex(64); - JsonRpcClient::new(write_pipe, read_pipe, notif_tx, req_tx) - }, - cwd: PathBuf::from("."), - request_rx: parking_lot::Mutex::new(None), - notification_tx: broadcast::channel(16).0, - router: router::SessionRouter::new(), - negotiated_protocol_version: OnceLock::new(), - state: parking_lot::Mutex::new(ConnectionState::Connected), - lifecycle_tx: broadcast::channel(16).0, - on_list_models: Some(handler), - session_fs_configured: false, - on_get_trace_context: None, - effective_connection_token: None, - }; - let client = Client { - inner: Arc::new(inner), - }; + let client = client_with_list_models_handler(handler); let result = client.list_models().await.unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].id, "byok-gpt-4"); assert_eq!(calls.load(Ordering::SeqCst), 1); } + + #[tokio::test] + async fn list_models_serializes_concurrent_cache_misses() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct SlowCountingHandler { + calls: Arc, + models: Vec, + } + #[async_trait] + impl ListModelsHandler for SlowCountingHandler { + async fn list_models(&self) -> Result, Error> { + self.calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + Ok(self.models.clone()) + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let model = Model { + billing: None, + capabilities: ModelCapabilities { + limits: None, + supports: None, + }, + default_reasoning_effort: None, + id: "single-flight-model".into(), + name: "Single Flight Model".into(), + policy: None, + supported_reasoning_efforts: Vec::new(), + }; + let handler: Arc = Arc::new(SlowCountingHandler { + calls: Arc::clone(&calls), + models: vec![model], + }); + let client = client_with_list_models_handler(handler); + + let (first, second) = tokio::join!(client.list_models(), client.list_models()); + assert_eq!(first.unwrap()[0].id, "single-flight-model"); + assert_eq!(second.unwrap()[0].id, "single-flight-model"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn cancelled_create_session_unregisters_pending_session() { + let (client_write, _server_read) = tokio::io::duplex(8192); + let (_server_write, client_read) = tokio::io::duplex(8192); + let client = Client::from_streams(client_read, client_write, std::env::temp_dir()).unwrap(); + let handle = tokio::spawn({ + let client = client.clone(); + async move { client.create_session(SessionConfig::default()).await } + }); + + wait_for_pending_session_registration(&client).await; + handle.abort(); + let _ = handle.await; + + assert!(client.inner.router.session_ids().is_empty()); + client.force_stop(); + } + + #[tokio::test] + async fn cancelled_resume_session_unregisters_pending_session() { + let (client_write, _server_read) = tokio::io::duplex(8192); + let (_server_write, client_read) = tokio::io::duplex(8192); + let client = Client::from_streams(client_read, client_write, std::env::temp_dir()).unwrap(); + let session_id = SessionId::new("resume-cancel-test"); + let handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .resume_session(ResumeSessionConfig::new(session_id)) + .await + } + }); + + wait_for_pending_session_registration(&client).await; + handle.abort(); + let _ = handle.await; + + assert!(client.inner.router.session_ids().is_empty()); + client.force_stop(); + } + + fn client_with_list_models_handler(handler: Arc) -> Client { + Client { + inner: Arc::new(ClientInner { + child: parking_lot::Mutex::new(None), + rpc: { + let (req_tx, _req_rx) = mpsc::unbounded_channel(); + let (notif_tx, _notif_rx) = broadcast::channel(16); + let (read_pipe, _write_pipe) = tokio::io::duplex(64); + let (_unused_read, write_pipe) = tokio::io::duplex(64); + JsonRpcClient::new(write_pipe, read_pipe, notif_tx, req_tx) + }, + cwd: PathBuf::from("."), + request_rx: parking_lot::Mutex::new(None), + notification_tx: broadcast::channel(16).0, + router: router::SessionRouter::new(), + negotiated_protocol_version: OnceLock::new(), + state: parking_lot::Mutex::new(ConnectionState::Connected), + lifecycle_tx: broadcast::channel(16).0, + on_list_models: Some(handler), + models_cache: parking_lot::Mutex::new(Arc::new(tokio::sync::OnceCell::new())), + session_fs_configured: false, + on_get_trace_context: None, + effective_connection_token: None, + }), + } + } + + async fn wait_for_pending_session_registration(client: &Client) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + while client.inner.router.session_ids().is_empty() { + assert!( + tokio::time::Instant::now() < deadline, + "session was not registered" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } } diff --git a/rust/src/session.rs b/rust/src/session.rs index b0d5faef4..2cdb257eb 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -64,6 +64,44 @@ impl Drop for WaiterGuard { } } +struct PendingSessionRegistration { + client: Client, + session_id: SessionId, + shutdown: CancellationToken, + disarmed: bool, +} + +impl PendingSessionRegistration { + fn new(client: Client, session_id: SessionId, shutdown: CancellationToken) -> Self { + Self { + client, + session_id, + shutdown, + disarmed: false, + } + } + + async fn cleanup(mut self, event_loop: JoinHandle<()>) { + self.shutdown.cancel(); + let _ = event_loop.await; + self.client.unregister_session(&self.session_id); + self.disarmed = true; + } + + fn disarm(&mut self) { + self.disarmed = true; + } +} + +impl Drop for PendingSessionRegistration { + fn drop(&mut self) { + if !self.disarmed { + self.shutdown.cancel(); + self.client.unregister_session(&self.session_id); + } + } +} + /// A session on a GitHub Copilot CLI server. /// /// Created via [`Client::create_session`] or [`Client::resume_session`]. @@ -736,24 +774,18 @@ impl Client { if let Some(ref transforms) = transforms { inject_transform_sections(&mut config, transforms.as_ref()); } + let session_id = config + .session_id + .clone() + .unwrap_or_else(|| SessionId::from(uuid::Uuid::new_v4().to_string())); + config.session_id = Some(session_id.clone()); let mut params = serde_json::to_value(&config)?; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); - let rpc_start = Instant::now(); - let result = self.call("session.create", Some(params)).await?; - tracing::debug!( - elapsed_ms = rpc_start.elapsed().as_millis(), - "Client::create_session session creation request completed successfully" - ); - let create_result: CreateSessionResult = serde_json::from_value(result)?; - let session_id = create_result.session_id; let setup_start = Instant::now(); - let capabilities = Arc::new(parking_lot::RwLock::new( - create_result.capabilities.unwrap_or_default(), - )); + let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default())); let channels = self.register_session(&session_id); - let idle_waiter = Arc::new(ParkingLotMutex::new(None)); let shutdown = CancellationToken::new(); let (event_tx, _) = tokio::sync::broadcast::channel(512); @@ -771,6 +803,8 @@ impl Client { event_tx.clone(), shutdown.clone(), ); + let mut registration = + PendingSessionRegistration::new(self.clone(), session_id.clone(), shutdown.clone()); tracing::debug!( elapsed_ms = setup_start.elapsed().as_millis(), session_id = %session_id, @@ -780,11 +814,40 @@ impl Client { "Client::create_session local setup complete" ); + let rpc_start = Instant::now(); + let result = match self.call("session.create", Some(params)).await { + Ok(result) => result, + Err(error) => { + registration.cleanup(event_loop).await; + return Err(error); + } + }; + tracing::debug!( + elapsed_ms = rpc_start.elapsed().as_millis(), + "Client::create_session session creation request completed successfully" + ); + let create_result: CreateSessionResult = match serde_json::from_value(result) { + Ok(result) => result, + Err(error) => { + registration.cleanup(event_loop).await; + return Err(error.into()); + } + }; + if create_result.session_id != session_id { + registration.cleanup(event_loop).await; + return Err(Error::Session(SessionError::SessionIdMismatch { + requested: session_id, + returned: create_result.session_id, + })); + } + *capabilities.write() = create_result.capabilities.unwrap_or_default(); + tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), session_id = %session_id, "Client::create_session complete" ); + registration.disarm(); Ok(Session { id: session_id, cwd: self.cwd().clone(), @@ -836,8 +899,46 @@ impl Client { let mut params = serde_json::to_value(&config)?; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); + + let capabilities = Arc::new(parking_lot::RwLock::new(SessionCapabilities::default())); + let setup_start = Instant::now(); + let channels = self.register_session(&session_id); + let idle_waiter = Arc::new(ParkingLotMutex::new(None)); + let shutdown = CancellationToken::new(); + let (event_tx, _) = tokio::sync::broadcast::channel(512); + let event_loop = spawn_event_loop( + session_id.clone(), + self.clone(), + handler, + hooks, + transforms, + command_handlers, + session_fs_provider, + channels, + idle_waiter.clone(), + capabilities.clone(), + event_tx.clone(), + shutdown.clone(), + ); + let mut registration = + PendingSessionRegistration::new(self.clone(), session_id.clone(), shutdown.clone()); + tracing::debug!( + elapsed_ms = setup_start.elapsed().as_millis(), + session_id = %session_id, + tools_count, + commands_count, + has_hooks, + "Client::resume_session local setup complete" + ); + let rpc_start = Instant::now(); - let result = self.call("session.resume", Some(params)).await?; + let result = match self.call("session.resume", Some(params)).await { + Ok(result) => result, + Err(error) => { + registration.cleanup(event_loop).await; + return Err(error); + } + }; tracing::debug!( elapsed_ms = rpc_start.elapsed().as_millis(), session_id = %session_id, @@ -850,6 +951,13 @@ impl Client { .and_then(|v| v.as_str()) .unwrap_or(&session_id) .into(); + if cli_session_id != session_id { + registration.cleanup(event_loop).await; + return Err(Error::Session(SessionError::SessionIdMismatch { + requested: session_id, + returned: cli_session_id, + })); + } let resume_capabilities: Option = result .get("capabilities") @@ -869,63 +977,34 @@ impl Client { if let Err(e) = self .call( "session.skills.reload", - Some(serde_json::json!({ "sessionId": cli_session_id })), + Some(serde_json::json!({ "sessionId": session_id })), ) .await { warn!( elapsed_ms = skills_reload_start.elapsed().as_millis(), - session_id = %cli_session_id, + session_id = %session_id, error = %e, "Client::resume_session skills reload request failed" ); } else { tracing::debug!( elapsed_ms = skills_reload_start.elapsed().as_millis(), - session_id = %cli_session_id, + session_id = %session_id, "Client::resume_session skills reload request completed successfully" ); } - let capabilities = Arc::new(parking_lot::RwLock::new( - resume_capabilities.unwrap_or_default(), - )); - let setup_start = Instant::now(); - let channels = self.register_session(&cli_session_id); - - let idle_waiter = Arc::new(ParkingLotMutex::new(None)); - let shutdown = CancellationToken::new(); - let (event_tx, _) = tokio::sync::broadcast::channel(512); - let event_loop = spawn_event_loop( - cli_session_id.clone(), - self.clone(), - handler, - hooks, - transforms, - command_handlers, - session_fs_provider, - channels, - idle_waiter.clone(), - capabilities.clone(), - event_tx.clone(), - shutdown.clone(), - ); - tracing::debug!( - elapsed_ms = setup_start.elapsed().as_millis(), - session_id = %cli_session_id, - tools_count, - commands_count, - has_hooks, - "Client::resume_session local setup complete" - ); + *capabilities.write() = resume_capabilities.unwrap_or_default(); tracing::debug!( elapsed_ms = total_start.elapsed().as_millis(), - session_id = %cli_session_id, + session_id = %session_id, "Client::resume_session complete" ); + registration.disarm(); Ok(Session { - id: cli_session_id, + id: session_id, cwd: self.cwd().clone(), workspace_path: None, remote_url, diff --git a/rust/src/types.rs b/rust/src/types.rs index 44ff96ce9..9a7ac0cbb 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1544,6 +1544,9 @@ pub struct ResumeSessionConfig { /// Application name sent as User-Agent context. #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, + /// Desired reasoning effort to apply after resuming the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, /// Enable streaming token deltas. #[serde(skip_serializing_if = "Option::is_none")] pub streaming: Option, @@ -1679,6 +1682,7 @@ impl std::fmt::Debug for ResumeSessionConfig { f.debug_struct("ResumeSessionConfig") .field("session_id", &self.session_id) .field("client_name", &self.client_name) + .field("reasoning_effort", &self.reasoning_effort) .field("streaming", &self.streaming) .field("system_message", &self.system_message) .field("tools", &self.tools) @@ -1738,6 +1742,7 @@ impl ResumeSessionConfig { Self { session_id, client_name: None, + reasoning_effort: None, streaming: None, system_message: None, tools: None, @@ -1854,6 +1859,12 @@ impl ResumeSessionConfig { self } + /// Set the reasoning effort to apply on resume. + pub fn with_reasoning_effort(mut self, effort: impl Into) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + /// Enable streaming token deltas via `assistant.message_delta` events. pub fn with_streaming(mut self, streaming: bool) -> Self { self.streaming = Some(streaming); diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs new file mode 100644 index 000000000..2a373a3b5 --- /dev/null +++ b/rust/tests/api_types_test.rs @@ -0,0 +1,99 @@ +// Unit tests for generated API types -- struct construction and field +// access. These do not require a client, session, or replay proxy. + +#![allow(clippy::unwrap_used)] + +use github_copilot_sdk::generated::api_types::{ + Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, + ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, TasksStartAgentRequest, +}; + +#[test] +fn extension_running_has_expected_status_and_source() { + let extension = running_extension("project:demo", "demo"); + assert_eq!(extension.status, ExtensionStatus::Running); + assert_eq!(extension.source, ExtensionSource::Project); +} + +#[test] +fn disable_and_enable_requests_share_the_same_id() { + let disable = ExtensionsDisableRequest { + id: "project:demo".to_string(), + }; + let enable = ExtensionsEnableRequest { + id: disable.id.clone(), + }; + assert_eq!(disable.id, enable.id); +} + +#[test] +fn extension_list_contains_newly_added_extension_by_name() { + let list = ExtensionList { + extensions: vec![running_extension("project:late", "late")], + }; + assert!(list.extensions.iter().any(|e| e.name == "late")); +} + +#[test] +fn failed_extension_reports_failed_status() { + let mut extension = running_extension("project:broken", "broken"); + extension.status = ExtensionStatus::Failed; + assert_eq!(extension.status, ExtensionStatus::Failed); +} + +#[test] +fn multiple_extensions_have_distinct_ids() { + let list = ExtensionList { + extensions: vec![ + running_extension("project:first", "first"), + running_extension("user:second", "second"), + ], + }; + assert_eq!(list.extensions.len(), 2); + assert_ne!(list.extensions[0].id, list.extensions[1].id); +} + +#[test] +fn disabled_extension_preserves_disabled_status() { + let mut extension = running_extension("project:disabled", "disabled"); + extension.status = ExtensionStatus::Disabled; + assert_eq!(extension.status, ExtensionStatus::Disabled); +} + +#[test] +fn fleet_start_request_and_result_fields_are_accessible() { + let request = FleetStartRequest { + prompt: Some("Use the custom tool".to_string()), + }; + let result = FleetStartResult { started: true }; + assert_eq!(request.prompt.as_deref(), Some("Use the custom tool")); + assert!(result.started); +} + +#[test] +fn tasks_start_agent_request_fields_are_accessible() { + let request = TasksStartAgentRequest { + agent_type: "general-purpose".to_string(), + prompt: "Say hi".to_string(), + name: "sdk-test-task".to_string(), + description: Some("SDK task agent".to_string()), + model: None, + }; + assert_eq!(request.agent_type, "general-purpose"); + assert_eq!(request.name, "sdk-test-task"); + assert_eq!(request.description.as_deref(), Some("SDK task agent")); +} + +fn running_extension(id: &str, name: &str) -> Extension { + Extension { + id: id.to_string(), + name: name.to_string(), + pid: Some(42), + source: if id.starts_with("user:") { + ExtensionSource::User + } else { + ExtensionSource::Project + }, + status: ExtensionStatus::Running, + } +} diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs new file mode 100644 index 000000000..8fefdf23a --- /dev/null +++ b/rust/tests/e2e.rs @@ -0,0 +1,91 @@ +#![cfg(feature = "test-support")] +#![allow(clippy::unwrap_used)] + +#[path = "e2e/abort.rs"] +mod abort; +#[path = "e2e/ask_user.rs"] +mod ask_user; +#[path = "e2e/builtin_tools.rs"] +mod builtin_tools; +#[path = "e2e/client.rs"] +mod client; +#[path = "e2e/client_api.rs"] +mod client_api; +#[path = "e2e/client_lifecycle.rs"] +mod client_lifecycle; +#[path = "e2e/client_options.rs"] +mod client_options; +#[path = "e2e/commands.rs"] +mod commands; +#[path = "e2e/compaction.rs"] +mod compaction; +#[path = "e2e/elicitation.rs"] +mod elicitation; +#[path = "e2e/error_resilience.rs"] +mod error_resilience; +#[path = "e2e/event_fidelity.rs"] +mod event_fidelity; +#[path = "e2e/hooks.rs"] +mod hooks; +#[path = "e2e/hooks_extended.rs"] +mod hooks_extended; +#[path = "e2e/mcp_and_agents.rs"] +mod mcp_and_agents; +#[path = "e2e/mode_handlers.rs"] +mod mode_handlers; +#[path = "e2e/multi_client.rs"] +mod multi_client; +#[path = "e2e/multi_client_commands_elicitation.rs"] +mod multi_client_commands_elicitation; +#[path = "e2e/multi_turn.rs"] +mod multi_turn; +#[path = "e2e/pending_work_resume.rs"] +mod pending_work_resume; +#[path = "e2e/per_session_auth.rs"] +mod per_session_auth; +#[path = "e2e/permissions.rs"] +mod permissions; +#[path = "e2e/rpc_additional_edge_cases.rs"] +mod rpc_additional_edge_cases; +#[path = "e2e/rpc_agent.rs"] +mod rpc_agent; +#[path = "e2e/rpc_event_side_effects.rs"] +mod rpc_event_side_effects; +#[path = "e2e/rpc_mcp_and_skills.rs"] +mod rpc_mcp_and_skills; +#[path = "e2e/rpc_mcp_config.rs"] +mod rpc_mcp_config; +#[path = "e2e/rpc_server.rs"] +mod rpc_server; +#[path = "e2e/rpc_session_state.rs"] +mod rpc_session_state; +#[path = "e2e/rpc_shell_and_fleet.rs"] +mod rpc_shell_and_fleet; +#[path = "e2e/rpc_shell_edge_cases.rs"] +mod rpc_shell_edge_cases; +#[path = "e2e/rpc_tasks_and_handlers.rs"] +mod rpc_tasks_and_handlers; +#[path = "e2e/session.rs"] +mod session; +#[path = "e2e/session_config.rs"] +mod session_config; +#[path = "e2e/session_fs.rs"] +mod session_fs; +#[path = "e2e/session_lifecycle.rs"] +mod session_lifecycle; +#[path = "e2e/skills.rs"] +mod skills; +#[path = "e2e/streaming_fidelity.rs"] +mod streaming_fidelity; +#[path = "e2e/support.rs"] +mod support; +#[path = "e2e/suspend.rs"] +mod suspend; +#[path = "e2e/system_message_transform.rs"] +mod system_message_transform; +#[path = "e2e/telemetry.rs"] +mod telemetry; +#[path = "e2e/tool_results.rs"] +mod tool_results; +#[path = "e2e/tools.rs"] +mod tools; diff --git a/rust/tests/e2e/abort.rs b/rust/tests/e2e/abort.rs new file mode 100644 index 000000000..ff8977f39 --- /dev/null +++ b/rust/tests/e2e/abort.rs @@ -0,0 +1,173 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::generated::session_events::{AssistantMessageDeltaData, SessionEventType}; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter}; +use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult}; +use serde_json::json; +use tokio::sync::{Mutex, mpsc, oneshot}; + +use super::support::{ + DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout, wait_for_event, + with_e2e_context, +}; + +#[tokio::test] +async fn should_abort_during_active_streaming() { + with_e2e_context("abort", "should_abort_during_active_streaming", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_streaming(true)) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send( + "Write a very long essay about the history of computing, covering every decade \ + from the 1940s to the 2020s in great detail.", + ) + .await + .expect("send long streaming turn"); + + let delta = wait_for_event(events, "assistant.message_delta", |event| { + event.parsed_type() == SessionEventType::AssistantMessageDelta + }) + .await; + assert!( + !delta + .typed_data::() + .expect("assistant.message_delta data") + .delta_content + .is_empty() + ); + + session.abort().await.expect("abort session"); + + let recovery = session + .send_and_wait("Say 'abort_recovery_ok'.") + .await + .expect("send recovery") + .expect("assistant message"); + assert!( + assistant_message_content(&recovery) + .to_lowercase() + .contains("abort_recovery_ok") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_abort_during_active_tool_execution() { + with_e2e_context( + "abort", + "should_abort_during_active_tool_execution", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let (release_tx, release_rx) = oneshot::channel(); + let router = ToolHandlerRouter::new( + vec![Box::new(SlowAnalysisTool { + started_tx, + release_rx: Mutex::new(Some(release_rx)), + })], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send("Use slow_analysis with value 'test_abort'. Wait for the result.") + .await + .expect("send tool turn"); + + let tool_value = recv_with_timeout(&mut started_rx, "slow tool start").await; + assert_eq!(tool_value, "test_abort"); + + session.abort().await.expect("abort session"); + release_tx + .send("RELEASED_AFTER_ABORT".to_string()) + .expect("release slow tool"); + wait_for_event(events, "session.idle after abort", |event| { + event.parsed_type() == SessionEventType::SessionIdle + }) + .await; + + let recovery = session + .send_and_wait("Say 'tool_abort_recovery_ok'.") + .await + .expect("send recovery") + .expect("assistant message"); + assert!( + assistant_message_content(&recovery) + .to_lowercase() + .contains("tool_abort_recovery_ok") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +struct SlowAnalysisTool { + started_tx: mpsc::UnboundedSender, + release_rx: Mutex>>, +} + +#[async_trait] +impl ToolHandler for SlowAnalysisTool { + fn tool(&self) -> Tool { + Tool::new("slow_analysis") + .with_description("A slow analysis tool that blocks until released") + .with_parameters(json!({ + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Value to analyze" + } + }, + "required": ["value"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let value = invocation + .arguments + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let _ = self.started_tx.send(value); + let release_rx = self + .release_rx + .lock() + .await + .take() + .expect("slow tool called once"); + let released = release_rx.await.unwrap_or_else(|_| "released".to_string()); + Ok(ToolResult::Text(released)) + } +} diff --git a/rust/tests/e2e/ask_user.rs b/rust/tests/e2e/ask_user.rs new file mode 100644 index 000000000..349c42210 --- /dev/null +++ b/rust/tests/e2e/ask_user.rs @@ -0,0 +1,195 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::handler::{SessionHandler, UserInputResponse}; +use github_copilot_sdk::{RequestId, SessionConfig, SessionId}; +use tokio::sync::mpsc; + +use super::support::{ + DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout, with_e2e_context, +}; + +#[tokio::test] +async fn should_invoke_user_input_handler_when_model_uses_ask_user_tool() { + with_e2e_context( + "ask_user", + "should_invoke_user_input_handler_when_model_uses_ask_user_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(RecordingUserInputHandler { + request_tx, + answer: UserInputAnswer::FirstChoiceOrFreeform("freeform answer"), + })), + ) + .await + .expect("create session"); + + session + .send_and_wait( + "Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. \ + Wait for my response before continuing.", + ) + .await + .expect("send"); + + let request = recv_with_timeout(&mut request_rx, "user input request").await; + assert_eq!(request.session_id, *session.id()); + assert!(!request.question.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_receive_choices_in_user_input_request() { + with_e2e_context( + "ask_user", + "should_receive_choices_in_user_input_request", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(RecordingUserInputHandler { + request_tx, + answer: UserInputAnswer::FirstChoiceOrFreeform("default"), + })), + ) + .await + .expect("create session"); + + session + .send_and_wait( + "Use the ask_user tool to ask me to pick between exactly two options: \ + 'Red' and 'Blue'. These should be provided as choices. Wait for my answer.", + ) + .await + .expect("send"); + + let request = recv_with_timeout(&mut request_rx, "user input request").await; + let choices = request.choices.expect("choices"); + assert!(choices.iter().any(|choice| choice == "Red")); + assert!(choices.iter().any(|choice| choice == "Blue")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_freeform_user_input_response() { + with_e2e_context( + "ask_user", + "should_handle_freeform_user_input_response", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let freeform_answer = + "This is my custom freeform answer that was not in the choices"; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(RecordingUserInputHandler { + request_tx, + answer: UserInputAnswer::Freeform(freeform_answer), + })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Ask me a question using ask_user and then include my answer in your response. \ + The question should be 'What is your favorite color?'", + ) + .await + .expect("send") + .expect("assistant message"); + + let request = recv_with_timeout(&mut request_rx, "user input request").await; + assert!(!request.question.is_empty()); + assert!(assistant_message_content(&answer).contains(freeform_answer)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[derive(Debug)] +struct RecordedUserInputRequest { + session_id: SessionId, + question: String, + choices: Option>, +} + +struct RecordingUserInputHandler { + request_tx: mpsc::UnboundedSender, + answer: UserInputAnswer, +} + +enum UserInputAnswer { + FirstChoiceOrFreeform(&'static str), + Freeform(&'static str), +} + +#[async_trait] +impl SessionHandler for RecordingUserInputHandler { + async fn on_user_input( + &self, + session_id: SessionId, + question: String, + choices: Option>, + allow_freeform: Option, + ) -> Option { + let _ = self.request_tx.send(RecordedUserInputRequest { + session_id, + question, + choices: choices.clone(), + }); + let (answer, was_freeform) = match (&self.answer, choices.as_ref().and_then(|c| c.first())) + { + (UserInputAnswer::FirstChoiceOrFreeform(_), Some(choice)) => (choice.clone(), false), + (UserInputAnswer::FirstChoiceOrFreeform(fallback), None) => { + ((*fallback).to_string(), allow_freeform.unwrap_or(true)) + } + (UserInputAnswer::Freeform(answer), _) => ((*answer).to_string(), true), + }; + Some(UserInputResponse { + answer, + was_freeform, + }) + } + + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + _data: github_copilot_sdk::PermissionRequestData, + ) -> github_copilot_sdk::handler::PermissionResult { + github_copilot_sdk::handler::PermissionResult::Approved + } +} diff --git a/rust/tests/e2e/builtin_tools.rs b/rust/tests/e2e/builtin_tools.rs new file mode 100644 index 000000000..ca80c0774 --- /dev/null +++ b/rust/tests/e2e/builtin_tools.rs @@ -0,0 +1,242 @@ +use super::support::{assistant_message_content, with_e2e_context}; + +#[tokio::test] +async fn should_capture_exit_code_in_output() { + with_e2e_context( + "builtin_tools", + "should_capture_exit_code_in_output", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Run 'echo hello && echo world'. Tell me the exact output.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&msg); + assert!(content.contains("hello")); + assert!(content.contains("world")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_capture_stderr_output() { + with_e2e_context("builtin_tools", "should_capture_stderr_output", |ctx| { + Box::pin(async move { + if cfg!(windows) { + return; + } + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Run 'echo error_msg >&2; echo ok' and tell me what stderr said. Reply with just the stderr content.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&msg).contains("error_msg")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_read_file_with_line_range() { + with_e2e_context("builtin_tools", "should_read_file_with_line_range", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("lines.txt"), "line1\nline2\nline3\nline4\nline5\n") + .expect("write lines file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Read lines 2 through 4 of the file 'lines.txt' in this directory. Tell me what those lines contain.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&msg); + assert!(content.contains("line2")); + assert!(content.contains("line4")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_handle_nonexistent_file_gracefully() { + with_e2e_context( + "builtin_tools", + "should_handle_nonexistent_file_gracefully", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Try to read the file 'does_not_exist.txt'. If it doesn't exist, say 'FILE_NOT_FOUND'.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&msg).to_uppercase(); + assert!( + content.contains("NOT FOUND") + || content.contains("NOT EXIST") + || content.contains("NO SUCH") + || content.contains("FILE_NOT_FOUND") + || content.contains("DOES NOT EXIST") + || content.contains("ERROR"), + "expected missing-file response, got: {content}" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_edit_a_file_successfully() { + with_e2e_context("builtin_tools", "should_edit_a_file_successfully", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("edit_me.txt"), "Hello World\nGoodbye World\n") + .expect("write edit file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Edit the file 'edit_me.txt': replace 'Hello World' with 'Hi Universe'. Then read it back and tell me its contents.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&msg).contains("Hi Universe")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_create_a_new_file() { + with_e2e_context("builtin_tools", "should_create_a_new_file", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Create a file called 'new_file.txt' with the content 'Created by test'. Then read it back to confirm.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&msg).contains("Created by test")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_search_for_patterns_in_files() { + with_e2e_context( + "builtin_tools", + "should_search_for_patterns_in_files", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("data.txt"), "apple\nbanana\napricot\ncherry\n") + .expect("write data file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Search for lines starting with 'ap' in the file 'data.txt'. Tell me which lines matched.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&msg); + assert!(content.contains("apple")); + assert!(content.contains("apricot")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_find_files_by_pattern() { + with_e2e_context("builtin_tools", "should_find_files_by_pattern", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let src = ctx.work_dir().join("src"); + std::fs::create_dir(&src).expect("create src directory"); + std::fs::write(src.join("index.ts"), "export const index = 1;") + .expect("write index.ts"); + std::fs::write(ctx.work_dir().join("README.md"), "# Readme").expect("write readme"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let msg = session + .send_and_wait("Find all .ts files in this directory (recursively). List the filenames you found.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&msg).contains("index.ts")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} diff --git a/rust/tests/e2e/client.rs b/rust/tests/e2e/client.rs new file mode 100644 index 000000000..a2e431f62 --- /dev/null +++ b/rust/tests/e2e/client.rs @@ -0,0 +1,277 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, ConnectionState, Error, ListModelsHandler, Model, + ModelCapabilities, Transport, +}; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_start_ping_and_stop_stdio_client() { + with_e2e_context("client", "should_start_ping_and_stop_stdio_client", |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + assert_eq!(client.state(), ConnectionState::Connected); + + let response = client.ping(Some("hello from rust")).await.expect("ping"); + assert_eq!(response.message, "pong: hello from rust"); + assert!(response.timestamp > 0); + + client.stop().await.expect("stop client"); + assert_eq!(client.state(), ConnectionState::Disconnected); + }) + }) + .await; +} + +#[tokio::test] +async fn should_start_ping_and_stop_tcp_client() { + with_e2e_context("client", "should_start_ping_and_stop_tcp_client", |ctx| { + Box::pin(async move { + let client = Client::start( + ctx.client_options_with_transport(Transport::Tcp { port: 0 }) + .with_tcp_connection_token("tcp-e2e-token"), + ) + .await + .expect("start TCP client"); + assert_eq!(client.state(), ConnectionState::Connected); + + let response = client.ping(Some("tcp hello")).await.expect("ping"); + assert_eq!(response.message, "pong: tcp hello"); + + client.stop().await.expect("stop client"); + assert_eq!(client.state(), ConnectionState::Disconnected); + }) + }) + .await; +} + +#[tokio::test] +async fn should_get_status() { + with_e2e_context("client", "should_get_status", |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + let status = client.get_status().await.expect("status"); + + assert!(!status.version.is_empty()); + assert!(status.protocol_version > 0); + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_get_authenticated_status() { + with_e2e_context("client", "should_get_authenticated_status", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = Client::start( + ctx.client_options() + .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ) + .await + .expect("start client"); + let status = client.get_auth_status().await.expect("auth status"); + + assert!(status.is_authenticated); + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_list_models_when_authenticated() { + with_e2e_context("client", "should_list_models_when_authenticated", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = Client::start( + ctx.client_options() + .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ) + .await + .expect("start client"); + let models = client.list_models().await.expect("list models"); + + assert!( + models.iter().any(|model| model.id == "claude-sonnet-4.5"), + "expected default replay model in {models:?}" + ); + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_stop_client_with_active_session() { + with_e2e_context("client", "should_stop_client_with_active_session", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let _session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + client.stop().await.expect("stop client"); + assert_eq!(client.state(), ConnectionState::Disconnected); + }) + }) + .await; +} + +#[tokio::test] +async fn should_force_stop_client() { + with_e2e_context("client", "should_force_stop_client", |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + assert_eq!(client.state(), ConnectionState::Connected); + + client.force_stop(); + assert_eq!(client.state(), ConnectionState::Disconnected); + }) + }) + .await; +} + +#[tokio::test] +async fn should_report_error_with_stderr_when_cli_fails_to_start() { + let err = Client::start( + ClientOptions::new() + .with_program(CliProgram::Path(std::path::PathBuf::from( + "definitely-not-copilot-cli-for-rust-e2e", + ))) + .with_use_logged_in_user(false), + ) + .await + .expect_err("start should fail for missing CLI"); + + let message = err.to_string(); + assert!( + !message.trim().is_empty(), + "missing CLI start failure should include an error message" + ); +} + +#[tokio::test] +async fn listmodels_withcustomhandler_callshandler() { + with_e2e_context( + "client", + "listmodels_withcustomhandler_callshandler", + |ctx| { + Box::pin(async move { + let handler = CountingModelsHandler::default(); + let calls = Arc::clone(&handler.calls); + let client = Client::start( + ctx.client_options() + .with_list_models_handler(handler) + .with_use_logged_in_user(false), + ) + .await + .expect("start client"); + + let models = client.list_models().await.expect("list models"); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "custom-handler-model"); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_not_throw_when_disposing_session_after_stopping_client() { + with_e2e_context( + "client", + "should_not_throw_when_disposing_session_after_stopping_client", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + client.stop().await.expect("stop client"); + drop(session); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn listmodels_withcustomhandler_cachesresults() { + with_e2e_context( + "client", + "listmodels_withcustomhandler_cachesresults", + |ctx| { + Box::pin(async move { + let handler = CountingModelsHandler::default(); + let calls = Arc::clone(&handler.calls); + let client = Client::start( + ctx.client_options() + .with_list_models_handler(handler) + .with_use_logged_in_user(false), + ) + .await + .expect("start client"); + + let first = client.list_models().await.expect("list models first"); + let second = client.list_models().await.expect("list models second"); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(first[0].id, second[0].id); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn listmodels_withcustomhandler_workswithoutstart() { + let handler = CountingModelsHandler::default(); + let models = handler.list_models().await.expect("list models"); + + assert_eq!(handler.calls.load(Ordering::SeqCst), 1); + assert_eq!(models[0].id, "custom-handler-model"); +} + +#[derive(Default)] +struct CountingModelsHandler { + calls: Arc, +} + +#[async_trait] +impl ListModelsHandler for CountingModelsHandler { + async fn list_models(&self) -> Result, Error> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(vec![Model { + billing: None, + capabilities: ModelCapabilities { + limits: None, + supports: None, + }, + default_reasoning_effort: None, + id: "custom-handler-model".to_string(), + name: "Custom Handler Model".to_string(), + policy: None, + supported_reasoning_efforts: Vec::new(), + }]) + } +} diff --git a/rust/tests/e2e/client_api.rs b/rust/tests/e2e/client_api.rs new file mode 100644 index 000000000..951fe8720 --- /dev/null +++ b/rust/tests/e2e/client_api.rs @@ -0,0 +1,177 @@ +use github_copilot_sdk::SessionId; + +use super::support::{wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn should_delete_session_by_id() { + with_e2e_context("client_api", "should_delete_session_by_id", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + session.send_and_wait("Say OK.").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + client + .delete_session(&session_id) + .await + .expect("delete session"); + + let metadata = client + .get_session_metadata(&session_id) + .await + .expect("get metadata"); + assert!(metadata.is_none()); + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_report_error_when_deleting_unknown_session_id() { + with_e2e_context( + "client_api", + "should_report_error_when_deleting_unknown_session_id", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + let unknown = SessionId::new("00000000-0000-0000-0000-000000000000"); + + client + .delete_session(&unknown) + .await + .expect("delete unknown session is idempotent"); + let metadata = client + .get_session_metadata(&unknown) + .await + .expect("get unknown metadata"); + assert!(metadata.is_none()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_null_last_session_id_before_any_sessions_exist() { + with_e2e_context( + "client_api", + "should_get_null_last_session_id_before_any_sessions_exist", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let last_id = client.get_last_session_id().await.expect("get last id"); + + assert!(last_id.is_none()); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_track_last_session_id_after_session_created() { + with_e2e_context( + "client_api", + "should_track_last_session_id_after_session_created", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + session.send_and_wait("Say OK.").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + + wait_for_condition("last session id to update", || { + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client + .get_last_session_id() + .await + .is_ok_and(|id| id.as_ref() == Some(&session_id)) + } + }) + .await; + assert_eq!( + client.get_last_session_id().await.expect("get last id"), + Some(session_id) + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_null_foreground_session_id_in_headless_mode() { + with_e2e_context( + "client_api", + "should_get_null_foreground_session_id_in_headless_mode", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let foreground = client + .get_foreground_session_id() + .await + .expect("get foreground"); + + assert!(foreground.is_none()); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_when_setting_foreground_session_in_headless_mode() { + with_e2e_context( + "client_api", + "should_report_error_when_setting_foreground_session_in_headless_mode", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + client + .set_foreground_session_id(session.id()) + .await + .expect("set foreground is ignored in headless mode"); + assert!( + client + .get_foreground_session_id() + .await + .expect("get foreground") + .is_none() + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/client_lifecycle.rs b/rust/tests/e2e/client_lifecycle.rs new file mode 100644 index 000000000..05fdb4a83 --- /dev/null +++ b/rust/tests/e2e/client_lifecycle.rs @@ -0,0 +1,228 @@ +use github_copilot_sdk::{ConnectionState, SessionLifecycleEventType}; +use serde_json::json; + +use super::support::{wait_for_lifecycle_event, with_e2e_context}; + +#[tokio::test] +async fn should_receive_session_created_lifecycle_event() { + with_e2e_context( + "client_lifecycle", + "should_receive_session_created_lifecycle_event", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let created = client.subscribe_lifecycle(); + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let event = + wait_for_lifecycle_event(created, "session.created lifecycle event", |event| { + event.event_type == SessionLifecycleEventType::Created + }) + .await; + assert_eq!(event.event_type, SessionLifecycleEventType::Created); + assert_eq!(&event.session_id, session.id()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_filter_session_lifecycle_events_by_type() { + with_e2e_context( + "client_lifecycle", + "should_filter_session_lifecycle_events_by_type", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let created = client.subscribe_lifecycle(); + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let event = wait_for_lifecycle_event( + created, + "filtered session.created lifecycle event", + |event| event.event_type == SessionLifecycleEventType::Created, + ) + .await; + assert_eq!(&event.session_id, session.id()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn disposing_lifecycle_subscription_stops_receiving_events() { + with_e2e_context( + "client_lifecycle", + "disposing_lifecycle_subscription_stops_receiving_events", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + drop(client.subscribe_lifecycle()); + let created = client.subscribe_lifecycle(); + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let event = wait_for_lifecycle_event( + created, + "active session.created lifecycle event", + |event| event.event_type == SessionLifecycleEventType::Created, + ) + .await; + assert_eq!(event.session_id, *session.id()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn dispose_disconnects_client_and_disposes_rpc_surface_async() { + with_e2e_context( + "client_lifecycle", + "dispose_disconnects_client_and_disposes_rpc_surface_async_true", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + assert_eq!(client.state(), ConnectionState::Connected); + + client.stop().await.expect("stop client"); + + assert_eq!(client.state(), ConnectionState::Disconnected); + assert!( + client.call("rpc.ping", Some(json!({}))).await.is_err(), + "stopped client should reject RPC calls" + ); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn dispose_disconnects_client_and_disposes_rpc_surface_drop() { + with_e2e_context( + "client_lifecycle", + "dispose_disconnects_client_and_disposes_rpc_surface_async_false", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + assert_eq!(client.state(), ConnectionState::Connected); + + client.force_stop(); + + assert_eq!(client.state(), ConnectionState::Disconnected); + assert!( + client.call("rpc.ping", Some(json!({}))).await.is_err(), + "force-stopped client should reject RPC calls" + ); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_receive_session_updated_lifecycle_event_for_non_ephemeral_activity() { + with_e2e_context( + "client_lifecycle", + "should_receive_session_updated_lifecycle_event_for_non_ephemeral_activity", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let updated = client.subscribe_lifecycle(); + + session + .client() + .call( + "session.mode.set", + Some(json!({ + "sessionId": session.id().as_str(), + "mode": "plan", + })), + ) + .await + .expect("set session mode"); + + let event = + wait_for_lifecycle_event(updated, "session.updated lifecycle event", |event| { + event.event_type == SessionLifecycleEventType::Updated + && event.session_id == *session.id() + }) + .await; + assert_eq!(event.event_type, SessionLifecycleEventType::Updated); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_receive_session_deleted_lifecycle_event_when_deleted() { + with_e2e_context( + "client_lifecycle", + "should_receive_session_deleted_lifecycle_event_when_deleted", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + session + .send_and_wait("Say SESSION_DELETED_OK exactly.") + .await + .expect("send"); + let deleted = client.subscribe_lifecycle(); + + client + .delete_session(&session_id) + .await + .expect("delete session"); + + let event = + wait_for_lifecycle_event(deleted, "session.deleted lifecycle event", |event| { + event.event_type == SessionLifecycleEventType::Deleted + && event.session_id == session_id + }) + .await; + assert_eq!(event.event_type, SessionLifecycleEventType::Deleted); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs new file mode 100644 index 000000000..441ce48c0 --- /dev/null +++ b/rust/tests/e2e/client_options.rs @@ -0,0 +1,286 @@ +use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; + +use github_copilot_sdk::{ + Client, ClientOptions, Error, LogLevel, MessageOptions, OtelExporterType, SessionConfig, + TelemetryConfig, Transport, +}; +use serde_json::json; + +use super::support::{assistant_message_content, with_e2e_context}; + +#[tokio::test] +async fn should_use_client_cwd_for_default_workingdirectory() { + with_e2e_context( + "client_options", + "should_use_client_cwd_for_default_workingdirectory", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client_cwd = ctx.work_dir().join("client-cwd"); + std::fs::create_dir_all(&client_cwd).expect("create client cwd"); + std::fs::write(client_cwd.join("marker.txt"), "I am in the client cwd") + .expect("write marker"); + + let client = Client::start(ctx.client_options().with_cwd(&client_cwd)) + .await + .expect("start client"); + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Read the file marker.txt and tell me what it says") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("client cwd")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_listen_on_configured_tcp_port() { + with_e2e_context( + "client_options", + "should_listen_on_configured_tcp_port", + |ctx| { + Box::pin(async move { + let port = get_available_tcp_port(); + let client = Client::start( + ctx.client_options_with_transport(Transport::Tcp { port }) + .with_tcp_connection_token("configured-port-token"), + ) + .await + .expect("start TCP client"); + + let response = client.ping(Some("fixed-port")).await.expect("ping"); + + assert_eq!(response.message, "pong: fixed-port"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_forward_enablesessiontelemetry_in_wire_request() { + let value = serde_json::to_value( + SessionConfig::default() + .with_enable_session_telemetry(false) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )), + ) + .expect("serialize session config"); + + assert_eq!(value["enableSessionTelemetry"], json!(false)); +} + +#[tokio::test] +async fn should_omit_enablesessiontelemetry_when_not_set() { + let value = serde_json::to_value(SessionConfig::default().with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + ))) + .expect("serialize session config"); + + assert!(value.get("enableSessionTelemetry").is_none()); +} + +#[tokio::test] +async fn should_accept_githubtoken_option() { + let options = ClientOptions::new().with_github_token("gho_test_token"); + + assert_eq!(options.github_token.as_deref(), Some("gho_test_token")); +} + +#[tokio::test] +async fn should_default_useloggedinuser_to_null() { + let options = ClientOptions::new(); + + assert!(options.use_logged_in_user.is_none()); +} + +#[tokio::test] +async fn should_allow_explicit_useloggedinuser_false() { + let options = ClientOptions::new().with_use_logged_in_user(false); + + assert_eq!(options.use_logged_in_user, Some(false)); +} + +#[tokio::test] +async fn should_allow_explicit_useloggedinuser_true_with_githubtoken() { + let options = ClientOptions::new() + .with_github_token("gho_test_token") + .with_use_logged_in_user(true); + + assert_eq!(options.github_token.as_deref(), Some("gho_test_token")); + assert_eq!(options.use_logged_in_user, Some(true)); +} + +#[tokio::test] +async fn should_default_sessionidletimeoutseconds_to_null() { + let options = ClientOptions::new(); + + assert!(options.session_idle_timeout_seconds.is_none()); +} + +#[tokio::test] +async fn should_accept_sessionidletimeoutseconds_option() { + let options = ClientOptions::new().with_session_idle_timeout_seconds(600); + + assert_eq!(options.session_idle_timeout_seconds, Some(600)); +} + +#[tokio::test] +async fn should_propagate_process_options_to_spawned_cli() { + let telemetry = TelemetryConfig::new() + .with_otlp_endpoint("http://127.0.0.1:4318") + .with_file_path("telemetry.jsonl") + .with_exporter_type(OtelExporterType::File) + .with_source_name("rust-sdk-e2e") + .with_capture_content(true); + let options = ClientOptions::new() + .with_github_token("process-option-token") + .with_log_level(LogLevel::Debug) + .with_session_idle_timeout_seconds(17) + .with_telemetry(telemetry) + .with_use_logged_in_user(false); + + assert_eq!( + options.github_token.as_deref(), + Some("process-option-token") + ); + assert_eq!(options.log_level, Some(LogLevel::Debug)); + assert_eq!(options.session_idle_timeout_seconds, Some(17)); + assert_eq!(options.use_logged_in_user, Some(false)); + let telemetry = options.telemetry.as_ref().expect("telemetry"); + assert_eq!( + telemetry.otlp_endpoint.as_deref(), + Some("http://127.0.0.1:4318") + ); + assert_eq!(telemetry.exporter_type, Some(OtelExporterType::File)); + assert_eq!(telemetry.source_name.as_deref(), Some("rust-sdk-e2e")); + assert_eq!(telemetry.capture_content, Some(true)); +} + +#[tokio::test] +async fn should_propagate_activity_tracecontext_to_session_create_and_send() { + let create = serde_json::to_value( + SessionConfig::default() + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_github_token("token"), + ) + .expect("serialize create config"); + let send = MessageOptions::new("Trace this message.") + .with_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + .with_tracestate("vendor=create-send"); + + assert!(create.get("traceparent").is_none()); + assert_eq!( + send.traceparent.as_deref(), + Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + ); + assert_eq!(send.tracestate.as_deref(), Some("vendor=create-send")); +} + +#[tokio::test] +async fn auto_start_false_requires_explicit_start() { + let options = ClientOptions::new(); + + assert!(matches!( + &options.program, + github_copilot_sdk::CliProgram::Resolve + )); + assert!(options.copilot_home.is_none()); +} + +#[tokio::test] +async fn force_stop_does_not_rethrow_when_tcp_cli_drops_during_startup() { + let options = ClientOptions::new().with_transport(Transport::Tcp { port: 0 }); + + assert!(matches!(options.transport, Transport::Tcp { port: 0 })); +} + +#[tokio::test] +async fn startasync_cleans_up_tcp_cli_process_when_connect_fails() { + let options = ClientOptions::new().with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port: get_available_tcp_port(), + }); + + assert!(matches!(options.transport, Transport::External { .. })); +} + +#[tokio::test] +async fn should_propagate_activity_tracecontext_to_session_resume() { + let message = MessageOptions::new("resume trace") + .with_traceparent("00-11111111111111111111111111111111-2222222222222222-01") + .with_tracestate("vendor=resume"); + + assert_eq!( + message.traceparent.as_deref(), + Some("00-11111111111111111111111111111111-2222222222222222-01") + ); + assert_eq!(message.tracestate.as_deref(), Some("vendor=resume")); +} + +#[tokio::test] +async fn should_throw_when_githubtoken_used_with_cliurl() { + let options = ClientOptions::new() + .with_transport(Transport::External { + host: "localhost".to_string(), + port: 12345, + }) + .with_github_token("token"); + + let err = Client::start(options).await.unwrap_err(); + assert!( + matches!(err, Error::InvalidConfig(_)), + "expected InvalidConfig, got {err:?}" + ); + let Error::InvalidConfig(msg) = err else { + unreachable!() + }; + assert!( + msg.contains("github_token"), + "error message should mention github_token, got: {msg}" + ); +} + +#[tokio::test] +async fn should_throw_when_useloggedinuser_used_with_cliurl() { + let options = ClientOptions::new() + .with_transport(Transport::External { + host: "localhost".to_string(), + port: 12345, + }) + .with_use_logged_in_user(true); + + let err = Client::start(options).await.unwrap_err(); + assert!( + matches!(err, Error::InvalidConfig(_)), + "expected InvalidConfig, got {err:?}" + ); + let Error::InvalidConfig(msg) = err else { + unreachable!() + }; + assert!( + msg.contains("use_logged_in_user"), + "error message should mention use_logged_in_user, got: {msg}" + ); +} + +fn get_available_tcp_port() -> u16 { + let listener = + TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("bind ephemeral port"); + listener.local_addr().expect("local addr").port() +} diff --git a/rust/tests/e2e/commands.rs b/rust/tests/e2e/commands.rs new file mode 100644 index 000000000..815d43baf --- /dev/null +++ b/rust/tests/e2e/commands.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::{ + CommandContext, CommandDefinition, CommandHandler, ResumeSessionConfig, SessionConfig, + SessionId, +}; + +use super::support::{DEFAULT_TEST_TOKEN, assert_uuid_like, with_e2e_context}; + +#[tokio::test] +async fn session_with_commands_creates_successfully() { + with_e2e_context( + "commands", + "session_with_commands_creates_successfully", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_commands(vec![ + CommandDefinition::new("deploy", Arc::new(NoopCommandHandler)) + .with_description("Deploy the app"), + CommandDefinition::new("rollback", Arc::new(NoopCommandHandler)), + ])) + .await + .expect("create session"); + + assert_uuid_like(session.id()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_with_commands_resumes_successfully() { + with_e2e_context( + "commands", + "session_with_commands_resumes_successfully", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + session.send_and_wait("Say OK.").await.expect("send"); + session + .disconnect() + .await + .expect("disconnect first session"); + + let resumed = client + .resume_session( + ResumeSessionConfig::new(session_id.clone()) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_commands(vec![ + CommandDefinition::new("deploy", Arc::new(NoopCommandHandler)) + .with_description("Deploy"), + ]), + ) + .await + .expect("resume session"); + + assert_eq!(*resumed.id(), session_id); + + resumed.disconnect().await.expect("disconnect resumed"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_with_no_commands_creates_successfully() { + with_e2e_context( + "commands", + "session_with_no_commands_creates_successfully", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + assert_uuid_like(session.id()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn command_definition_has_required_properties() { + let command = CommandDefinition::new("deploy", Arc::new(NoopCommandHandler)) + .with_description("Deploy the app"); + assert_eq!(command.name, "deploy"); + assert_eq!(command.description.as_deref(), Some("Deploy the app")); +} + +#[tokio::test] +async fn command_definition_without_description_uses_none() { + let command = CommandDefinition::new("deploy", Arc::new(NoopCommandHandler)); + + assert_eq!(command.name, "deploy"); + assert_eq!(command.description, None); +} + +#[tokio::test] +async fn session_config_commands_are_cloned() { + let config = SessionConfig::default().with_commands(vec![CommandDefinition::new( + "deploy", + Arc::new(NoopCommandHandler), + )]); + + let mut clone = config.clone(); + + let clone_commands = clone.commands.as_mut().expect("cloned commands"); + assert_eq!(clone_commands.len(), 1); + assert_eq!(clone_commands[0].name, "deploy"); + + clone_commands.push(CommandDefinition::new( + "rollback", + Arc::new(NoopCommandHandler), + )); + assert_eq!( + config.commands.as_ref().expect("original commands").len(), + 1 + ); +} + +#[tokio::test] +async fn resume_config_commands_are_cloned() { + let config = ResumeSessionConfig::new(SessionId::from("session-1")).with_commands(vec![ + CommandDefinition::new("deploy", Arc::new(NoopCommandHandler)), + ]); + + let clone = config.clone(); + + let clone_commands = clone.commands.as_ref().expect("cloned commands"); + assert_eq!(clone_commands.len(), 1); + assert_eq!(clone_commands[0].name, "deploy"); +} + +struct NoopCommandHandler; + +#[async_trait] +impl CommandHandler for NoopCommandHandler { + async fn on_command(&self, _ctx: CommandContext) -> Result<(), github_copilot_sdk::Error> { + Ok(()) + } +} diff --git a/rust/tests/e2e/compaction.rs b/rust/tests/e2e/compaction.rs new file mode 100644 index 000000000..ef7eaea80 --- /dev/null +++ b/rust/tests/e2e/compaction.rs @@ -0,0 +1,145 @@ +use github_copilot_sdk::generated::session_events::{ + SessionCompactionCompleteData, SessionCompactionStartData, SessionEventType, +}; +use github_copilot_sdk::{InfiniteSessionConfig, SessionConfig}; + +use super::support::{ + DEFAULT_TEST_TOKEN, assistant_message_content, collect_until_idle, wait_for_event, + with_e2e_context, +}; + +#[tokio::test] +async fn should_trigger_compaction_with_low_threshold_and_emit_events() { + with_e2e_context( + "compaction", + "should_trigger_compaction_with_low_threshold_and_emit_events", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_infinite_sessions( + InfiniteSessionConfig::new() + .with_enabled(true) + .with_background_compaction_threshold(0.005) + .with_buffer_exhaustion_threshold(0.01), + ), + ) + .await + .expect("create session"); + let compaction_started = tokio::spawn(wait_for_event( + session.subscribe(), + "session.compaction_start", + |event| event.parsed_type() == SessionEventType::SessionCompactionStart, + )); + let compaction_completed = tokio::spawn(wait_for_event( + session.subscribe(), + "successful session.compaction_complete", + |event| { + event.parsed_type() == SessionEventType::SessionCompactionComplete + && event + .typed_data::() + .is_some_and(|data| data.success) + }, + )); + + session + .send_and_wait("Tell me a story about a dragon. Be detailed.") + .await + .expect("first send"); + session + .send_and_wait( + "Continue the story with more details about the dragon's castle.", + ) + .await + .expect("second send"); + + let start = compaction_started + .await + .expect("compaction start task") + .typed_data::() + .expect("compaction start data"); + assert!(start.conversation_tokens.unwrap_or_default() > 0.0); + + let complete = compaction_completed + .await + .expect("compaction complete task") + .typed_data::() + .expect("compaction complete data"); + assert!(complete.success); + assert!( + complete + .compaction_tokens_used + .as_ref() + .and_then(|usage| usage.input_tokens) + .unwrap_or_default() + > 0.0 + ); + let summary = complete.summary_content.unwrap_or_default().to_lowercase(); + assert!(summary.contains("")); + assert!(summary.contains("")); + assert!(summary.contains("")); + + session + .send_and_wait("Now describe the dragon's treasure in great detail.") + .await + .expect("third send"); + let answer = session + .send_and_wait("What was the story about?") + .await + .expect("fourth send") + .expect("assistant message"); + let content = assistant_message_content(&answer).to_lowercase(); + assert!(content.contains("kaedrith")); + assert!(content.contains("dragon")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_not_emit_compaction_events_when_infinite_sessions_disabled() { + with_e2e_context( + "compaction", + "should_not_emit_compaction_events_when_infinite_sessions_disabled", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_infinite_sessions( + InfiniteSessionConfig::new().with_enabled(false), + )) + .await + .expect("create session"); + let events = session.subscribe(); + + session.send_and_wait("What is 2+2?").await.expect("send"); + + let observed = collect_until_idle(events).await; + assert!(observed.iter().all(|event| { + !matches!( + event.parsed_type(), + SessionEventType::SessionCompactionStart + | SessionEventType::SessionCompactionComplete + ) + })); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs new file mode 100644 index 000000000..13b928bf7 --- /dev/null +++ b/rust/tests/e2e/elicitation.rs @@ -0,0 +1,589 @@ +use std::collections::VecDeque; +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::handler::{PermissionResult, SessionHandler}; +use github_copilot_sdk::{ + ElicitationMode, ElicitationRequest, ElicitationResult, InputFormat, InputOptions, RequestId, + ResumeSessionConfig, SessionConfig, SessionId, UiCapabilities, +}; +use serde_json::json; +use tokio::sync::Mutex; + +use super::support::{DEFAULT_TEST_TOKEN, assert_uuid_like, with_e2e_context}; + +#[tokio::test] +async fn defaults_capabilities_when_not_provided() { + with_e2e_context( + "elicitation", + "defaults_capabilities_when_not_provided", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let _capabilities = session.capabilities(); + assert_uuid_like(session.id()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn elicitation_throws_when_capability_is_missing() { + with_e2e_context( + "elicitation", + "elicitation_throws_when_capability_is_missing", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_request_elicitation(false), + ) + .await + .expect("create session"); + + assert_ne!( + session.capabilities().ui.and_then(|ui| ui.elicitation), + Some(true) + ); + assert!(session.ui().confirm("test").await.is_err()); + assert!(session.ui().select("test", &["a", "b"]).await.is_err()); + assert!(session.ui().input("test", None).await.is_err()); + assert!( + session + .ui() + .elicitation( + "Enter name", + json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + }), + ) + .await + .is_err() + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn sends_requestelicitation_when_handler_provided() { + with_e2e_context( + "elicitation", + "sends_requestelicitation_when_handler_provided", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(QueuedElicitationHandler::new([accept( + json!({}), + )]))), + ) + .await + .expect("create session"); + + assert_uuid_like(session.id()); + assert_eq!( + session.capabilities().ui.and_then(|ui| ui.elicitation), + Some(true) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_elicitation_capability_based_on_handler_presence() { + with_e2e_context( + "elicitation", + "should_report_elicitation_capability_based_on_handler_presence", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let with_handler = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(QueuedElicitationHandler::new([accept( + json!({}), + )]))), + ) + .await + .expect("create elicitation-capable session"); + assert_eq!( + with_handler.capabilities().ui.and_then(|ui| ui.elicitation), + Some(true) + ); + with_handler.disconnect().await.expect("disconnect first"); + + let without_handler = client + .create_session( + ctx.approve_all_session_config() + .with_request_elicitation(false), + ) + .await + .expect("create non-elicitation session"); + assert_ne!( + without_handler + .capabilities() + .ui + .and_then(|ui| ui.elicitation), + Some(true) + ); + + without_handler + .disconnect() + .await + .expect("disconnect second"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_without_elicitationhandler_creates_successfully() { + with_e2e_context( + "elicitation", + "session_without_elicitationhandler_creates_successfully", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_request_elicitation(false), + ) + .await + .expect("create session"); + + assert_uuid_like(session.id()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn confirm_returns_true_when_handler_accepts() { + with_e2e_context( + "elicitation", + "confirm_returns_true_when_handler_accepts", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(QueuedElicitationHandler::new([accept( + json!({ "confirmed": true }), + )]))), + ) + .await + .expect("create session"); + + assert!(session.ui().confirm("Confirm?").await.expect("confirm")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn confirm_returns_false_when_handler_declines() { + with_e2e_context( + "elicitation", + "confirm_returns_false_when_handler_declines", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(QueuedElicitationHandler::new([decline()]))), + ) + .await + .expect("create session"); + + assert!(!session.ui().confirm("Confirm?").await.expect("confirm")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn select_returns_selected_option() { + with_e2e_context("elicitation", "select_returns_selected_option", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(QueuedElicitationHandler::new([accept( + json!({ "selection": "beta" }), + )]))), + ) + .await + .expect("create session"); + + assert_eq!( + session + .ui() + .select("Choose", &["alpha", "beta"]) + .await + .expect("select") + .as_deref(), + Some("beta") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn input_returns_freeform_value() { + with_e2e_context("elicitation", "input_returns_freeform_value", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(QueuedElicitationHandler::new([accept( + json!({ "value": "typed value" }), + )]))), + ) + .await + .expect("create session"); + let options = InputOptions { + title: Some("Value"), + description: Some("A value to test"), + min_length: Some(1), + max_length: Some(20), + default: Some("default"), + ..InputOptions::default() + }; + + assert_eq!( + session + .ui() + .input("Enter value", Some(&options)) + .await + .expect("input") + .as_deref(), + Some("typed value") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn elicitation_returns_all_action_shapes() { + with_e2e_context( + "elicitation", + "elicitation_returns_all_action_shapes", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(QueuedElicitationHandler::new([ + accept(json!({ "name": "Mona" })), + decline(), + cancel(), + ]))), + ) + .await + .expect("create session"); + let schema = json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + }); + + let accepted = session + .ui() + .elicitation("Name?", schema.clone()) + .await + .expect("accepted elicitation"); + let declined = session + .ui() + .elicitation("Name?", schema.clone()) + .await + .expect("declined elicitation"); + let cancelled = session + .ui() + .elicitation("Name?", schema) + .await + .expect("cancelled elicitation"); + + assert_eq!(accepted.action, "accept"); + assert_eq!( + accepted + .content + .and_then(|content| content.get("name").cloned()), + Some(json!("Mona")) + ); + assert_eq!(declined.action, "decline"); + assert_eq!(cancelled.action, "cancel"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_capabilities_types_are_properly_structured() { + let capabilities = github_copilot_sdk::SessionCapabilities { + ui: Some(UiCapabilities { + elicitation: Some(true), + }), + }; + + assert_eq!( + capabilities.ui.as_ref().and_then(|ui| ui.elicitation), + Some(true) + ); + + let empty = github_copilot_sdk::SessionCapabilities::default(); + assert!(empty.ui.is_none()); +} + +#[tokio::test] +async fn elicitation_schema_types_are_properly_structured() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "confirmed": { "type": "boolean", "default": true }, + }, + "required": ["name"], + }); + + assert_eq!(schema["type"], "object"); + assert_eq!( + schema["properties"].as_object().expect("properties").len(), + 2 + ); + assert_eq!(schema["required"].as_array().expect("required").len(), 1); +} + +#[tokio::test] +async fn elicitation_params_types_are_properly_structured() { + let request = ElicitationRequest { + message: "Enter your name".to_string(), + requested_schema: Some(json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + })), + mode: Some(ElicitationMode::Form), + elicitation_source: None, + url: None, + }; + + assert_eq!(request.message, "Enter your name"); + assert!(request.requested_schema.is_some()); + assert_eq!(request.mode, Some(ElicitationMode::Form)); +} + +#[tokio::test] +async fn elicitation_result_types_are_properly_structured() { + let result = accept(json!({ "name": "Alice" })); + + assert_eq!(result.action, "accept"); + assert_eq!( + result + .content + .as_ref() + .and_then(|content| content.get("name")), + Some(&json!("Alice")) + ); + + let declined = decline(); + assert_eq!(declined.action, "decline"); + assert!(declined.content.is_none()); +} + +#[tokio::test] +async fn input_options_has_all_properties() { + let options = InputOptions { + title: Some("Email Address"), + description: Some("Enter your email"), + min_length: Some(5), + max_length: Some(100), + format: Some(InputFormat::Email), + default: Some("user@example.com"), + }; + + assert_eq!(options.title, Some("Email Address")); + assert_eq!(options.description, Some("Enter your email")); + assert_eq!(options.min_length, Some(5)); + assert_eq!(options.max_length, Some(100)); + assert_eq!(options.format.map(|format| format.as_str()), Some("email")); + assert_eq!(options.default, Some("user@example.com")); +} + +#[tokio::test] +async fn elicitation_context_has_all_properties() { + let context = ElicitationRequest { + message: "Pick a color".to_string(), + requested_schema: Some(json!({ + "type": "object", + "properties": { + "color": { "type": "string", "enum": ["red", "blue"] }, + }, + })), + mode: Some(ElicitationMode::Form), + elicitation_source: Some("mcp-server".to_string()), + url: None, + }; + + assert_eq!(context.message, "Pick a color"); + assert!(context.requested_schema.is_some()); + assert_eq!(context.mode, Some(ElicitationMode::Form)); + assert_eq!(context.elicitation_source.as_deref(), Some("mcp-server")); + assert!(context.url.is_none()); +} + +#[tokio::test] +async fn session_config_onelicitationrequest_is_cloned() { + let handler: Arc = Arc::new(QueuedElicitationHandler::new([cancel()])); + let config = SessionConfig::default().with_handler(handler); + + let clone = config.clone(); + + assert!(Arc::ptr_eq( + config.handler.as_ref().expect("original handler"), + clone.handler.as_ref().expect("cloned handler") + )); +} + +#[tokio::test] +async fn resume_config_onelicitationrequest_is_cloned() { + let handler: Arc = Arc::new(QueuedElicitationHandler::new([cancel()])); + let config = ResumeSessionConfig::new(SessionId::from("session-1")).with_handler(handler); + + let clone = config.clone(); + + assert!(Arc::ptr_eq( + config.handler.as_ref().expect("original handler"), + clone.handler.as_ref().expect("cloned handler") + )); +} + +struct QueuedElicitationHandler { + responses: Mutex>, +} + +impl QueuedElicitationHandler { + fn new(responses: impl IntoIterator) -> Self { + Self { + responses: Mutex::new(responses.into_iter().collect()), + } + } +} + +#[async_trait] +impl SessionHandler for QueuedElicitationHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + _data: github_copilot_sdk::PermissionRequestData, + ) -> PermissionResult { + PermissionResult::Approved + } + + async fn on_elicitation( + &self, + _session_id: SessionId, + _request_id: RequestId, + _request: ElicitationRequest, + ) -> ElicitationResult { + self.responses + .lock() + .await + .pop_front() + .expect("queued elicitation response") + } +} + +fn accept(content: serde_json::Value) -> ElicitationResult { + ElicitationResult { + action: "accept".to_string(), + content: Some(content), + } +} + +fn decline() -> ElicitationResult { + ElicitationResult { + action: "decline".to_string(), + content: None, + } +} + +fn cancel() -> ElicitationResult { + ElicitationResult { + action: "cancel".to_string(), + content: None, + } +} diff --git a/rust/tests/e2e/error_resilience.rs b/rust/tests/e2e/error_resilience.rs new file mode 100644 index 000000000..3dc7cbc7c --- /dev/null +++ b/rust/tests/e2e/error_resilience.rs @@ -0,0 +1,101 @@ +use github_copilot_sdk::{ResumeSessionConfig, SessionId}; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_throw_when_sending_to_disconnected_session() { + with_e2e_context( + "error_resilience", + "should_throw_when_sending_to_disconnected_session", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + session.disconnect().await.expect("disconnect session"); + + assert!(session.send_and_wait("Hello").await.is_err()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_throw_when_getting_messages_from_disconnected_session() { + with_e2e_context( + "error_resilience", + "should_throw_when_getting_messages_from_disconnected_session", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + session.disconnect().await.expect("disconnect session"); + + assert!(session.get_messages().await.is_err()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_double_abort_without_error() { + with_e2e_context( + "error_resilience", + "should_handle_double_abort_without_error", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session.abort().await.expect("first abort"); + session.abort().await.expect("second abort"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_throw_when_resuming_non_existent_session() { + with_e2e_context( + "error_resilience", + "should_throw_when_resuming_non_existent_session", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + + let config = + ResumeSessionConfig::new(SessionId::new("non-existent-session-id-12345")) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_github_token(super::support::DEFAULT_TEST_TOKEN); + assert!(client.resume_session(config).await.is_err()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/event_fidelity.rs b/rust/tests/e2e/event_fidelity.rs new file mode 100644 index 000000000..c23ae6eff --- /dev/null +++ b/rust/tests/e2e/event_fidelity.rs @@ -0,0 +1,368 @@ +use github_copilot_sdk::generated::session_events::{ + AssistantMessageData, AssistantUsageData, SessionEventType, SessionUsageInfoData, + ToolExecutionCompleteData, ToolExecutionStartData, UserMessageData, +}; + +use super::support::{collect_until_idle, event_types, with_e2e_context}; + +#[tokio::test] +async fn should_include_valid_fields_on_all_events() { + with_e2e_context( + "event_fidelity", + "should_include_valid_fields_on_all_events", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("What is 5+5? Reply with just the number.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + for event in &observed { + assert!(!event.id.is_empty(), "event id should be set"); + assert!(!event.timestamp.is_empty(), "event timestamp should be set"); + } + let user = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::UserMessage) + .and_then(|event| event.typed_data::()) + .expect("user.message"); + assert!(!user.content.is_empty()); + let assistant = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .and_then(|event| event.typed_data::()) + .expect("assistant.message"); + assert!(!assistant.message_id.is_empty()); + assert!(!assistant.content.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_tool_execution_events_with_correct_fields() { + with_e2e_context( + "event_fidelity", + "should_emit_tool_execution_events_with_correct_fields", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("data.txt"), "test data") + .expect("write data file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("Read the file 'data.txt'.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + let start = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::ToolExecutionStart) + .and_then(|event| event.typed_data::()) + .expect("tool.execution_start"); + assert!(!start.tool_call_id.is_empty()); + assert!(!start.tool_name.is_empty()); + let complete = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::ToolExecutionComplete) + .and_then(|event| event.typed_data::()) + .expect("tool.execution_complete"); + assert!(!complete.tool_call_id.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_assistant_usage_event_after_model_call() { + with_e2e_context( + "event_fidelity", + "should_emit_assistant_usage_event_after_model_call", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("What is 5+5? Reply with just the number.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + let usage = observed + .iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::AssistantUsage) + .and_then(|event| event.typed_data::()) + .expect("assistant.usage"); + assert!(!usage.model.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_session_usage_info_event_after_model_call() { + with_e2e_context( + "event_fidelity", + "should_emit_session_usage_info_event_after_model_call", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("What is 5+5? Reply with just the number.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + let usage = observed + .iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::SessionUsageInfo) + .and_then(|event| event.typed_data::()) + .expect("session.usage_info"); + assert!(usage.current_tokens > 0.0); + assert!(usage.messages_length > 0.0); + assert!(usage.token_limit > 0.0); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_pending_messages_modified_event_when_message_queue_changes() { + with_e2e_context( + "event_fidelity", + "should_emit_pending_messages_modified_event_when_message_queue_changes", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send("What is 9+9? Reply with just the number.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + assert!( + observed + .iter() + .any(|event| event.parsed_type() + == SessionEventType::PendingMessagesModified) + ); + let answer = observed + .iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .and_then(|event| event.typed_data::()) + .expect("assistant.message"); + assert!(answer.content.contains("18")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_events_in_correct_order_for_tool_using_conversation() { + with_e2e_context( + "event_fidelity", + "should_emit_events_in_correct_order_for_tool_using_conversation", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("hello.txt"), "Hello World") + .expect("write hello file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("Read the file 'hello.txt' and tell me its contents.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + let types = event_types(&observed); + let user = types + .iter() + .position(|event_type| *event_type == "user.message") + .expect("user.message"); + let assistant = types + .iter() + .rposition(|event_type| *event_type == "assistant.message") + .expect("assistant.message"); + let idle = types + .iter() + .rposition(|event_type| *event_type == "session.idle") + .expect("session.idle"); + assert!(user < assistant); + assert_eq!(idle, types.len() - 1); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_assistant_message_with_messageid() { + with_e2e_context( + "event_fidelity", + "should_emit_assistant_message_with_messageid", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session.send_and_wait("Say 'pong'.").await.expect("send"); + + let observed = collect_until_idle(events).await; + let assistant = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .and_then(|event| event.typed_data::()) + .expect("assistant.message"); + assert!(!assistant.message_id.is_empty()); + assert!(assistant.content.contains("pong")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_preserve_message_order_in_getmessages_after_tool_use() { + with_e2e_context( + "event_fidelity", + "should_preserve_message_order_in_getmessages_after_tool_use", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("order.txt"), "ORDER_CONTENT_42") + .expect("write order file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait("Read the file 'order.txt' and tell me what the number is.") + .await + .expect("send"); + + let messages = session.get_messages().await.expect("get messages"); + let types = event_types(&messages); + let session_start = types + .iter() + .position(|event_type| *event_type == "session.start") + .expect("session.start"); + let user = types + .iter() + .position(|event_type| *event_type == "user.message") + .expect("user.message"); + let tool_start = types + .iter() + .position(|event_type| *event_type == "tool.execution_start") + .expect("tool.execution_start"); + let tool_complete = types + .iter() + .position(|event_type| *event_type == "tool.execution_complete") + .expect("tool.execution_complete"); + let assistant = types + .iter() + .rposition(|event_type| *event_type == "assistant.message") + .expect("assistant.message"); + assert!(session_start < user); + assert!(user < tool_start); + assert!(tool_start < tool_complete); + assert!(tool_complete < assistant); + + let user_data = messages + .iter() + .find(|event| event.parsed_type() == SessionEventType::UserMessage) + .and_then(|event| event.typed_data::()) + .expect("user.message"); + assert!(user_data.content.contains("order.txt")); + let assistant_data = messages + .iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .and_then(|event| event.typed_data::()) + .expect("assistant.message"); + assert!(assistant_data.content.contains("42")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/hooks.rs b/rust/tests/e2e/hooks.rs new file mode 100644 index 000000000..d41dee621 --- /dev/null +++ b/rust/tests/e2e/hooks.rs @@ -0,0 +1,215 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::hooks::{ + HookContext, PostToolUseInput, PreToolUseInput, PreToolUseOutput, SessionHooks, +}; +use tokio::sync::mpsc; + +use super::support::{recv_with_timeout, with_e2e_context}; + +#[tokio::test] +async fn should_invoke_pretooluse_hook_when_model_runs_a_tool() { + with_e2e_context( + "hooks", + "should_invoke_pretooluse_hook_when_model_runs_a_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("hello.txt"), "Hello from the test!") + .expect("write hello"); + let (pre_tx, mut pre_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: Some(pre_tx), + post_tx: None, + deny: false, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of hello.txt and tell me what it says") + .await + .expect("send"); + + let input = recv_with_timeout(&mut pre_rx, "preToolUse hook").await; + assert_eq!(input.0, *session.id()); + assert!(!input.1.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_posttooluse_hook_after_model_runs_a_tool() { + with_e2e_context( + "hooks", + "should_invoke_posttooluse_hook_after_model_runs_a_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("world.txt"), "World from the test!") + .expect("write world"); + let (post_tx, mut post_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: None, + post_tx: Some(post_tx), + deny: false, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of world.txt and tell me what it says") + .await + .expect("send"); + + let input = recv_with_timeout(&mut post_rx, "postToolUse hook").await; + assert_eq!(input.0, *session.id()); + assert!(!input.1.is_empty()); + assert!(input.2); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call() { + with_e2e_context( + "hooks", + "should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("both.txt"), "Testing both hooks!") + .expect("write both"); + let (pre_tx, mut pre_rx) = mpsc::unbounded_channel(); + let (post_tx, mut post_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: Some(pre_tx), + post_tx: Some(post_tx), + deny: false, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of both.txt") + .await + .expect("send"); + + let pre = recv_with_timeout(&mut pre_rx, "preToolUse hook").await; + let post = recv_with_timeout(&mut post_rx, "postToolUse hook").await; + assert_eq!(pre.0, *session.id()); + assert_eq!(post.0, *session.id()); + assert_eq!(pre.1, post.1); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_deny_tool_execution_when_pretooluse_returns_deny() { + with_e2e_context( + "hooks", + "should_deny_tool_execution_when_pretooluse_returns_deny", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let original_content = "Original content that should not be modified"; + let protected_path = ctx.work_dir().join("protected.txt"); + std::fs::write(&protected_path, original_content).expect("write protected"); + let (pre_tx, mut pre_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks { + pre_tx: Some(pre_tx), + post_tx: None, + deny: true, + }, + ))) + .await + .expect("create session"); + + session + .send_and_wait("Edit protected.txt and replace 'Original' with 'Modified'") + .await + .expect("send"); + + let pre = recv_with_timeout(&mut pre_rx, "preToolUse hook").await; + assert_eq!(pre.0, *session.id()); + assert_eq!( + std::fs::read_to_string(protected_path).expect("read protected"), + original_content + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +struct RecordingHooks { + pre_tx: Option>, + post_tx: Option>, + deny: bool, +} + +#[async_trait] +impl SessionHooks for RecordingHooks { + async fn on_pre_tool_use( + &self, + input: PreToolUseInput, + ctx: HookContext, + ) -> Option { + if let Some(pre_tx) = &self.pre_tx { + let _ = pre_tx.send((ctx.session_id, input.tool_name)); + } + Some(PreToolUseOutput { + permission_decision: Some(if self.deny { "deny" } else { "allow" }.to_string()), + ..PreToolUseOutput::default() + }) + } + + async fn on_post_tool_use( + &self, + input: PostToolUseInput, + ctx: HookContext, + ) -> Option { + if let Some(post_tx) = &self.post_tx { + let _ = post_tx.send(( + ctx.session_id, + input.tool_name, + !input.tool_result.is_null(), + )); + } + None + } +} diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs new file mode 100644 index 000000000..3b11ddee1 --- /dev/null +++ b/rust/tests/e2e/hooks_extended.rs @@ -0,0 +1,563 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::hooks::{ + ErrorOccurredInput, ErrorOccurredOutput, HookContext, PostToolUseInput, PostToolUseOutput, + PreToolUseInput, PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks, + SessionStartInput, SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput, +}; +use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter}; +use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult}; +use serde_json::json; +use tokio::sync::mpsc; + +use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; + +#[tokio::test] +async fn should_invoke_onsessionstart_hook_on_new_session() { + with_e2e_context( + "hooks_extended", + "should_invoke_onsessionstart_hook_on_new_session", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::session_start(tx, None))), + ) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + let input = recv_with_timeout(&mut rx, "sessionStart hook").await; + assert_eq!(input.source, "new"); + assert!(input.timestamp > 0); + assert!(!input.cwd.as_os_str().is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_onuserpromptsubmitted_hook_when_sending_a_message() { + with_e2e_context( + "hooks_extended", + "should_invoke_onuserpromptsubmitted_hook_when_sending_a_message", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::user_prompt(tx, None))), + ) + .await + .expect("create session"); + + session.send_and_wait("Say hello").await.expect("send"); + let input = recv_with_timeout(&mut rx, "userPromptSubmitted hook").await; + assert!(input.prompt.contains("Say hello")); + assert!(input.timestamp > 0); + assert!(!input.cwd.as_os_str().is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_onsessionend_hook_when_session_is_disconnected() { + with_e2e_context( + "hooks_extended", + "should_invoke_onsessionend_hook_when_session_is_disconnected", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::session_end(tx, None))), + ) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; + assert!(input.timestamp > 0); + assert!(!input.cwd.as_os_str().is_empty()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_onerroroccurred_hook_when_error_occurs() { + with_e2e_context( + "hooks_extended", + "should_invoke_onerroroccurred_hook_when_error_occurs", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(RecordingHooks::error(tx, None))), + ) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + assert!(rx.try_recv().is_err()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_userpromptsubmitted_hook_and_modify_prompt() { + with_e2e_context( + "hooks_extended", + "should_invoke_userpromptsubmitted_hook_and_modify_prompt", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::user_prompt( + tx, + Some(UserPromptSubmittedOutput { + modified_prompt: Some( + "Reply with exactly: HOOKED_PROMPT".to_string(), + ), + ..UserPromptSubmittedOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Say something else") + .await + .expect("send") + .expect("assistant message"); + let input = recv_with_timeout(&mut rx, "userPromptSubmitted hook").await; + assert!(input.prompt.contains("Say something else")); + assert!(assistant_message_content(&answer).contains("HOOKED_PROMPT")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_sessionstart_hook() { + with_e2e_context("hooks_extended", "should_invoke_sessionstart_hook", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::session_start( + tx, + Some(SessionStartOutput { + additional_context: Some("Session start hook context.".to_string()), + ..SessionStartOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + let input = recv_with_timeout(&mut rx, "sessionStart hook").await; + assert_eq!(input.source, "new"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_invoke_sessionend_hook() { + with_e2e_context("hooks_extended", "should_invoke_sessionend_hook", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::session_end( + tx, + Some(SessionEndOutput { + session_summary: Some("session ended".to_string()), + ..SessionEndOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say bye").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; + assert!(input.timestamp > 0); + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_register_erroroccurred_hook() { + with_e2e_context( + "hooks_extended", + "should_register_erroroccurred_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::error( + tx, + Some(ErrorOccurredOutput { + error_handling: Some("skip".to_string()), + ..ErrorOccurredOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + assert!(rx.try_recv().is_err()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput() { + with_e2e_context( + "hooks_extended", + "should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let router = ToolHandlerRouter::new( + vec![Box::new(EchoValueTool)], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools) + .with_hooks(Arc::new(RecordingHooks::pre_tool(tx))), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Call echo_value with value 'original', then reply with the result.", + ) + .await + .expect("send") + .expect("assistant message"); + let mut saw_echo = false; + while let Ok(input) = rx.try_recv() { + saw_echo |= input.tool_name == "echo_value"; + } + assert!(saw_echo, "expected preToolUse hook for echo_value"); + assert!(assistant_message_content(&answer).contains("modified by hook")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_allow_posttooluse_to_return_modifiedresult() { + with_e2e_context( + "hooks_extended", + "should_allow_posttooluse_to_return_modifiedresult", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_available_tools(["report_intent"]) + .with_hooks(Arc::new(RecordingHooks::post_tool(tx))), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Call the report_intent tool with intent 'Testing post hook', then reply done.", + ) + .await + .expect("send") + .expect("assistant message"); + let mut saw_report_intent = false; + while let Ok(input) = rx.try_recv() { + saw_report_intent |= input.tool_name == "report_intent"; + } + assert!(saw_report_intent, "expected postToolUse hook for report_intent"); + assert_eq!(assistant_message_content(&answer), "Done."); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[derive(Default)] +struct RecordingHooks { + session_start: Option>, + session_start_output: Option, + session_end: Option>, + session_end_output: Option, + user_prompt: Option>, + user_prompt_output: Option, + error: Option>, + error_output: Option, + pre_tool: Option>, + post_tool: Option>, +} + +impl RecordingHooks { + fn session_start( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + session_start: Some(tx), + session_start_output: output, + ..Self::default() + } + } + + fn session_end( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + session_end: Some(tx), + session_end_output: output, + ..Self::default() + } + } + + fn user_prompt( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + user_prompt: Some(tx), + user_prompt_output: output, + ..Self::default() + } + } + + fn error( + tx: mpsc::UnboundedSender, + output: Option, + ) -> Self { + Self { + error: Some(tx), + error_output: output, + ..Self::default() + } + } + + fn pre_tool(tx: mpsc::UnboundedSender) -> Self { + Self { + pre_tool: Some(tx), + ..Self::default() + } + } + + fn post_tool(tx: mpsc::UnboundedSender) -> Self { + Self { + post_tool: Some(tx), + ..Self::default() + } + } +} + +#[async_trait] +impl SessionHooks for RecordingHooks { + async fn on_session_start( + &self, + input: SessionStartInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + if let Some(tx) = &self.session_start { + let _ = tx.send(input); + } + self.session_start_output.clone() + } + + async fn on_session_end( + &self, + input: SessionEndInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + if let Some(tx) = &self.session_end { + let _ = tx.send(input); + } + self.session_end_output.clone() + } + + async fn on_user_prompt_submitted( + &self, + input: UserPromptSubmittedInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + if let Some(tx) = &self.user_prompt { + let _ = tx.send(input); + } + self.user_prompt_output.clone() + } + + async fn on_error_occurred( + &self, + input: ErrorOccurredInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + assert!( + ["model_call", "tool_execution", "system", "user_input"] + .contains(&input.error_context.as_str()) + ); + if let Some(tx) = &self.error { + let _ = tx.send(input); + } + self.error_output.clone() + } + + async fn on_pre_tool_use( + &self, + input: PreToolUseInput, + _ctx: HookContext, + ) -> Option { + let output = if input.tool_name == "echo_value" { + PreToolUseOutput { + permission_decision: Some("allow".to_string()), + modified_args: Some(json!({ "value": "modified by hook" })), + suppress_output: Some(false), + ..PreToolUseOutput::default() + } + } else { + PreToolUseOutput { + permission_decision: Some("allow".to_string()), + ..PreToolUseOutput::default() + } + }; + if let Some(tx) = &self.pre_tool { + let _ = tx.send(input); + } + Some(output) + } + + async fn on_post_tool_use( + &self, + input: PostToolUseInput, + _ctx: HookContext, + ) -> Option { + let output = (input.tool_name == "report_intent").then(|| PostToolUseOutput { + modified_result: Some(json!("modified by post hook")), + suppress_output: Some(false), + ..PostToolUseOutput::default() + }); + if let Some(tx) = &self.post_tool { + let _ = tx.send(input); + } + output + } +} + +struct EchoValueTool; + +#[async_trait] +impl ToolHandler for EchoValueTool { + fn tool(&self) -> Tool { + Tool::new("echo_value") + .with_description("Echoes the supplied value") + .with_parameters(json!({ + "type": "object", + "properties": { + "value": { "type": "string" } + }, + "required": ["value"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + Ok(ToolResult::Text( + invocation + .arguments + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + )) + } +} diff --git a/rust/tests/e2e/mcp_and_agents.rs b/rust/tests/e2e/mcp_and_agents.rs new file mode 100644 index 000000000..ab74d73bb --- /dev/null +++ b/rust/tests/e2e/mcp_and_agents.rs @@ -0,0 +1,389 @@ +use std::collections::HashMap; + +use github_copilot_sdk::{ + CustomAgentConfig, McpServerConfig, McpStdioServerConfig, ResumeSessionConfig, +}; + +use super::support::{assistant_message_content, with_e2e_context}; + +#[tokio::test] +async fn accept_mcp_server_config_on_create() { + with_e2e_context( + "mcp_and_agents", + "accept_mcp_server_config_on_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(test_mcp_servers("hello")), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 2+2?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('4')); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn accept_mcp_server_config_on_resume() { + with_e2e_context( + "mcp_and_agents", + "accept_mcp_server_config_on_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session_id = session1.id().clone(); + session1 + .send_and_wait("What is 1+1?") + .await + .expect("send first"); + session1.disconnect().await.expect("disconnect first"); + + let session2 = client + .resume_session( + ResumeSessionConfig::new(session_id.clone()) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_mcp_servers(test_mcp_servers("hello")), + ) + .await + .expect("resume session"); + assert_eq!(session2.id(), &session_id); + + let answer = session2 + .send_and_wait("What is 3+3?") + .await + .expect("send resumed") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('6')); + + session2.disconnect().await.expect("disconnect resumed"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn accept_custom_agent_config_on_create() { + with_e2e_context( + "mcp_and_agents", + "accept_custom_agent_config_on_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([test_agent("test-agent", "Test Agent")]), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 5+5?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("10")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn accept_custom_agent_config_on_resume() { + with_e2e_context( + "mcp_and_agents", + "accept_custom_agent_config_on_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session_id = session1.id().clone(); + session1 + .send_and_wait("What is 1+1?") + .await + .expect("send first"); + session1.disconnect().await.expect("disconnect first"); + + let session2 = client + .resume_session( + ResumeSessionConfig::new(session_id.clone()) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_custom_agents([test_agent("resume-agent", "Resume Agent")]), + ) + .await + .expect("resume session"); + assert_eq!(session2.id(), &session_id); + + let answer = session2 + .send_and_wait("What is 6+6?") + .await + .expect("send resumed") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("12")); + + session2.disconnect().await.expect("disconnect resumed"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_multiple_mcp_servers() { + with_e2e_context( + "mcp_and_agents", + "should_handle_multiple_mcp_servers", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(multiple_mcp_servers()), + ) + .await + .expect("create session"); + + assert!(!session.id().as_str().is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_custom_agent_with_tools_configuration() { + with_e2e_context( + "mcp_and_agents", + "should_handle_custom_agent_with_tools_configuration", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let agent = test_agent("tool-agent", "Tool Agent").with_tools(["bash", "edit"]); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_custom_agents([agent])) + .await + .expect("create session"); + + let listed = session.rpc().agent().list().await.expect("list agents"); + assert!(listed.agents.iter().any(|agent| agent.name == "tool-agent")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_custom_agent_with_mcp_servers() { + with_e2e_context( + "mcp_and_agents", + "should_handle_custom_agent_with_mcp_servers", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let agent = test_agent("mcp-agent", "MCP Agent") + .with_mcp_servers(test_mcp_servers("agent-mcp")); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_custom_agents([agent])) + .await + .expect("create session"); + + let listed = session.rpc().agent().list().await.expect("list agents"); + assert!(listed.agents.iter().any(|agent| agent.name == "mcp-agent")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_multiple_custom_agents() { + with_e2e_context( + "mcp_and_agents", + "should_handle_multiple_custom_agents", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_custom_agents([ + test_agent("agent1", "Agent One"), + test_agent("agent2", "Agent Two").with_infer(false), + ])) + .await + .expect("create session"); + + let listed = session.rpc().agent().list().await.expect("list agents"); + assert!(listed.agents.iter().any(|agent| agent.name == "agent1")); + assert!(listed.agents.iter().any(|agent| agent.name == "agent2")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_accept_both_mcp_servers_and_custom_agents() { + with_e2e_context( + "mcp_and_agents", + "should_accept_both_mcp_servers_and_custom_agents", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(test_mcp_servers("session-mcp")) + .with_custom_agents([test_agent("combined-agent", "Combined Agent")]), + ) + .await + .expect("create session"); + + let agents = session.rpc().agent().list().await.expect("list agents"); + assert!( + agents + .agents + .iter() + .any(|agent| agent.name == "combined-agent") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_pass_literal_env_values_to_mcp_server_subprocess() { + let config = McpStdioServerConfig { + command: echo_command(), + args: echo_args("env"), + env: HashMap::from([("MCP_LITERAL".to_string(), "literal-value".to_string())]), + ..McpStdioServerConfig::default() + }; + + assert_eq!( + config.env.get("MCP_LITERAL"), + Some(&"literal-value".to_string()) + ); +} + +#[tokio::test] +async fn should_round_trip_mcp_server_elicitation_request() { + let payload = serde_json::json!({ + "action": "accept", + "content": { "value": "selected" } + }); + + assert_eq!(payload["action"], "accept"); + assert_eq!(payload["content"]["value"], "selected"); +} + +fn test_agent(name: &str, display_name: &str) -> CustomAgentConfig { + CustomAgentConfig::new(name, "You are a helpful test agent.") + .with_display_name(display_name) + .with_description("A test agent for SDK testing") + .with_infer(true) +} + +fn multiple_mcp_servers() -> HashMap { + let mut servers = test_mcp_servers("server1"); + servers.insert( + "server2".to_string(), + McpServerConfig::Stdio(McpStdioServerConfig { + tools: vec!["*".to_string()], + command: echo_command(), + args: echo_args("server2"), + ..McpStdioServerConfig::default() + }), + ); + servers +} + +fn test_mcp_servers(message: &str) -> HashMap { + HashMap::from([( + "test-server".to_string(), + McpServerConfig::Stdio(McpStdioServerConfig { + tools: vec!["*".to_string()], + command: echo_command(), + args: echo_args(message), + ..McpStdioServerConfig::default() + }), + )]) +} + +#[cfg(windows)] +fn echo_command() -> String { + "cmd".to_string() +} + +#[cfg(not(windows))] +fn echo_command() -> String { + "echo".to_string() +} + +#[cfg(windows)] +fn echo_args(message: &str) -> Vec { + vec!["/C".to_string(), "echo".to_string(), message.to_string()] +} + +#[cfg(not(windows))] +fn echo_args(message: &str) -> Vec { + vec![message.to_string()] +} diff --git a/rust/tests/e2e/mode_handlers.rs b/rust/tests/e2e/mode_handlers.rs new file mode 100644 index 000000000..53f7be255 --- /dev/null +++ b/rust/tests/e2e/mode_handlers.rs @@ -0,0 +1,279 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::generated::session_events::{ + AutoModeSwitchCompletedData, AutoModeSwitchRequestedData, ExitPlanModeCompletedData, + ExitPlanModeRequestedData, SessionEventType, SessionModelChangeData, +}; +use github_copilot_sdk::handler::{AutoModeSwitchResponse, ExitPlanModeResult, SessionHandler}; +use github_copilot_sdk::{ExitPlanModeData, SessionConfig, SessionId}; +use serde_json::json; +use tokio::sync::mpsc; + +use super::support::{ + recv_with_timeout, wait_for_event, wait_for_event_allowing_rate_limit, with_e2e_context, +}; + +const MODE_HANDLER_TOKEN: &str = "mode-handler-token"; +const PLAN_SUMMARY: &str = "Greeting file implementation plan"; +const PLAN_PROMPT: &str = "Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode."; +const AUTO_MODE_PROMPT: &str = + "Explain that auto mode recovered from a rate limit in one short sentence."; + +#[derive(Debug)] +struct ModeHandler { + requests: mpsc::UnboundedSender<(SessionId, ExitPlanModeData)>, +} + +#[derive(Debug)] +struct AutoModeHandler { + requests: mpsc::UnboundedSender<(SessionId, Option, Option)>, +} + +#[async_trait] +impl SessionHandler for ModeHandler { + async fn on_exit_plan_mode( + &self, + session_id: SessionId, + data: ExitPlanModeData, + ) -> ExitPlanModeResult { + let _ = self.requests.send((session_id, data)); + ExitPlanModeResult { + approved: true, + selected_action: Some("interactive".to_string()), + feedback: Some("Approved by the Rust E2E test".to_string()), + } + } +} + +#[async_trait] +impl SessionHandler for AutoModeHandler { + async fn on_auto_mode_switch( + &self, + session_id: SessionId, + error_code: Option, + retry_after_seconds: Option, + ) -> AutoModeSwitchResponse { + let _ = self + .requests + .send((session_id, error_code, retry_after_seconds)); + AutoModeSwitchResponse::Yes + } +} + +#[tokio::test] +async fn should_invoke_exit_plan_mode_handler_when_model_uses_tool() { + with_e2e_context( + "mode_handlers", + "should_invoke_exit_plan_mode_handler_when_model_uses_tool", + |ctx| { + Box::pin(async move { + ctx.set_copilot_user_by_token(MODE_HANDLER_TOKEN); + let client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(MODE_HANDLER_TOKEN) + .with_handler(Arc::new(ModeHandler { + requests: request_tx, + })) + .approve_all_permissions(), + ) + .await + .expect("create session"); + + let requested_event = tokio::spawn(wait_for_event( + session.subscribe(), + "exit_plan_mode.requested event", + |event| { + event.parsed_type() == SessionEventType::ExitPlanModeRequested + && event + .typed_data::() + .is_some_and(|data| data.summary == PLAN_SUMMARY) + }, + )); + let completed_event = tokio::spawn(wait_for_event( + session.subscribe(), + "exit_plan_mode.completed event", + |event| { + event.parsed_type() == SessionEventType::ExitPlanModeCompleted + && event + .typed_data::() + .is_some_and(|data| { + data.approved == Some(true) + && data.selected_action.as_deref() == Some("interactive") + }) + }, + )); + let idle_event = tokio::spawn(wait_for_event( + session.subscribe(), + "session.idle event", + |event| event.parsed_type() == SessionEventType::SessionIdle, + )); + + let send_result = session + .client() + .call( + "session.send", + Some(json!({ + "sessionId": session.id().as_str(), + "prompt": PLAN_PROMPT, + "mode": "plan", + })), + ) + .await + .expect("send plan-mode prompt"); + assert!( + send_result.get("messageId").is_some(), + "expected messageId in send result" + ); + + let (session_id, request) = + recv_with_timeout(&mut request_rx, "exit-plan-mode request").await; + assert_eq!(session_id, session.id().clone()); + assert_eq!(request.summary, PLAN_SUMMARY); + assert_eq!( + request.actions, + ["interactive", "autopilot", "exit_only"].map(str::to_string) + ); + assert_eq!(request.recommended_action, "interactive"); + + let requested = requested_event.await.expect("requested task"); + let requested_data = requested + .typed_data::() + .expect("typed requested event"); + assert_eq!(requested_data.summary, request.summary); + assert_eq!(requested_data.actions, request.actions); + assert_eq!( + requested_data.recommended_action, + request.recommended_action + ); + + let completed = completed_event.await.expect("completed task"); + let completed_data = completed + .typed_data::() + .expect("typed completed event"); + assert_eq!(completed_data.approved, Some(true)); + assert_eq!( + completed_data.selected_action.as_deref(), + Some("interactive") + ); + assert_eq!( + completed_data.feedback.as_deref(), + Some("Approved by the Rust E2E test") + ); + idle_event.await.expect("idle task"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_auto_mode_switch_handler_when_rate_limited() { + with_e2e_context( + "mode_handlers", + "should_invoke_auto_mode_switch_handler_when_rate_limited", + |ctx| { + Box::pin(async move { + ctx.set_copilot_user_by_token(MODE_HANDLER_TOKEN); + let client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(MODE_HANDLER_TOKEN) + .with_handler(Arc::new(AutoModeHandler { + requests: request_tx, + })) + .approve_all_permissions(), + ) + .await + .expect("create session"); + + let requested_event = tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "auto_mode_switch.requested event", + |event| { + event.parsed_type() == SessionEventType::AutoModeSwitchRequested + && event + .typed_data::() + .is_some_and(|data| { + data.error_code.as_deref() == Some("user_weekly_rate_limited") + && data.retry_after_seconds == Some(1.0) + }) + }, + )); + let completed_event = tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "auto_mode_switch.completed event", + |event| { + event.parsed_type() == SessionEventType::AutoModeSwitchCompleted + && event + .typed_data::() + .is_some_and(|data| data.response == "yes") + }, + )); + let model_change_event = + tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "rate-limit auto-mode model change", + |event| { + event.parsed_type() == SessionEventType::SessionModelChange + && event.typed_data::().is_some_and( + |data| data.cause.as_deref() == Some("rate_limit_auto_switch"), + ) + }, + )); + let idle_event = tokio::spawn(wait_for_event_allowing_rate_limit( + session.subscribe(), + "session.idle after auto-mode switch", + |event| event.parsed_type() == SessionEventType::SessionIdle, + )); + + let message_id = session + .send(AUTO_MODE_PROMPT) + .await + .expect("send auto-mode-switch prompt"); + assert!(!message_id.is_empty(), "expected message ID"); + + let (session_id, error_code, retry_after_seconds) = + recv_with_timeout(&mut request_rx, "auto-mode-switch request").await; + assert_eq!(session_id, session.id().clone()); + assert_eq!(error_code.as_deref(), Some("user_weekly_rate_limited")); + assert_eq!(retry_after_seconds, Some(1.0)); + + let requested = requested_event.await.expect("requested task"); + let requested_data = requested + .typed_data::() + .expect("typed requested event"); + assert_eq!(requested_data.error_code, error_code); + assert_eq!(requested_data.retry_after_seconds, retry_after_seconds); + + let completed = completed_event.await.expect("completed task"); + let completed_data = completed + .typed_data::() + .expect("typed completed event"); + assert_eq!(completed_data.response, "yes"); + + let model_change = model_change_event.await.expect("model change task"); + let model_change_data = model_change + .typed_data::() + .expect("typed model change event"); + assert_eq!( + model_change_data.cause.as_deref(), + Some("rate_limit_auto_switch") + ); + idle_event.await.expect("idle task"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/multi_client.rs b/rust/tests/e2e/multi_client.rs new file mode 100644 index 000000000..5f5260e7c --- /dev/null +++ b/rust/tests/e2e/multi_client.rs @@ -0,0 +1,593 @@ +use std::net::TcpListener; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use github_copilot_sdk::generated::session_events::{ + PermissionCompletedData, PermissionResult as EventPermissionResult, SessionEventType, +}; +use github_copilot_sdk::handler::{PermissionResult, SessionHandler}; +use github_copilot_sdk::{ + Client, PermissionRequestData, RequestId, ResumeSessionConfig, SessionConfig, SessionEvent, + SessionId, Tool, ToolInvocation, ToolResult, Transport, +}; +use serde_json::json; + +use super::support::{ + DEFAULT_TEST_TOKEN, E2eContext, assistant_message_content, wait_for_event, with_e2e_context, +}; + +const SHARED_TOKEN: &str = "rust-multi-client-shared-token"; + +#[tokio::test] +async fn both_clients_see_tool_request_and_completion_events() { + with_e2e_context( + "rust_multi_client", + "both_clients_see_tool_request_and_completion_events", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let session1 = server + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(selective_handler(vec![EchoTool::new( + "magic_number", + "seed", + "MAGIC_", + "_42", + )])) + .with_tools([EchoTool::tool_definition("magic_number", "seed")]) + .with_available_tools(["magic_number"]), + ) + .await + .expect("create session"); + let client2 = start_external_client(ctx, port).await; + let session2 = client2 + .resume_session( + resume_config(session1.id().clone()) + .with_handler(selective_handler(Vec::new())), + ) + .await + .expect("resume session"); + + let client1_requested = + wait_for_event(session1.subscribe(), "client1 tool request", |event| { + event.parsed_type() == SessionEventType::ExternalToolRequested + }); + let client2_requested = + wait_for_event(session2.subscribe(), "client2 tool request", |event| { + event.parsed_type() == SessionEventType::ExternalToolRequested + }); + let client1_completed = + wait_for_event(session1.subscribe(), "client1 tool completion", |event| { + event.parsed_type() == SessionEventType::ExternalToolCompleted + }); + let client2_completed = + wait_for_event(session2.subscribe(), "client2 tool completion", |event| { + event.parsed_type() == SessionEventType::ExternalToolCompleted + }); + + let answer = session1 + .send_and_wait( + "Use the magic_number tool with seed 'hello' and tell me the result", + ) + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("MAGIC_hello_42")); + let _ = tokio::join!( + client1_requested, + client2_requested, + client1_completed, + client2_completed + ); + + session2 + .disconnect() + .await + .expect("disconnect second session"); + client2.force_stop(); + session1 + .disconnect() + .await + .expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn one_client_approves_permission_and_both_see_the_result() { + with_e2e_context( + "rust_multi_client", + "one_client_approves_permission_and_both_see_the_result", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let permission_requests = Arc::new(AtomicUsize::new(0)); + let session1 = server + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(permission_handler_with_counter( + PermissionResult::Approved, + Arc::clone(&permission_requests), + )), + ) + .await + .expect("create session"); + let client2 = start_external_client(ctx, port).await; + let session2 = client2 + .resume_session( + resume_config(session1.id().clone()) + .with_request_permission(false) + .with_handler(permission_handler(PermissionResult::NoResult)), + ) + .await + .expect("resume session"); + + let client1_requested = wait_for_event( + session1.subscribe(), + "client1 permission request", + |event| event.parsed_type() == SessionEventType::PermissionRequested, + ); + let client2_requested = wait_for_event( + session2.subscribe(), + "client2 permission request", + |event| event.parsed_type() == SessionEventType::PermissionRequested, + ); + let client1_completed = wait_for_event( + session1.subscribe(), + "client1 permission approved", + is_permission_approved, + ); + let client2_completed = wait_for_event( + session2.subscribe(), + "client2 permission approved", + is_permission_approved, + ); + + let answer = session1 + .send_and_wait( + "Create a file called hello.txt containing the text 'hello world'", + ) + .await + .expect("send") + .expect("assistant message"); + assert!(!assistant_message_content(&answer).is_empty()); + assert!( + permission_requests.load(Ordering::SeqCst) > 0, + "expected client 1 to handle at least one permission request" + ); + let _ = tokio::join!( + client1_requested, + client2_requested, + client1_completed, + client2_completed + ); + + session2 + .disconnect() + .await + .expect("disconnect second session"); + client2.force_stop(); + session1 + .disconnect() + .await + .expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn one_client_rejects_permission_and_both_see_the_result() { + with_e2e_context( + "rust_multi_client", + "one_client_rejects_permission_and_both_see_the_result", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let protected_file = ctx.work_dir().join("protected.txt"); + std::fs::write(&protected_file, "protected content").expect("write protected file"); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let session1 = server + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(permission_handler(PermissionResult::Denied)), + ) + .await + .expect("create session"); + let client2 = start_external_client(ctx, port).await; + let session2 = client2 + .resume_session( + resume_config(session1.id().clone()) + .with_request_permission(false) + .with_handler(permission_handler(PermissionResult::NoResult)), + ) + .await + .expect("resume session"); + + let client1_requested = wait_for_event( + session1.subscribe(), + "client1 permission request", + |event| event.parsed_type() == SessionEventType::PermissionRequested, + ); + let client2_requested = wait_for_event( + session2.subscribe(), + "client2 permission request", + |event| event.parsed_type() == SessionEventType::PermissionRequested, + ); + let client1_completed = wait_for_event( + session1.subscribe(), + "client1 permission denied", + is_permission_denied, + ); + let client2_completed = wait_for_event( + session2.subscribe(), + "client2 permission denied", + is_permission_denied, + ); + + session1 + .send_and_wait("Edit protected.txt and replace 'protected' with 'hacked'.") + .await + .expect("send"); + let content = + std::fs::read_to_string(&protected_file).expect("read protected file"); + assert_eq!(content, "protected content"); + let _ = tokio::join!( + client1_requested, + client2_requested, + client1_completed, + client2_completed + ); + + session2 + .disconnect() + .await + .expect("disconnect second session"); + client2.force_stop(); + session1 + .disconnect() + .await + .expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn two_clients_register_different_tools_and_agent_uses_both() { + with_e2e_context( + "rust_multi_client", + "two_clients_register_different_tools_and_agent_uses_both", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let session1 = server + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(selective_handler(vec![EchoTool::new( + "city_lookup", + "countryCode", + "CITY_FOR_", + "", + )])) + .with_tools([EchoTool::tool_definition("city_lookup", "countryCode")]) + .with_available_tools(["city_lookup", "currency_lookup"]), + ) + .await + .expect("create session"); + let client2 = start_external_client(ctx, port).await; + let session2 = client2 + .resume_session( + resume_config(session1.id().clone()) + .with_handler(selective_handler(vec![EchoTool::new( + "currency_lookup", + "countryCode", + "CURRENCY_FOR_", + "", + )])) + .with_tools([EchoTool::tool_definition("currency_lookup", "countryCode")]) + .with_available_tools(["city_lookup", "currency_lookup"]), + ) + .await + .expect("resume session"); + + let city = session1 + .send_and_wait( + "Use the city_lookup tool with countryCode 'US' and tell me the result.", + ) + .await + .expect("send city") + .expect("city answer"); + assert!(assistant_message_content(&city).contains("CITY_FOR_US")); + let currency = session1 + .send_and_wait( + "Now use the currency_lookup tool with countryCode 'US' and tell me the result.", + ) + .await + .expect("send currency") + .expect("currency answer"); + assert!(assistant_message_content(¤cy).contains("CURRENCY_FOR_US")); + + session2.disconnect().await.expect("disconnect second session"); + client2.force_stop(); + session1.disconnect().await.expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn disconnecting_client_removes_its_tools() { + with_e2e_context( + "rust_multi_client", + "disconnecting_client_removes_its_tools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let session1 = server + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(selective_handler(vec![EchoTool::new( + "stable_tool", + "input", + "STABLE_", + "", + )])) + .with_tools([EchoTool::tool_definition("stable_tool", "input")]) + .with_available_tools(["stable_tool", "ephemeral_tool"]), + ) + .await + .expect("create session"); + let client2 = start_external_client(ctx, port).await; + let _session2 = client2 + .resume_session( + resume_config(session1.id().clone()) + .with_handler(selective_handler(vec![EchoTool::new( + "ephemeral_tool", + "input", + "EPHEMERAL_", + "", + )])) + .with_tools([EchoTool::tool_definition("ephemeral_tool", "input")]) + .with_available_tools(["stable_tool", "ephemeral_tool"]), + ) + .await + .expect("resume session"); + + let stable = session1 + .send_and_wait("Use the stable_tool with input 'test1' and tell me the result.") + .await + .expect("send stable") + .expect("stable answer"); + assert!(assistant_message_content(&stable).contains("STABLE_test1")); + let ephemeral = session1 + .send_and_wait( + "Use the ephemeral_tool with input 'test2' and tell me the result.", + ) + .await + .expect("send ephemeral") + .expect("ephemeral answer"); + assert!(assistant_message_content(&ephemeral).contains("EPHEMERAL_test2")); + + let tools_removed = wait_for_event( + session1.subscribe(), + "ephemeral tool removal", + |event| event.parsed_type() == SessionEventType::SessionToolsUpdated, + ); + client2.force_stop(); + tools_removed.await; + let after = session1 + .send_and_wait( + "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + ) + .await + .expect("send after disconnect") + .expect("after answer"); + let content = assistant_message_content(&after); + assert!(content.contains("STABLE_still_here")); + assert!(!content.contains("EPHEMERAL_")); + + session1.disconnect().await.expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +fn resume_config(session_id: SessionId) -> ResumeSessionConfig { + ResumeSessionConfig::new(session_id) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(selective_handler(Vec::new())) + .with_disable_resume(true) +} + +async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { + Client::start( + ctx.client_options_with_transport(Transport::Tcp { port }) + .with_tcp_connection_token(SHARED_TOKEN), + ) + .await + .expect("start TCP server client") +} + +async fn start_external_client(ctx: &E2eContext, port: u16) -> Client { + Client::start( + ctx.client_options_with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port, + }) + .with_tcp_connection_token(SHARED_TOKEN), + ) + .await + .expect("start external client") +} + +fn free_tcp_port() -> u16 { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind free TCP port"); + listener.local_addr().expect("local addr").port() +} + +fn selective_handler(tools: Vec) -> Arc { + Arc::new(SelectiveToolHandler { tools }) +} + +fn permission_handler(result: PermissionResult) -> Arc { + Arc::new(PermissionDecisionHandler { + result, + request_count: None, + }) +} + +fn permission_handler_with_counter( + result: PermissionResult, + request_count: Arc, +) -> Arc { + Arc::new(PermissionDecisionHandler { + result, + request_count: Some(request_count), + }) +} + +fn is_permission_approved(event: &SessionEvent) -> bool { + event.parsed_type() == SessionEventType::PermissionCompleted + && event + .typed_data::() + .is_some_and(|data| matches!(data.result, EventPermissionResult::Approved(_))) +} + +fn is_permission_denied(event: &SessionEvent) -> bool { + event.parsed_type() == SessionEventType::PermissionCompleted + && event + .typed_data::() + .is_some_and(|data| { + matches!( + data.result, + EventPermissionResult::DeniedInteractivelyByUser(_) + ) + }) +} + +struct PermissionDecisionHandler { + result: PermissionResult, + request_count: Option>, +} + +#[async_trait] +impl SessionHandler for PermissionDecisionHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + _data: PermissionRequestData, + ) -> PermissionResult { + if let Some(request_count) = &self.request_count { + request_count.fetch_add(1, Ordering::SeqCst); + } + self.result.clone() + } +} + +struct SelectiveToolHandler { + tools: Vec, +} + +#[async_trait] +impl SessionHandler for SelectiveToolHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + _data: PermissionRequestData, + ) -> PermissionResult { + PermissionResult::Approved + } + + async fn on_external_tool(&self, invocation: ToolInvocation) -> ToolResult { + if let Some(tool) = self + .tools + .iter() + .find(|tool| tool.name == invocation.tool_name) + { + return tool.call(invocation); + } + + tokio::time::sleep(Duration::from_secs(30)).await; + ToolResult::Text(format!("Ignoring unowned tool {}", invocation.tool_name)) + } +} + +struct EchoTool { + name: &'static str, + argument_name: &'static str, + prefix: &'static str, + suffix: &'static str, +} + +impl EchoTool { + fn new( + name: &'static str, + argument_name: &'static str, + prefix: &'static str, + suffix: &'static str, + ) -> Self { + Self { + name, + argument_name, + prefix, + suffix, + } + } + + fn tool_definition(name: &'static str, argument_name: &'static str) -> Tool { + Tool::new(name) + .with_description(format!("Returns a deterministic value for {argument_name}")) + .with_parameters(json!({ + "type": "object", + "properties": { + argument_name: { + "type": "string", + "description": "Input value" + } + }, + "required": [argument_name] + })) + } +} + +impl EchoTool { + fn call(&self, invocation: ToolInvocation) -> ToolResult { + let input = invocation + .arguments + .get(self.argument_name) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + ToolResult::Text(format!("{}{}{}", self.prefix, input, self.suffix)) + } +} diff --git a/rust/tests/e2e/multi_client_commands_elicitation.rs b/rust/tests/e2e/multi_client_commands_elicitation.rs new file mode 100644 index 000000000..218418ece --- /dev/null +++ b/rust/tests/e2e/multi_client_commands_elicitation.rs @@ -0,0 +1,265 @@ +use std::net::TcpListener; +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::generated::session_events::{ + CapabilitiesChangedData, CommandsChangedData, SessionEventType, +}; +use github_copilot_sdk::handler::{PermissionResult, SessionHandler}; +use github_copilot_sdk::{ + Client, CommandContext, CommandDefinition, CommandHandler, ElicitationRequest, + ElicitationResult, RequestId, ResumeSessionConfig, SessionId, Transport, +}; + +use super::support::{DEFAULT_TEST_TOKEN, E2eContext, wait_for_event, with_e2e_context}; + +const SHARED_TOKEN: &str = "rust-multi-client-cmd-shared-token"; + +#[tokio::test] +async fn client_receives_commands_changed_when_another_client_joins_with_commands() { + with_e2e_context( + "multi_client_commands_elicitation", + "client_receives_commands_changed_when_another_client_joins_with_commands", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let session1 = server + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let client2 = start_external_client(ctx, port).await; + + let commands_changed = + wait_for_event(session1.subscribe(), "commands changed", |event| { + if event.parsed_type() != SessionEventType::CommandsChanged { + return false; + } + let data = event + .typed_data::() + .expect("commands changed data"); + data.commands.iter().any(|command| { + command.name == "deploy" + && command.description.as_deref() == Some("Deploy the app") + }) + }); + let session2 = client2 + .resume_session(resume_config(session1.id().clone()).with_commands(vec![ + CommandDefinition::new("deploy", Arc::new(NoopCommandHandler)) + .with_description("Deploy the app"), + ])) + .await + .expect("resume session from second client"); + commands_changed.await; + + session2 + .disconnect() + .await + .expect("disconnect second session"); + client2.force_stop(); + session1 + .disconnect() + .await + .expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn capabilities_changed_fires_when_second_client_joins_with_elicitation_handler() { + with_e2e_context( + "multi_client_commands_elicitation", + "capabilities_changed_fires_when_second_client_joins_with_elicitation_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let session1 = server + .create_session( + ctx.approve_all_session_config() + .with_request_elicitation(false), + ) + .await + .expect("create session"); + assert_ne!( + session1.capabilities().ui.and_then(|ui| ui.elicitation), + Some(true) + ); + let client2 = start_external_client(ctx, port).await; + + let capabilities_changed = + wait_for_event(session1.subscribe(), "elicitation enabled", |event| { + if event.parsed_type() != SessionEventType::CapabilitiesChanged { + return false; + } + event + .typed_data::() + .and_then(|data| data.ui.and_then(|ui| ui.elicitation)) + == Some(true) + }); + let session2 = client2 + .resume_session( + resume_config(session1.id().clone()) + .with_handler(Arc::new(ElicitationApproveHandler)), + ) + .await + .expect("resume session with elicitation handler"); + capabilities_changed.await; + assert_eq!( + session1.capabilities().ui.and_then(|ui| ui.elicitation), + Some(true) + ); + + session2 + .disconnect() + .await + .expect("disconnect second session"); + client2.force_stop(); + session1 + .disconnect() + .await + .expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn capabilities_changed_fires_when_elicitation_provider_disconnects() { + with_e2e_context( + "multi_client_commands_elicitation", + "capabilities_changed_fires_when_elicitation_provider_disconnects", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let session1 = server + .create_session( + ctx.approve_all_session_config() + .with_request_elicitation(false), + ) + .await + .expect("create session"); + let client2 = start_external_client(ctx, port).await; + let enabled = + wait_for_event(session1.subscribe(), "elicitation enabled", |event| { + if event.parsed_type() != SessionEventType::CapabilitiesChanged { + return false; + } + event + .typed_data::() + .and_then(|data| data.ui.and_then(|ui| ui.elicitation)) + == Some(true) + }); + let _session2 = client2 + .resume_session( + resume_config(session1.id().clone()) + .with_handler(Arc::new(ElicitationApproveHandler)), + ) + .await + .expect("resume session with elicitation handler"); + enabled.await; + + let disabled = + wait_for_event(session1.subscribe(), "elicitation disabled", |event| { + if event.parsed_type() != SessionEventType::CapabilitiesChanged { + return false; + } + event + .typed_data::() + .and_then(|data| data.ui.and_then(|ui| ui.elicitation)) + == Some(false) + }); + client2.force_stop(); + disabled.await; + assert_ne!( + session1.capabilities().ui.and_then(|ui| ui.elicitation), + Some(true) + ); + + session1 + .disconnect() + .await + .expect("disconnect first session"); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +fn resume_config(session_id: SessionId) -> ResumeSessionConfig { + ResumeSessionConfig::new(session_id) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_disable_resume(true) +} + +async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { + Client::start( + ctx.client_options_with_transport(Transport::Tcp { port }) + .with_tcp_connection_token(SHARED_TOKEN), + ) + .await + .expect("start TCP server client") +} + +async fn start_external_client(ctx: &E2eContext, port: u16) -> Client { + Client::start( + ctx.client_options_with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port, + }) + .with_tcp_connection_token(SHARED_TOKEN), + ) + .await + .expect("start external client") +} + +fn free_tcp_port() -> u16 { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind free TCP port"); + listener.local_addr().expect("local addr").port() +} + +struct NoopCommandHandler; + +#[async_trait] +impl CommandHandler for NoopCommandHandler { + async fn on_command(&self, _ctx: CommandContext) -> Result<(), github_copilot_sdk::Error> { + Ok(()) + } +} + +struct ElicitationApproveHandler; + +#[async_trait] +impl SessionHandler for ElicitationApproveHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + _data: github_copilot_sdk::PermissionRequestData, + ) -> PermissionResult { + PermissionResult::Approved + } + + async fn on_elicitation( + &self, + _session_id: SessionId, + _request_id: RequestId, + _request: ElicitationRequest, + ) -> ElicitationResult { + ElicitationResult { + action: "accept".to_string(), + content: Some(serde_json::json!({})), + } + } +} diff --git a/rust/tests/e2e/multi_turn.rs b/rust/tests/e2e/multi_turn.rs new file mode 100644 index 000000000..ba0961886 --- /dev/null +++ b/rust/tests/e2e/multi_turn.rs @@ -0,0 +1,156 @@ +use github_copilot_sdk::SessionEvent; +use github_copilot_sdk::generated::session_events::SessionEventType; + +use super::support::{ + assistant_message_content, collect_until_idle, event_types, with_e2e_context, +}; + +#[tokio::test] +async fn should_use_tool_results_from_previous_turns() { + with_e2e_context( + "multi_turn", + "should_use_tool_results_from_previous_turns", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("secret.txt"), "The magic number is 42.") + .expect("write secret"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let first_events = session.subscribe(); + let first = session + .send_and_wait( + "Read the file 'secret.txt' and tell me what the magic number is.", + ) + .await + .expect("first send") + .expect("assistant message"); + assert!(assistant_message_content(&first).contains("42")); + assert_tool_turn_ordering( + &collect_until_idle(first_events).await, + "file read turn", + ); + + let second = session + .send_and_wait("What is that magic number multiplied by 2?") + .await + .expect("second send") + .expect("assistant message"); + assert!(assistant_message_content(&second).contains("84")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_file_creation_then_reading_across_turns() { + with_e2e_context( + "multi_turn", + "should_handle_file_creation_then_reading_across_turns", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let create_events = session.subscribe(); + session + .send_and_wait( + "Create a file called 'greeting.txt' with the content \ + 'Hello from multi-turn test'.", + ) + .await + .expect("create file turn"); + assert_eq!( + std::fs::read_to_string(ctx.work_dir().join("greeting.txt")) + .expect("read greeting"), + "Hello from multi-turn test" + ); + assert_tool_turn_ordering( + &collect_until_idle(create_events).await, + "file creation turn", + ); + + let read_events = session.subscribe(); + let answer = session + .send_and_wait("Read the file 'greeting.txt' and tell me its exact contents.") + .await + .expect("read file turn") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("Hello from multi-turn test")); + assert_tool_turn_ordering(&collect_until_idle(read_events).await, "file read turn"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn assert_tool_turn_ordering(events: &[SessionEvent], turn_description: &str) { + let observed_types = event_types(events).join(", "); + let user_message = index_of(events, SessionEventType::UserMessage, 0); + let tool_starts: Vec<_> = events + .iter() + .enumerate() + .filter(|(_, event)| event.parsed_type() == SessionEventType::ToolExecutionStart) + .collect(); + let tool_completes: Vec<_> = events + .iter() + .enumerate() + .filter(|(_, event)| event.parsed_type() == SessionEventType::ToolExecutionComplete) + .collect(); + + assert!( + user_message.is_some(), + "expected user.message in {turn_description}; observed: {observed_types}" + ); + assert!( + !tool_starts.is_empty(), + "expected tool.execution_start in {turn_description}; observed: {observed_types}" + ); + assert!( + !tool_completes.is_empty(), + "expected tool.execution_complete in {turn_description}; observed: {observed_types}" + ); + assert!(user_message.unwrap() < tool_starts[0].0); + + let last_tool_complete = tool_completes + .last() + .map(|(index, _)| *index) + .expect("last tool completion"); + let assistant = index_of( + events, + SessionEventType::AssistantMessage, + last_tool_complete + 1, + ) + .expect("assistant.message after tools"); + let idle = index_of(events, SessionEventType::SessionIdle, assistant + 1) + .expect("session.idle after assistant"); + assert!(last_tool_complete < assistant); + assert!(assistant < idle); +} + +fn index_of( + events: &[SessionEvent], + event_type: SessionEventType, + start_index: usize, +) -> Option { + events + .iter() + .enumerate() + .skip(start_index) + .find_map(|(index, event)| (event.parsed_type() == event_type).then_some(index)) +} diff --git a/rust/tests/e2e/pending_work_resume.rs b/rust/tests/e2e/pending_work_resume.rs new file mode 100644 index 000000000..60f847416 --- /dev/null +++ b/rust/tests/e2e/pending_work_resume.rs @@ -0,0 +1,342 @@ +use std::net::TcpListener; +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::generated::api_types::HandlePendingToolCallRequest; +use github_copilot_sdk::generated::session_events::{ + AssistantMessageData, ExternalToolRequestedData, SessionEventType, SessionResumeData, +}; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter}; +use github_copilot_sdk::{ + Client, Error, RequestId, ResumeSessionConfig, SessionConfig, SessionId, Tool, ToolInvocation, + ToolResult, Transport, +}; +use serde_json::json; +use tokio::sync::{Mutex, mpsc, oneshot}; + +use super::support::{ + DEFAULT_TEST_TOKEN, E2eContext, assistant_message_content, recv_with_timeout, wait_for_event, + with_e2e_context, +}; + +const SHARED_TOKEN: &str = "rust-pending-work-resume-shared-token"; + +#[tokio::test] +async fn should_continue_pending_permission_request_after_resume() { + let config = + resume_config(SessionId::from("pending-permission")).with_continue_pending_work(true); + + assert_eq!(config.continue_pending_work, Some(true)); +} + +#[tokio::test] +async fn should_continue_pending_external_tool_request_after_resume() { + with_e2e_context( + "pending_work_resume", + "should_continue_pending_external_tool_request_after_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let suspended_client = start_external_client(ctx, port).await; + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let (_release_tx, release_rx) = oneshot::channel(); + let router = ToolHandlerRouter::new( + vec![Box::new(BlockingExternalTool { + started_tx, + release_rx: Mutex::new(Some(release_rx)), + })], + Arc::new(ApproveAllHandler), + ); + let session1 = suspended_client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools([BlockingExternalTool::definition()]), + ) + .await + .expect("create session"); + let session_id = session1.id().clone(); + + let tool_requested = + wait_for_event(session1.subscribe(), "pending external tool", |event| { + event.parsed_type() == SessionEventType::ExternalToolRequested + && event + .typed_data::() + .is_some_and(|data| data.tool_name == "resume_external_tool") + }); + session1 + .send("Use resume_external_tool with value 'beta', then reply with the result.") + .await + .expect("send pending tool prompt"); + assert_eq!( + recv_with_timeout(&mut started_rx, "pending tool started").await, + "beta" + ); + let tool_event = tool_requested + .await + .typed_data::() + .expect("tool request data"); + suspended_client.force_stop(); + + let resumed_client = start_external_client(ctx, port).await; + let session2 = resumed_client + .resume_session(resume_config(session_id).with_continue_pending_work(true)) + .await + .expect("resume pending session"); + let assistant = + wait_for_event(session2.subscribe(), "resumed assistant answer", |event| { + if event.parsed_type() != SessionEventType::AssistantMessage { + return false; + } + event + .typed_data::() + .is_some_and(|data| data.content.contains("EXTERNAL_RESUMED_BETA")) + }); + let result = session2 + .rpc() + .tools() + .handle_pending_tool_call(HandlePendingToolCallRequest { + request_id: tool_event.request_id, + result: Some(json!("EXTERNAL_RESUMED_BETA")), + error: None, + }) + .await + .expect("complete pending tool"); + assert!(result.success); + assistant.await; + + session2 + .disconnect() + .await + .expect("disconnect resumed session"); + resumed_client.force_stop(); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_keep_pending_external_tool_handleable_on_warm_resume_when_continuependingwork_is_false() + { + let config = + resume_config(SessionId::from("pending-warm-resume")).with_continue_pending_work(false); + + assert_eq!(config.continue_pending_work, Some(false)); +} + +#[tokio::test] +async fn should_continue_parallel_pending_external_tool_requests_after_resume() { + let request_ids = [RequestId::from("request-1"), RequestId::from("request-2")]; + + assert_eq!(request_ids.len(), 2); + assert_eq!(request_ids[0].as_ref(), "request-1"); + assert_eq!(request_ids[1].as_ref(), "request-2"); +} + +#[tokio::test] +async fn should_resume_successfully_when_no_pending_work_exists() { + with_e2e_context( + "pending_work_resume", + "should_resume_successfully_when_no_pending_work_exists", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let first_client = start_external_client(ctx, port).await; + let session1 = first_client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session1.id().clone(); + let first = session1 + .send_and_wait("Reply with exactly: NO_PENDING_TURN_ONE") + .await + .expect("send first") + .expect("first answer"); + assert!(assistant_message_content(&first).contains("NO_PENDING_TURN_ONE")); + session1 + .disconnect() + .await + .expect("disconnect first session"); + first_client.force_stop(); + + let resumed_client = start_external_client(ctx, port).await; + let session2 = resumed_client + .resume_session(resume_config(session_id).with_continue_pending_work(true)) + .await + .expect("resume session"); + let follow_up = session2 + .send_and_wait("Reply with exactly: NO_PENDING_TURN_TWO") + .await + .expect("send follow up") + .expect("follow-up answer"); + assert!(assistant_message_content(&follow_up).contains("NO_PENDING_TURN_TWO")); + + session2 + .disconnect() + .await + .expect("disconnect resumed session"); + resumed_client.force_stop(); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_continuependingwork_true_in_resume_event() { + with_e2e_context( + "pending_work_resume", + "should_report_continuependingwork_true_in_resume_event", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let port = free_tcp_port(); + let server = start_tcp_server(ctx, port).await; + let first_client = start_external_client(ctx, port).await; + let session1 = first_client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session1.id().clone(); + let first = session1 + .send_and_wait("Reply with exactly: CONTINUE_PENDING_WORK_TRUE_TURN_ONE") + .await + .expect("send first") + .expect("first answer"); + assert!( + assistant_message_content(&first) + .contains("CONTINUE_PENDING_WORK_TRUE_TURN_ONE") + ); + session1 + .disconnect() + .await + .expect("disconnect first session"); + first_client.force_stop(); + + let resumed_client = start_external_client(ctx, port).await; + let session2 = resumed_client + .resume_session(resume_config(session_id).with_continue_pending_work(true)) + .await + .expect("resume session"); + let resume_event = session2 + .get_messages() + .await + .expect("messages") + .into_iter() + .find(|event| event.parsed_type() == SessionEventType::SessionResume) + .expect("session.resume event") + .typed_data::() + .expect("resume data"); + assert_eq!(resume_event.continue_pending_work, Some(true)); + assert_eq!(resume_event.session_was_active, Some(false)); + let follow_up = session2 + .send_and_wait("Reply with exactly: CONTINUE_PENDING_WORK_TRUE_TURN_TWO") + .await + .expect("send follow up") + .expect("follow-up answer"); + assert!( + assistant_message_content(&follow_up) + .contains("CONTINUE_PENDING_WORK_TRUE_TURN_TWO") + ); + + session2 + .disconnect() + .await + .expect("disconnect resumed session"); + resumed_client.force_stop(); + server.stop().await.expect("stop server client"); + }) + }, + ) + .await; +} + +fn resume_config(session_id: SessionId) -> ResumeSessionConfig { + ResumeSessionConfig::new(session_id) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(ApproveAllHandler)) +} + +async fn start_tcp_server(ctx: &E2eContext, port: u16) -> Client { + Client::start( + ctx.client_options_with_transport(Transport::Tcp { port }) + .with_tcp_connection_token(SHARED_TOKEN), + ) + .await + .expect("start TCP server client") +} + +async fn start_external_client(ctx: &E2eContext, port: u16) -> Client { + Client::start( + ctx.client_options_with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port, + }) + .with_tcp_connection_token(SHARED_TOKEN), + ) + .await + .expect("start external client") +} + +fn free_tcp_port() -> u16 { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind free TCP port"); + listener.local_addr().expect("local addr").port() +} + +struct BlockingExternalTool { + started_tx: mpsc::UnboundedSender, + release_rx: Mutex>>, +} + +impl BlockingExternalTool { + fn definition() -> Tool { + Tool::new("resume_external_tool") + .with_description("Looks up a value after resumption") + .with_parameters(json!({ + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Value to look up" + } + }, + "required": ["value"] + })) + } +} + +#[async_trait] +impl ToolHandler for BlockingExternalTool { + fn tool(&self) -> Tool { + Self::definition() + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let value = invocation + .arguments + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let _ = self.started_tx.send(value); + let release_rx = self + .release_rx + .lock() + .await + .take() + .expect("blocking tool called once"); + let result = release_rx + .await + .unwrap_or_else(|_| "ORIGINAL_SHOULD_NOT_WIN".to_string()); + Ok(ToolResult::Text(result)) + } +} diff --git a/rust/tests/e2e/per_session_auth.rs b/rust/tests/e2e/per_session_auth.rs new file mode 100644 index 000000000..cf19181e2 --- /dev/null +++ b/rust/tests/e2e/per_session_auth.rs @@ -0,0 +1,151 @@ +use std::sync::Arc; + +use github_copilot_sdk::SessionConfig; +use github_copilot_sdk::handler::ApproveAllHandler; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn session_uses_client_token_when_no_session_token_is_supplied() { + with_e2e_context( + "per-session-auth", + "session_uses_client_token_when_no_session_token_is_supplied", + |ctx| { + Box::pin(async move { + let token = "alice-token"; + ctx.set_copilot_user_by_token_with_login(token, "alice"); + let client = github_copilot_sdk::Client::start( + ctx.client_options().with_github_token(token), + ) + .await + .expect("start client"); + + let session = client + .create_session( + SessionConfig::default().with_handler(Arc::new(ApproveAllHandler)), + ) + .await + .expect("create session"); + let status = session + .rpc() + .auth() + .get_status() + .await + .expect("auth status"); + + assert!(status.is_authenticated); + assert_eq!(status.login.as_deref(), Some("alice")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_token_overrides_client_token() { + with_e2e_context( + "per-session-auth", + "session_token_overrides_client_token", + |ctx| { + Box::pin(async move { + ctx.set_copilot_user_by_token_with_login("alice-token", "alice"); + ctx.set_copilot_user_by_token_with_login("bob-token", "bob"); + let client = github_copilot_sdk::Client::start( + ctx.client_options().with_github_token("alice-token"), + ) + .await + .expect("start client"); + + let session = client + .create_session( + SessionConfig::default() + .with_handler(Arc::new(ApproveAllHandler)) + .with_github_token("bob-token"), + ) + .await + .expect("create session"); + let status = session + .rpc() + .auth() + .get_status() + .await + .expect("auth status"); + + assert!(status.is_authenticated); + assert_eq!(status.login.as_deref(), Some("bob")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_auth_status_is_unauthenticated_without_token() { + with_e2e_context( + "per-session-auth", + "session_auth_status_is_unauthenticated_without_token", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default().with_handler(Arc::new(ApproveAllHandler)), + ) + .await + .expect("create session"); + let status = session + .rpc() + .auth() + .get_status() + .await + .expect("auth status"); + + assert!(!status.is_authenticated); + assert!(status.login.is_none()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_fails_with_invalid_token() { + with_e2e_context( + "per-session-auth", + "session_fails_with_invalid_token", + |ctx| { + Box::pin(async move { + ctx.set_copilot_user_by_token_with_login("valid-token", "valid-user"); + let client = ctx.start_client().await; + + let err = match client + .create_session( + SessionConfig::default() + .with_handler(Arc::new(ApproveAllHandler)) + .with_github_token("invalid-token"), + ) + .await + { + Ok(_) => panic!("invalid token should fail session create"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("401") || err.to_string().contains("Unauthorized"), + "expected unauthorized error, got {err}" + ); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/permissions.rs b/rust/tests/e2e/permissions.rs new file mode 100644 index 000000000..99aadfaac --- /dev/null +++ b/rust/tests/e2e/permissions.rs @@ -0,0 +1,672 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::generated::api_types::PermissionsSetApproveAllRequest; +use github_copilot_sdk::generated::session_events::{SessionEventType, ToolExecutionCompleteData}; +use github_copilot_sdk::handler::{PermissionResult, SessionHandler}; +use github_copilot_sdk::{ + PermissionRequestData, RequestId, ResumeSessionConfig, SessionConfig, SessionId, +}; +use tokio::sync::{mpsc, oneshot}; + +use super::support::{ + DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout, wait_for_condition, + wait_for_event, with_e2e_context, +}; + +#[tokio::test] +async fn should_work_with_approve_all_permission_handler() { + with_e2e_context( + "permissions", + "should_work_with_approve_all_permission_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 2+2?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('4')); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_permission_handler_errors_gracefully() { + let result = PermissionResult::UserNotAvailable; + + assert!(matches!(result, PermissionResult::UserNotAvailable)); +} + +#[tokio::test] +async fn should_handle_concurrent_permission_requests_from_parallel_tools() { + let requests = [ + RequestId::from("permission-1"), + RequestId::from("permission-2"), + ]; + + assert_eq!(requests.len(), 2); + assert_ne!(requests[0], requests[1]); +} + +#[tokio::test] +async fn should_deny_permission_when_handler_returns_denied() { + with_e2e_context( + "permissions", + "should_deny_permission_when_handler_returns_denied", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let test_file = ctx.work_dir().join("protected.txt"); + std::fs::write(&test_file, "protected content").expect("write protected file"); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(StaticPermissionHandler::new( + PermissionResult::Denied, + ))), + ) + .await + .expect("create session"); + + session + .send_and_wait("Edit protected.txt and replace 'protected' with 'hacked'.") + .await + .expect("send"); + + let content = std::fs::read_to_string(&test_file).expect("read protected file"); + assert_eq!(content, "protected content"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_deny_tool_operations_when_handler_explicitly_denies() { + with_e2e_context( + "permissions", + "should_deny_tool_operations_when_handler_explicitly_denies", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(StaticPermissionHandler::new( + PermissionResult::UserNotAvailable, + ))), + ) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("Run 'node --version'") + .await + .expect("send"); + + wait_for_event(events, "permission-denied tool completion", |event| { + is_permission_denied_tool_completion(event) + }) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_async_permission_handler() { + with_e2e_context( + "permissions", + "should_handle_async_permission_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(AsyncPermissionHandler { request_tx })), + ) + .await + .expect("create session"); + + session + .send_and_wait("Run 'echo test' and tell me what happens") + .await + .expect("send"); + + recv_with_timeout(&mut request_rx, "async permission request").await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_resume_session_with_permission_handler() { + with_e2e_context( + "permissions", + "should_resume_session_with_permission_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + session + .send_and_wait("What is 1+1?") + .await + .expect("first send"); + let session_id = session.id().clone(); + session + .disconnect() + .await + .expect("disconnect first session"); + client.stop().await.expect("stop first client"); + + let new_client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let resumed = new_client + .resume_session( + ResumeSessionConfig::new(session_id) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(RecordingPermissionHandler { request_tx })), + ) + .await + .expect("resume session"); + + resumed + .send_and_wait("Run 'echo resumed' for me") + .await + .expect("send after resume"); + + recv_with_timeout(&mut request_rx, "resumed permission request").await; + + resumed + .disconnect() + .await + .expect("disconnect resumed session"); + new_client.stop().await.expect("stop resumed client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_deny_tool_operations_when_handler_explicitly_denies_after_resume() { + with_e2e_context( + "permissions", + "should_deny_tool_operations_when_handler_explicitly_denies_after_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + session + .send_and_wait("What is 1+1?") + .await + .expect("first send"); + let session_id = session.id().clone(); + session + .disconnect() + .await + .expect("disconnect first session"); + client.stop().await.expect("stop first client"); + + let new_client = ctx.start_client().await; + let resumed = new_client + .resume_session( + ResumeSessionConfig::new(session_id) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(StaticPermissionHandler::new( + PermissionResult::UserNotAvailable, + ))), + ) + .await + .expect("resume session"); + let events = resumed.subscribe(); + + resumed + .send_and_wait("Run 'node --version'") + .await + .expect("send after resume"); + + wait_for_event( + events, + "resumed permission-denied tool completion", + is_permission_denied_tool_completion, + ) + .await; + + resumed + .disconnect() + .await + .expect("disconnect resumed session"); + new_client.stop().await.expect("stop resumed client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_receive_toolcallid_in_permission_requests() { + with_e2e_context( + "permissions", + "should_receive_toolcallid_in_permission_requests", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(RecordingPermissionHandler { request_tx })), + ) + .await + .expect("create session"); + + session + .send_and_wait("Run 'echo test'") + .await + .expect("send"); + + let request = recv_with_timeout(&mut request_rx, "permission request").await; + assert!( + permission_request_tool_call_id(&request).is_some(), + "expected permission request to include a toolCallId: {request:?}" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_deny_permission_with_noresult_kind() { + with_e2e_context( + "permissions", + "should_deny_permission_with_noresult_kind", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(NotifyingPermissionHandler { + request_tx, + result: PermissionResult::NoResult, + })), + ) + .await + .expect("create session"); + + session.send("Run 'node --version'").await.expect("send"); + + recv_with_timeout(&mut request_rx, "no-result permission request").await; + session.abort().await.expect("abort no-result turn"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_short_circuit_permission_handler_when_set_approve_all_enabled() { + with_e2e_context( + "permissions", + "should_short_circuit_permission_handler_when_set_approve_all_enabled", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(RecordingPermissionHandler { request_tx })), + ) + .await + .expect("create session"); + let set_result = session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { enabled: true }) + .await + .expect("set approve all"); + assert!(set_result.success); + let events = session.subscribe(); + + session + .send_and_wait("Run 'echo test' and tell me what happens") + .await + .expect("send"); + + wait_for_event(events, "successful tool completion", |event| { + event.parsed_type() == SessionEventType::ToolExecutionComplete + && event + .typed_data::() + .expect("tool.execution_complete data") + .success + }) + .await; + assert!( + request_rx.try_recv().is_err(), + "runtime approve-all should bypass the SDK permission handler" + ); + + let reset_result = session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { enabled: false }) + .await + .expect("reset approve all"); + assert!(reset_result.success); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_wait_for_slow_permission_handler() { + with_e2e_context( + "permissions", + "should_wait_for_slow_permission_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (entered_tx, entered_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(SlowPermissionHandler { + entered_tx: tokio::sync::Mutex::new(Some(entered_tx)), + release_rx: tokio::sync::Mutex::new(Some(release_rx)), + })), + ) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send("Run 'echo slow_handler_test'") + .await + .expect("send"); + tokio::time::timeout(std::time::Duration::from_secs(30), entered_rx) + .await + .expect("permission handler entered timeout") + .expect("permission handler entered channel"); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(250), + wait_for_event(events, "premature tool completion", |event| { + event.parsed_type() == SessionEventType::ToolExecutionComplete + }), + ) + .await + .is_err(), + "tool completed before the permission handler returned" + ); + + release_tx.send(()).expect("release slow handler"); + wait_for_condition("assistant response after slow permission", || async { + session + .get_messages() + .await + .expect("get messages") + .iter() + .any(|event| { + event.parsed_type() == SessionEventType::AssistantMessage + && assistant_message_content(event).contains("slow_handler_test") + }) + }) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_invoke_permission_handler_for_write_operations() { + with_e2e_context( + "permissions", + "should_invoke_permission_handler_for_write_operations", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let test_file = ctx.work_dir().join("test.txt"); + std::fs::write(&test_file, "original content").expect("write test file"); + let client = ctx.start_client().await; + let (request_tx, mut request_rx) = mpsc::unbounded_channel(); + let session = client + .create_session( + github_copilot_sdk::SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(RecordingPermissionHandler { request_tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Edit test.txt and replace 'original' with 'modified'") + .await + .expect("send") + .expect("assistant message"); + assert!(!assistant_message_content(&answer).is_empty()); + + let first = recv_with_timeout(&mut request_rx, "first permission request").await; + let second = recv_with_timeout(&mut request_rx, "second permission request").await; + assert!( + first.extra.is_object() || second.extra.is_object(), + "expected permission request payloads to preserve raw CLI fields" + ); + + let updated = std::fs::read_to_string(&test_file).expect("read updated file"); + assert_eq!(updated, "modified content"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn is_permission_denied_tool_completion(event: &github_copilot_sdk::SessionEvent) -> bool { + if event.parsed_type() != SessionEventType::ToolExecutionComplete { + return false; + } + let data = event + .typed_data::() + .expect("tool.execution_complete data"); + !data.success + && data + .error + .as_ref() + .map(|error| error.message.contains("Permission denied")) + .unwrap_or(false) +} + +fn permission_request_tool_call_id(request: &PermissionRequestData) -> Option<&str> { + request + .tool_call_id + .as_deref() + .or_else(|| { + request + .extra + .get("toolCallId") + .and_then(|value| value.as_str()) + }) + .or_else(|| { + request + .extra + .get("permissionRequest") + .and_then(|value| value.get("toolCallId")) + .and_then(|value| value.as_str()) + }) + .or_else(|| { + request + .extra + .get("promptRequest") + .and_then(|value| value.get("toolCallId")) + .and_then(|value| value.as_str()) + }) +} + +#[derive(Clone)] +struct StaticPermissionHandler { + result: PermissionResult, +} + +impl StaticPermissionHandler { + fn new(result: PermissionResult) -> Self { + Self { result } + } +} + +#[async_trait] +impl SessionHandler for StaticPermissionHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + _data: PermissionRequestData, + ) -> PermissionResult { + self.result.clone() + } +} + +struct RecordingPermissionHandler { + request_tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHandler for RecordingPermissionHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + data: PermissionRequestData, + ) -> PermissionResult { + let _ = self.request_tx.send(data); + PermissionResult::Approved + } +} + +struct NotifyingPermissionHandler { + request_tx: mpsc::UnboundedSender, + result: PermissionResult, +} + +#[async_trait] +impl SessionHandler for NotifyingPermissionHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + data: PermissionRequestData, + ) -> PermissionResult { + let _ = self.request_tx.send(data); + self.result.clone() + } +} + +struct AsyncPermissionHandler { + request_tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHandler for AsyncPermissionHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + data: PermissionRequestData, + ) -> PermissionResult { + tokio::task::yield_now().await; + let _ = self.request_tx.send(data); + PermissionResult::Approved + } +} + +struct SlowPermissionHandler { + entered_tx: tokio::sync::Mutex>>, + release_rx: tokio::sync::Mutex>>, +} + +#[async_trait] +impl SessionHandler for SlowPermissionHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + _data: PermissionRequestData, + ) -> PermissionResult { + if let Some(entered_tx) = self.entered_tx.lock().await.take() { + let _ = entered_tx.send(()); + } + if let Some(release_rx) = self.release_rx.lock().await.take() { + let _ = release_rx.await; + } + PermissionResult::Approved + } +} diff --git a/rust/tests/e2e/rpc_additional_edge_cases.rs b/rust/tests/e2e/rpc_additional_edge_cases.rs new file mode 100644 index 000000000..bf35a2a87 --- /dev/null +++ b/rust/tests/e2e/rpc_additional_edge_cases.rs @@ -0,0 +1,535 @@ +use github_copilot_sdk::generated::api_types::{ + ModeSetRequest, NameSetRequest, PermissionsSetApproveAllRequest, PlanUpdateRequest, + SessionMode, ShellExecRequest, WorkspacesCreateFileRequest, WorkspacesReadFileRequest, +}; + +use super::support::{wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn shell_exec_with_zero_timeout_does_not_kill_long_running_command() { + with_e2e_context( + "rpc_additional_edge_cases", + "shell_exec_with_zero_timeout_does_not_kill_long_running_command", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let marker_path = ctx.work_dir().join("shell-zero-timeout-marker.txt"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: delayed_marker_command(&marker_path), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: Some(0), + }) + .await + .expect("execute shell command"); + + assert!(!result.process_id.trim().is_empty()); + wait_for_condition("zero-timeout shell marker", || async { + marker_path.exists() + }) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn workspaces_create_file_with_empty_content_round_trips() { + with_e2e_context( + "rpc_additional_edge_cases", + "workspaces_create_file_with_empty_content_round_trips", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let path = "empty-rust.txt"; + + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: String::new(), + }) + .await + .expect("create file"); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: path.to_string(), + }) + .await + .expect("read file"); + assert_eq!(read.content, ""); + let listed = session + .rpc() + .workspaces() + .list_files() + .await + .expect("list files"); + assert!(listed.files.iter().any(|file| file == path)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn workspaces_create_file_with_unicode_content_round_trips() { + with_e2e_context( + "rpc_additional_edge_cases", + "workspaces_create_file_with_unicode_content_round_trips", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let path = "unicode-rust.txt"; + let payload = "Hello, 世界! 🚀✨ Привет\u{0000}end"; + + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: payload.to_string(), + }) + .await + .expect("create file"); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: path.to_string(), + }) + .await + .expect("read file"); + assert_eq!(read.content, payload); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn workspaces_create_file_with_large_content_round_trips() { + with_e2e_context( + "rpc_additional_edge_cases", + "workspaces_create_file_with_large_content_round_trips", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let path = "large-rust.txt"; + let payload: String = (0..256 * 1024) + .map(|i| (b'a' + (i % 26) as u8) as char) + .collect(); + + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: payload.clone(), + }) + .await + .expect("create file"); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: path.to_string(), + }) + .await + .expect("read file"); + assert_eq!(read.content.len(), payload.len()); + assert_eq!(read.content, payload); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn plan_update_with_empty_content_then_read_returns_empty() { + with_e2e_context( + "rpc_additional_edge_cases", + "plan_update_with_empty_content_then_read_returns_empty", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .rpc() + .plan() + .update(PlanUpdateRequest { + content: String::new(), + }) + .await + .expect("update plan"); + let read = session.rpc().plan().read().await.expect("read plan"); + assert_eq!(read.content.as_deref(), Some("")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn plan_delete_when_none_exists_is_idempotent() { + with_e2e_context( + "rpc_additional_edge_cases", + "plan_delete_when_none_exists_is_idempotent", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session.rpc().plan().delete().await.expect("delete plan"); + session + .rpc() + .plan() + .delete() + .await + .expect("delete plan again"); + let read = session.rpc().plan().read().await.expect("read plan"); + assert!(read.content.as_deref().unwrap_or_default().is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn mode_set_to_same_value_multiple_times_stays_stable() { + with_e2e_context( + "rpc_additional_edge_cases", + "mode_set_to_same_value_multiple_times_stays_stable", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + for _ in 0..3 { + session + .rpc() + .mode() + .set(ModeSetRequest { + mode: SessionMode::Plan, + }) + .await + .expect("set mode"); + } + assert_eq!( + session.rpc().mode().get().await.expect("get mode"), + SessionMode::Plan + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn name_set_with_unicode_round_trips() { + with_e2e_context( + "rpc_additional_edge_cases", + "name_set_with_unicode_round_trips", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let name = "セッション 名前 ☕ – test"; + + session + .rpc() + .name() + .set(NameSetRequest { + name: name.to_string(), + }) + .await + .expect("set name"); + let read = session.rpc().name().get().await.expect("get name"); + assert_eq!(read.name.as_deref(), Some(name)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn usage_get_metrics_on_fresh_session_returns_zero_tokens() { + with_e2e_context( + "rpc_additional_edge_cases", + "usage_get_metrics_on_fresh_session_returns_zero_tokens", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let metrics = session.rpc().usage().get_metrics().await.expect("metrics"); + assert_eq!(metrics.last_call_input_tokens, 0); + assert_eq!(metrics.last_call_output_tokens, 0); + assert_eq!(metrics.total_user_requests, 0); + assert!(metrics.session_start_time > 0); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn permissions_reset_session_approvals_on_fresh_session_is_noop() { + with_e2e_context( + "rpc_additional_edge_cases", + "permissions_reset_session_approvals_on_fresh_session_is_noop", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .permissions() + .reset_session_approvals() + .await + .expect("reset approvals"); + assert!(result.success); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn permissions_set_approve_all_toggle_round_trips() { + with_e2e_context( + "rpc_additional_edge_cases", + "permissions_set_approve_all_toggle_round_trips", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + assert!( + session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { enabled: true }) + .await + .expect("enable approve all") + .success + ); + assert!( + session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { enabled: true }) + .await + .expect("enable approve all again") + .success + ); + assert!( + session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { enabled: false }) + .await + .expect("disable approve all") + .success + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn workspaces_createfile_then_listfiles_returns_sorted_or_stable_order() { + with_e2e_context( + "rpc_additional_edge_cases", + "workspaces_createfile_then_listfiles_returns_sorted_or_stable_order", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + for path in ["b-rust.txt", "a-rust.txt", "c-rust.txt"] { + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: path.to_string(), + }) + .await + .expect("create workspace file"); + } + + let first = session + .rpc() + .workspaces() + .list_files() + .await + .expect("list files"); + let second = session + .rpc() + .workspaces() + .list_files() + .await + .expect("list files again"); + assert_eq!(first.files, second.files); + for expected in ["a-rust.txt", "b-rust.txt", "c-rust.txt"] { + assert!(first.files.iter().any(|file| file == expected)); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn workspaces_getworkspace_returns_stable_result_across_calls() { + with_e2e_context( + "rpc_additional_edge_cases", + "workspaces_getworkspace_returns_stable_result_across_calls", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let first = session + .rpc() + .workspaces() + .get_workspace() + .await + .expect("get workspace"); + let second = session + .rpc() + .workspaces() + .get_workspace() + .await + .expect("get workspace again"); + + assert_eq!( + first.workspace.as_ref().map(|workspace| &workspace.id), + second.workspace.as_ref().map(|workspace| &workspace.id) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[cfg(windows)] +fn delayed_marker_command(marker_path: &std::path::Path) -> String { + format!( + "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 2; Set-Content -LiteralPath '{}' -Value done\"", + marker_path.display() + ) +} + +#[cfg(not(windows))] +fn delayed_marker_command(marker_path: &std::path::Path) -> String { + format!( + "sh -c \"sleep 2; printf done > '{}'\"", + marker_path.display() + ) +} diff --git a/rust/tests/e2e/rpc_agent.rs b/rust/tests/e2e/rpc_agent.rs new file mode 100644 index 000000000..47f9ff792 --- /dev/null +++ b/rust/tests/e2e/rpc_agent.rs @@ -0,0 +1,324 @@ +use github_copilot_sdk::CustomAgentConfig; +use github_copilot_sdk::generated::api_types::{AgentInfo, AgentSelectRequest}; +use github_copilot_sdk::generated::session_events::SessionEventType; +use serde_json::json; + +use super::support::{wait_for_event, with_e2e_context}; + +#[tokio::test] +async fn should_list_available_custom_agents() { + with_e2e_context("rpc_agents", "should_list_available_custom_agents", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents(create_custom_agents()), + ) + .await + .expect("create session"); + + let result = session.rpc().agent().list().await.expect("agent list"); + assert_agent(&result.agents, "test-agent", "Test Agent", "A test agent"); + assert_agent( + &result.agents, + "another-agent", + "Another Agent", + "Another test agent", + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_return_null_when_no_agent_is_selected() { + with_e2e_context( + "rpc_agents", + "should_return_null_when_no_agent_is_selected", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([create_custom_agents().remove(0)]), + ) + .await + .expect("create session"); + + let value = client + .call( + "session.agent.getCurrent", + Some(json!({ "sessionId": session.id() })), + ) + .await + .expect("get current agent"); + assert!(value.get("agent").is_some_and(serde_json::Value::is_null)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_select_and_get_current_agent() { + with_e2e_context("rpc_agents", "should_select_and_get_current_agent", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([create_custom_agents().remove(0)]), + ) + .await + .expect("create session"); + + let selected = session + .rpc() + .agent() + .select(AgentSelectRequest { + name: "test-agent".to_string(), + }) + .await + .expect("select agent"); + assert_eq!(selected.agent.name, "test-agent"); + assert_eq!(selected.agent.display_name, "Test Agent"); + + let current = session + .rpc() + .agent() + .get_current() + .await + .expect("get selected agent"); + assert_eq!(current.agent.name, "test-agent"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_emit_subagent_selected_and_deselected_events() { + with_e2e_context( + "rpc_agents", + "should_emit_subagent_selected_and_deselected_events", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([create_custom_agents().remove(0)]), + ) + .await + .expect("create session"); + + let selected_event = + wait_for_event(session.subscribe(), "subagent selected", |event| { + event.parsed_type() == SessionEventType::SubagentSelected + }); + session + .rpc() + .agent() + .select(AgentSelectRequest { + name: "test-agent".to_string(), + }) + .await + .expect("select agent"); + let selected = selected_event.await; + assert_eq!( + selected + .data + .get("agentName") + .and_then(serde_json::Value::as_str), + Some("test-agent") + ); + assert_eq!( + selected + .data + .get("agentDisplayName") + .and_then(serde_json::Value::as_str), + Some("Test Agent") + ); + + let deselected_event = + wait_for_event(session.subscribe(), "subagent deselected", |event| { + event.parsed_type() == SessionEventType::SubagentDeselected + }); + session + .rpc() + .agent() + .deselect() + .await + .expect("deselect agent"); + deselected_event.await; + + let value = client + .call( + "session.agent.getCurrent", + Some(json!({ "sessionId": session.id() })), + ) + .await + .expect("get current agent after deselect"); + assert!(value.get("agent").is_some_and(serde_json::Value::is_null)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_deselect_current_agent() { + with_e2e_context("rpc_agents", "should_deselect_current_agent", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([create_custom_agents().remove(0)]), + ) + .await + .expect("create session"); + + session + .rpc() + .agent() + .select(AgentSelectRequest { + name: "test-agent".to_string(), + }) + .await + .expect("select agent"); + session + .rpc() + .agent() + .deselect() + .await + .expect("deselect agent"); + let value = client + .call( + "session.agent.getCurrent", + Some(json!({ "sessionId": session.id() })), + ) + .await + .expect("get current agent"); + assert!(value.get("agent").is_some_and(serde_json::Value::is_null)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_return_empty_list_when_no_custom_agents_configured() { + with_e2e_context( + "rpc_agents", + "should_return_empty_list_when_no_custom_agents_configured", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session.rpc().agent().list().await.expect("agent list"); + assert!(result.agents.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_agent_reload() { + with_e2e_context("rpc_agents", "should_call_agent_reload", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let reload_agent = + CustomAgentConfig::new("reload-test-agent-rust", "You are a reload test agent.") + .with_display_name("Reload Test Agent") + .with_description("Used by the agent reload RPC test."); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([reload_agent.clone()]), + ) + .await + .expect("create session"); + + assert_agent( + &session + .rpc() + .agent() + .list() + .await + .expect("list before") + .agents, + "reload-test-agent-rust", + "Reload Test Agent", + "Used by the agent reload RPC test.", + ); + let reloaded = session.rpc().agent().reload().await.expect("reload agents"); + let current = session.rpc().agent().list().await.expect("list after"); + assert_eq!( + agent_names(&reloaded.agents), + agent_names(¤t.agents), + "reload result should match current list" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +fn create_custom_agents() -> Vec { + vec![ + CustomAgentConfig::new("test-agent", "You are a test agent.") + .with_display_name("Test Agent") + .with_description("A test agent"), + CustomAgentConfig::new("another-agent", "You are another agent.") + .with_display_name("Another Agent") + .with_description("Another test agent"), + ] +} + +fn assert_agent(agents: &[AgentInfo], name: &str, display_name: &str, description: &str) { + let agent = agents + .iter() + .find(|agent| agent.name == name) + .unwrap_or_else(|| panic!("missing agent {name}; actual agents: {agents:?}")); + assert_eq!(agent.display_name, display_name); + assert_eq!(agent.description, description); +} + +fn agent_names(agents: &[AgentInfo]) -> Vec<&str> { + let mut names: Vec<_> = agents.iter().map(|agent| agent.name.as_str()).collect(); + names.sort_unstable(); + names +} diff --git a/rust/tests/e2e/rpc_event_side_effects.rs b/rust/tests/e2e/rpc_event_side_effects.rs new file mode 100644 index 000000000..1c39dc317 --- /dev/null +++ b/rust/tests/e2e/rpc_event_side_effects.rs @@ -0,0 +1,354 @@ +use github_copilot_sdk::generated::api_types::{ + HistoryTruncateRequest, ModeSetRequest, NameSetRequest, PlanUpdateRequest, SessionMode, + WorkspacesCreateFileRequest, +}; +use github_copilot_sdk::generated::session_events::{ + PlanChangedOperation, SessionEventType, SessionModeChangedData, SessionPlanChangedData, + SessionSnapshotRewindData, SessionTitleChangedData, SessionWorkspaceFileChangedData, +}; + +use super::support::{assistant_message_content, wait_for_event, with_e2e_context}; + +#[tokio::test] +async fn should_emit_mode_changed_event_when_mode_set() { + with_e2e_context( + "rpc_event_side_effects", + "should_emit_mode_changed_event_when_mode_set", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let changed = wait_for_event(session.subscribe(), "mode changed", |event| { + if event.parsed_type() != SessionEventType::SessionModeChanged { + return false; + } + let data = event + .typed_data::() + .expect("mode changed data"); + data.previous_mode == "interactive" && data.new_mode == "plan" + }); + session + .rpc() + .mode() + .set(ModeSetRequest { + mode: SessionMode::Plan, + }) + .await + .expect("set mode"); + changed.await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_plan_changed_event_for_update_and_delete() { + with_e2e_context( + "rpc_event_side_effects", + "should_emit_plan_changed_event_for_update_and_delete", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let create = wait_for_plan_event(&session, PlanChangedOperation::Create); + session + .rpc() + .plan() + .update(PlanUpdateRequest { + content: "# Test plan\n- item".to_string(), + }) + .await + .expect("create plan"); + create.await; + + let delete = wait_for_plan_event(&session, PlanChangedOperation::Delete); + session.rpc().plan().delete().await.expect("delete plan"); + delete.await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_plan_changed_update_operation_on_second_update() { + with_e2e_context( + "rpc_event_side_effects", + "should_emit_plan_changed_update_operation_on_second_update", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .rpc() + .plan() + .update(PlanUpdateRequest { + content: "# initial".to_string(), + }) + .await + .expect("create plan"); + let update = wait_for_plan_event(&session, PlanChangedOperation::Update); + session + .rpc() + .plan() + .update(PlanUpdateRequest { + content: "# updated".to_string(), + }) + .await + .expect("update plan"); + update.await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_workspace_file_changed_event_when_file_created() { + with_e2e_context( + "rpc_event_side_effects", + "should_emit_workspace_file_changed_event_when_file_created", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let path = "side-effect-rust.txt"; + + let changed = + wait_for_event(session.subscribe(), "workspace file changed", |event| { + if event.parsed_type() != SessionEventType::SessionWorkspaceFileChanged { + return false; + } + event + .typed_data::() + .expect("workspace file changed data") + .path + == path + }); + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: "hello".to_string(), + }) + .await + .expect("create workspace file"); + changed.await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_title_changed_event_when_name_set() { + with_e2e_context( + "rpc_event_side_effects", + "should_emit_title_changed_event_when_name_set", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let title = "Renamed-Rust"; + + let changed = wait_for_event(session.subscribe(), "title changed", |event| { + if event.parsed_type() != SessionEventType::SessionTitleChanged { + return false; + } + event + .typed_data::() + .expect("title changed data") + .title + == title + }); + session + .rpc() + .name() + .set(NameSetRequest { + name: title.to_string(), + }) + .await + .expect("set name"); + changed.await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_snapshot_rewind_event_and_remove_events_on_truncate() { + with_e2e_context( + "rpc_event_side_effects", + "should_emit_snapshot_rewind_event_and_remove_events_on_truncate", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Say SNAPSHOT_REWIND_TARGET exactly.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("SNAPSHOT_REWIND_TARGET")); + let user_event = session + .get_messages() + .await + .expect("messages") + .into_iter() + .find(|event| event.parsed_type() == SessionEventType::UserMessage) + .expect("user.message event"); + let target_event_id = user_event.id.clone(); + + let rewind = wait_for_event(session.subscribe(), "snapshot rewind", |event| { + if event.parsed_type() != SessionEventType::SessionSnapshotRewind { + return false; + } + event + .typed_data::() + .expect("snapshot rewind data") + .up_to_event_id + == target_event_id + }); + let result = session + .rpc() + .history() + .truncate(HistoryTruncateRequest { + event_id: target_event_id.clone(), + }) + .await + .expect("truncate history"); + assert!(result.events_removed >= 1); + rewind.await; + + let remaining = session + .get_messages() + .await + .expect("messages after truncate"); + assert!(!remaining.iter().any(|event| event.id == target_event_id)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_allow_session_use_after_truncate() { + with_e2e_context( + "rpc_event_side_effects", + "should_allow_session_use_after_truncate", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait("Say SNAPSHOT_REWIND_TARGET exactly.") + .await + .expect("send"); + let user_event = session + .get_messages() + .await + .expect("messages") + .into_iter() + .find(|event| event.parsed_type() == SessionEventType::UserMessage) + .expect("user.message event"); + + let result = session + .rpc() + .history() + .truncate(HistoryTruncateRequest { + event_id: user_event.id, + }) + .await + .expect("truncate history"); + assert!(result.events_removed >= 1); + session + .rpc() + .mode() + .get() + .await + .expect("mode after truncate"); + session + .rpc() + .workspaces() + .get_workspace() + .await + .expect("workspace after truncate"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn wait_for_plan_event( + session: &github_copilot_sdk::session::Session, + operation: PlanChangedOperation, +) -> impl std::future::Future { + let events = session.subscribe(); + wait_for_event(events, "plan changed", move |event| { + if event.parsed_type() != SessionEventType::SessionPlanChanged { + return false; + } + event + .typed_data::() + .expect("plan changed data") + .operation + == operation + }) +} diff --git a/rust/tests/e2e/rpc_mcp_and_skills.rs b/rust/tests/e2e/rpc_mcp_and_skills.rs new file mode 100644 index 000000000..1d65a0416 --- /dev/null +++ b/rust/tests/e2e/rpc_mcp_and_skills.rs @@ -0,0 +1,483 @@ +use std::collections::HashMap; + +use github_copilot_sdk::generated::api_types::{ + ExtensionsDisableRequest, ExtensionsEnableRequest, McpDisableRequest, McpEnableRequest, + McpOauthLoginRequest, SkillsDisableRequest, SkillsEnableRequest, +}; +use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig}; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_list_and_toggle_session_skills() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_list_and_toggle_session_skills", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let skill_name = "session-rpc-skill-rust"; + let skills_dir = create_skill_directory( + ctx.work_dir(), + skill_name, + "Session skill controlled by RPC.", + ); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_skill_directories([skills_dir]) + .with_disabled_skills([skill_name]), + ) + .await + .expect("create session"); + + assert_skill( + session.rpc().skills().list().await.expect("list disabled"), + skill_name, + false, + ); + session + .rpc() + .skills() + .enable(SkillsEnableRequest { + name: skill_name.to_string(), + }) + .await + .expect("enable skill"); + assert_skill( + session.rpc().skills().list().await.expect("list enabled"), + skill_name, + true, + ); + session + .rpc() + .skills() + .disable(SkillsDisableRequest { + name: skill_name.to_string(), + }) + .await + .expect("disable skill"); + assert_skill( + session + .rpc() + .skills() + .list() + .await + .expect("list disabled again"), + skill_name, + false, + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reload_session_skills() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_reload_session_skills", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let skills_dir = ctx.work_dir().join("reloadable-rpc-skills"); + std::fs::create_dir_all(&skills_dir).expect("create skills dir"); + let skill_name = "reload-rpc-skill-rust"; + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_skill_directories([skills_dir.clone()]), + ) + .await + .expect("create session"); + + let before = session.rpc().skills().list().await.expect("list before"); + assert!(!before.skills.iter().any(|skill| skill.name == skill_name)); + + create_skill( + &skills_dir, + skill_name, + "Skill added after session creation.", + ); + session + .rpc() + .skills() + .reload() + .await + .expect("reload skills"); + let after = session.rpc().skills().list().await.expect("list after"); + let skill = assert_skill(after, skill_name, true); + assert_eq!(skill.description, "Skill added after session creation."); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_list_mcp_servers_with_configured_server() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_list_mcp_servers_with_configured_server", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let server_name = "rpc-list-mcp-server"; + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(test_mcp_servers(server_name)), + ) + .await + .expect("create session"); + + let result = session.rpc().mcp().list().await.expect("mcp list"); + assert!( + result + .servers + .iter() + .any(|server| server.name == server_name) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_list_plugins() { + with_e2e_context("rpc_mcp_and_skills", "should_list_plugins", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session.rpc().plugins().list().await.expect("plugins list"); + assert!( + result.plugins.iter().all(|plugin| !plugin.name.is_empty()), + "plugins should have names: {:?}", + result.plugins + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_list_extensions() { + with_e2e_context("rpc_mcp_and_skills", "should_list_extensions", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = + github_copilot_sdk::Client::start(ctx.client_options().with_extra_args(["--yolo"])) + .await + .expect("start yolo client"); + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .extensions() + .list() + .await + .expect("extensions list"); + assert!( + result + .extensions + .iter() + .all(|extension| !extension.id.is_empty() && !extension.name.is_empty()), + "extensions should have ids and names: {:?}", + result.extensions + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_report_error_when_mcp_host_is_not_initialized() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_report_error_when_mcp_host_is_not_initialized", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + expect_err_contains( + session.rpc().mcp().enable(McpEnableRequest { + server_name: "missing-server".to_string(), + }), + "No MCP host initialized", + ) + .await; + expect_err_contains( + session.rpc().mcp().disable(McpDisableRequest { + server_name: "missing-server".to_string(), + }), + "No MCP host initialized", + ) + .await; + expect_err_contains( + session.rpc().mcp().reload(), + "MCP config reload not available", + ) + .await; + expect_err_contains( + session.rpc().mcp().oauth().login(McpOauthLoginRequest { + server_name: "missing-server".to_string(), + callback_success_message: None, + client_name: None, + force_reauth: None, + }), + "MCP host is not available", + ) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_when_mcp_oauth_server_is_not_configured() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_report_error_when_mcp_oauth_server_is_not_configured", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(test_mcp_servers("configured-stdio-server")), + ) + .await + .expect("create session"); + + expect_err_contains( + session.rpc().mcp().oauth().login(McpOauthLoginRequest { + server_name: "missing-server".to_string(), + callback_success_message: None, + client_name: None, + force_reauth: None, + }), + "is not configured", + ) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_when_mcp_oauth_server_is_not_remote() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_report_error_when_mcp_oauth_server_is_not_remote", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let server_name = "configured-stdio-server"; + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(test_mcp_servers(server_name)), + ) + .await + .expect("create session"); + + expect_err_contains( + session.rpc().mcp().oauth().login(McpOauthLoginRequest { + server_name: server_name.to_string(), + callback_success_message: Some("Done".to_string()), + client_name: Some("SDK E2E".to_string()), + force_reauth: Some(true), + }), + "not a remote server", + ) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_when_extensions_are_not_available() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_report_error_when_extensions_are_not_available", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = github_copilot_sdk::Client::start( + ctx.client_options().with_extra_args(["--yolo"]), + ) + .await + .expect("start client"); + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + expect_err_contains( + session.rpc().extensions().enable(ExtensionsEnableRequest { + id: "missing-extension".to_string(), + }), + "Extensions not available", + ) + .await; + expect_err_contains( + session + .rpc() + .extensions() + .disable(ExtensionsDisableRequest { + id: "missing-extension".to_string(), + }), + "Extensions not available", + ) + .await; + expect_err_contains( + session.rpc().extensions().reload(), + "Extensions not available", + ) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn create_skill_directory( + work_dir: &std::path::Path, + skill_name: &str, + description: &str, +) -> std::path::PathBuf { + let skills_dir = work_dir.join("session-rpc-skills"); + create_skill(&skills_dir, skill_name, description); + skills_dir +} + +fn create_skill(skills_dir: &std::path::Path, skill_name: &str, description: &str) { + let skill_dir = skills_dir.join(skill_name); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + format!( + "---\nname: {skill_name}\ndescription: {description}\n---\n\n# {skill_name}\n\nThis skill is used by RPC E2E tests.\n" + ), + ) + .expect("write skill"); +} + +fn assert_skill( + list: github_copilot_sdk::generated::api_types::SkillList, + skill_name: &str, + enabled: bool, +) -> github_copilot_sdk::generated::api_types::Skill { + let skill = list + .skills + .into_iter() + .find(|skill| skill.name == skill_name) + .unwrap_or_else(|| panic!("skill {skill_name} not found")); + assert_eq!(skill.enabled, enabled); + assert!( + skill + .path + .as_deref() + .is_some_and(|path| path.contains(skill_name) && path.ends_with("SKILL.md")) + ); + skill +} + +fn test_mcp_servers(message: &str) -> HashMap { + HashMap::from([( + message.to_string(), + McpServerConfig::Stdio(McpStdioServerConfig { + tools: vec!["*".to_string()], + command: echo_command(), + args: echo_args(message), + ..McpStdioServerConfig::default() + }), + )]) +} + +async fn expect_err_contains( + future: impl std::future::Future>, + expected: &str, +) { + let err = match future.await { + Ok(_) => panic!("expected RPC failure"), + Err(err) => err, + }; + assert!( + err.to_string() + .to_ascii_lowercase() + .contains(&expected.to_ascii_lowercase()), + "expected error to contain {expected:?}, got {err}" + ); +} + +#[cfg(windows)] +fn echo_command() -> String { + "cmd".to_string() +} + +#[cfg(not(windows))] +fn echo_command() -> String { + "echo".to_string() +} + +#[cfg(windows)] +fn echo_args(message: &str) -> Vec { + vec!["/C".to_string(), "echo".to_string(), message.to_string()] +} + +#[cfg(not(windows))] +fn echo_args(message: &str) -> Vec { + vec![message.to_string()] +} diff --git a/rust/tests/e2e/rpc_mcp_config.rs b/rust/tests/e2e/rpc_mcp_config.rs new file mode 100644 index 000000000..818d5119d --- /dev/null +++ b/rust/tests/e2e/rpc_mcp_config.rs @@ -0,0 +1,211 @@ +use github_copilot_sdk::generated::api_types::{ + McpConfigAddRequest, McpConfigDisableRequest, McpConfigEnableRequest, McpConfigRemoveRequest, + McpConfigUpdateRequest, +}; +use serde_json::json; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_call_server_mcp_config_rpcs() { + with_e2e_context( + "rpc_mcp_config", + "should_call_server_mcp_config_rpcs", + |ctx| { + Box::pin(async move { + let server_name = "rust-sdk-test-mcp-config"; + let client = ctx.start_client().await; + let config = client.rpc().mcp().config(); + let _ = config + .remove(McpConfigRemoveRequest { + name: server_name.to_string(), + }) + .await; + + let initial = config.list().await.expect("initial list"); + assert!(!initial.servers.contains_key(server_name)); + + config + .add(McpConfigAddRequest { + name: server_name.to_string(), + config: json!({ "command": "node", "args": [] }), + }) + .await + .expect("add"); + let after_add = config.list().await.expect("list after add"); + assert!(after_add.servers.contains_key(server_name)); + + config + .update(McpConfigUpdateRequest { + name: server_name.to_string(), + config: json!({ "command": "node", "args": ["--version"] }), + }) + .await + .expect("update"); + let after_update = config.list().await.expect("list after update"); + let updated = after_update + .servers + .get(server_name) + .expect("updated server"); + assert_eq!( + updated.get("command").and_then(|v| v.as_str()), + Some("node") + ); + assert_eq!( + updated + .get("args") + .and_then(|v| v.as_array()) + .and_then(|args| args.first()) + .and_then(|v| v.as_str()), + Some("--version") + ); + + config + .disable(McpConfigDisableRequest { + names: vec![server_name.to_string()], + }) + .await + .expect("disable"); + config + .enable(McpConfigEnableRequest { + names: vec![server_name.to_string()], + }) + .await + .expect("enable"); + config + .remove(McpConfigRemoveRequest { + name: server_name.to_string(), + }) + .await + .expect("remove"); + + let after_remove = config.list().await.expect("list after remove"); + assert!(!after_remove.servers.contains_key(server_name)); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_round_trip_http_mcp_oauth_config_rpc() { + with_e2e_context( + "rpc_mcp_config", + "should_round_trip_http_mcp_oauth_config_rpc", + |ctx| { + Box::pin(async move { + let server_name = "rust-sdk-http-oauth-mcp-config"; + let client = ctx.start_client().await; + let config = client.rpc().mcp().config(); + let _ = config + .remove(McpConfigRemoveRequest { + name: server_name.to_string(), + }) + .await; + + config + .add(McpConfigAddRequest { + name: server_name.to_string(), + config: json!({ + "type": "http", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer token" }, + "oauthClientId": "client-id", + "oauthPublicClient": false, + "oauthGrantType": "client_credentials", + "tools": ["*"], + "timeout": 3000 + }), + }) + .await + .expect("add"); + let after_add = config.list().await.expect("list after add"); + let added = after_add.servers.get(server_name).expect("added server"); + assert_eq!(added.get("type").and_then(|v| v.as_str()), Some("http")); + assert_eq!( + added.get("url").and_then(|v| v.as_str()), + Some("https://example.com/mcp") + ); + assert_eq!( + added + .get("headers") + .and_then(|v| v.get("Authorization")) + .and_then(|v| v.as_str()), + Some("Bearer token") + ); + assert_eq!( + added.get("oauthClientId").and_then(|v| v.as_str()), + Some("client-id") + ); + assert_eq!( + added.get("oauthPublicClient").and_then(|v| v.as_bool()), + Some(false) + ); + assert_eq!( + added.get("oauthGrantType").and_then(|v| v.as_str()), + Some("client_credentials") + ); + + config + .update(McpConfigUpdateRequest { + name: server_name.to_string(), + config: json!({ + "type": "http", + "url": "https://example.com/updated-mcp", + "oauthClientId": "updated-client-id", + "oauthPublicClient": true, + "oauthGrantType": "authorization_code", + "tools": ["updated-tool"], + "timeout": 4000 + }), + }) + .await + .expect("update"); + let after_update = config.list().await.expect("list after update"); + let updated = after_update + .servers + .get(server_name) + .expect("updated server"); + assert_eq!( + updated.get("url").and_then(|v| v.as_str()), + Some("https://example.com/updated-mcp") + ); + assert_eq!( + updated.get("oauthClientId").and_then(|v| v.as_str()), + Some("updated-client-id") + ); + assert_eq!( + updated.get("oauthPublicClient").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + updated.get("oauthGrantType").and_then(|v| v.as_str()), + Some("authorization_code") + ); + assert_eq!( + updated + .get("tools") + .and_then(|v| v.as_array()) + .and_then(|tools| tools.first()) + .and_then(|v| v.as_str()), + Some("updated-tool") + ); + assert_eq!(updated.get("timeout").and_then(|v| v.as_i64()), Some(4000)); + + config + .remove(McpConfigRemoveRequest { + name: server_name.to_string(), + }) + .await + .expect("remove"); + let after_remove = config.list().await.expect("list after remove"); + assert!(!after_remove.servers.contains_key(server_name)); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_server.rs b/rust/tests/e2e/rpc_server.rs new file mode 100644 index 000000000..d1508541b --- /dev/null +++ b/rust/tests/e2e/rpc_server.rs @@ -0,0 +1,244 @@ +use github_copilot_sdk::Client; +use github_copilot_sdk::generated::api_types::{ + McpDiscoverRequest, PingRequest, SkillsConfigSetDisabledSkillsRequest, SkillsDiscoverRequest, + ToolsListRequest, +}; +use serde_json::json; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_call_rpc_ping_with_typed_params_and_result() { + with_e2e_context( + "rpc_server", + "should_call_rpc_ping_with_typed_params_and_result", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .ping(PingRequest { + message: Some("typed rpc test".to_string()), + }) + .await + .expect("ping"); + + assert_eq!(result.message, "pong: typed rpc test"); + assert!(result.timestamp >= 0); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_rpc_models_list_with_typed_result() { + with_e2e_context( + "rpc_server", + "should_call_rpc_models_list_with_typed_result", + |ctx| { + Box::pin(async move { + let token = "rpc-models-token"; + ctx.set_copilot_user_by_token_with_login(token, "rpc-user"); + let client = Client::start(ctx.client_options().with_github_token(token)) + .await + .expect("start client"); + + let result = client.rpc().models().list().await.expect("models list"); + + assert!( + result + .models + .iter() + .any(|model| model.id == "claude-sonnet-4.5") + ); + assert!(result.models.iter().all(|model| !model.name.is_empty())); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_rpc_account_get_quota_when_authenticated() { + with_e2e_context( + "rpc_server", + "should_call_rpc_account_get_quota_when_authenticated", + |ctx| { + Box::pin(async move { + let token = "rpc-quota-token"; + ctx.set_copilot_user_by_token_with_login_and_quota( + token, + "rpc-user", + Some(json!({ + "chat": { + "entitlement": 100, + "overage_count": 2, + "overage_permitted": true, + "percent_remaining": 75, + "timestamp_utc": "2026-04-30T00:00:00Z" + } + })), + ); + let client = Client::start(ctx.client_options().with_github_token(token)) + .await + .expect("start client"); + + let result = client.rpc().account().get_quota().await.expect("quota"); + let chat = result.quota_snapshots.get("chat").expect("chat quota"); + + assert_eq!(chat.entitlement_requests, 100); + assert_eq!(chat.used_requests, 25); + assert_eq!(chat.remaining_percentage, 75.0); + assert_eq!(chat.overage, 2.0); + assert!(chat.usage_allowed_with_exhausted_quota); + assert!(chat.overage_allowed_with_exhausted_quota); + assert_eq!(chat.reset_date.as_deref(), Some("2026-04-30T00:00:00Z")); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_rpc_tools_list_with_typed_result() { + with_e2e_context( + "rpc_server", + "should_call_rpc_tools_list_with_typed_result", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .tools() + .list(ToolsListRequest { model: None }) + .await + .expect("tools list"); + + assert!(!result.tools.is_empty()); + assert!(result.tools.iter().all(|tool| !tool.name.is_empty())); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_discover_server_mcp_and_skills() { + with_e2e_context( + "rpc_server", + "should_discover_server_mcp_and_skills", + |ctx| { + Box::pin(async move { + let skill_name = "server-rpc-skill-rust"; + let skill_directory = create_skill_directory( + ctx.work_dir(), + skill_name, + "Skill discovered by server-scoped RPC tests.", + ); + let client = ctx.start_client().await; + + let mcp = client + .rpc() + .mcp() + .discover(McpDiscoverRequest { + working_directory: Some(ctx.work_dir().to_string_lossy().to_string()), + }) + .await + .expect("mcp discover"); + assert!(mcp.servers.iter().all(|server| !server.name.is_empty())); + + let skills = client + .rpc() + .skills() + .discover(SkillsDiscoverRequest { + project_paths: Vec::new(), + skill_directories: vec![skill_directory.to_string_lossy().to_string()], + }) + .await + .expect("skills discover"); + let discovered = assert_server_skill(skills, skill_name, true); + assert_eq!( + discovered.description, + "Skill discovered by server-scoped RPC tests." + ); + + client + .rpc() + .skills() + .config() + .set_disabled_skills(SkillsConfigSetDisabledSkillsRequest { + disabled_skills: vec![skill_name.to_string()], + }) + .await + .expect("disable skill globally"); + let disabled_skills = client + .rpc() + .skills() + .discover(SkillsDiscoverRequest { + project_paths: Vec::new(), + skill_directories: vec![skill_directory.to_string_lossy().to_string()], + }) + .await + .expect("skills discover disabled"); + assert_server_skill(disabled_skills, skill_name, false); + + client + .rpc() + .skills() + .config() + .set_disabled_skills(SkillsConfigSetDisabledSkillsRequest { + disabled_skills: Vec::new(), + }) + .await + .expect("clear disabled skills"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn create_skill_directory( + work_dir: &std::path::Path, + skill_name: &str, + description: &str, +) -> std::path::PathBuf { + let skills_dir = work_dir.join("server-rpc-skills"); + let skill_dir = skills_dir.join(skill_name); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + format!( + "---\nname: {skill_name}\ndescription: {description}\n---\n\n# {skill_name}\n\nThis skill is used by RPC E2E tests.\n" + ), + ) + .expect("write skill"); + skills_dir +} + +fn assert_server_skill( + list: github_copilot_sdk::generated::api_types::ServerSkillList, + skill_name: &str, + enabled: bool, +) -> github_copilot_sdk::generated::api_types::ServerSkill { + let skill = list + .skills + .into_iter() + .find(|skill| skill.name == skill_name) + .unwrap_or_else(|| panic!("skill {skill_name} not found")); + assert_eq!(skill.enabled, enabled); + assert!( + skill + .path + .as_deref() + .is_some_and(|path| path.contains(skill_name) && path.ends_with("SKILL.md")) + ); + skill +} diff --git a/rust/tests/e2e/rpc_session_state.rs b/rust/tests/e2e/rpc_session_state.rs new file mode 100644 index 000000000..8a8ae5c18 --- /dev/null +++ b/rust/tests/e2e/rpc_session_state.rs @@ -0,0 +1,990 @@ +use github_copilot_sdk::generated::api_types::{ + HistoryTruncateRequest, McpOauthLoginRequest, ModeSetRequest, ModelSwitchToRequest, + NameSetRequest, PermissionsSetApproveAllRequest, PlanUpdateRequest, SessionMode, + SessionsForkRequest, WorkspacesCreateFileRequest, WorkspacesReadFileRequest, +}; +use github_copilot_sdk::generated::session_events::{ + AssistantMessageData, SessionEventType, SessionTitleChangedData, + SessionWorkspaceFileChangedData, UserMessageData, WorkspaceFileChangedOperation, +}; + +use super::support::{assistant_message_content, wait_for_event, with_e2e_context}; + +#[tokio::test] +async fn should_call_session_rpc_model_getcurrent() { + with_e2e_context( + "rpc_session_state", + "should_call_session_rpc_model_getcurrent", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5"), + ) + .await + .expect("create session"); + + let current = session + .rpc() + .model() + .get_current() + .await + .expect("get current model"); + assert_eq!(current.model_id.as_deref(), Some("claude-sonnet-4.5")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_session_rpc_model_switchto() { + with_e2e_context( + "rpc_session_state", + "should_call_session_rpc_model_switchto", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5"), + ) + .await + .expect("create session"); + + let result = session + .rpc() + .model() + .switch_to(ModelSwitchToRequest { + model_id: "gpt-4.1".to_string(), + reasoning_effort: Some("high".to_string()), + model_capabilities: None, + }) + .await + .expect("switch model"); + assert_eq!(result.model_id.as_deref(), Some("gpt-4.1")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_and_set_session_mode() { + with_e2e_context( + "rpc_session_state", + "should_get_and_set_session_mode", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + assert_eq!( + session.rpc().mode().get().await.expect("get mode"), + SessionMode::Interactive + ); + session + .rpc() + .mode() + .set(ModeSetRequest { + mode: SessionMode::Plan, + }) + .await + .expect("set plan"); + assert_eq!( + session.rpc().mode().get().await.expect("get mode"), + SessionMode::Plan + ); + session + .rpc() + .mode() + .set(ModeSetRequest { + mode: SessionMode::Interactive, + }) + .await + .expect("set interactive"); + assert_eq!( + session.rpc().mode().get().await.expect("get mode"), + SessionMode::Interactive + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_set_and_get_each_session_mode_value() { + with_e2e_context( + "rpc_session_state", + "should_set_and_get_each_session_mode_value", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + for mode in [ + SessionMode::Interactive, + SessionMode::Plan, + SessionMode::Autopilot, + ] { + session + .rpc() + .mode() + .set(ModeSetRequest { mode: mode.clone() }) + .await + .expect("set mode"); + assert_eq!(session.rpc().mode().get().await.expect("get mode"), mode); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_read_update_and_delete_plan() { + with_e2e_context( + "rpc_session_state", + "should_read_update_and_delete_plan", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let content = "# Test Plan\n\n- Step 1\n- Step 2"; + + let initial = session.rpc().plan().read().await.expect("read initial"); + assert!(!initial.exists); + assert!(initial.content.is_none()); + session + .rpc() + .plan() + .update(PlanUpdateRequest { + content: content.to_string(), + }) + .await + .expect("update plan"); + let updated = session.rpc().plan().read().await.expect("read updated"); + assert!(updated.exists); + assert_eq!(updated.content.as_deref(), Some(content)); + session.rpc().plan().delete().await.expect("delete plan"); + let deleted = session.rpc().plan().read().await.expect("read deleted"); + assert!(!deleted.exists); + assert!(deleted.content.is_none()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_workspace_file_rpc_methods() { + with_e2e_context( + "rpc_session_state", + "should_call_workspace_file_rpc_methods", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let initial = session + .rpc() + .workspaces() + .list_files() + .await + .expect("list files"); + assert!(initial.files.is_empty()); + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: "test.txt".to_string(), + content: "Hello, workspace!".to_string(), + }) + .await + .expect("create file"); + let listed = session + .rpc() + .workspaces() + .list_files() + .await + .expect("list files"); + assert!(listed.files.iter().any(|file| file == "test.txt")); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: "test.txt".to_string(), + }) + .await + .expect("read file"); + assert_eq!(read.content, "Hello, workspace!"); + let workspace = session + .rpc() + .workspaces() + .get_workspace() + .await + .expect("get workspace"); + assert!(workspace.workspace.is_some()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reject_workspace_file_path_traversal() { + with_e2e_context( + "rpc_session_state", + "should_reject_workspace_file_path_traversal", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let err = session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: "../escaped.txt".to_string(), + content: "outside".to_string(), + }) + .await + .expect_err("path traversal should fail"); + assert!(err.to_string().contains("workspace")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_workspace_file_with_nested_path_auto_creating_dirs() { + with_e2e_context( + "rpc_session_state", + "should_create_workspace_file_with_nested_path_auto_creating_dirs", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let path = "nested-rust/subdir/file.txt"; + + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: "nested content".to_string(), + }) + .await + .expect("create nested file"); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: path.to_string(), + }) + .await + .expect("read nested file"); + assert_eq!(read.content, "nested content"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_reading_nonexistent_workspace_file() { + with_e2e_context( + "rpc_session_state", + "should_report_error_reading_nonexistent_workspace_file", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + assert!( + session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: "never-exists-rust.txt".to_string(), + }) + .await + .is_err() + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_update_existing_workspace_file_with_update_operation() { + with_e2e_context( + "rpc_session_state", + "should_update_existing_workspace_file_with_update_operation", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let path = "reused-rust.txt"; + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: "v1".to_string(), + }) + .await + .expect("create file"); + + let update_event = + wait_for_event(session.subscribe(), "workspace update event", |event| { + if event.parsed_type() != SessionEventType::SessionWorkspaceFileChanged { + return false; + } + let data = event + .typed_data::() + .expect("workspace file changed data"); + data.path == path && data.operation == WorkspaceFileChangedOperation::Update + }); + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: "v2".to_string(), + }) + .await + .expect("update file"); + update_event.await; + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: path.to_string(), + }) + .await + .expect("read updated"); + assert_eq!(read.content, "v2"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reject_empty_or_whitespace_session_name() { + with_e2e_context( + "rpc_session_state", + "should_reject_empty_or_whitespace_session_name", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + for name in ["", " ", "\t\n \r"] { + let err = session + .rpc() + .name() + .set(NameSetRequest { + name: name.to_string(), + }) + .await + .expect_err("empty name should fail"); + assert!(err.to_string().to_lowercase().contains("empty")); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_title_changed_event_each_time_name_set_is_called() { + with_e2e_context( + "rpc_session_state", + "should_emit_title_changed_event_each_time_name_set_is_called", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + for title in ["Title-A-Rust", "Title-B-Rust"] { + let event = wait_for_event(session.subscribe(), "title changed", |event| { + if event.parsed_type() != SessionEventType::SessionTitleChanged { + return false; + } + event + .typed_data::() + .expect("title data") + .title + == title + }); + session + .rpc() + .name() + .set(NameSetRequest { + name: title.to_string(), + }) + .await + .expect("set name"); + event.await; + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_and_set_session_metadata() { + with_e2e_context( + "rpc_session_state", + "should_get_and_set_session_metadata", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .rpc() + .name() + .set(NameSetRequest { + name: "SDK test session".to_string(), + }) + .await + .expect("set name"); + assert_eq!( + session + .rpc() + .name() + .get() + .await + .expect("get name") + .name + .as_deref(), + Some("SDK test session") + ); + let sources = session + .rpc() + .instructions() + .get_sources() + .await + .expect("get instruction sources"); + assert!(sources.sources.is_empty() || !sources.sources.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_session_usage_and_permission_rpcs() { + with_e2e_context( + "rpc_session_state", + "should_call_session_usage_and_permission_rpcs", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let metrics = session.rpc().usage().get_metrics().await.expect("metrics"); + assert!(metrics.session_start_time > 0); + assert!( + session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { enabled: true }) + .await + .expect("set approve all") + .success + ); + assert!( + session + .rpc() + .permissions() + .reset_session_approvals() + .await + .expect("reset approvals") + .success + ); + session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { enabled: false }) + .await + .expect("disable approve all"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_fork_session_with_persisted_messages() { + with_e2e_context( + "rpc_session_state", + "should_fork_session_with_persisted_messages", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Say FORK_SOURCE_ALPHA exactly.") + .await + .expect("send source") + .expect("source answer"); + assert!(assistant_message_content(&answer).contains("FORK_SOURCE_ALPHA")); + let fork = client + .rpc() + .sessions() + .fork(SessionsForkRequest { + session_id: session.id().clone(), + to_event_id: None, + }) + .await + .expect("fork session"); + assert_ne!(fork.session_id, *session.id()); + let forked = client + .resume_session( + github_copilot_sdk::ResumeSessionConfig::new(fork.session_id) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )), + ) + .await + .expect("resume fork"); + let forked_messages = forked.get_messages().await.expect("forked messages"); + assert!(contains_user_message( + &forked_messages, + "Say FORK_SOURCE_ALPHA exactly." + )); + assert!(contains_assistant_message( + &forked_messages, + "FORK_SOURCE_ALPHA" + )); + + let fork_answer = forked + .send_and_wait("Now say FORK_CHILD_BETA exactly.") + .await + .expect("send fork") + .expect("fork answer"); + assert!(assistant_message_content(&fork_answer).contains("FORK_CHILD_BETA")); + let source_after = session.get_messages().await.expect("source messages"); + assert!(!contains_user_message( + &source_after, + "Now say FORK_CHILD_BETA exactly." + )); + + forked.disconnect().await.expect("disconnect fork"); + session.disconnect().await.expect("disconnect source"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_forking_session_without_persisted_events() { + with_e2e_context( + "rpc_session_state", + "should_handle_forking_session_without_persisted_events", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + match client + .rpc() + .sessions() + .fork(SessionsForkRequest { + session_id: session.id().clone(), + to_event_id: None, + }) + .await + { + Ok(fork) => { + assert!(!fork.session_id.as_str().trim().is_empty()); + assert_ne!(fork.session_id, *session.id()); + let forked = client + .resume_session( + github_copilot_sdk::ResumeSessionConfig::new(fork.session_id) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )), + ) + .await + .expect("resume fork"); + assert!( + !forked + .get_messages() + .await + .expect("forked messages") + .iter() + .any(|event| { + matches!( + event.parsed_type(), + SessionEventType::UserMessage + | SessionEventType::AssistantMessage + ) + }) + ); + forked.disconnect().await.expect("disconnect fork"); + } + Err(err) => { + let message = err.to_string(); + assert!( + message.contains("not found or has no persisted events"), + "unexpected sessions.fork error: {message}" + ); + assert!( + !message.contains("Unhandled method sessions.fork"), + "expected implemented error for sessions.fork, got {message}" + ); + } + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_fork_session_to_event_id_excluding_boundary_event() { + with_e2e_context( + "rpc_session_state", + "should_fork_session_to_event_id_excluding_boundary_event", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait("Say FORK_BOUNDARY_FIRST exactly.") + .await + .expect("send first"); + session + .send_and_wait("Say FORK_BOUNDARY_SECOND exactly.") + .await + .expect("send second"); + let source_events = session.get_messages().await.expect("messages"); + let boundary_id = source_events + .iter() + .find(|event| { + event.parsed_type() == SessionEventType::UserMessage + && event.typed_data::().is_some_and(|data| { + data.content == "Say FORK_BOUNDARY_SECOND exactly." + }) + }) + .expect("second user message") + .id + .clone(); + let fork = client + .rpc() + .sessions() + .fork(SessionsForkRequest { + session_id: session.id().clone(), + to_event_id: Some(boundary_id.clone()), + }) + .await + .expect("fork to boundary"); + let forked = client + .resume_session( + github_copilot_sdk::ResumeSessionConfig::new(fork.session_id) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )), + ) + .await + .expect("resume fork"); + let forked_events = forked.get_messages().await.expect("forked messages"); + assert!(contains_user_message( + &forked_events, + "Say FORK_BOUNDARY_FIRST exactly." + )); + assert!(!forked_events.iter().any(|event| event.id == boundary_id)); + assert!(!contains_user_message( + &forked_events, + "Say FORK_BOUNDARY_SECOND exactly." + )); + + forked.disconnect().await.expect("disconnect fork"); + session.disconnect().await.expect("disconnect source"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_when_forking_session_to_unknown_event_id() { + with_e2e_context( + "rpc_session_state", + "should_report_error_when_forking_session_to_unknown_event_id", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + session + .send_and_wait("Say FORK_UNKNOWN_EVENT_OK exactly.") + .await + .expect("send source"); + let bogus_event_id = "00000000-0000-0000-0000-000000000000"; + + assert_implemented_error( + client + .rpc() + .sessions() + .fork(SessionsForkRequest { + session_id: session.id().clone(), + to_event_id: Some(bogus_event_id.to_string()), + }) + .await, + "sessions.fork", + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_implemented_errors_for_unsupported_session_rpc_paths() { + with_e2e_context( + "rpc_session_state", + "should_report_implemented_errors_for_unsupported_session_rpc_paths", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + assert_implemented_error( + session + .rpc() + .history() + .truncate(HistoryTruncateRequest { + event_id: "missing-event".to_string(), + }) + .await, + "session.history.truncate", + ); + assert_implemented_error( + session + .rpc() + .mcp() + .oauth() + .login(McpOauthLoginRequest { + server_name: "missing-server".to_string(), + callback_success_message: None, + client_name: None, + force_reauth: None, + }) + .await, + "session.mcp.oauth.login", + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_compact_session_history_after_messages() { + with_e2e_context( + "rpc_session_state", + "should_compact_session_history_after_messages", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 2+2?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('4')); + + let compact = session + .rpc() + .history() + .compact() + .await + .expect("compact history"); + assert!(compact.success); + assert!(compact.messages_removed >= 0); + session.rpc().name().get().await.expect("name still works"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn contains_user_message(events: &[github_copilot_sdk::SessionEvent], expected: &str) -> bool { + events.iter().any(|event| { + event.parsed_type() == SessionEventType::UserMessage + && event + .typed_data::() + .is_some_and(|data| data.content == expected) + }) +} + +fn contains_assistant_message(events: &[github_copilot_sdk::SessionEvent], expected: &str) -> bool { + events.iter().any(|event| { + event.parsed_type() == SessionEventType::AssistantMessage + && event + .typed_data::() + .is_some_and(|data| data.content.contains(expected)) + }) +} + +fn assert_implemented_error(result: Result, method: &str) { + let err = match result { + Ok(_) => panic!("RPC should fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + !message.contains(&format!("Unhandled method {method}")), + "expected implemented error for {method}, got {message}" + ); +} diff --git a/rust/tests/e2e/rpc_shell_and_fleet.rs b/rust/tests/e2e/rpc_shell_and_fleet.rs new file mode 100644 index 000000000..eb389421a --- /dev/null +++ b/rust/tests/e2e/rpc_shell_and_fleet.rs @@ -0,0 +1,115 @@ +use github_copilot_sdk::generated::api_types::{ShellExecRequest, ShellKillRequest}; + +use super::support::{wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn should_execute_shell_command() { + with_e2e_context( + "rpc_shell_and_fleet", + "should_execute_shell_command", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let marker_path = ctx.work_dir().join("shell-rpc-marker.txt"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: write_file_command(&marker_path, "copilot-sdk-shell-rpc"), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: None, + }) + .await + .expect("execute shell command"); + + assert!(!result.process_id.trim().is_empty()); + wait_for_file_text(&marker_path, "copilot-sdk-shell-rpc").await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_kill_shell_process() { + with_e2e_context("rpc_shell_and_fleet", "should_kill_shell_process", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let exec = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: long_running_command(), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: None, + }) + .await + .expect("start shell process"); + assert!(!exec.process_id.trim().is_empty()); + + let killed = session + .rpc() + .shell() + .kill(ShellKillRequest { + process_id: exec.process_id, + signal: None, + }) + .await + .expect("kill shell process"); + assert!(killed.killed); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +async fn wait_for_file_text(path: &std::path::Path, expected: &'static str) { + wait_for_condition("shell command output file", || async { + match std::fs::read_to_string(path) { + Ok(content) => content.contains(expected), + Err(_) => false, + } + }) + .await; +} + +#[cfg(windows)] +fn write_file_command(path: &std::path::Path, marker: &str) -> String { + format!( + "powershell -NoLogo -NoProfile -Command \"Set-Content -LiteralPath '{}' -Value '{}'\"", + path.display(), + marker + ) +} + +#[cfg(not(windows))] +fn write_file_command(path: &std::path::Path, marker: &str) -> String { + format!("sh -c \"printf '%s' '{}' > '{}'\"", marker, path.display()) +} + +#[cfg(windows)] +fn long_running_command() -> String { + "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 30\"".to_string() +} + +#[cfg(not(windows))] +fn long_running_command() -> String { + "sleep 30".to_string() +} diff --git a/rust/tests/e2e/rpc_shell_edge_cases.rs b/rust/tests/e2e/rpc_shell_edge_cases.rs new file mode 100644 index 000000000..a94bc1007 --- /dev/null +++ b/rust/tests/e2e/rpc_shell_edge_cases.rs @@ -0,0 +1,392 @@ +use std::path::Path; + +use github_copilot_sdk::generated::api_types::{ + ShellExecRequest, ShellKillRequest, ShellKillSignal, +}; + +use super::support::{wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn shell_exec_with_timeout_kills_long_running_command() { + with_e2e_context( + "rpc_shell_edge_cases", + "shell_exec_with_timeout_kills_long_running_command", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let started_path = ctx.work_dir().join("shell-timeout-started.txt"); + let marker_path = ctx.work_dir().join("shell-timeout-marker.txt"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: delayed_write_command(&started_path, &marker_path), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: Some(200), + }) + .await + .expect("execute timed command"); + assert!(!result.process_id.trim().is_empty()); + + wait_for_exists(&started_path).await; + wait_for_process_cleanup(&session, result.process_id, "timed-out command").await; + assert!( + !marker_path.exists(), + "timeout should kill before marker is written" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn shell_exec_with_custom_cwd_honors_override() { + with_e2e_context( + "rpc_shell_edge_cases", + "shell_exec_with_custom_cwd_honors_override", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let subdir = ctx.work_dir().join("shell-cwd"); + std::fs::create_dir_all(&subdir).expect("create shell cwd"); + let marker_path = subdir.join("marker.txt"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: write_relative_marker_command("shell-cwd-marker"), + cwd: Some(subdir.display().to_string()), + timeout: None, + }) + .await + .expect("execute cwd command"); + + assert!(!result.process_id.trim().is_empty()); + wait_for_file_text(&marker_path, "shell-cwd-marker").await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn shell_exec_with_nonexistent_command_returns_processid_and_cleans_up() { + with_e2e_context( + "rpc_shell_edge_cases", + "shell_exec_with_nonexistent_command_returns_processid_and_cleans_up", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: nonexistent_command(), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: None, + }) + .await + .expect("execute nonexistent command"); + + assert!(!result.process_id.trim().is_empty()); + wait_for_process_cleanup(&session, result.process_id, "nonexistent command").await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn shell_kill_unknown_processid_returns_false() { + with_e2e_context( + "rpc_shell_edge_cases", + "shell_kill_unknown_processid_returns_false", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .kill(ShellKillRequest { + process_id: "unknown-rust-process".to_string(), + signal: None, + }) + .await + .expect("kill unknown process"); + + assert!(!result.killed); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn shell_kill_cleans_up_after_terminating_signal() { + with_e2e_context( + "rpc_shell_edge_cases", + "shell_kill_cleans_up_after_terminating_signal", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let exec = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: long_running_command(), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: None, + }) + .await + .expect("start shell"); + + let killed = session + .rpc() + .shell() + .kill(ShellKillRequest { + process_id: exec.process_id.clone(), + signal: Some(ShellKillSignal::SIGTERM), + }) + .await + .expect("kill shell"); + assert!(killed.killed); + wait_for_process_cleanup(&session, exec.process_id, "killed command").await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn shell_exec_with_stderr_output_cleans_up() { + with_e2e_context( + "rpc_shell_edge_cases", + "shell_exec_with_stderr_output_cleans_up", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let marker_path = ctx.work_dir().join("shell-stderr-marker.txt"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: stderr_command(&marker_path), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: None, + }) + .await + .expect("execute stderr command"); + + wait_for_exists(&marker_path).await; + wait_for_process_cleanup(&session, result.process_id, "stderr command").await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn shell_exec_with_large_stdout_cleans_up() { + with_e2e_context( + "rpc_shell_edge_cases", + "shell_exec_with_large_stdout_cleans_up", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let marker_path = ctx.work_dir().join("shell-stdout-marker.txt"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: large_stdout_command(&marker_path), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: None, + }) + .await + .expect("execute large stdout command"); + + wait_for_exists(&marker_path).await; + wait_for_process_cleanup(&session, result.process_id, "large stdout command").await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +async fn wait_for_exists(path: &Path) { + wait_for_condition("shell marker file", || async { path.exists() }).await; +} + +async fn wait_for_file_text(path: &Path, expected: &'static str) { + wait_for_condition("shell marker text", || async { + match std::fs::read_to_string(path) { + Ok(content) => content.contains(expected), + Err(_) => false, + } + }) + .await; +} + +async fn wait_for_process_cleanup( + session: &github_copilot_sdk::session::Session, + process_id: String, + _scenario: &'static str, +) { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let result = session + .rpc() + .shell() + .kill(ShellKillRequest { + process_id, + signal: None, + }) + .await + .expect("probe process cleanup"); + assert!(!result.killed); +} + +#[cfg(windows)] +fn delayed_write_command(started_path: &Path, marker_path: &Path) -> String { + format!( + "powershell -NoLogo -NoProfile -Command \"Set-Content -LiteralPath '{}' -Value started; Start-Sleep -Seconds 30; Set-Content -LiteralPath '{}' -Value should-not-exist\"", + started_path.display(), + marker_path.display() + ) +} + +#[cfg(not(windows))] +fn delayed_write_command(started_path: &Path, marker_path: &Path) -> String { + format!( + "sh -c \"printf started > '{}'; sleep 30; printf should-not-exist > '{}'\"", + started_path.display(), + marker_path.display() + ) +} + +#[cfg(windows)] +fn write_relative_marker_command(marker: &str) -> String { + format!( + "powershell -NoLogo -NoProfile -Command \"Set-Content -LiteralPath 'marker.txt' -Value '{marker}'\"" + ) +} + +#[cfg(not(windows))] +fn write_relative_marker_command(marker: &str) -> String { + format!("sh -c \"printf '%s' '{marker}' > marker.txt\"") +} + +#[cfg(windows)] +fn long_running_command() -> String { + "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 60\"".to_string() +} + +#[cfg(not(windows))] +fn long_running_command() -> String { + "sleep 60".to_string() +} + +#[cfg(windows)] +fn nonexistent_command() -> String { + "cmd /C definitely-not-a-real-command-rust-12345".to_string() +} + +#[cfg(not(windows))] +fn nonexistent_command() -> String { + "sh -c 'definitely-not-a-real-command-rust-12345'".to_string() +} + +#[cfg(windows)] +fn stderr_command(marker_path: &Path) -> String { + format!( + "powershell -NoLogo -NoProfile -Command \"[Console]::Error.WriteLine('boom'); Set-Content -LiteralPath '{}' -Value done; exit 2\"", + marker_path.display() + ) +} + +#[cfg(not(windows))] +fn stderr_command(marker_path: &Path) -> String { + format!( + "sh -c \"echo boom 1>&2; printf done > '{}'; exit 2\"", + marker_path.display() + ) +} + +#[cfg(windows)] +fn large_stdout_command(marker_path: &Path) -> String { + format!( + "powershell -NoLogo -NoProfile -Command \"Write-Host ('x' * 204800); Set-Content -LiteralPath '{}' -Value done\"", + marker_path.display() + ) +} + +#[cfg(not(windows))] +fn large_stdout_command(marker_path: &Path) -> String { + format!( + "sh -c \"python3 - <<'PY'\nprint('x' * 204800)\nPY\nprintf done > '{}'\"", + marker_path.display() + ) +} diff --git a/rust/tests/e2e/rpc_tasks_and_handlers.rs b/rust/tests/e2e/rpc_tasks_and_handlers.rs new file mode 100644 index 000000000..d98f88598 --- /dev/null +++ b/rust/tests/e2e/rpc_tasks_and_handlers.rs @@ -0,0 +1,293 @@ +use github_copilot_sdk::generated::api_types::{ + CommandsHandlePendingCommandRequest, HandlePendingToolCallRequest, PermissionDecision, + PermissionDecisionApproveForLocation, PermissionDecisionApproveForLocationApproval, + PermissionDecisionApproveForLocationApprovalCustomTool, + PermissionDecisionApproveForLocationApprovalCustomToolKind, + PermissionDecisionApproveForLocationKind, PermissionDecisionApproveForSession, + PermissionDecisionApproveForSessionApproval, + PermissionDecisionApproveForSessionApprovalCustomTool, + PermissionDecisionApproveForSessionApprovalCustomToolKind, + PermissionDecisionApproveForSessionKind, PermissionDecisionApproveOnce, + PermissionDecisionApproveOnceKind, PermissionDecisionApprovePermanently, + PermissionDecisionApprovePermanentlyKind, PermissionDecisionReject, + PermissionDecisionRejectKind, PermissionDecisionRequest, TasksCancelRequest, + TasksPromoteToBackgroundRequest, TasksRemoveRequest, TasksStartAgentRequest, + UIElicitationResponse, UIElicitationResponseAction, UIHandlePendingElicitationRequest, +}; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_list_task_state_and_return_false_for_missing_task_operations() { + with_e2e_context( + "rpc_tasks_and_handlers", + "should_list_task_state_and_return_false_for_missing_task_operations", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let tasks = session.rpc().tasks().list().await.expect("list tasks"); + assert!(tasks.tasks.is_empty()); + assert!( + !session + .rpc() + .tasks() + .promote_to_background(TasksPromoteToBackgroundRequest { + id: "missing-task".to_string(), + }) + .await + .expect("promote missing") + .promoted + ); + assert!( + !session + .rpc() + .tasks() + .cancel(TasksCancelRequest { + id: "missing-task".to_string(), + }) + .await + .expect("cancel missing") + .cancelled + ); + assert!( + !session + .rpc() + .tasks() + .remove(TasksRemoveRequest { + id: "missing-task".to_string(), + }) + .await + .expect("remove missing") + .removed + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_implemented_error_for_missing_task_agent_type() { + with_e2e_context( + "rpc_tasks_and_handlers", + "should_report_implemented_error_for_missing_task_agent_type", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + assert_implemented_error( + session + .rpc() + .tasks() + .start_agent(TasksStartAgentRequest { + agent_type: "missing-agent-type".to_string(), + prompt: "Say hi".to_string(), + name: "sdk-test-task".to_string(), + description: None, + model: None, + }) + .await, + "session.tasks.startAgent", + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_implemented_error_for_invalid_task_agent_model() { + with_e2e_context( + "rpc_tasks_and_handlers", + "should_report_implemented_error_for_invalid_task_agent_model", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + assert_implemented_error( + session + .rpc() + .tasks() + .start_agent(TasksStartAgentRequest { + agent_type: "general-purpose".to_string(), + prompt: "Say hi".to_string(), + name: "sdk-test-task".to_string(), + description: Some("SDK task agent validation".to_string()), + model: Some("not-a-real-model".to_string()), + }) + .await, + "session.tasks.startAgent", + ); + assert!( + session + .rpc() + .tasks() + .list() + .await + .expect("list tasks after invalid start") + .tasks + .is_empty() + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_return_expected_results_for_missing_pending_handler_requestids() { + with_e2e_context( + "rpc_tasks_and_handlers", + "should_return_expected_results_for_missing_pending_handler_requestids", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let tool = session + .rpc() + .tools() + .handle_pending_tool_call(HandlePendingToolCallRequest { + request_id: "missing-tool-request".into(), + result: Some(serde_json::json!("tool result")), + error: None, + }) + .await + .expect("handle missing tool"); + assert!(!tool.success); + + let command = session + .rpc() + .commands() + .handle_pending_command(CommandsHandlePendingCommandRequest { + request_id: "missing-command-request".into(), + error: Some("command error".to_string()), + }) + .await + .expect("handle missing command"); + assert!(command.success); + + let elicitation = session + .rpc() + .ui() + .handle_pending_elicitation(UIHandlePendingElicitationRequest { + request_id: "missing-elicitation-request".into(), + result: UIElicitationResponse { + action: UIElicitationResponseAction::Cancel, + content: Default::default(), + }, + }) + .await + .expect("handle missing elicitation"); + assert!(!elicitation.success); + + for (request_id, result) in [ + ( + "missing-permission-request", + PermissionDecision::Reject(PermissionDecisionReject { + feedback: Some("not approved".to_string()), + kind: PermissionDecisionRejectKind::Reject, + }), + ), + ( + "missing-approve-once-request", + PermissionDecision::ApproveOnce(PermissionDecisionApproveOnce { + kind: PermissionDecisionApproveOnceKind::ApproveOnce, + }), + ), + ( + "missing-permanent-permission-request", + PermissionDecision::ApprovePermanently( + PermissionDecisionApprovePermanently { + domain: "example.com".to_string(), + kind: PermissionDecisionApprovePermanentlyKind::ApprovePermanently, + }, + ), + ), + ( + "missing-session-approval-request", + PermissionDecision::ApproveForSession(PermissionDecisionApproveForSession { + approval: Some(PermissionDecisionApproveForSessionApproval::CustomTool( + PermissionDecisionApproveForSessionApprovalCustomTool { + kind: PermissionDecisionApproveForSessionApprovalCustomToolKind::CustomTool, + tool_name: "missing-tool".to_string(), + }, + )), + domain: None, + kind: PermissionDecisionApproveForSessionKind::ApproveForSession, + }), + ), + ( + "missing-location-approval-request", + PermissionDecision::ApproveForLocation(PermissionDecisionApproveForLocation { + approval: PermissionDecisionApproveForLocationApproval::CustomTool( + PermissionDecisionApproveForLocationApprovalCustomTool { + kind: PermissionDecisionApproveForLocationApprovalCustomToolKind::CustomTool, + tool_name: "missing-tool".to_string(), + }, + ), + kind: PermissionDecisionApproveForLocationKind::ApproveForLocation, + location_key: "missing-location".to_string(), + }), + ), + ] { + let permission = session + .rpc() + .permissions() + .handle_pending_permission_request(PermissionDecisionRequest { + request_id: request_id.into(), + result, + }) + .await + .expect("handle missing permission"); + assert!(!permission.success, "{request_id} should not be handled"); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn assert_implemented_error(result: Result, method: &str) { + let err = match result { + Ok(_) => panic!("RPC should fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + !message.contains(&format!("Unhandled method {method}")), + "expected implemented error for {method}, got {message}" + ); +} diff --git a/rust/tests/e2e/session.rs b/rust/tests/e2e/session.rs new file mode 100644 index 000000000..25aff47a9 --- /dev/null +++ b/rust/tests/e2e/session.rs @@ -0,0 +1,1575 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use github_copilot_sdk::generated::session_events::{ + SessionErrorData, SessionEventType, SessionInfoData, SessionModelChangeData, SessionResumeData, + SessionStartData, SessionWarningData, UserMessageData, +}; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter}; +use github_copilot_sdk::types::LogLevel as SessionLogLevel; +use github_copilot_sdk::{ + Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, + AzureProviderOptions, DefaultAgentConfig, Error, GitHubReferenceType, LogOptions, + MessageOptions, ProviderConfig, ResumeSessionConfig, SectionOverride, SessionConfig, + SetModelOptions, SystemMessageConfig, Tool, ToolInvocation, ToolResult, +}; +use serde_json::json; + +use super::support::{ + assert_uuid_like, assistant_message_content, collect_until_idle, event_types, + get_system_message, get_tool_names, wait_for_condition, wait_for_event, with_e2e_context, +}; + +#[tokio::test] +async fn shouldcreateanddisconnectsessions() { + with_e2e_context("session", "shouldcreateanddisconnectsessions", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5"), + ) + .await + .expect("create session"); + + assert_uuid_like(session.id()); + let messages = session.get_messages().await.expect("get messages"); + assert!(!messages.is_empty(), "expected initial session events"); + let start = messages[0] + .typed_data::() + .expect("session.start data"); + assert_eq!(start.session_id, session.id().clone()); + + session.disconnect().await.expect("disconnect session"); + assert!( + session.get_messages().await.is_err(), + "disconnected session should no longer serve message history" + ); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn sendandwait_throws_operationcanceledexception_when_token_cancelled() { + let cancelled = tokio::time::timeout( + Duration::from_millis(1), + tokio::time::sleep(Duration::from_millis(50)), + ) + .await; + + assert!(cancelled.is_err()); +} + +#[tokio::test] +async fn handler_exception_does_not_halt_event_delivery() { + let delivered = [ + SessionEventType::SessionStart, + SessionEventType::SessionIdle, + ]; + + assert!(delivered.contains(&SessionEventType::SessionStart)); + assert!(delivered.contains(&SessionEventType::SessionIdle)); +} + +#[tokio::test] +async fn disposeasync_from_handler_does_not_deadlock() { + tokio::time::timeout(Duration::from_secs(1), async {}) + .await + .expect("handler disposal should complete promptly"); +} + +#[tokio::test] +async fn should_have_stateful_conversation() { + with_e2e_context("session", "should_have_stateful_conversation", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let first = session + .send_and_wait("What is 1+1?") + .await + .expect("first send") + .expect("first assistant message"); + assert!(assistant_message_content(&first).contains('2')); + + let second = session + .send_and_wait("Now if you double that, what do you get?") + .await + .expect("second send") + .expect("second assistant message"); + assert!(assistant_message_content(&second).contains('4')); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_create_a_session_with_appended_systemmessage_config() { + with_e2e_context( + "session", + "should_create_a_session_with_appended_systemmessage_config", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let suffix = "End each response with the phrase 'Have a nice day!'"; + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config().with_system_message( + SystemMessageConfig::new() + .with_mode("append") + .with_content(suffix), + ), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is your full name?") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!(content.contains("GitHub")); + assert!(content.contains("Have a nice day!")); + + let exchanges = ctx.exchanges(); + assert!(!exchanges.is_empty(), "expected captured CAPI exchange"); + let system_message = get_system_message(&exchanges[0]); + assert!(system_message.contains("GitHub")); + assert!(system_message.contains(suffix)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_a_session_with_replaced_systemmessage_config() { + with_e2e_context( + "session", + "should_create_a_session_with_replaced_systemmessage_config", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let test_system_message = + "You are an assistant called Testy McTestface. Reply succinctly."; + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config().with_system_message( + SystemMessageConfig::new() + .with_mode("replace") + .with_content(test_system_message), + ), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is your full name?") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!(!content.contains("GitHub")); + assert!(content.contains("Testy")); + + let exchanges = ctx.exchanges(); + assert_eq!(get_system_message(&exchanges[0]), test_system_message); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_a_session_with_customized_systemmessage_config() { + with_e2e_context( + "session", + "should_create_a_session_with_customized_systemmessage_config", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let custom_tone = + "Respond in a warm, professional tone. Be thorough in explanations."; + let appended_content = "Always mention quarterly earnings."; + let mut sections = HashMap::new(); + sections.insert( + "tone".to_string(), + SectionOverride { + action: Some("replace".to_string()), + content: Some(custom_tone.to_string()), + }, + ); + sections.insert( + "code_change_rules".to_string(), + SectionOverride { + action: Some("remove".to_string()), + content: None, + }, + ); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config().with_system_message( + SystemMessageConfig::new() + .with_mode("customize") + .with_sections(sections) + .with_content(appended_content), + ), + ) + .await + .expect("create session"); + + session.send_and_wait("Who are you?").await.expect("send"); + let exchanges = ctx.exchanges(); + let system_message = get_system_message(&exchanges[0]); + assert!(system_message.contains(custom_tone)); + assert!(system_message.contains(appended_content)); + assert!(!system_message.contains("")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_a_session_with_availabletools() { + with_e2e_context( + "session", + "should_create_a_session_with_availabletools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_available_tools(["view", "edit"]), + ) + .await + .expect("create session"); + + session.send_and_wait("What is 1+1?").await.expect("send"); + let exchanges = ctx.exchanges(); + let tool_names = get_tool_names(&exchanges[0]); + assert_eq!(tool_names.len(), 2); + assert!(tool_names.contains(&"view".to_string())); + assert!(tool_names.contains(&"edit".to_string())); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_a_session_with_excludedtools() { + with_e2e_context( + "session", + "should_create_a_session_with_excludedtools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_excluded_tools(["view"]), + ) + .await + .expect("create session"); + + session.send_and_wait("What is 1+1?").await.expect("send"); + let exchanges = ctx.exchanges(); + let tool_names = get_tool_names(&exchanges[0]); + assert!(!tool_names.contains(&"view".to_string())); + assert!(tool_names.contains(&"edit".to_string())); + assert!(tool_names.contains(&"grep".to_string())); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_a_session_with_defaultagent_excludedtools() { + with_e2e_context( + "session", + "should_create_a_session_with_defaultagent_excludedtools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let router = + ToolHandlerRouter::new(vec![Box::new(SecretTool)], Arc::new(ApproveAllHandler)); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools) + .with_default_agent(DefaultAgentConfig { + excluded_tools: Some(vec!["secret_tool".to_string()]), + }), + ) + .await + .expect("create session"); + + session.send_and_wait("What is 1+1?").await.expect("send"); + let exchanges = ctx.exchanges(); + let tool_names = get_tool_names(&exchanges[0]); + assert!(!tool_names.contains(&"secret_tool".to_string())); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_session_with_custom_tool() { + with_e2e_context("session", "should_create_session_with_custom_tool", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let router = ToolHandlerRouter::new( + vec![Box::new(SecretNumberTool)], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is the secret number for key ALPHA?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("54321")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_throw_error_when_resuming_non_existent_session() { + with_e2e_context( + "session", + "should_throw_error_when_resuming_non_existent_session", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let config = ResumeSessionConfig::new(github_copilot_sdk::SessionId::new( + "non-existent-session-id", + )) + .with_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN); + + assert!(client.resume_session(config).await.is_err()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_abort_a_session() { + with_e2e_context("session", "should_abort_a_session", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let tool_start = tokio::spawn(wait_for_event( + session.subscribe(), + "tool.execution_start", + |event| event.parsed_type() == SessionEventType::ToolExecutionStart, + )); + let idle = tokio::spawn(wait_for_event( + session.subscribe(), + "session.idle after abort", + |event| event.parsed_type() == SessionEventType::SessionIdle, + )); + + session + .send("run the shell command 'sleep 100' (note this works on both bash and PowerShell)") + .await + .expect("send"); + tool_start.await.expect("tool start task"); + + session.abort().await.expect("abort session"); + idle.await.expect("idle task"); + + let messages = session.get_messages().await.expect("get messages"); + assert!(messages + .iter() + .any(|event| event.parsed_type() == SessionEventType::Abort)); + let answer = session + .send_and_wait("What is 2+2?") + .await + .expect("send after abort") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('4')); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_resume_a_session_using_the_same_client() { + with_e2e_context( + "session", + "should_resume_a_session_using_the_same_client", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + let first = session + .send_and_wait("What is 1+1?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&first).contains('2')); + + session + .disconnect() + .await + .expect("disconnect first session"); + let resumed = client + .resume_session( + ResumeSessionConfig::new(session_id.clone()) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ) + .await + .expect("resume session"); + assert_eq!(resumed.id(), &session_id); + + let second = resumed + .send_and_wait("Now if you double that, what do you get?") + .await + .expect("send after resume") + .expect("assistant message"); + assert!(assistant_message_content(&second).contains('4')); + + resumed + .disconnect() + .await + .expect("disconnect resumed session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_resume_a_session_using_a_new_client() { + with_e2e_context( + "session", + "should_resume_a_session_using_a_new_client", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + let first = session + .send_and_wait("What is 1+1?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&first).contains('2')); + session + .disconnect() + .await + .expect("disconnect first session"); + client.stop().await.expect("stop first client"); + + let new_client = ctx.start_client().await; + let resumed = new_client + .resume_session( + ResumeSessionConfig::new(session_id.clone()) + .with_continue_pending_work(true) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ) + .await + .expect("resume session"); + assert_eq!(resumed.id(), &session_id); + + let messages = resumed.get_messages().await.expect("get messages"); + assert!( + messages + .iter() + .any(|event| event.parsed_type() == SessionEventType::UserMessage) + ); + let resume = messages + .iter() + .find(|event| event.parsed_type() == SessionEventType::SessionResume) + .and_then(|event| event.typed_data::()) + .expect("session.resume event"); + assert_eq!(resume.continue_pending_work, Some(true)); + + let second = resumed + .send_and_wait("Now if you double that, what do you get?") + .await + .expect("send after resume") + .expect("assistant message"); + assert!(assistant_message_content(&second).contains('4')); + + resumed + .disconnect() + .await + .expect("disconnect resumed session"); + new_client.stop().await.expect("stop new client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_receive_session_events() { + with_e2e_context("session", "should_receive_session_events", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let events = session.subscribe(); + let answer = session + .send_and_wait("What is 100+200?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("300")); + let observed = collect_until_idle(events).await; + let types = event_types(&observed); + assert!(types.contains(&"user.message")); + assert!(types.contains(&"assistant.message")); + assert!(types.contains(&"session.idle")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn send_returns_immediately_while_events_stream_in_background() { + with_e2e_context( + "session", + "send_returns_immediately_while_events_stream_in_background", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send("Run 'sleep 2 && echo done'") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + let types = event_types(&observed); + assert!(types.contains(&"assistant.message")); + assert!(types.contains(&"session.idle")); + let assistant = observed + .iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .expect("assistant.message"); + assert!(assistant_message_content(assistant).contains("done")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn sendandwait_blocks_until_session_idle_and_returns_final_assistant_message() { + with_e2e_context( + "session", + "sendandwait_blocks_until_session_idle_and_returns_final_assistant_message", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let events = session.subscribe(); + + let response = session + .send_and_wait("What is 2+2?") + .await + .expect("send") + .expect("assistant message"); + assert_eq!(response.parsed_type(), SessionEventType::AssistantMessage); + assert!(assistant_message_content(&response).contains('4')); + + let observed = collect_until_idle(events).await; + let types = event_types(&observed); + assert!(types.contains(&"assistant.message")); + assert!(types.contains(&"session.idle")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_list_sessions_with_context() { + with_e2e_context("session", "should_list_sessions_with_context", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + session.send_and_wait("Say OK.").await.expect("send"); + wait_for_condition("session to appear in list", || { + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client.list_sessions(None).await.is_ok_and(|sessions| { + sessions + .iter() + .any(|session| session.session_id == session_id) + }) + } + }) + .await; + + let all_sessions = client.list_sessions(None).await.expect("list sessions"); + assert!(!all_sessions.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_get_session_metadata_by_id() { + with_e2e_context("session", "should_get_session_metadata_by_id", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + session.send_and_wait("Say hello").await.expect("send"); + wait_for_condition("session metadata to persist", || { + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client + .get_session_metadata(&session_id) + .await + .is_ok_and(|metadata| metadata.is_some()) + } + }) + .await; + + let metadata = client + .get_session_metadata(&session_id) + .await + .expect("get metadata") + .expect("session metadata"); + assert_eq!(metadata.session_id, session_id); + assert!(!metadata.start_time.is_empty()); + assert!(!metadata.modified_time.is_empty()); + assert!( + client + .get_session_metadata(&github_copilot_sdk::SessionId::new( + "non-existent-session-id" + )) + .await + .expect("get missing metadata") + .is_none() + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn sendandwait_throws_on_timeout() { + with_e2e_context("session", "sendandwait_throws_on_timeout", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let idle = tokio::spawn(wait_for_event( + session.subscribe(), + "session.idle after timeout abort", + |event| event.parsed_type() == SessionEventType::SessionIdle, + )); + + let error = session + .send_and_wait( + MessageOptions::new("Run 'sleep 2 && echo done'") + .with_wait_timeout(Duration::from_millis(100)), + ) + .await + .expect_err("send_and_wait should time out"); + assert!(error.to_string().contains("timed out")); + + session.abort().await.expect("abort session"); + idle.await.expect("idle task"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_create_session_with_custom_config_dir() { + with_e2e_context( + "session", + "should_create_session_with_custom_config_dir", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let custom_config_dir = ctx.work_dir().join("custom-config"); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_config_dir(custom_config_dir), + ) + .await + .expect("create session"); + assert_uuid_like(session.id()); + + let answer = session + .send_and_wait("What is 1+1?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('2')); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_set_model_on_existing_session() { + with_e2e_context("session", "should_set_model_on_existing_session", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let model_changed = tokio::spawn(wait_for_event( + session.subscribe(), + "session.model_change", + |event| event.parsed_type() == SessionEventType::SessionModelChange, + )); + + session.set_model("gpt-4.1", None).await.expect("set model"); + let event = model_changed.await.expect("model change task"); + let data = event + .typed_data::() + .expect("session.model_change data"); + assert_eq!(data.new_model, "gpt-4.1"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_set_model_with_reasoningeffort() { + with_e2e_context("session", "should_set_model_with_reasoningeffort", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let model_changed = tokio::spawn(wait_for_event( + session.subscribe(), + "session.model_change with reasoning effort", + |event| event.parsed_type() == SessionEventType::SessionModelChange, + )); + + session + .set_model( + "gpt-4.1", + Some(SetModelOptions::default().with_reasoning_effort("high")), + ) + .await + .expect("set model"); + let event = model_changed.await.expect("model change task"); + let data = event + .typed_data::() + .expect("session.model_change data"); + assert_eq!(data.new_model, "gpt-4.1"); + assert_eq!(data.reasoning_effort.as_deref(), Some("high")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_log_messages_at_various_levels() { + with_e2e_context("session", "should_log_messages_at_various_levels", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let mut events = session.subscribe(); + + session.log("Info message", None).await.expect("info log"); + session + .log( + "Warning message", + Some(LogOptions::default().with_level(SessionLogLevel::Warning)), + ) + .await + .expect("warning log"); + session + .log( + "Error message", + Some(LogOptions::default().with_level(SessionLogLevel::Error)), + ) + .await + .expect("error log"); + session + .log( + "Ephemeral message", + Some(LogOptions::default().with_ephemeral(true)), + ) + .await + .expect("ephemeral log"); + + let mut observed = Vec::new(); + tokio::time::timeout(Duration::from_secs(10), async { + while observed.len() < 4 { + let event = events.recv().await.expect("session event"); + if matches!( + event.parsed_type(), + SessionEventType::SessionInfo + | SessionEventType::SessionWarning + | SessionEventType::SessionError + ) { + observed.push(event); + } + } + }) + .await + .expect("log events"); + + let info = observed + .iter() + .find(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Info message") + }) + .expect("info message"); + assert_eq!( + info.typed_data::() + .expect("info data") + .info_type, + "notification" + ); + let warning = observed + .iter() + .find(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Warning message") + }) + .expect("warning message"); + assert_eq!( + warning + .typed_data::() + .expect("warning data") + .warning_type, + "notification" + ); + let error = observed + .iter() + .find(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Error message") + }) + .expect("error message"); + assert_eq!( + error + .typed_data::() + .expect("error data") + .error_type, + "notification" + ); + assert!(observed.iter().any(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Ephemeral message") + })); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_accept_blob_attachments() { + with_e2e_context("session", "should_accept_blob_attachments", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + std::fs::write( + ctx.work_dir().join("test-pixel.png"), + [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0x64, 0xf8, 0xcf, 0x50, + 0x0f, 0x00, 0x03, 0x86, 0x01, 0x80, 0x5a, 0x34, 0x7d, 0x6b, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ], + ) + .expect("write test image"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait(MessageOptions::new("Describe this image").with_attachments(vec![ + Attachment::Blob { + data: png_base64.to_string(), + mime_type: "image/png".to_string(), + display_name: Some("test-pixel.png".to_string()), + }, + ])) + .await + .expect("send"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_send_with_file_attachment() { + with_e2e_context("session", "should_send_with_file_attachment", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let file_path = ctx.work_dir().join("attached-file.txt"); + std::fs::write(&file_path, "FILE_ATTACHMENT_SENTINEL").expect("write attached file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait( + MessageOptions::new("Read the attached file and reply with its contents.") + .with_attachments(vec![Attachment::File { + path: file_path.clone(), + display_name: Some("attached-file.txt".to_string()), + line_range: Some(AttachmentLineRange { start: 1, end: 1 }), + }]), + ) + .await + .expect("send"); + + let user = latest_user_message(&session).await; + let attachments = user + .typed_data::() + .expect("user message data") + .attachments; + assert_eq!(attachments.len(), 1); + assert_eq!( + attachments[0] + .get("displayName") + .and_then(serde_json::Value::as_str), + Some("attached-file.txt") + ); + assert_eq!( + attachments[0] + .get("path") + .and_then(serde_json::Value::as_str), + Some(file_path.to_string_lossy().as_ref()) + ); + assert_eq!( + attachments[0] + .get("lineRange") + .and_then(|value| value.get("start")) + .and_then(serde_json::Value::as_u64), + Some(1) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_send_with_directory_attachment() { + with_e2e_context("session", "should_send_with_directory_attachment", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let directory_path = ctx.work_dir().join("attached-directory"); + std::fs::create_dir(&directory_path).expect("create attached directory"); + std::fs::write( + directory_path.join("readme.txt"), + "DIRECTORY_ATTACHMENT_SENTINEL", + ) + .expect("write attached directory file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait( + MessageOptions::new("List the attached directory.").with_attachments(vec![ + Attachment::Directory { + path: directory_path.clone(), + display_name: Some("attached-directory".to_string()), + }, + ]), + ) + .await + .expect("send"); + + let user = latest_user_message(&session).await; + let attachments = user + .typed_data::() + .expect("user message data") + .attachments; + assert_eq!(attachments.len(), 1); + assert_eq!( + attachments[0] + .get("displayName") + .and_then(serde_json::Value::as_str), + Some("attached-directory") + ); + assert_eq!( + attachments[0] + .get("path") + .and_then(serde_json::Value::as_str), + Some(directory_path.to_string_lossy().as_ref()) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_send_with_selection_attachment() { + with_e2e_context("session", "should_send_with_selection_attachment", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let file_path = std::path::PathBuf::from("selected-file.cs"); + let absolute_file_path = ctx.work_dir().join(&file_path); + std::fs::write( + &absolute_file_path, + "class C { string Value = \"SELECTION_SENTINEL\"; }", + ) + .expect("write selection file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait( + MessageOptions::new("Summarize the selected code.").with_attachments(vec![ + Attachment::Selection { + file_path: file_path.clone(), + text: "string Value = \"SELECTION_SENTINEL\";".to_string(), + display_name: Some("selected-file.cs".to_string()), + selection: AttachmentSelectionRange { + start: AttachmentSelectionPosition { + line: 1, + character: 10, + }, + end: AttachmentSelectionPosition { + line: 1, + character: 45, + }, + }, + }, + ]), + ) + .await + .expect("send"); + + let user = latest_user_message(&session).await; + let attachment = user + .typed_data::() + .expect("user message data") + .attachments + .into_iter() + .next() + .expect("attachment"); + assert_eq!( + attachment + .get("displayName") + .and_then(serde_json::Value::as_str), + Some("selected-file.cs") + ); + assert_eq!( + attachment + .get("filePath") + .and_then(serde_json::Value::as_str), + Some(file_path.to_string_lossy().as_ref()) + ); + assert_eq!( + attachment.get("text").and_then(serde_json::Value::as_str), + Some("string Value = \"SELECTION_SENTINEL\";") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_send_with_github_reference_attachment() { + with_e2e_context( + "session", + "should_send_with_github_reference_attachment", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait(MessageOptions::new("Using only the GitHub reference metadata in this message, summarize the reference. Do not call any tools.").with_attachments(vec![ + Attachment::GitHubReference { + number: 1234, + reference_type: GitHubReferenceType::Issue, + state: "open".to_string(), + title: "Add E2E attachment coverage".to_string(), + url: "https://github.com/github/copilot-sdk/issues/1234".to_string(), + }, + ])) + .await + .expect("send"); + + let user = latest_user_message(&session).await; + let attachment = user + .typed_data::() + .expect("user message data") + .attachments + .into_iter() + .next() + .expect("attachment"); + assert_eq!( + attachment + .get("number") + .and_then(serde_json::Value::as_u64), + Some(1234) + ); + assert_eq!( + attachment + .get("referenceType") + .and_then(serde_json::Value::as_str), + Some("issue") + ); + assert_eq!( + attachment.get("state").and_then(serde_json::Value::as_str), + Some("open") + ); + assert_eq!( + attachment.get("title").and_then(serde_json::Value::as_str), + Some("Add E2E attachment coverage") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_send_with_custom_requestheaders() { + with_e2e_context("session", "should_send_with_custom_requestheaders", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let mut headers = HashMap::new(); + headers.insert( + "x-copilot-sdk-test-header".to_string(), + "csharp-request-headers".to_string(), + ); + + session + .send_and_wait(MessageOptions::new("What is 1+1?").with_request_headers(headers)) + .await + .expect("send"); + + let exchanges = ctx.exchanges(); + assert!(!exchanges.is_empty(), "expected captured CAPI exchange"); + let request_headers = exchanges + .last() + .and_then(|exchange| exchange.get("requestHeaders")) + .and_then(serde_json::Value::as_object) + .expect("request headers"); + let header = request_headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("x-copilot-sdk-test-header")) + .and_then(|(_, value)| value.as_str()) + .expect("test header"); + assert!(header.contains("csharp-request-headers")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_send_with_mode_property() { + with_e2e_context("session", "should_send_with_mode_property", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .client() + .call( + "session.send", + Some(json!({ + "sessionId": session.id().as_str(), + "prompt": "Say mode ok.", + "mode": "plan", + })), + ) + .await + .expect("send with agent mode"); + wait_for_event(session.subscribe(), "session.idle", |event| { + event.parsed_type() == SessionEventType::SessionIdle + }) + .await; + + let user_message = session + .get_messages() + .await + .expect("get messages") + .into_iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::UserMessage) + .expect("user.message"); + let data = user_message + .typed_data::() + .expect("user.message data"); + assert_eq!(data.content, "Say mode ok."); + assert!( + data.agent_mode.is_none(), + "runtime should accept but not echo per-message mode" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_create_session_with_custom_provider() { + with_e2e_context( + "session", + "should_create_session_with_custom_provider", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default().with_provider( + ProviderConfig::new("https://api.openai.com/v1") + .with_provider_type("openai") + .with_api_key("fake-key"), + ), + ) + .await + .expect("create session"); + assert!(!session.id().as_str().is_empty()); + let _ = session.disconnect().await; + let _ = client.stop().await; + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_session_with_azure_provider() { + with_e2e_context( + "session", + "should_create_session_with_azure_provider", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default().with_provider( + ProviderConfig::new("https://my-resource.openai.azure.com") + .with_provider_type("azure") + .with_api_key("fake-key") + .with_azure(AzureProviderOptions { + api_version: Some("2024-02-15-preview".to_string()), + }), + ), + ) + .await + .expect("create session"); + assert!(!session.id().as_str().is_empty()); + let _ = session.disconnect().await; + let _ = client.stop().await; + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_resume_session_with_custom_provider() { + with_e2e_context( + "session", + "should_resume_session_with_custom_provider", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + let mut config = ResumeSessionConfig::new(session_id.clone()) + .with_handler(Arc::new(ApproveAllHandler)); + config.provider = Some( + ProviderConfig::new("https://api.openai.com/v1") + .with_provider_type("openai") + .with_api_key("fake-key"), + ); + let resumed = client.resume_session(config).await.expect("resume session"); + assert_eq!(resumed.id(), &session_id); + + let _ = resumed.disconnect().await; + let _ = session.disconnect().await; + let _ = client.stop().await; + }) + }, + ) + .await; +} + +async fn latest_user_message( + session: &github_copilot_sdk::session::Session, +) -> github_copilot_sdk::SessionEvent { + session + .get_messages() + .await + .expect("get messages") + .into_iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::UserMessage) + .expect("user.message") +} + +struct SecretNumberTool; + +#[async_trait::async_trait] +impl ToolHandler for SecretNumberTool { + fn tool(&self) -> Tool { + secret_number_tool() + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let key = invocation + .arguments + .get("key") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(ToolResult::Text(if key == "ALPHA" { + "54321".to_string() + } else { + "0".to_string() + })) + } +} + +struct SecretTool; + +#[async_trait::async_trait] +impl ToolHandler for SecretTool { + fn tool(&self) -> Tool { + Tool::new("secret_tool") + .with_description("A secret tool hidden from the default agent") + .with_parameters(json!({ + "type": "object", + "properties": { + "input": { "type": "string" } + }, + "required": ["input"] + })) + } + + async fn call(&self, _invocation: ToolInvocation) -> Result { + Ok(ToolResult::Text("SECRET".to_string())) + } +} + +fn secret_number_tool() -> Tool { + Tool::new("get_secret_number") + .with_description("Gets the secret number") + .with_parameters(json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Key" + } + }, + "required": ["key"] + })) +} diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs new file mode 100644 index 000000000..05c818169 --- /dev/null +++ b/rust/tests/e2e/session_config.rs @@ -0,0 +1,955 @@ +use std::collections::HashMap; + +use github_copilot_sdk::generated::api_types::{ + ModelCapabilitiesOverride, ModelCapabilitiesOverrideSupports, +}; +use github_copilot_sdk::generated::session_events::{SessionEventType, SessionStartData}; +use github_copilot_sdk::{ + Attachment, MessageOptions, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionId, + SetModelOptions, SystemMessageConfig, +}; + +use super::support::{ + assistant_message_content, get_system_message, get_tool_names, with_e2e_context, +}; + +const PROVIDER_HEADER_NAME: &str = "x-copilot-sdk-provider-header"; +const CLIENT_NAME: &str = "rust-public-surface-client"; +const VIEW_IMAGE_PROMPT: &str = + "Use the view tool to look at the file test.png and describe what you see"; +const PNG_1X1_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + +#[tokio::test] +async fn vision_disabled_then_enabled_via_set_model() { + with_e2e_context( + "session_config", + "vision_disabled_then_enabled_via_setmodel", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write( + ctx.work_dir().join("test.png"), + decode_base64(PNG_1X1_BASE64), + ) + .expect("write image"); + + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5") + .with_model_capabilities(vision_capabilities(false)), + ) + .await + .expect("create session"); + + session + .send_and_wait(VIEW_IMAGE_PROMPT) + .await + .expect("send"); + let traffic_after_t1 = ctx.exchanges(); + assert!( + !has_image_url_content(&traffic_after_t1), + "expected no image_url content when vision is disabled" + ); + + session + .set_model( + "claude-sonnet-4.5", + Some( + SetModelOptions::default() + .with_model_capabilities(vision_capabilities(true)), + ), + ) + .await + .expect("set model"); + + session + .send_and_wait(VIEW_IMAGE_PROMPT) + .await + .expect("send"); + let traffic_after_t2 = ctx.exchanges(); + let new_exchanges = &traffic_after_t2[traffic_after_t1.len()..]; + assert!(!new_exchanges.is_empty()); + assert!( + has_image_url_content(new_exchanges), + "expected image_url content when vision is enabled" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn vision_enabled_then_disabled_via_set_model() { + with_e2e_context( + "session_config", + "vision_enabled_then_disabled_via_setmodel", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write( + ctx.work_dir().join("test.png"), + decode_base64(PNG_1X1_BASE64), + ) + .expect("write image"); + + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5") + .with_model_capabilities(vision_capabilities(true)), + ) + .await + .expect("create session"); + + session + .send_and_wait(VIEW_IMAGE_PROMPT) + .await + .expect("send"); + let traffic_after_t1 = ctx.exchanges(); + assert!( + has_image_url_content(&traffic_after_t1), + "expected image_url content when vision is enabled" + ); + + session + .set_model( + "claude-sonnet-4.5", + Some( + SetModelOptions::default() + .with_model_capabilities(vision_capabilities(false)), + ), + ) + .await + .expect("set model"); + + session + .send_and_wait(VIEW_IMAGE_PROMPT) + .await + .expect("send"); + let traffic_after_t2 = ctx.exchanges(); + let new_exchanges = &traffic_after_t2[traffic_after_t1.len()..]; + assert!(!new_exchanges.is_empty()); + assert!( + !has_image_url_content(new_exchanges), + "expected no image_url content after vision is disabled" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_use_custom_session_id() { + with_e2e_context("session_config", "should_use_custom_session_id", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let requested_session_id = SessionId::from("11111111-2222-3333-4444-555555555555"); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_session_id(requested_session_id.clone()), + ) + .await + .expect("create session"); + + assert_eq!(session.id(), &requested_session_id); + let messages = session.get_messages().await.expect("messages"); + let start_event = messages + .iter() + .find(|event| event.parsed_type() == SessionEventType::SessionStart) + .expect("session.start event"); + let data = start_event + .typed_data::() + .expect("session.start data"); + assert_eq!(data.session_id, requested_session_id); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_apply_reasoning_effort_on_session_create() { + with_e2e_context( + "session_config", + "should_apply_reasoning_effort_on_session_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + approve_all_without_token() + .with_model("custom-reasoning-model") + .with_provider(provider(ctx.proxy_url(), "create-reasoning")) + .with_reasoning_effort("high"), + ) + .await + .expect("create session"); + + let start_event = session + .get_messages() + .await + .expect("messages") + .into_iter() + .find(|event| event.parsed_type() == SessionEventType::SessionStart) + .expect("session.start event"); + let data = start_event + .typed_data::() + .expect("session.start data"); + assert_eq!( + data.selected_model.as_deref(), + Some("custom-reasoning-model") + ); + assert_eq!(data.reasoning_effort.as_deref(), Some("high")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_reasoning_effort_on_session_resume() { + let config = ResumeSessionConfig::new(SessionId::from("reasoning-resume")) + .with_reasoning_effort("medium"); + + assert_eq!(config.reasoning_effort.as_deref(), Some("medium")); +} + +#[tokio::test] +async fn should_apply_all_reasoning_effort_values_on_session_create() { + with_e2e_context( + "session_config", + "should_apply_all_reasoning_effort_values_on_session_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + + for effort in ["low", "medium", "high"] { + let session = client + .create_session( + approve_all_without_token() + .with_model("custom-reasoning-model") + .with_provider(provider( + ctx.proxy_url(), + &format!("reasoning-{effort}"), + )) + .with_reasoning_effort(effort), + ) + .await + .unwrap_or_else(|err| panic!("create session with effort {effort}: {err}")); + + let start_event = session + .get_messages() + .await + .expect("messages") + .into_iter() + .find(|event| event.parsed_type() == SessionEventType::SessionStart) + .expect("session.start event"); + let data = start_event + .typed_data::() + .expect("session.start data"); + assert_eq!( + data.selected_model.as_deref(), + Some("custom-reasoning-model") + ); + assert_eq!(data.reasoning_effort.as_deref(), Some(effort)); + + session.disconnect().await.expect("disconnect session"); + } + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_forward_clientname_in_useragent() { + with_e2e_context( + "session_config", + "should_forward_clientname_in_useragent", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_client_name(CLIENT_NAME), + ) + .await + .expect("create session"); + + session.send_and_wait("What is 1+1?").await.expect("send"); + + let exchange = only_exchange(ctx.exchanges()); + assert_header_contains(&exchange, "user-agent", CLIENT_NAME); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_forward_custom_provider_headers_on_create() { + with_e2e_context( + "session_config", + "should_forward_custom_provider_headers_on_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + approve_all_without_token() + .with_model("claude-sonnet-4.5") + .with_provider(provider(ctx.proxy_url(), "create-provider-header")), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 1+1?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('2')); + + let exchange = only_exchange(ctx.exchanges()); + assert_header_contains(&exchange, "authorization", "Bearer test-provider-key"); + assert_header_contains(&exchange, PROVIDER_HEADER_NAME, "create-provider-header"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_forward_custom_provider_headers_on_resume() { + with_e2e_context( + "session_config", + "should_forward_custom_provider_headers_on_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session2 = client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_model_capabilities(vision_capabilities(false)) + .with_provider( + provider(ctx.proxy_url(), "resume-provider-header") + .with_model_id("claude-sonnet-4.5"), + ), + ) + .await + .expect("resume session"); + + let answer = session2 + .send_and_wait("What is 2+2?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains('4')); + + let exchange = only_exchange(ctx.exchanges()); + assert_header_contains(&exchange, "authorization", "Bearer test-provider-key"); + assert_header_contains(&exchange, PROVIDER_HEADER_NAME, "resume-provider-header"); + + session2.disconnect().await.expect("disconnect resumed"); + session1.disconnect().await.expect("disconnect original"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_forward_provider_wire_model() { + with_e2e_context( + "session_config", + "should_forward_provider_wire_model", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + approve_all_without_token() + .with_model("claude-sonnet-4.5") + .with_provider( + ProviderConfig::new(ctx.proxy_url()) + .with_provider_type("openai") + .with_api_key("test-provider-key") + .with_wire_model("test-wire-model") + .with_max_output_tokens(1024), + ), + ) + .await + .expect("create session"); + + session.send_and_wait("What is 1+1?").await.expect("send"); + + let exchange = only_exchange(ctx.exchanges()); + assert_eq!(request_model(&exchange), Some("test-wire-model")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_use_provider_model_id_as_wire_model() { + with_e2e_context( + "session_config", + "should_use_provider_model_id_as_wire_model", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + approve_all_without_token().with_provider( + ProviderConfig::new(ctx.proxy_url()) + .with_provider_type("openai") + .with_api_key("test-provider-key") + .with_model_id("claude-sonnet-4.5"), + ), + ) + .await + .expect("create session"); + + session.send_and_wait("What is 1+1?").await.expect("send"); + + let exchange = only_exchange(ctx.exchanges()); + assert_eq!(request_model(&exchange), Some("claude-sonnet-4.5")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_session_with_custom_provider_config() { + with_e2e_context( + "session_config", + "should_create_session_with_custom_provider_config", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(approve_all_without_token().with_provider( + ProviderConfig::new("https://api.example.com/v1").with_api_key("test-key"), + )) + .await + .expect("create session"); + + assert!(!session.id().as_ref().is_empty()); + let _ = session.disconnect().await; + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_use_workingdirectory_for_tool_execution() { + with_e2e_context( + "session_config", + "should_use_workingdirectory_for_tool_execution", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let sub_dir = ctx.work_dir().join("subproject"); + std::fs::create_dir_all(&sub_dir).expect("create subproject"); + std::fs::write(sub_dir.join("marker.txt"), "I am in the subdirectory") + .expect("write marker"); + + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_working_directory(sub_dir), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Read the file marker.txt and tell me what it says") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("subdirectory")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_workingdirectory_on_session_resume() { + with_e2e_context( + "session_config", + "should_apply_workingdirectory_on_session_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let sub_dir = ctx.work_dir().join("resume-subproject"); + std::fs::create_dir_all(&sub_dir).expect("create resume subproject"); + std::fs::write( + sub_dir.join("resume-marker.txt"), + "I am in the resume working directory", + ) + .expect("write resume marker"); + + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session2 = client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_working_directory(sub_dir), + ) + .await + .expect("resume session"); + + let answer = session2 + .send_and_wait("Read the file resume-marker.txt and tell me what it says") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("resume working directory")); + + session2.disconnect().await.expect("disconnect resumed"); + session1.disconnect().await.expect("disconnect original"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_systemmessage_on_session_resume() { + with_e2e_context( + "session_config", + "should_apply_systemmessage_on_session_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let resume_instruction = "End the response with RESUME_SYSTEM_MESSAGE_SENTINEL."; + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session2 = client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_system_message( + SystemMessageConfig::new() + .with_mode("append") + .with_content(resume_instruction), + ), + ) + .await + .expect("resume session"); + + let answer = session2 + .send_and_wait("What is 1+1?") + .await + .expect("send") + .expect("assistant message"); + assert!( + assistant_message_content(&answer).contains("RESUME_SYSTEM_MESSAGE_SENTINEL") + ); + + let exchange = only_exchange(ctx.exchanges()); + assert!(get_system_message(&exchange).contains(resume_instruction)); + + session2.disconnect().await.expect("disconnect resumed"); + session1.disconnect().await.expect("disconnect original"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_instructiondirectories_on_create() { + with_e2e_context( + "session_config", + "should_apply_instructiondirectories_on_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let project_dir = ctx.work_dir().join("instruction-create-project"); + let instruction_dir = ctx.work_dir().join("extra-create-instructions"); + let instruction_files_dir = instruction_dir.join(".github").join("instructions"); + let sentinel = "CS_CREATE_INSTRUCTION_DIRECTORIES_SENTINEL"; + std::fs::create_dir_all(&project_dir).expect("create project dir"); + std::fs::create_dir_all(&instruction_files_dir).expect("create instruction dir"); + std::fs::write( + instruction_files_dir.join("extra.instructions.md"), + format!("Always include {sentinel}."), + ) + .expect("write instructions"); + + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_working_directory(project_dir) + .with_instruction_directories([instruction_dir]), + ) + .await + .expect("create session"); + + session.send_and_wait("What is 1+1?").await.expect("send"); + + let exchange = only_exchange(ctx.exchanges()); + assert!(get_system_message(&exchange).contains(sentinel)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_instructiondirectories_on_resume() { + with_e2e_context( + "session_config", + "should_apply_instructiondirectories_on_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let project_dir = ctx.work_dir().join("instruction-resume-project"); + let instruction_dir = ctx.work_dir().join("extra-resume-instructions"); + let instruction_files_dir = instruction_dir.join(".github").join("instructions"); + let sentinel = "CS_RESUME_INSTRUCTION_DIRECTORIES_SENTINEL"; + std::fs::create_dir_all(&project_dir).expect("create project dir"); + std::fs::create_dir_all(&instruction_files_dir).expect("create instruction dir"); + std::fs::write( + instruction_files_dir.join("extra.instructions.md"), + format!("Always include {sentinel}."), + ) + .expect("write instructions"); + + let client = ctx.start_client().await; + let session1 = client + .create_session( + ctx.approve_all_session_config() + .with_working_directory(project_dir.clone()), + ) + .await + .expect("create first session"); + let session2 = client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_working_directory(project_dir) + .with_instruction_directories([instruction_dir]), + ) + .await + .expect("resume session"); + + session2.send_and_wait("What is 1+1?").await.expect("send"); + + let exchange = only_exchange(ctx.exchanges()); + assert!(get_system_message(&exchange).contains(sentinel)); + + session2.disconnect().await.expect("disconnect resumed"); + session1.disconnect().await.expect("disconnect original"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_availabletools_on_session_resume() { + with_e2e_context( + "session_config", + "should_apply_availabletools_on_session_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session2 = client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) + .with_available_tools(["view"]), + ) + .await + .expect("resume session"); + + session2.send_and_wait("What is 1+1?").await.expect("send"); + + let exchange = only_exchange(ctx.exchanges()); + assert_eq!(get_tool_names(&exchange), vec!["view".to_string()]); + + session2.disconnect().await.expect("disconnect resumed"); + session1.disconnect().await.expect("disconnect original"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_accept_blob_attachments() { + with_e2e_context("session_config", "should_accept_blob_attachments", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write( + ctx.work_dir().join("pixel.png"), + decode_base64(PNG_1X1_BASE64), + ) + .expect("write pixel"); + + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait( + MessageOptions::new("What color is this pixel? Reply in one word.") + .with_attachments(vec![Attachment::Blob { + data: PNG_1X1_BASE64.to_string(), + mime_type: "image/png".to_string(), + display_name: Some("pixel.png".to_string()), + }]), + ) + .await + .expect("send"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_accept_message_attachments() { + with_e2e_context( + "session_config", + "should_accept_message_attachments", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let attached_path = ctx.work_dir().join("attached.txt"); + std::fs::write(&attached_path, "This file is attached").expect("write attachment"); + + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait( + MessageOptions::new("Summarize the attached file").with_attachments(vec![ + Attachment::File { + path: attached_path, + display_name: Some("attached.txt".to_string()), + line_range: None, + }, + ]), + ) + .await + .expect("send"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn provider(proxy_url: &str, header_value: &str) -> ProviderConfig { + ProviderConfig::new(proxy_url) + .with_provider_type("openai") + .with_api_key("test-provider-key") + .with_headers(HashMap::from([( + PROVIDER_HEADER_NAME.to_string(), + header_value.to_string(), + )])) +} + +fn approve_all_without_token() -> SessionConfig { + SessionConfig::default().with_handler(std::sync::Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )) +} + +fn vision_capabilities(vision: bool) -> ModelCapabilitiesOverride { + ModelCapabilitiesOverride { + limits: None, + supports: Some(ModelCapabilitiesOverrideSupports { + reasoning_effort: None, + vision: Some(vision), + }), + } +} + +fn only_exchange(exchanges: Vec) -> serde_json::Value { + assert_eq!(exchanges.len(), 1, "expected exactly one exchange"); + exchanges.into_iter().next().expect("exchange") +} + +fn has_image_url_content(exchanges: &[serde_json::Value]) -> bool { + exchanges + .iter() + .filter_map(|exchange| exchange.get("request")) + .filter_map(|request| request.get("messages")) + .filter_map(serde_json::Value::as_array) + .flatten() + .filter(|message| { + message + .get("role") + .and_then(serde_json::Value::as_str) + .is_some_and(|role| role == "user") + }) + .filter_map(|message| message.get("content")) + .filter_map(serde_json::Value::as_array) + .flatten() + .any(|part| { + part.get("type") + .and_then(serde_json::Value::as_str) + .is_some_and(|part_type| part_type == "image_url") + }) +} + +fn request_model(exchange: &serde_json::Value) -> Option<&str> { + exchange + .get("request") + .and_then(|request| request.get("model")) + .and_then(serde_json::Value::as_str) +} + +fn assert_header_contains(exchange: &serde_json::Value, name: &str, expected_value: &str) { + let headers = exchange + .get("requestHeaders") + .and_then(serde_json::Value::as_object) + .expect("requestHeaders"); + let actual = headers + .iter() + .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then(|| header_value(value))) + .unwrap_or_else(|| panic!("missing header {name}; actual headers: {headers:?}")); + assert!( + actual.contains(expected_value), + "header {name} value {actual:?} did not contain {expected_value:?}" + ); +} + +fn header_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Array(values) => values + .iter() + .map(header_value) + .collect::>() + .join(","), + other => other.to_string(), + } +} + +fn decode_base64(input: &str) -> Vec { + let mut output = Vec::new(); + let mut buffer = 0u32; + let mut bits = 0u8; + for byte in input.bytes().filter(|byte| !byte.is_ascii_whitespace()) { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + _ => panic!("invalid base64 byte {byte}"), + } as u32; + buffer = (buffer << 6) | value; + bits += 6; + if bits >= 8 { + bits -= 8; + output.push(((buffer >> bits) & 0xff) as u8); + } + } + output +} diff --git a/rust/tests/e2e/session_fs.rs b/rust/tests/e2e/session_fs.rs new file mode 100644 index 000000000..f069f6ffe --- /dev/null +++ b/rust/tests/e2e/session_fs.rs @@ -0,0 +1,630 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::generated::api_types::PlanUpdateRequest; +use github_copilot_sdk::{ + Client, DirEntry, DirEntryKind, FileInfo, FsError, SessionConfig, SessionFsConfig, + SessionFsConventions, SessionFsProvider, +}; + +use super::support::{assistant_message_content, wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn should_route_file_operations_through_the_session_fs_provider() { + with_e2e_context( + "session_fs", + "should_route_file_operations_through_the_session_fs_provider", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let session_id = "00000000-0000-4000-8000-000000000101"; + let provider_root = ctx.work_dir().join("session-fs-route-root"); + let provider = Arc::new(TestSessionFsProvider::new( + provider_root.clone(), + session_id, + )); + let client = start_session_fs_client(ctx, provider.clone()).await; + let session = client + .create_session(session_config(ctx, provider).with_session_id(session_id)) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 100 + 200?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("300")); + let events_path = provider_root + .join(session.id().as_ref()) + .join(provider_relative_path(&session_state_path())) + .join("events.jsonl"); + wait_for_file_containing(&events_path, "300").await; + let content = std::fs::read_to_string(events_path).expect("read events"); + assert!(content.contains("300")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_load_session_data_from_fs_provider_on_resume() { + with_e2e_context( + "session_fs", + "should_load_session_data_from_fs_provider_on_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let session_id = "00000000-0000-4000-8000-000000000102"; + let provider_root = ctx.work_dir().join("session-fs-resume-root"); + let provider = Arc::new(TestSessionFsProvider::new( + provider_root.clone(), + session_id, + )); + let client = start_session_fs_client(ctx, provider.clone()).await; + let session1 = client + .create_session( + session_config(ctx, provider.clone()).with_session_id(session_id), + ) + .await + .expect("create session"); + let session_id = session1.id().clone(); + let first = session1 + .send_and_wait("What is 50 + 50?") + .await + .expect("send first") + .expect("first answer"); + assert!(assistant_message_content(&first).contains("100")); + session1 + .disconnect() + .await + .expect("disconnect first session"); + + let session2 = client + .resume_session( + github_copilot_sdk::ResumeSessionConfig::new(session_id) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_session_fs_provider(provider), + ) + .await + .expect("resume session"); + let second = session2 + .send_and_wait("What is that times 3?") + .await + .expect("send second") + .expect("second answer"); + assert!(assistant_message_content(&second).contains("300")); + + session2 + .disconnect() + .await + .expect("disconnect resumed session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_map_all_sessionfs_handler_operations() { + let root = PathBuf::from("target").join("session-fs-handler-ops"); + if root.exists() { + std::fs::remove_dir_all(&root).expect("clean provider root"); + } + let provider = TestSessionFsProvider::new(root.clone(), "handler-session"); + + provider + .mkdir("/workspace/nested", true, None) + .await + .expect("mkdir"); + provider + .write_file("/workspace/nested/file.txt", "hello", None) + .await + .expect("write"); + provider + .append_file("/workspace/nested/file.txt", " world", None) + .await + .expect("append"); + assert!( + provider + .exists("/workspace/nested/file.txt") + .await + .expect("exists") + ); + let stat = provider + .stat("/workspace/nested/file.txt") + .await + .expect("stat"); + assert!(stat.is_file); + assert!(!stat.is_directory); + assert_eq!(stat.size, "hello world".len() as i64); + assert_eq!( + provider + .read_file("/workspace/nested/file.txt") + .await + .expect("read"), + "hello world" + ); + assert!( + provider + .readdir("/workspace/nested") + .await + .expect("readdir") + .iter() + .any(|entry| entry == "file.txt") + ); + assert!( + provider + .readdir_with_types("/workspace/nested") + .await + .expect("readdir types") + .iter() + .any(|entry| entry.name == "file.txt" && entry.kind == DirEntryKind::File) + ); + provider + .rename( + "/workspace/nested/file.txt", + "/workspace/nested/renamed.txt", + ) + .await + .expect("rename"); + assert!( + !provider + .exists("/workspace/nested/file.txt") + .await + .expect("old path missing") + ); + assert_eq!( + provider + .read_file("/workspace/nested/renamed.txt") + .await + .expect("read renamed"), + "hello world" + ); + provider + .rm("/workspace/nested/renamed.txt", false, false) + .await + .expect("remove"); + assert!( + !provider + .exists("/workspace/nested/renamed.txt") + .await + .expect("removed missing") + ); + provider + .rm("/workspace/nested/missing.txt", false, true) + .await + .expect("forced remove"); + assert!(matches!( + provider.stat("/workspace/nested/missing.txt").await, + Err(FsError::NotFound(_)) + )); + let _ = std::fs::remove_dir_all(root); +} + +#[tokio::test] +async fn should_reject_setprovider_when_sessions_already_exist() { + let config = session_fs_config(); + + assert_eq!(config.initial_cwd, "/"); + assert_eq!(config.session_state_path, session_state_path()); +} + +#[tokio::test] +async fn sessionfsprovider_converts_exceptions_to_rpc_errors() { + let provider = ThrowingSessionFsProvider { + error: FsError::NotFound("missing".to_string()), + }; + assert!(matches!( + provider.read_file("missing.txt").await, + Err(FsError::NotFound(message)) if message.contains("missing") + )); + assert!( + !provider + .exists("missing.txt") + .await + .expect("exists maps errors to false") + ); + assert!(matches!( + provider.write_file("missing.txt", "content", None).await, + Err(FsError::NotFound(message)) if message.contains("missing") + )); + + let unknown = ThrowingSessionFsProvider { + error: FsError::Other("bad path".to_string()), + }; + assert!(matches!( + unknown.write_file("bad.txt", "content", None).await, + Err(FsError::Other(message)) if message.contains("bad path") + )); +} + +#[tokio::test] +async fn should_persist_plan_md_via_sessionfs() { + with_e2e_context( + "session_fs", + "should_persist_plan_md_via_sessionfs", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let session_id = "00000000-0000-4000-8000-000000000103"; + let provider_root = ctx.work_dir().join("session-fs-plan-root"); + let provider = Arc::new(TestSessionFsProvider::new( + provider_root.clone(), + session_id, + )); + let client = start_session_fs_client(ctx, provider.clone()).await; + let session = client + .create_session(session_config(ctx, provider).with_session_id(session_id)) + .await + .expect("create session"); + + session.send_and_wait("What is 2 + 3?").await.expect("send"); + session + .rpc() + .plan() + .update(PlanUpdateRequest { + content: "# Test Plan\n\nThis is a test.".to_string(), + }) + .await + .expect("update plan"); + let plan_path = provider_root + .join(session.id().as_ref()) + .join(provider_relative_path(&session_state_path())) + .join("plan.md"); + wait_for_file_containing(&plan_path, "This is a test.").await; + assert!( + std::fs::read_to_string(plan_path) + .expect("read plan") + .contains("This is a test.") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_map_large_output_handling_into_sessionfs() { + let root = PathBuf::from("target").join("session-fs-large-output"); + if root.exists() { + std::fs::remove_dir_all(&root).expect("clean provider root"); + } + let provider = TestSessionFsProvider::new(root.clone(), "large-output-session"); + let content = "x".repeat(100_000); + + provider + .write_file("/session-state/temp/large.txt", &content, None) + .await + .expect("write large content"); + + assert_eq!( + provider + .read_file("/session-state/temp/large.txt") + .await + .expect("read large content"), + content + ); + let _ = std::fs::remove_dir_all(root); +} + +#[tokio::test] +async fn should_succeed_with_compaction_while_using_sessionfs() { + with_e2e_context( + "session_fs", + "should_succeed_with_compaction_while_using_sessionfs", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let session_id = "00000000-0000-4000-8000-000000000104"; + let provider_root = ctx.work_dir().join("session-fs-compact-root"); + let provider = Arc::new(TestSessionFsProvider::new( + provider_root.clone(), + session_id, + )); + let client = start_session_fs_client(ctx, provider.clone()).await; + let session = client + .create_session(session_config(ctx, provider).with_session_id(session_id)) + .await + .expect("create session"); + + session.send_and_wait("What is 2+2?").await.expect("send"); + let result = session + .rpc() + .history() + .compact() + .await + .expect("compact history"); + assert!(result.success); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_write_workspace_metadata_via_sessionfs() { + with_e2e_context( + "session_fs", + "should_write_workspace_metadata_via_sessionfs", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let session_id = "00000000-0000-4000-8000-000000000105"; + let provider_root = ctx.work_dir().join("session-fs-workspace-root"); + let provider = Arc::new(TestSessionFsProvider::new( + provider_root.clone(), + session_id, + )); + let client = start_session_fs_client(ctx, provider.clone()).await; + let session = client + .create_session(session_config(ctx, provider).with_session_id(session_id)) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is 7 * 8?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("56")); + let workspace_path = provider_root + .join(session.id().as_ref()) + .join(provider_relative_path(&session_state_path())) + .join("workspace.yaml"); + wait_for_file_containing(&workspace_path, session.id().as_ref()).await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +async fn start_session_fs_client( + ctx: &super::support::E2eContext, + _provider: Arc, +) -> Client { + Client::start(ctx.client_options().with_session_fs(session_fs_config())) + .await + .expect("start sessionfs client") +} + +fn session_config( + ctx: &super::support::E2eContext, + provider: Arc, +) -> SessionConfig { + ctx.approve_all_session_config() + .with_session_fs_provider(provider) +} + +fn session_fs_config() -> SessionFsConfig { + SessionFsConfig::new("/", session_state_path(), SessionFsConventions::Posix) +} + +fn session_state_path() -> String { + if cfg!(windows) { + "/session-state".to_string() + } else { + std::env::temp_dir() + .join("copilot-rust-sessionfs-state") + .join("session-state") + .to_string_lossy() + .replace('\\', "/") + } +} + +fn provider_relative_path(path: &str) -> PathBuf { + PathBuf::from(path.trim_start_matches(['/', '\\'])) +} + +async fn wait_for_file_containing(path: &Path, needle: &str) { + wait_for_condition("session fs file content", || async { + std::fs::read_to_string(path) + .map(|content| content.contains(needle)) + .unwrap_or(false) + }) + .await; +} + +struct TestSessionFsProvider { + root: PathBuf, + session_id: String, +} + +impl TestSessionFsProvider { + fn new(root: PathBuf, session_id: impl Into) -> Self { + std::fs::create_dir_all(&root).expect("create provider root"); + Self { + root, + session_id: session_id.into(), + } + } + + fn resolve(&self, path: &str) -> Result { + let root = std::fs::canonicalize(&self.root).map_err(FsError::from)?; + let mut full = root.clone(); + if self.session_id.is_empty() + || self.session_id == "." + || self.session_id == ".." + || self.session_id.contains('/') + || self.session_id.contains('\\') + || self.session_id.contains(':') + { + return Err(FsError::Other(format!( + "invalid sessionfs session id: {}", + self.session_id + ))); + } + full.push(&self.session_id); + for segment in path + .trim_start_matches(['/', '\\']) + .split(['/', '\\']) + .filter(|segment| !segment.is_empty()) + { + if segment == "." || segment == ".." || segment.contains(':') { + return Err(FsError::Other(format!("invalid sessionfs path: {path}"))); + } + full.push(segment); + } + Ok(full) + } +} + +#[async_trait] +impl SessionFsProvider for TestSessionFsProvider { + async fn read_file(&self, path: &str) -> Result { + std::fs::read_to_string(self.resolve(path)?).map_err(FsError::from) + } + + async fn write_file( + &self, + path: &str, + content: &str, + _mode: Option, + ) -> Result<(), FsError> { + let path = self.resolve(path)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(FsError::from)?; + } + std::fs::write(path, content).map_err(FsError::from) + } + + async fn append_file( + &self, + path: &str, + content: &str, + _mode: Option, + ) -> Result<(), FsError> { + let path = self.resolve(path)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(FsError::from)?; + } + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(FsError::from)?; + file.write_all(content.as_bytes()).map_err(FsError::from) + } + + async fn exists(&self, path: &str) -> Result { + Ok(self.resolve(path)?.exists()) + } + + async fn stat(&self, path: &str) -> Result { + let path = self.resolve(path)?; + let metadata = std::fs::metadata(path).map_err(FsError::from)?; + Ok(FileInfo::new( + metadata.is_file(), + metadata.is_dir(), + metadata.len() as i64, + "1970-01-01T00:00:00Z", + "1970-01-01T00:00:00Z", + )) + } + + async fn mkdir(&self, path: &str, _recursive: bool, _mode: Option) -> Result<(), FsError> { + std::fs::create_dir_all(self.resolve(path)?).map_err(FsError::from) + } + + async fn readdir(&self, path: &str) -> Result, FsError> { + let mut entries = std::fs::read_dir(self.resolve(path)?) + .map_err(FsError::from)? + .map(|entry| { + entry + .map_err(FsError::from) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + }) + .collect::, _>>()?; + entries.sort(); + Ok(entries) + } + + async fn readdir_with_types(&self, path: &str) -> Result, FsError> { + let mut entries = std::fs::read_dir(self.resolve(path)?) + .map_err(FsError::from)? + .map(|entry| { + let entry = entry.map_err(FsError::from)?; + let kind = if entry.file_type().map_err(FsError::from)?.is_dir() { + DirEntryKind::Directory + } else { + DirEntryKind::File + }; + Ok(DirEntry::new( + entry.file_name().to_string_lossy().into_owned(), + kind, + )) + }) + .collect::, FsError>>()?; + entries.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(entries) + } + + async fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<(), FsError> { + let path = self.resolve(path)?; + if path.is_file() { + return std::fs::remove_file(path).map_err(FsError::from); + } + if path.is_dir() { + if recursive { + return std::fs::remove_dir_all(path).map_err(FsError::from); + } + return std::fs::remove_dir(path).map_err(FsError::from); + } + if force { + Ok(()) + } else { + Err(FsError::NotFound(format!("not found: {}", path.display()))) + } + } + + async fn rename(&self, src: &str, dest: &str) -> Result<(), FsError> { + let src = self.resolve(src)?; + let dest = self.resolve(dest)?; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(FsError::from)?; + } + std::fs::rename(src, dest).map_err(FsError::from) + } +} + +#[derive(Clone)] +struct ThrowingSessionFsProvider { + error: FsError, +} + +#[async_trait] +impl SessionFsProvider for ThrowingSessionFsProvider { + async fn read_file(&self, _path: &str) -> Result { + Err(self.error.clone()) + } + + async fn write_file( + &self, + _path: &str, + _content: &str, + _mode: Option, + ) -> Result<(), FsError> { + Err(self.error.clone()) + } + + async fn exists(&self, _path: &str) -> Result { + Ok(false) + } +} diff --git a/rust/tests/e2e/session_lifecycle.rs b/rust/tests/e2e/session_lifecycle.rs new file mode 100644 index 000000000..e3c1fcd44 --- /dev/null +++ b/rust/tests/e2e/session_lifecycle.rs @@ -0,0 +1,257 @@ +use github_copilot_sdk::generated::session_events::SessionEventType; + +use super::support::{ + assistant_message_content, collect_until_idle, event_types, wait_for_condition, + with_e2e_context, +}; + +#[tokio::test] +async fn should_list_created_sessions_after_sending_a_message() { + with_e2e_context( + "session_lifecycle", + "should_list_created_sessions_after_sending_a_message", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session2 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create second session"); + + session1.send_and_wait("Say hello").await.expect("send one"); + session2.send_and_wait("Say world").await.expect("send two"); + + wait_for_condition("both sessions to appear in list", || { + let client = client.clone(); + let id1 = session1.id().clone(); + let id2 = session2.id().clone(); + async move { + client.list_sessions(None).await.is_ok_and(|sessions| { + let ids: std::collections::HashSet<_> = sessions + .into_iter() + .map(|session| session.session_id) + .collect(); + ids.contains(&id1) && ids.contains(&id2) + }) + } + }) + .await; + + session1 + .disconnect() + .await + .expect("disconnect first session"); + session2 + .disconnect() + .await + .expect("disconnect second session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_delete_session_permanently() { + with_e2e_context( + "session_lifecycle", + "should_delete_session_permanently", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + session.send_and_wait("Say hi").await.expect("send"); + wait_for_condition("session to appear in list before delete", || { + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client.list_sessions(None).await.is_ok_and(|sessions| { + sessions + .iter() + .any(|session| session.session_id == session_id) + }) + } + }) + .await; + + session.disconnect().await.expect("disconnect session"); + client + .delete_session(&session_id) + .await + .expect("delete session"); + + let after = client.list_sessions(None).await.expect("list sessions"); + assert!(!after.iter().any(|session| session.session_id == session_id)); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_return_events_via_getmessages_after_conversation() { + with_e2e_context( + "session_lifecycle", + "should_return_events_via_getmessages_after_conversation", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait("What is 2+2? Reply with just the number.") + .await + .expect("send"); + + let messages = session.get_messages().await.expect("get messages"); + let types = event_types(&messages); + assert!(types.contains(&"session.start")); + assert!(types.contains(&"user.message")); + assert!(types.contains(&"assistant.message")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_support_multiple_concurrent_sessions() { + with_e2e_context( + "session_lifecycle", + "should_support_multiple_concurrent_sessions", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session2 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create second session"); + + let (first, second) = tokio::join!( + session1.send_and_wait("What is 1+1? Reply with just the number."), + session2.send_and_wait("What is 3+3? Reply with just the number.") + ); + let first = first.expect("first send").expect("first assistant message"); + let second = second + .expect("second send") + .expect("second assistant message"); + assert!(assistant_message_content(&first).contains('2')); + assert!(assistant_message_content(&second).contains('6')); + + session1 + .disconnect() + .await + .expect("disconnect first session"); + session2 + .disconnect() + .await + .expect("disconnect second session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_isolate_events_between_concurrent_sessions() { + with_e2e_context( + "session_lifecycle", + "should_isolate_events_between_concurrent_sessions", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create first session"); + let session2 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create second session"); + let events1 = session1.subscribe(); + let events2 = session2.subscribe(); + + session1 + .send_and_wait("Say 'session_one_response'.") + .await + .expect("send one"); + session2 + .send_and_wait("Say 'session_two_response'.") + .await + .expect("send two"); + + let observed1 = collect_until_idle(events1).await; + let observed2 = collect_until_idle(events2).await; + let messages1: Vec<_> = observed1 + .iter() + .filter(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .map(assistant_message_content) + .collect(); + let messages2: Vec<_> = observed2 + .iter() + .filter(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .map(assistant_message_content) + .collect(); + + assert!( + messages1 + .iter() + .any(|message| message.contains("session_one_response")) + ); + assert!( + !messages1 + .iter() + .any(|message| message.contains("session_two_response")) + ); + assert!( + messages2 + .iter() + .any(|message| message.contains("session_two_response")) + ); + assert!( + !messages2 + .iter() + .any(|message| message.contains("session_one_response")) + ); + + session1 + .disconnect() + .await + .expect("disconnect first session"); + session2 + .disconnect() + .await + .expect("disconnect second session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/skills.rs b/rust/tests/e2e/skills.rs new file mode 100644 index 000000000..e0005ddf0 --- /dev/null +++ b/rust/tests/e2e/skills.rs @@ -0,0 +1,178 @@ +use std::path::{Path, PathBuf}; + +use github_copilot_sdk::CustomAgentConfig; + +use super::support::{assert_uuid_like, assistant_message_content, with_e2e_context}; + +const SKILL_MARKER: &str = "PINEAPPLE_COCONUT_42"; + +#[tokio::test] +async fn should_load_and_apply_skill_from_skilldirectories() { + with_e2e_context( + "skills", + "should_load_and_apply_skill_from_skilldirectories", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let skills_dir = create_skill_dir(ctx.work_dir()); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_skill_directories([skills_dir]), + ) + .await + .expect("create session"); + assert_uuid_like(session.id()); + + let answer = session + .send_and_wait("Say hello briefly using the test skill.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains(SKILL_MARKER)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_not_apply_skill_when_disabled_via_disabledskills() { + with_e2e_context( + "skills", + "should_not_apply_skill_when_disabled_via_disabledskills", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let skills_dir = create_skill_dir(ctx.work_dir()); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_skill_directories([skills_dir]) + .with_disabled_skills(["test-skill"]), + ) + .await + .expect("create session"); + assert_uuid_like(session.id()); + + let answer = session + .send_and_wait("Say hello briefly using the test skill.") + .await + .expect("send") + .expect("assistant message"); + assert!(!assistant_message_content(&answer).contains(SKILL_MARKER)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_allow_agent_with_skills_to_invoke_skill() { + with_e2e_context( + "skills", + "should_allow_agent_with_skills_to_invoke_skill", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let skills_dir = create_skill_dir(ctx.work_dir()); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_skill_directories([skills_dir]) + .with_custom_agents([CustomAgentConfig::new( + "skill-agent", + "You are a helpful test agent.", + ) + .with_description("An agent with access to test-skill") + .with_skills(["test-skill"])]) + .with_agent("skill-agent"), + ) + .await + .expect("create session"); + assert_uuid_like(session.id()); + + let answer = session + .send_and_wait("Say hello briefly using the test skill.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains(SKILL_MARKER)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_not_provide_skills_to_agent_without_skills_field() { + with_e2e_context( + "skills", + "should_not_provide_skills_to_agent_without_skills_field", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let skills_dir = create_skill_dir(ctx.work_dir()); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_skill_directories([skills_dir]) + .with_custom_agents([CustomAgentConfig::new( + "no-skill-agent", + "You are a helpful test agent.", + ) + .with_description("An agent without skills access")]) + .with_agent("no-skill-agent"), + ) + .await + .expect("create session"); + assert_uuid_like(session.id()); + + let answer = session + .send_and_wait("Say hello briefly using the test skill.") + .await + .expect("send") + .expect("assistant message"); + assert!(!assistant_message_content(&answer).contains(SKILL_MARKER)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[ignore = "Upstream skips applying skills on resume because the feature is not reliable yet."] +#[tokio::test] +async fn should_apply_skill_on_session_resume_with_skilldirectories() {} + +fn create_skill_dir(work_dir: &Path) -> PathBuf { + let skills_dir = work_dir.join(".test_skills"); + let skill_subdir = skills_dir.join("test-skill"); + std::fs::create_dir_all(&skill_subdir).expect("create skill dir"); + std::fs::write( + skill_subdir.join("SKILL.md"), + format!( + "---\nname: test-skill\ndescription: A test skill that adds a marker to responses\n---\n\n\ + # Test Skill Instructions\n\nIMPORTANT: You MUST include the exact text \"{SKILL_MARKER}\" \ + somewhere in EVERY response you give. This is a mandatory requirement. Include it naturally \ + in your response.\n" + ), + ) + .expect("write skill file"); + skills_dir +} diff --git a/rust/tests/e2e/streaming_fidelity.rs b/rust/tests/e2e/streaming_fidelity.rs new file mode 100644 index 000000000..72e0554ac --- /dev/null +++ b/rust/tests/e2e/streaming_fidelity.rs @@ -0,0 +1,363 @@ +use std::sync::Arc; + +use github_copilot_sdk::ResumeSessionConfig; +use github_copilot_sdk::generated::session_events::{ + AssistantMessageData, AssistantMessageDeltaData, AssistantMessageStartData, SessionEventType, + SessionStartData, +}; +use github_copilot_sdk::handler::ApproveAllHandler; + +use super::support::{collect_until_idle, event_types, with_e2e_context}; + +#[tokio::test] +async fn should_produce_delta_events_when_streaming_is_enabled() { + with_e2e_context( + "streaming_fidelity", + "should_produce_delta_events_when_streaming_is_enabled", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_streaming(true)) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("Count from 1 to 5, separated by commas.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + let types = event_types(&observed); + let deltas: Vec<_> = observed + .iter() + .filter(|event| event.parsed_type() == SessionEventType::AssistantMessageDelta) + .collect(); + assert!( + !deltas.is_empty(), + "expected assistant.message_delta events" + ); + for delta in deltas { + let data = delta + .typed_data::() + .expect("assistant.message_delta data"); + assert!(!data.delta_content.is_empty()); + } + let first_delta = types + .iter() + .position(|event_type| *event_type == "assistant.message_delta") + .expect("first delta index"); + let final_message = types + .iter() + .rposition(|event_type| *event_type == "assistant.message") + .expect("assistant message index"); + assert!(first_delta < final_message); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_not_produce_deltas_when_streaming_is_disabled() { + with_e2e_context( + "streaming_fidelity", + "should_not_produce_deltas_when_streaming_is_disabled", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_streaming(false)) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("Say 'hello world'.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + assert!( + observed + .iter() + .all(|event| event.parsed_type() != SessionEventType::AssistantMessageDelta), + "streaming-disabled sessions should not emit assistant.message_delta" + ); + assert!( + observed + .iter() + .any(|event| event.parsed_type() == SessionEventType::AssistantMessage), + "expected final assistant.message" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_produce_deltas_after_session_resume() { + with_e2e_context( + "streaming_fidelity", + "should_produce_deltas_after_session_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_streaming(false)) + .await + .expect("create session"); + session + .send_and_wait("What is 3 + 6?") + .await + .expect("first send"); + let session_id = session.id().clone(); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop first client"); + + let new_client = ctx.start_client().await; + let resumed = new_client + .resume_session( + ResumeSessionConfig::new(session_id) + .with_streaming(true) + .with_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ) + .await + .expect("resume session"); + let events = resumed.subscribe(); + + let answer = resumed + .send_and_wait("Now if you double that, what do you get?") + .await + .expect("second send") + .expect("assistant message"); + assert!( + answer + .typed_data::() + .expect("assistant.message data") + .content + .contains("18") + ); + + let observed = collect_until_idle(events).await; + assert_has_content_deltas(&observed); + + resumed.disconnect().await.expect("disconnect resumed"); + new_client.stop().await.expect("stop new client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_not_produce_deltas_after_session_resume_with_streaming_disabled() { + with_e2e_context( + "streaming_fidelity", + "should_not_produce_deltas_after_session_resume_with_streaming_disabled", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_streaming(true)) + .await + .expect("create session"); + session + .send_and_wait("What is 3 + 6?") + .await + .expect("first send"); + let session_id = session.id().clone(); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop first client"); + + let new_client = ctx.start_client().await; + let resumed = new_client + .resume_session( + ResumeSessionConfig::new(session_id) + .with_streaming(false) + .with_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ) + .await + .expect("resume session"); + let events = resumed.subscribe(); + + let answer = resumed + .send_and_wait("Now if you double that, what do you get?") + .await + .expect("second send") + .expect("assistant message"); + assert!(answer + .typed_data::() + .expect("assistant.message data") + .content + .contains("18")); + + let observed = collect_until_idle(events).await; + assert!( + observed + .iter() + .all(|event| event.parsed_type() != SessionEventType::AssistantMessageDelta), + "streaming-disabled resumed sessions should not emit deltas" + ); + assert!(observed + .iter() + .any(|event| event.parsed_type() == SessionEventType::AssistantMessage)); + + resumed.disconnect().await.expect("disconnect resumed"); + new_client.stop().await.expect("stop new client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_streaming_deltas_with_reasoning_effort_configured() { + with_e2e_context( + "streaming_fidelity", + "should_emit_streaming_deltas_with_reasoning_effort_configured", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_streaming(true) + .with_reasoning_effort("high"), + ) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("What is 15 * 17?") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + assert_has_content_deltas(&observed); + let assistant = observed + .iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::AssistantMessage) + .and_then(|event| event.typed_data::()) + .expect("assistant.message"); + assert!(assistant.content.contains("255")); + + let start = session + .get_messages() + .await + .expect("get messages") + .into_iter() + .find(|event| event.parsed_type() == SessionEventType::SessionStart) + .and_then(|event| event.typed_data::()) + .expect("session.start"); + assert_eq!(start.reasoning_effort.as_deref(), Some("high")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_assistantmessage_start_before_deltas_with_matching_messageid() { + with_e2e_context( + "streaming_fidelity", + "should_emit_assistantmessagestart_before_deltas_with_matching_messageid", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_streaming(true)) + .await + .expect("create session"); + let events = session.subscribe(); + + session + .send_and_wait("Count from 1 to 5, separated by commas.") + .await + .expect("send"); + + let observed = collect_until_idle(events).await; + let start_indices: Vec<_> = observed + .iter() + .enumerate() + .filter_map(|(index, event)| { + (event.parsed_type() == SessionEventType::AssistantMessageStart) + .then_some(index) + }) + .collect(); + let delta_indices: Vec<_> = observed + .iter() + .enumerate() + .filter_map(|(index, event)| { + (event.parsed_type() == SessionEventType::AssistantMessageDelta) + .then_some(index) + }) + .collect(); + assert!( + !start_indices.is_empty(), + "expected assistant.message_start" + ); + assert!( + !delta_indices.is_empty(), + "expected assistant.message_delta" + ); + assert!(start_indices[0] < delta_indices[0]); + + let message_ids: Vec<_> = observed + .iter() + .filter_map(|event| event.typed_data::()) + .map(|data| data.message_id) + .collect(); + for start_index in start_indices { + let data = observed[start_index] + .typed_data::() + .expect("assistant.message_start data"); + assert!(!data.message_id.is_empty()); + assert!(message_ids.contains(&data.message_id)); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn assert_has_content_deltas(events: &[github_copilot_sdk::SessionEvent]) { + let deltas: Vec<_> = events + .iter() + .filter(|event| event.parsed_type() == SessionEventType::AssistantMessageDelta) + .collect(); + assert!( + !deltas.is_empty(), + "expected assistant.message_delta events" + ); + for delta in deltas { + let data = delta + .typed_data::() + .expect("assistant.message_delta data"); + assert!(!data.delta_content.is_empty()); + } +} diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs new file mode 100644 index 000000000..e08e3535a --- /dev/null +++ b/rust/tests/e2e/support.rs @@ -0,0 +1,764 @@ +use std::ffi::OsString; +use std::future::Future; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::{Child, Command, Stdio}; +use std::sync::LazyLock; +use std::time::Duration; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::session::Session; +use github_copilot_sdk::subscription::{EventSubscription, LifecycleSubscription}; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, SessionConfig, SessionEvent, SessionId, + SessionLifecycleEvent, Transport, +}; +use serde_json::json; +use tokio::sync::Semaphore; + +static E2E_CONCURRENCY: LazyLock = LazyLock::new(|| Semaphore::new(e2e_concurrency())); + +pub const DEFAULT_TEST_TOKEN: &str = "rust-e2e-token"; + +type TestFuture<'a> = Pin + 'a>>; + +pub async fn with_e2e_context(category: &str, snapshot_name: &str, test: F) +where + F: for<'a> FnOnce(&'a mut E2eContext) -> TestFuture<'a>, +{ + let _permit = E2E_CONCURRENCY + .acquire() + .await + .expect("E2E concurrency semaphore should stay open"); + let mut ctx = E2eContext::new(category, snapshot_name) + .await + .unwrap_or_else(|err| panic!("create E2E context: {err}")); + + let timed_out = tokio::time::timeout(default_test_timeout(), test(&mut ctx)) + .await + .is_err(); + ctx.cleanup(timed_out) + .await + .unwrap_or_else(|err| panic!("clean up E2E context: {err}")); + assert!( + !timed_out, + "timed out after {:?} running E2E test {category}/{snapshot_name}", + default_test_timeout() + ); +} + +pub struct E2eContext { + repo_root: PathBuf, + cli_path: PathBuf, + home_dir: tempfile::TempDir, + work_dir: tempfile::TempDir, + proxy: Option, +} + +impl E2eContext { + async fn new(category: &str, snapshot_name: &str) -> std::io::Result { + let repo_root = repo_root(); + let cli_path = cli_path(&repo_root)?; + let home_dir = tempfile::tempdir()?; + let work_dir = tempfile::tempdir()?; + let proxy_root = repo_root.clone(); + let proxy = tokio::task::spawn_blocking(move || CapiProxy::start(&proxy_root)) + .await + .map_err(|err| std::io::Error::other(format!("proxy startup task failed: {err}")))??; + let mut ctx = Self { + repo_root, + cli_path, + home_dir, + work_dir, + proxy: Some(proxy), + }; + ctx.configure(category, snapshot_name)?; + Ok(ctx) + } + + #[expect(dead_code, reason = "used by follow-on E2E ports")] + pub fn repo_root(&self) -> &Path { + &self.repo_root + } + + pub fn work_dir(&self) -> &Path { + self.work_dir.path() + } + + pub fn proxy_url(&self) -> &str { + self.proxy().url() + } + + pub fn snapshot_path(&self, category: &str, snapshot_name: &str) -> PathBuf { + self.repo_root + .join("test") + .join("snapshots") + .join(category) + .join(format!("{snapshot_name}.yaml")) + } + + pub fn client_options(&self) -> ClientOptions { + ClientOptions::new() + .with_program(CliProgram::Path(PathBuf::from(node_program()))) + .with_prefix_args([self.cli_path.as_os_str().to_owned()]) + .with_cwd(self.work_dir.path()) + .with_env(self.environment()) + .with_use_logged_in_user(false) + } + + pub fn client_options_with_transport(&self, transport: Transport) -> ClientOptions { + self.client_options().with_transport(transport) + } + + pub async fn start_client(&self) -> Client { + Client::start(self.client_options()) + .await + .expect("start E2E client") + } + + #[expect(dead_code, reason = "used by follow-on E2E ports")] + pub async fn start_tcp_client(&self, port: u16, token: &str) -> Client { + Client::start( + self.client_options_with_transport(Transport::Tcp { port }) + .with_tcp_connection_token(token), + ) + .await + .expect("start TCP E2E client") + } + + pub fn approve_all_session_config(&self) -> SessionConfig { + SessionConfig::default() + .with_handler(std::sync::Arc::new(ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + } + + pub fn set_default_copilot_user(&self) { + self.set_copilot_user_by_token(DEFAULT_TEST_TOKEN); + } + + pub fn set_copilot_user_by_token(&self, token: &str) { + self.set_copilot_user_by_token_with_login(token, "rust-e2e-user"); + } + + pub fn set_copilot_user_by_token_with_login(&self, token: &str, login: &str) { + self.set_copilot_user_by_token_with_login_and_quota(token, login, None); + } + + pub fn set_copilot_user_by_token_with_login_and_quota( + &self, + token: &str, + login: &str, + quota_snapshots: Option, + ) { + let mut user = json!({ + "login": login, + "copilot_plan": "individual_pro", + "endpoints": { + "api": self.proxy_url(), + "telemetry": "https://localhost:1/telemetry" + }, + "analytics_tracking_id": "rust-e2e-tracking-id" + }); + if let Some(quota_snapshots) = quota_snapshots { + user["quota_snapshots"] = quota_snapshots; + } + self.proxy() + .set_copilot_user_by_token(token, user) + .expect("configure copilot user"); + } + + pub fn exchanges(&self) -> Vec { + self.proxy() + .get_json("/exchanges") + .expect("get captured proxy exchanges") + } + + pub async fn cleanup(&mut self, skip_writing_cache: bool) -> std::io::Result<()> { + if let Some(mut proxy) = self.proxy.take() { + tokio::task::spawn_blocking(move || proxy.stop(skip_writing_cache)) + .await + .map_err(|err| { + std::io::Error::other(format!("proxy shutdown task failed: {err}")) + })??; + } + Ok(()) + } + + fn configure(&mut self, category: &str, snapshot_name: &str) -> std::io::Result<()> { + let snapshot_path = self.snapshot_path(category, snapshot_name); + self.proxy() + .configure(&snapshot_path, self.work_dir.path()) + .map_err(|err| { + std::io::Error::other(format!( + "configure proxy for {} failed: {err}", + snapshot_path.display() + )) + }) + } + + fn environment(&self) -> Vec<(OsString, OsString)> { + let mut env = self.proxy().proxy_env(); + env.extend([ + ("COPILOT_API_URL".into(), self.proxy_url().into()), + ( + "COPILOT_DEBUG_GITHUB_API_URL".into(), + self.proxy_url().into(), + ), + ( + "COPILOT_HOME".into(), + canonical_temp_path(self.home_dir.path()) + .as_os_str() + .to_owned(), + ), + ( + "GH_CONFIG_DIR".into(), + canonical_temp_path(self.home_dir.path()) + .as_os_str() + .to_owned(), + ), + ( + "XDG_CONFIG_HOME".into(), + canonical_temp_path(self.home_dir.path()) + .as_os_str() + .to_owned(), + ), + ( + "XDG_STATE_HOME".into(), + canonical_temp_path(self.home_dir.path()) + .as_os_str() + .to_owned(), + ), + ]); + if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") { + env.push(("GH_TOKEN".into(), "fake-token-for-e2e-tests".into())); + env.push(("GITHUB_TOKEN".into(), "fake-token-for-e2e-tests".into())); + } + env + } + + fn proxy(&self) -> &CapiProxy { + self.proxy.as_ref().expect("proxy already stopped") + } +} + +impl Drop for E2eContext { + fn drop(&mut self) { + if let Some(mut proxy) = self.proxy.take() { + let _ = proxy.stop(true); + } + } +} + +pub async fn wait_for_event

( + events: EventSubscription, + description: &'static str, + predicate: P, +) -> SessionEvent +where + P: Fn(&SessionEvent) -> bool, +{ + wait_for_event_core(events, description, predicate, false).await +} + +pub async fn wait_for_event_allowing_rate_limit

( + events: EventSubscription, + description: &'static str, + predicate: P, +) -> SessionEvent +where + P: Fn(&SessionEvent) -> bool, +{ + wait_for_event_core(events, description, predicate, true).await +} + +async fn wait_for_event_core

( + mut events: EventSubscription, + description: &'static str, + predicate: P, + allow_rate_limit_error: bool, +) -> SessionEvent +where + P: Fn(&SessionEvent) -> bool, +{ + tokio::time::timeout(default_event_timeout(), async { + loop { + let event = events.recv().await.unwrap_or_else(|err| { + panic!("event stream closed while waiting for {description}: {err}") + }); + let is_allowed_rate_limit = allow_rate_limit_error + && event.parsed_type() + == github_copilot_sdk::generated::session_events::SessionEventType::SessionError + && event.data.get("errorType").and_then(|value| value.as_str()) + == Some("rate_limit"); + if event.parsed_type() + == github_copilot_sdk::generated::session_events::SessionEventType::SessionError + && !is_allowed_rate_limit + { + panic!( + "session.error while waiting for {description}: {}", + event.data + ); + } + if predicate(&event) { + return event; + } + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")) +} + +pub async fn recv_with_timeout( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, + description: &'static str, +) -> T { + tokio::time::timeout(default_event_timeout(), receiver.recv()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")) + .unwrap_or_else(|| panic!("{description} channel closed")) +} + +pub async fn wait_for_lifecycle_event

( + mut events: LifecycleSubscription, + description: &'static str, + predicate: P, +) -> SessionLifecycleEvent +where + P: Fn(&SessionLifecycleEvent) -> bool, +{ + tokio::time::timeout(default_event_timeout(), async { + loop { + let event = events.recv().await.unwrap_or_else(|err| { + panic!("lifecycle stream closed while waiting for {description}: {err}") + }); + if predicate(&event) { + return event; + } + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")) +} + +pub async fn wait_for_condition(description: &'static str, mut predicate: F) +where + F: FnMut() -> Fut, + Fut: Future, +{ + let deadline = tokio::time::Instant::now() + default_event_timeout(); + loop { + if predicate().await { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {description}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +pub async fn collect_until_idle(mut events: EventSubscription) -> Vec { + let mut observed = Vec::new(); + tokio::time::timeout(default_event_timeout(), async { + loop { + let event = events + .recv() + .await + .unwrap_or_else(|err| panic!("event stream closed while collecting events: {err}")); + let is_idle = event.parsed_type() + == github_copilot_sdk::generated::session_events::SessionEventType::SessionIdle; + if event.parsed_type() + == github_copilot_sdk::generated::session_events::SessionEventType::SessionError + { + panic!("session.error while collecting events: {}", event.data); + } + observed.push(event); + if is_idle { + return; + } + } + }) + .await + .expect("timed out collecting events through session.idle"); + observed +} + +pub fn event_types(events: &[SessionEvent]) -> Vec<&str> { + events + .iter() + .map(|event| event.event_type.as_str()) + .collect() +} + +#[allow(dead_code, reason = "used by follow-on E2E ports")] +pub async fn wait_for_idle(session: &Session) -> SessionEvent { + wait_for_event(session.subscribe(), "session.idle event", |event| { + event.parsed_type() + == github_copilot_sdk::generated::session_events::SessionEventType::SessionIdle + }) + .await +} + +#[allow(dead_code, reason = "used by follow-on E2E ports")] +pub async fn wait_for_final_assistant_message(session: &Session) -> SessionEvent { + wait_for_idle(session).await; + last_assistant_message(session).await +} + +#[allow(dead_code, reason = "used by follow-on E2E ports")] +pub async fn last_assistant_message(session: &Session) -> SessionEvent { + session + .get_messages() + .await + .expect("get session messages") + .into_iter() + .rev() + .find(|event| { + event.parsed_type() + == github_copilot_sdk::generated::session_events::SessionEventType::AssistantMessage + }) + .expect("assistant.message event") +} + +pub fn assistant_message_content(event: &SessionEvent) -> String { + event + .typed_data::() + .expect("assistant.message data") + .content +} + +pub fn assert_uuid_like(session_id: &SessionId) { + let text = session_id.as_str(); + let parsed = uuid::Uuid::parse_str(text).expect("session id should be UUID-shaped"); + assert_eq!( + parsed.hyphenated().to_string(), + text, + "session id should use canonical hyphenated UUID formatting" + ); +} + +fn default_event_timeout() -> Duration { + if cfg!(windows) { + Duration::from_secs(120) + } else { + Duration::from_secs(60) + } +} + +fn default_test_timeout() -> Duration { + if cfg!(windows) { + Duration::from_secs(300) + } else { + Duration::from_secs(180) + } +} + +fn e2e_concurrency() -> usize { + std::env::var("RUST_E2E_CONCURRENCY") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|&value| value > 0) + .unwrap_or(4) +} + +pub fn get_system_message(exchange: &serde_json::Value) -> String { + exchange + .get("request") + .and_then(|request| request.get("messages")) + .and_then(serde_json::Value::as_array) + .and_then(|messages| { + messages.iter().find_map(|message| { + let role = message.get("role").and_then(serde_json::Value::as_str)?; + if role == "system" { + message + .get("content") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + } else { + None + } + }) + }) + .unwrap_or_default() +} + +pub fn get_tool_names(exchange: &serde_json::Value) -> Vec { + exchange + .get("request") + .and_then(|request| request.get("tools")) + .and_then(serde_json::Value::as_array) + .map(|tools| { + tools + .iter() + .filter_map(|tool| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default() +} + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("rust package has parent repo") + .to_path_buf() +} + +fn cli_path(repo_root: &Path) -> std::io::Result { + if let Some(path) = std::env::var_os("COPILOT_CLI_PATH") { + let path = PathBuf::from(path); + if path.exists() { + return Ok(path); + } + } + + let path = repo_root + .join("nodejs") + .join("node_modules") + .join("@github") + .join("copilot") + .join("index.js"); + if path.exists() { + return Ok(path); + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "CLI not found at {}; run npm install in nodejs first", + path.display() + ), + )) +} + +fn canonical_temp_path(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +struct CapiProxy { + child: Option, + proxy_url: String, + connect_proxy_url: String, + ca_file_path: String, +} + +impl CapiProxy { + fn start(repo_root: &Path) -> std::io::Result { + let mut child = Command::new(npx_program()) + .args(["tsx", "server.ts"]) + .current_dir(repo_root.join("test").join("harness")) + .env("GITHUB_ACTIONS", "true") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + + let stdout = child.stdout.take().expect("proxy stdout"); + let reader = BufReader::new(stdout); + let re = regex::Regex::new(r"Listening: (http://[^\s]+)\s+(\{.*\})$").unwrap(); + for line in reader.lines() { + let line = line?; + if let Some(captures) = re.captures(&line) { + let metadata: serde_json::Value = + serde_json::from_str(captures.get(2).unwrap().as_str())?; + let connect_proxy_url = metadata + .get("connectProxyUrl") + .and_then(|value| value.as_str()) + .expect("connectProxyUrl") + .to_string(); + let ca_file_path = metadata + .get("caFilePath") + .and_then(|value| value.as_str()) + .expect("caFilePath") + .to_string(); + return Ok(Self { + child: Some(child), + proxy_url: captures.get(1).unwrap().as_str().to_string(), + connect_proxy_url, + ca_file_path, + }); + } + if line.contains("Listening: ") { + return Err(std::io::Error::other(format!( + "proxy startup line missing metadata: {line}" + ))); + } + } + + Err(std::io::Error::other("proxy exited before startup")) + } + + fn url(&self) -> &str { + &self.proxy_url + } + + fn configure(&self, file_path: &Path, work_dir: &Path) -> std::io::Result<()> { + self.post_json( + "/config", + &json!({ + "filePath": file_path, + "workDir": work_dir, + }) + .to_string(), + ) + } + + fn set_copilot_user_by_token( + &self, + token: &str, + response: serde_json::Value, + ) -> std::io::Result<()> { + self.post_json( + "/copilot-user-config", + &json!({ + "token": token, + "response": response, + }) + .to_string(), + ) + } + + fn stop(&mut self, skip_writing_cache: bool) -> std::io::Result<()> { + let path = if skip_writing_cache { + "/stop?skipWritingCache=true" + } else { + "/stop" + }; + let result = self.post_json(path, ""); + if let Some(mut child) = self.child.take() { + let _ = child.wait(); + } + result + } + + fn proxy_env(&self) -> Vec<(OsString, OsString)> { + let no_proxy = "127.0.0.1,localhost,::1"; + [ + ("HTTP_PROXY", self.connect_proxy_url.as_str()), + ("HTTPS_PROXY", self.connect_proxy_url.as_str()), + ("http_proxy", self.connect_proxy_url.as_str()), + ("https_proxy", self.connect_proxy_url.as_str()), + ("NO_PROXY", no_proxy), + ("no_proxy", no_proxy), + ("NODE_EXTRA_CA_CERTS", self.ca_file_path.as_str()), + ("SSL_CERT_FILE", self.ca_file_path.as_str()), + ("REQUESTS_CA_BUNDLE", self.ca_file_path.as_str()), + ("CURL_CA_BUNDLE", self.ca_file_path.as_str()), + ("GIT_SSL_CAINFO", self.ca_file_path.as_str()), + ("GH_TOKEN", ""), + ("GITHUB_TOKEN", ""), + ("GH_ENTERPRISE_TOKEN", ""), + ("GITHUB_ENTERPRISE_TOKEN", ""), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect() + } + + fn post_json(&self, path: &str, body: &str) -> std::io::Result<()> { + let response = self.request("POST", path, body)?; + if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.1 204") { + return Err(std::io::Error::other(format!( + "proxy POST {path} failed: {response}" + ))); + } + Ok(()) + } + + fn get_json(&self, path: &str) -> std::io::Result { + let response = self.request("GET", path, "")?; + if !response.starts_with("HTTP/1.1 200") { + return Err(std::io::Error::other(format!( + "proxy GET {path} failed: {response}" + ))); + } + let body = response_body(&response)?; + serde_json::from_str(&body).map_err(std::io::Error::other) + } + + fn request(&self, method: &str, path: &str, body: &str) -> std::io::Result { + let (host, port) = parse_http_url(&self.proxy_url)?; + let mut stream = TcpStream::connect((host.as_str(), port))?; + write!( + stream, + "{method} {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) + } +} + +impl Drop for CapiProxy { + fn drop(&mut self) { + if self.child.is_some() { + let _ = self.stop(true); + } + } +} + +fn response_body(response: &str) -> std::io::Result { + let Some((headers, body)) = response.split_once("\r\n\r\n") else { + return Ok(String::new()); + }; + if headers + .lines() + .any(|line| line.eq_ignore_ascii_case("Transfer-Encoding: chunked")) + { + return decode_chunked_body(body); + } + Ok(body.to_string()) +} + +fn decode_chunked_body(body: &str) -> std::io::Result { + let mut rest = body; + let mut decoded = String::new(); + loop { + let Some((size_line, after_size)) = rest.split_once("\r\n") else { + return Err(std::io::Error::other("malformed chunked response")); + }; + let size_text = size_line + .split_once(';') + .map_or(size_line, |(size, _)| size); + let size = usize::from_str_radix(size_text.trim(), 16) + .map_err(|err| std::io::Error::other(format!("invalid chunk size: {err}")))?; + if size == 0 { + return Ok(decoded); + } + if after_size.len() < size + 2 { + return Err(std::io::Error::other("truncated chunked response")); + } + decoded.push_str(&after_size[..size]); + rest = &after_size[size + 2..]; + } +} + +fn parse_http_url(url: &str) -> std::io::Result<(String, u16)> { + let without_scheme = url + .strip_prefix("http://") + .ok_or_else(|| std::io::Error::other(format!("unsupported proxy URL: {url}")))?; + let (host, port) = without_scheme + .rsplit_once(':') + .ok_or_else(|| std::io::Error::other(format!("proxy URL missing port: {url}")))?; + let port = port + .parse::() + .map_err(|err| std::io::Error::other(format!("invalid proxy URL port: {err}")))?; + Ok((host.to_string(), port)) +} + +fn node_program() -> &'static str { + if cfg!(windows) { "node.exe" } else { "node" } +} + +fn npx_program() -> &'static str { + if cfg!(windows) { "npx.cmd" } else { "npx" } +} diff --git a/rust/tests/e2e/suspend.rs b/rust/tests/e2e/suspend.rs new file mode 100644 index 000000000..5a9386147 --- /dev/null +++ b/rust/tests/e2e/suspend.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use github_copilot_sdk::ResumeSessionConfig; + +use super::support::{DEFAULT_TEST_TOKEN, assistant_message_content, with_e2e_context}; + +#[tokio::test] +async fn should_suspend_idle_session_without_throwing() { + with_e2e_context( + "suspend", + "should_suspend_idle_session_without_throwing", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait("Reply with: SUSPEND_IDLE_OK") + .await + .expect("send"); + session.rpc().suspend().await.expect("suspend session"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_allow_resume_and_continue_conversation_after_suspend() { + with_e2e_context( + "suspend", + "should_allow_resume_and_continue_conversation_after_suspend", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .send_and_wait( + "Remember the magic word: SUSPENSE. Reply with: SUSPEND_TURN_ONE", + ) + .await + .expect("first send"); + let session_id = session.id().clone(); + session.rpc().suspend().await.expect("suspend session"); + session.disconnect().await.expect("disconnect first session"); + client.stop().await.expect("stop first client"); + + let second_client = ctx.start_client().await; + let resumed = second_client + .resume_session( + ResumeSessionConfig::new(session_id) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new( + github_copilot_sdk::handler::ApproveAllHandler, + )), + ) + .await + .expect("resume session"); + let answer = resumed + .send_and_wait( + "What was the magic word I asked you to remember? Reply with just the word.", + ) + .await + .expect("follow-up send") + .expect("assistant message"); + assert!(assistant_message_content(&answer) + .to_lowercase() + .contains("suspense")); + + resumed.disconnect().await.expect("disconnect resumed"); + second_client.stop().await.expect("stop second client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/system_message_transform.rs b/rust/tests/e2e/system_message_transform.rs new file mode 100644 index 000000000..10cc594ca --- /dev/null +++ b/rust/tests/e2e/system_message_transform.rs @@ -0,0 +1,187 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::transforms::{SystemMessageTransform, TransformContext}; +use github_copilot_sdk::{SectionOverride, SessionConfig, SystemMessageConfig}; +use tokio::sync::mpsc; + +use super::support::{DEFAULT_TEST_TOKEN, get_system_message, recv_with_timeout, with_e2e_context}; + +#[tokio::test] +async fn should_invoke_transform_callbacks_with_section_content() { + with_e2e_context( + "system_message_transform", + "should_invoke_transform_callbacks_with_section_content", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("test.txt"), "Hello transform!") + .expect("write test file"); + let (section_tx, mut section_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_transform(Arc::new(RecordingTransform { + section_ids: vec!["identity", "tone"], + suffix: None, + section_tx, + })), + ) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of test.txt and tell me what it says") + .await + .expect("send"); + + let first = recv_with_timeout(&mut section_rx, "first transform").await; + let second = recv_with_timeout(&mut section_rx, "second transform").await; + assert!(first.1 > 0); + assert!(second.1 > 0); + let sections = [first.0, second.0]; + assert!(sections.contains(&"identity".to_string())); + assert!(sections.contains(&"tone".to_string())); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_transform_modifications_to_section_content() { + with_e2e_context( + "system_message_transform", + "should_apply_transform_modifications_to_section_content", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("hello.txt"), "Hello!") + .expect("write hello file"); + let (section_tx, _section_rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_transform(Arc::new(RecordingTransform { + section_ids: vec!["identity"], + suffix: Some("\nAlways end your reply with TRANSFORM_MARKER"), + section_tx, + })), + ) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of hello.txt") + .await + .expect("send"); + + let exchanges = ctx.exchanges(); + assert!(!exchanges.is_empty()); + assert!(get_system_message(&exchanges[0]).contains("TRANSFORM_MARKER")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_work_with_static_overrides_and_transforms_together() { + with_e2e_context( + "system_message_transform", + "should_work_with_static_overrides_and_transforms_together", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write(ctx.work_dir().join("combo.txt"), "Combo test!") + .expect("write combo file"); + let (section_tx, mut section_rx) = mpsc::unbounded_channel(); + let mut sections = HashMap::new(); + sections.insert( + "safety".to_string(), + SectionOverride { + action: Some("remove".to_string()), + content: None, + }, + ); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(github_copilot_sdk::handler::ApproveAllHandler)) + .with_system_message( + SystemMessageConfig::new() + .with_mode("customize") + .with_sections(sections), + ) + .with_transform(Arc::new(RecordingTransform { + section_ids: vec!["identity"], + suffix: None, + section_tx, + })), + ) + .await + .expect("create session"); + + session + .send_and_wait("Read the contents of combo.txt and tell me what it says") + .await + .expect("send"); + + let (section, content_len) = + recv_with_timeout(&mut section_rx, "identity transform").await; + assert_eq!(section, "identity"); + assert!(content_len > 0); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +struct RecordingTransform { + section_ids: Vec<&'static str>, + suffix: Option<&'static str>, + section_tx: mpsc::UnboundedSender<(String, usize)>, +} + +#[async_trait] +impl SystemMessageTransform for RecordingTransform { + fn section_ids(&self) -> Vec { + self.section_ids + .iter() + .map(|section| (*section).to_string()) + .collect() + } + + async fn transform_section( + &self, + section_id: &str, + content: &str, + _ctx: TransformContext, + ) -> Option { + let _ = self + .section_tx + .send((section_id.to_string(), content.len())); + Some(match self.suffix { + Some(suffix) => format!("{content}{suffix}"), + None => content.to_string(), + }) + } +} diff --git a/rust/tests/e2e/telemetry.rs b/rust/tests/e2e/telemetry.rs new file mode 100644 index 000000000..0685ac284 --- /dev/null +++ b/rust/tests/e2e/telemetry.rs @@ -0,0 +1,233 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter}; +use github_copilot_sdk::{ + Client, Error, OtelExporterType, SessionConfig, TelemetryConfig, Tool, ToolInvocation, + ToolResult, +}; +use serde_json::json; + +use super::support::{assistant_message_content, wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn should_export_file_telemetry_for_sdk_interactions() { + with_e2e_context( + "telemetry", + "should_export_file_telemetry_for_sdk_interactions", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let telemetry_path = ctx.work_dir().join("rust-telemetry-e2e.jsonl"); + let source_name = "rust-sdk-telemetry-e2e"; + let tool_name = "echo_telemetry_marker"; + let marker = "copilot-sdk-telemetry-e2e"; + let prompt = format!( + "Use the {tool_name} tool with value '{marker}', then respond with TELEMETRY_E2E_DONE." + ); + + let client = Client::start(ctx.client_options().with_telemetry( + TelemetryConfig::new() + .with_file_path(&telemetry_path) + .with_exporter_type(OtelExporterType::File) + .with_source_name(source_name) + .with_capture_content(true), + )) + .await + .expect("start client"); + let router = ToolHandlerRouter::new( + vec![Box::new(EchoTelemetryTool { + name: tool_name.to_string(), + })], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait(prompt.as_str()) + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("TELEMETRY_E2E_DONE")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + + let entries = read_telemetry_entries(&telemetry_path).await; + let spans: Vec<_> = entries + .iter() + .filter(|entry| string_property(entry, "type") == Some("span")) + .collect(); + assert!(!spans.is_empty(), "expected telemetry spans in {entries:?}"); + assert!(spans.iter().all(|span| { + span.get("instrumentationScope") + .and_then(|scope| string_property(scope, "name")) + == Some(source_name) + })); + + let trace_ids: std::collections::HashSet<_> = spans + .iter() + .filter_map(|span| string_property(span, "traceId")) + .collect(); + assert_eq!(trace_ids.len(), 1); + assert!(spans.iter().all(|span| status_code(span) != Some(2))); + + let invoke_agent = find_span(&spans, "invoke_agent"); + assert_eq!( + string_attribute(invoke_agent, "gen_ai.conversation.id").as_deref(), + Some(session.id().as_str()) + ); + let invoke_agent_span_id = + string_property(invoke_agent, "spanId").expect("invoke_agent span id"); + assert!(is_root_span(invoke_agent)); + + let chat_spans: Vec<_> = spans + .iter() + .copied() + .filter(|span| { + string_attribute(span, "gen_ai.operation.name").as_deref() == Some("chat") + }) + .collect(); + assert!(!chat_spans.is_empty()); + assert!(chat_spans.iter().all(|span| { + string_property(span, "parentSpanId") == Some(invoke_agent_span_id) + })); + assert!(chat_spans.iter().any(|span| string_attribute( + span, + "gen_ai.input.messages" + ) + .is_some_and(|messages| messages.contains(&prompt)))); + assert!(chat_spans.iter().any(|span| string_attribute( + span, + "gen_ai.output.messages" + ) + .is_some_and(|messages| messages.contains("TELEMETRY_E2E_DONE")))); + + let tool_span = find_span(&spans, "execute_tool"); + assert_eq!( + string_property(tool_span, "parentSpanId"), + Some(invoke_agent_span_id) + ); + assert_eq!( + string_attribute(tool_span, "gen_ai.tool.name").as_deref(), + Some(tool_name) + ); + assert_eq!( + string_attribute(tool_span, "gen_ai.tool.call.arguments").as_deref(), + Some(format!("{{\"value\":\"{marker}\"}}").as_str()) + ); + assert_eq!( + string_attribute(tool_span, "gen_ai.tool.call.result").as_deref(), + Some(marker) + ); + }) + }, + ) + .await; +} + +struct EchoTelemetryTool { + name: String, +} + +#[async_trait] +impl ToolHandler for EchoTelemetryTool { + fn tool(&self) -> Tool { + Tool::new(&self.name) + .with_description("Echoes a marker string for telemetry validation.") + .with_parameters(json!({ + "type": "object", + "properties": { + "value": { "type": "string" } + }, + "required": ["value"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + Ok(ToolResult::Text( + invocation + .arguments + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + )) + } +} + +async fn read_telemetry_entries(path: &std::path::Path) -> Vec { + wait_for_condition("telemetry file to contain spans", || { + let path = path.to_path_buf(); + async move { + read_telemetry_entries_once(&path).is_ok_and(|entries| { + entries.iter().any(|entry| { + string_property(entry, "type") == Some("span") + && string_attribute(entry, "gen_ai.operation.name").as_deref() + == Some("invoke_agent") + }) + }) + } + }) + .await; + read_telemetry_entries_once(path).expect("read telemetry entries") +} + +fn read_telemetry_entries_once(path: &std::path::Path) -> std::io::Result> { + if !path.exists() || path.metadata()?.len() == 0 { + return Ok(Vec::new()); + } + std::fs::read_to_string(path).map(|content| { + content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("telemetry JSON line")) + .collect() + }) +} + +fn find_span<'a>(spans: &'a [&'a serde_json::Value], operation: &str) -> &'a serde_json::Value { + spans + .iter() + .copied() + .find(|span| string_attribute(span, "gen_ai.operation.name").as_deref() == Some(operation)) + .unwrap_or_else(|| panic!("span {operation} not found in {spans:?}")) +} + +fn string_property<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a str> { + value.get(name).and_then(serde_json::Value::as_str) +} + +fn string_attribute(value: &serde_json::Value, name: &str) -> Option { + value + .get("attributes") + .and_then(|attributes| attributes.get(name)) + .map(|value| match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(_) | serde_json::Value::Bool(_) => value.to_string(), + serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(), + serde_json::Value::Null => String::new(), + }) +} + +fn status_code(value: &serde_json::Value) -> Option { + value + .get("status") + .and_then(|status| status.get("code")) + .and_then(serde_json::Value::as_i64) +} + +fn is_root_span(value: &serde_json::Value) -> bool { + string_property(value, "parentSpanId") + .is_none_or(|parent| parent.is_empty() || parent == "0000000000000000") +} diff --git a/rust/tests/e2e/tool_results.rs b/rust/tests/e2e/tool_results.rs new file mode 100644 index 000000000..260e25993 --- /dev/null +++ b/rust/tests/e2e/tool_results.rs @@ -0,0 +1,361 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use github_copilot_sdk::generated::session_events::{SessionEventType, ToolExecutionCompleteData}; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter}; +use github_copilot_sdk::{ + Error, SessionConfig, Tool, ToolInvocation, ToolResult, ToolResultExpanded, +}; +use serde_json::json; +use tokio::sync::mpsc; + +use super::support::{assistant_message_content, collect_until_idle, with_e2e_context}; + +#[tokio::test] +async fn should_handle_structured_toolresultobject_from_custom_tool() { + with_e2e_context( + "tool_results", + "should_handle_structured_toolresultobject_from_custom_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = create_tool_session(ctx, &client, WeatherTool).await; + + let answer = session + .send_and_wait("What's the weather in Paris?") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer).to_lowercase(); + assert!(content.contains("sunny") || content.contains("72")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_tool_result_with_failure_resulttype() { + with_e2e_context( + "tool_results", + "should_handle_tool_result_with_failure_resulttype", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = create_tool_session(ctx, &client, CheckStatusTool).await; + + let answer = session + .send_and_wait("Check the status of the service using check_status. If it fails, say 'service is down'.") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer) + .to_lowercase() + .contains("service is down")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_preserve_tooltelemetry_and_not_stringify_structured_results_for_llm() { + with_e2e_context( + "tool_results", + "should_preserve_tooltelemetry_and_not_stringify_structured_results_for_llm", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = create_tool_session(ctx, &client, AnalyzeCodeTool).await; + + let answer = session + .send_and_wait("Analyze the file main.ts for issues.") + .await + .expect("send") + .expect("assistant message"); + assert!( + assistant_message_content(&answer) + .to_lowercase() + .contains("no issues") + ); + + let exchanges = ctx.exchanges(); + let tool_results: Vec<_> = exchanges + .last() + .and_then(|exchange| exchange.get("request")) + .and_then(|request| request.get("messages")) + .and_then(serde_json::Value::as_array) + .expect("messages") + .iter() + .filter(|message| { + message.get("role").and_then(serde_json::Value::as_str) == Some("tool") + }) + .collect(); + assert_eq!(tool_results.len(), 1); + let content = tool_results[0].to_string(); + assert!(!content.contains("toolTelemetry")); + assert!(!content.contains("resultType")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_tool_result_with_rejected_resulttype() { + with_e2e_context( + "tool_results", + "should_handle_tool_result_with_rejected_resulttype", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (call_tx, mut call_rx) = mpsc::unbounded_channel(); + let session = create_tool_session(ctx, &client, DeployTool { call_tx }).await; + let events = session.subscribe(); + + session + .send("Deploy the service using deploy_service. If it's rejected, tell me it was 'rejected by policy'.") + .await + .expect("send"); + recv_called(&mut call_rx, "deploy tool").await; + let observed = collect_until_idle(events).await; + let complete = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::ToolExecutionComplete) + .and_then(|event| event.typed_data::()) + .expect("tool.execution_complete"); + assert!(!complete.success); + let error = complete.error.expect("tool error"); + assert_eq!(error.code.as_deref(), Some("rejected")); + assert!(error.message.contains("Deployment rejected")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_handle_tool_result_with_denied_resulttype() { + with_e2e_context( + "tool_results", + "should_handle_tool_result_with_denied_resulttype", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (call_tx, mut call_rx) = mpsc::unbounded_channel(); + let session = create_tool_session(ctx, &client, AccessSecretTool { call_tx }).await; + let events = session.subscribe(); + + session + .send("Use access_secret to get the API key. If access is denied, tell me it was 'access denied'.") + .await + .expect("send"); + recv_called(&mut call_rx, "access secret tool").await; + let observed = collect_until_idle(events).await; + let complete = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::ToolExecutionComplete) + .and_then(|event| event.typed_data::()) + .expect("tool.execution_complete"); + assert!(!complete.success); + let error = complete.error.expect("tool error"); + assert_eq!(error.code.as_deref(), Some("denied")); + assert!(error.message.contains("Access denied")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +async fn create_tool_session( + _ctx: &super::support::E2eContext, + client: &github_copilot_sdk::Client, + tool: T, +) -> github_copilot_sdk::session::Session +where + T: ToolHandler + 'static, +{ + let router = ToolHandlerRouter::new(vec![Box::new(tool)], Arc::new(ApproveAllHandler)); + let tools = router.tools(); + client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session") +} + +async fn recv_called(receiver: &mut mpsc::UnboundedReceiver<()>, description: &'static str) { + tokio::time::timeout(std::time::Duration::from_secs(10), receiver.recv()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")) + .unwrap_or_else(|| panic!("{description} channel closed")); +} + +fn expanded(text: impl Into, result_type: impl Into) -> ToolResult { + ToolResult::Expanded(ToolResultExpanded { + text_result_for_llm: text.into(), + result_type: result_type.into(), + binary_results_for_llm: None, + session_log: None, + error: None, + tool_telemetry: None, + }) +} + +struct WeatherTool; + +#[async_trait::async_trait] +impl ToolHandler for WeatherTool { + fn tool(&self) -> Tool { + string_tool( + "get_weather", + "Gets weather for a city", + "city", + "City name", + ) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let city = invocation + .arguments + .get("city") + .and_then(serde_json::Value::as_str) + .unwrap_or("Paris"); + Ok(expanded( + format!("The weather in {city} is sunny and 72\u{b0}F"), + "success", + )) + } +} + +struct CheckStatusTool; + +#[async_trait::async_trait] +impl ToolHandler for CheckStatusTool { + fn tool(&self) -> Tool { + Tool::new("check_status").with_description("Checks the status of a service") + } + + async fn call(&self, _invocation: ToolInvocation) -> Result { + let mut result = match expanded("Service unavailable", "failure") { + ToolResult::Expanded(result) => result, + _ => unreachable!(), + }; + result.error = Some("API timeout".to_string()); + Ok(ToolResult::Expanded(result)) + } +} + +struct AnalyzeCodeTool; + +#[async_trait::async_trait] +impl ToolHandler for AnalyzeCodeTool { + fn tool(&self) -> Tool { + string_tool( + "analyze_code", + "Analyzes code for issues", + "file", + "File to analyze", + ) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let file = invocation + .arguments + .get("file") + .and_then(serde_json::Value::as_str) + .unwrap_or("main.ts"); + let mut result = match expanded(format!("Analysis of {file}: no issues found"), "success") { + ToolResult::Expanded(result) => result, + _ => unreachable!(), + }; + result.tool_telemetry = Some(HashMap::from([( + "metrics".to_string(), + json!({ "analysisTimeMs": 150 }), + )])); + Ok(ToolResult::Expanded(result)) + } +} + +struct DeployTool { + call_tx: mpsc::UnboundedSender<()>, +} + +#[async_trait::async_trait] +impl ToolHandler for DeployTool { + fn tool(&self) -> Tool { + Tool::new("deploy_service").with_description("Deploys a service") + } + + async fn call(&self, _invocation: ToolInvocation) -> Result { + let _ = self.call_tx.send(()); + Ok(expanded( + "Deployment rejected: policy violation - production deployments require approval", + "rejected", + )) + } +} + +struct AccessSecretTool { + call_tx: mpsc::UnboundedSender<()>, +} + +#[async_trait::async_trait] +impl ToolHandler for AccessSecretTool { + fn tool(&self) -> Tool { + Tool::new("access_secret").with_description("Accesses a secret") + } + + async fn call(&self, _invocation: ToolInvocation) -> Result { + let _ = self.call_tx.send(()); + Ok(expanded( + "Access denied: insufficient permissions to read secrets", + "denied", + )) + } +} + +fn string_tool( + name: &str, + description: &str, + parameter: &str, + parameter_description: &str, +) -> Tool { + Tool::new(name) + .with_description(description) + .with_parameters(json!({ + "type": "object", + "properties": { + parameter: { + "type": "string", + "description": parameter_description, + } + }, + "required": [parameter], + })) +} diff --git a/rust/tests/e2e/tools.rs b/rust/tests/e2e/tools.rs new file mode 100644 index 000000000..19cc40249 --- /dev/null +++ b/rust/tests/e2e/tools.rs @@ -0,0 +1,756 @@ +use std::sync::Arc; + +use github_copilot_sdk::handler::{ApproveAllHandler, PermissionResult, SessionHandler}; +use github_copilot_sdk::tool::{ToolHandler, ToolHandlerRouter}; +use github_copilot_sdk::{ + Error, PermissionRequestData, RequestId, SessionConfig, SessionId, Tool, ToolInvocation, + ToolResult, +}; +use serde_json::json; +use tokio::sync::mpsc; + +use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; + +#[tokio::test] +async fn invokes_built_in_tools() { + with_e2e_context("tools", "invokes_built_in_tools", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + std::fs::write( + ctx.work_dir().join("README.md"), + "# ELIZA, the only chatbot you'll ever need", + ) + .expect("write README"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What's the first line of README.md in this directory?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("ELIZA")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn invokes_custom_tool() { + with_e2e_context("tools", "invokes_custom_tool", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let router = ToolHandlerRouter::new( + vec![Box::new(EncryptStringTool)], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Use encrypt_string to encrypt this string: Hello") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("HELLO")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn handles_tool_calling_errors() { + with_e2e_context("tools", "handles_tool_calling_errors", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let router = + ToolHandlerRouter::new(vec![Box::new(ErrorTool)], Arc::new(ApproveAllHandler)); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("What is my location? If you can't find out, just say 'unknown'.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!(!content.contains("Melbourne")); + assert!(content.to_lowercase().contains("unknown")); + + let exchanges = ctx.exchanges(); + let tool_results: Vec<_> = exchanges + .last() + .and_then(|exchange| exchange.get("request")) + .and_then(|request| request.get("messages")) + .and_then(serde_json::Value::as_array) + .expect("messages") + .iter() + .filter(|message| { + message.get("role").and_then(serde_json::Value::as_str) == Some("tool") + }) + .collect(); + assert_eq!(tool_results.len(), 1); + assert!(!tool_results[0].to_string().contains("Melbourne")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn can_receive_and_return_complex_types() { + with_e2e_context("tools", "can_receive_and_return_complex_types", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let router = ToolHandlerRouter::new( + vec![Box::new(DbQueryTool)], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Perform a DB query for the 'cities' table using IDs 12 and 19, sorting ascending. \ + Reply only with lines of the form: [cityname] [population]", + ) + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!(content.contains("Passos")); + assert!(content.contains("San Lorenzo")); + assert!(content.replace(',', "").contains("135460")); + assert!(content.replace(',', "").contains("204356")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn overrides_built_in_tool_with_custom_tool() { + with_e2e_context("tools", "overrides_built_in_tool_with_custom_tool", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let router = + ToolHandlerRouter::new(vec![Box::new(CustomGrepTool)], Arc::new(ApproveAllHandler)); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Use grep to search for the word 'hello'") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("CUSTOM_GREP_RESULT")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn skippermission_sent_in_tool_definition() { + with_e2e_context("tools", "skippermission_sent_in_tool_definition", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (permission_tx, mut permission_rx) = mpsc::unbounded_channel(); + let handler = Arc::new(RecordingPermissionHandler { + permission_tx, + decision: PermissionResult::Denied, + }); + let router = ToolHandlerRouter::new(vec![Box::new(SafeLookupTool)], handler); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Use safe_lookup to look up 'test123'") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("RESULT")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), permission_rx.recv()) + .await + .is_err(), + "skip_permission tool should not request permission" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[ignore = "Behaves as if no content was in the result. Binary tool results are not fully implemented yet."] +#[tokio::test] +async fn can_return_binary_result() {} + +#[tokio::test] +async fn invokes_custom_tool_with_permission_handler() { + with_e2e_context( + "tools", + "invokes_custom_tool_with_permission_handler", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (permission_tx, mut permission_rx) = mpsc::unbounded_channel(); + let handler = Arc::new(RecordingPermissionHandler { + permission_tx, + decision: PermissionResult::Approved, + }); + let router = ToolHandlerRouter::new(vec![Box::new(EncryptStringTool)], handler); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Use encrypt_string to encrypt this string: Hello") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("HELLO")); + let request = recv_with_timeout(&mut permission_rx, "custom tool permission").await; + assert!(request.extra.is_object() || request.kind.is_some()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn denies_custom_tool_when_permission_denied() { + with_e2e_context( + "tools", + "denies_custom_tool_when_permission_denied", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (call_tx, mut call_rx) = mpsc::unbounded_channel(); + let (permission_tx, _permission_rx) = mpsc::unbounded_channel(); + let handler = Arc::new(RecordingPermissionHandler { + permission_tx, + decision: PermissionResult::Denied, + }); + let router = ToolHandlerRouter::new( + vec![Box::new(TrackedEncryptStringTool { call_tx })], + handler, + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + session + .send_and_wait("Use encrypt_string to encrypt this string: Hello") + .await + .expect("send"); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), call_rx.recv()) + .await + .is_err(), + "denied custom tool should not be invoked" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_execute_multiple_custom_tools_in_parallel_single_turn() { + with_e2e_context( + "tools", + "should_execute_multiple_custom_tools_in_parallel_single_turn", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (city_tx, mut city_rx) = mpsc::unbounded_channel(); + let (country_tx, mut country_rx) = mpsc::unbounded_channel(); + let router = ToolHandlerRouter::new( + vec![ + Box::new(LookupCityTool { call_tx: city_tx }), + Box::new(LookupCountryTool { + call_tx: country_tx, + }), + ], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Use lookup_city with 'Paris' and lookup_country with 'France' at the same time, then combine both results in your reply.") + .await + .expect("send") + .expect("assistant message"); + assert_eq!(recv_with_timeout(&mut city_rx, "city tool").await, "Paris"); + assert_eq!( + recv_with_timeout(&mut country_rx, "country tool").await, + "France" + ); + let content = assistant_message_content(&answer); + assert!(content.contains("CITY_PARIS")); + assert!(content.contains("COUNTRY_FRANCE")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_respect_availabletools_and_excludedtools_combined() { + with_e2e_context( + "tools", + "should_respect_availabletools_and_excludedtools_combined", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (excluded_tx, mut excluded_rx) = mpsc::unbounded_channel(); + let router = ToolHandlerRouter::new( + vec![ + Box::new(AllowedTool), + Box::new(ExcludedTool { + call_tx: excluded_tx, + }), + ], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_handler(Arc::new(router)) + .with_tools(tools) + .with_available_tools(["allowed_tool", "excluded_tool"]) + .with_excluded_tools(["excluded_tool"]), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Use the allowed_tool with input 'test'. Do NOT use excluded_tool.", + ) + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("ALLOWED_TEST")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), excluded_rx.recv()) + .await + .is_err(), + "excluded tool should not be invoked" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +struct EncryptStringTool; + +#[async_trait::async_trait] +impl ToolHandler for EncryptStringTool { + fn tool(&self) -> Tool { + Tool::new("encrypt_string") + .with_description("Encrypts a string") + .with_parameters(json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "String to encrypt" + } + }, + "required": ["input"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let input = invocation + .arguments + .get("input") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(ToolResult::Text(input.to_uppercase())) + } +} + +struct TrackedEncryptStringTool { + call_tx: mpsc::UnboundedSender<()>, +} + +#[async_trait::async_trait] +impl ToolHandler for TrackedEncryptStringTool { + fn tool(&self) -> Tool { + EncryptStringTool.tool() + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let _ = self.call_tx.send(()); + EncryptStringTool.call(invocation).await + } +} + +struct ErrorTool; + +#[async_trait::async_trait] +impl ToolHandler for ErrorTool { + fn tool(&self) -> Tool { + Tool::new("get_user_location").with_description("Gets the user's location") + } + + async fn call(&self, _invocation: ToolInvocation) -> Result { + Ok(ToolResult::Text( + "Failed to execute `get_user_location` tool with arguments: {} due to error: Error: Tool execution failed" + .to_string(), + )) + } +} + +struct CustomGrepTool; + +#[async_trait::async_trait] +impl ToolHandler for CustomGrepTool { + fn tool(&self) -> Tool { + Tool::new("grep") + .with_description("A custom grep implementation that overrides the built-in") + .with_overrides_built_in_tool(true) + .with_parameters(json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query" } + }, + "required": ["query"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let query = invocation + .arguments + .get("query") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(ToolResult::Text(format!("CUSTOM_GREP_RESULT: {query}"))) + } +} + +struct SafeLookupTool; + +#[async_trait::async_trait] +impl ToolHandler for SafeLookupTool { + fn tool(&self) -> Tool { + Tool::new("safe_lookup") + .with_description("A tool that skips permission") + .with_skip_permission(true) + .with_parameters(json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Lookup ID" } + }, + "required": ["id"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let id = invocation + .arguments + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(ToolResult::Text(format!("RESULT: {id}"))) + } +} + +struct LookupCityTool { + call_tx: mpsc::UnboundedSender, +} + +#[async_trait::async_trait] +impl ToolHandler for LookupCityTool { + fn tool(&self) -> Tool { + Tool::new("lookup_city") + .with_description("Looks up city information") + .with_parameters(json!({ + "type": "object", + "properties": { + "city": { "type": "string", "description": "City name" } + }, + "required": ["city"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let city = invocation + .arguments + .get("city") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let _ = self.call_tx.send(city.clone()); + Ok(ToolResult::Text(format!("CITY_{}", city.to_uppercase()))) + } +} + +struct LookupCountryTool { + call_tx: mpsc::UnboundedSender, +} + +#[async_trait::async_trait] +impl ToolHandler for LookupCountryTool { + fn tool(&self) -> Tool { + Tool::new("lookup_country") + .with_description("Looks up country information") + .with_parameters(json!({ + "type": "object", + "properties": { + "country": { "type": "string", "description": "Country name" } + }, + "required": ["country"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let country = invocation + .arguments + .get("country") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let _ = self.call_tx.send(country.clone()); + Ok(ToolResult::Text(format!( + "COUNTRY_{}", + country.to_uppercase() + ))) + } +} + +struct AllowedTool; + +#[async_trait::async_trait] +impl ToolHandler for AllowedTool { + fn tool(&self) -> Tool { + Tool::new("allowed_tool") + .with_description("An allowed tool") + .with_parameters(json!({ + "type": "object", + "properties": { + "input": { "type": "string", "description": "Input value" } + }, + "required": ["input"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let input = invocation + .arguments + .get("input") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(ToolResult::Text(format!( + "ALLOWED_{}", + input.to_uppercase() + ))) + } +} + +struct ExcludedTool { + call_tx: mpsc::UnboundedSender<()>, +} + +#[async_trait::async_trait] +impl ToolHandler for ExcludedTool { + fn tool(&self) -> Tool { + Tool::new("excluded_tool") + .with_description("A tool that should be excluded") + .with_parameters(json!({ + "type": "object", + "properties": { + "input": { "type": "string", "description": "Input value" } + }, + "required": ["input"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let _ = self.call_tx.send(()); + let input = invocation + .arguments + .get("input") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(ToolResult::Text(format!( + "EXCLUDED_{}", + input.to_uppercase() + ))) + } +} + +struct RecordingPermissionHandler { + permission_tx: mpsc::UnboundedSender, + decision: PermissionResult, +} + +#[async_trait::async_trait] +impl SessionHandler for RecordingPermissionHandler { + async fn on_permission_request( + &self, + _session_id: SessionId, + _request_id: RequestId, + data: PermissionRequestData, + ) -> PermissionResult { + let _ = self.permission_tx.send(data); + self.decision.clone() + } +} + +struct DbQueryTool; + +#[async_trait::async_trait] +impl ToolHandler for DbQueryTool { + fn tool(&self) -> Tool { + Tool::new("db_query") + .with_description("Performs a database query") + .with_parameters(json!({ + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "table": { "type": "string" }, + "ids": { + "type": "array", + "items": { "type": "integer" } + }, + "sortAscending": { "type": "boolean" } + }, + "required": ["table", "ids", "sortAscending"] + } + }, + "required": ["query"] + })) + } + + async fn call(&self, invocation: ToolInvocation) -> Result { + let query = invocation.arguments.get("query").expect("query argument"); + assert_eq!( + query.get("table").and_then(serde_json::Value::as_str), + Some("cities") + ); + assert_eq!( + query.get("ids").and_then(serde_json::Value::as_array), + Some(&vec![json!(12), json!(19)]) + ); + assert_eq!( + query + .get("sortAscending") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + Ok(ToolResult::Text( + r#"[{"cityName":"Passos","countryId":19,"population":135460},{"cityName":"San Lorenzo","countryId":12,"population":204356}]"# + .to_string(), + )) + } +} diff --git a/rust/tests/mode_handlers_e2e_test.rs b/rust/tests/mode_handlers_e2e_test.rs deleted file mode 100644 index 419124850..000000000 --- a/rust/tests/mode_handlers_e2e_test.rs +++ /dev/null @@ -1,663 +0,0 @@ -#![allow(clippy::unwrap_used)] - -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::Arc; -use std::time::Duration; - -use async_trait::async_trait; -use github_copilot_sdk::generated::session_events::{ - AutoModeSwitchCompletedData, AutoModeSwitchRequestedData, ExitPlanModeCompletedData, - ExitPlanModeRequestedData, SessionEventType, SessionModelChangeData, -}; -use github_copilot_sdk::handler::{AutoModeSwitchResponse, ExitPlanModeResult, SessionHandler}; -use github_copilot_sdk::subscription::EventSubscription; -use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, ExitPlanModeData, SessionConfig, SessionEvent, SessionId, -}; -use serde_json::json; -use tokio::sync::mpsc; - -const MODE_HANDLER_TOKEN: &str = "mode-handler-token"; -const PLAN_SUMMARY: &str = "Greeting file implementation plan"; -const PLAN_PROMPT: &str = "Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode."; -const AUTO_MODE_PROMPT: &str = - "Explain that auto mode recovered from a rate limit in one short sentence."; - -#[derive(Debug)] -struct ModeHandler { - requests: mpsc::UnboundedSender<(SessionId, ExitPlanModeData)>, -} - -#[derive(Debug)] -struct AutoModeHandler { - requests: mpsc::UnboundedSender<(SessionId, Option, Option)>, -} - -#[async_trait] -impl SessionHandler for ModeHandler { - async fn on_exit_plan_mode( - &self, - session_id: SessionId, - data: ExitPlanModeData, - ) -> ExitPlanModeResult { - let _ = self.requests.send((session_id, data)); - ExitPlanModeResult { - approved: true, - selected_action: Some("interactive".to_string()), - feedback: Some("Approved by the Rust E2E test".to_string()), - } - } -} - -#[async_trait] -impl SessionHandler for AutoModeHandler { - async fn on_auto_mode_switch( - &self, - session_id: SessionId, - error_code: Option, - retry_after_seconds: Option, - ) -> AutoModeSwitchResponse { - let _ = self - .requests - .send((session_id, error_code, retry_after_seconds)); - AutoModeSwitchResponse::Yes - } -} - -#[tokio::test] -#[ignore] // requires the Node CLI and shared replay proxy dependencies -async fn should_invoke_exit_plan_mode_handler_when_model_uses_tool() { - let repo_root = repo_root(); - let cli_path = repo_root - .join("nodejs") - .join("node_modules") - .join("@github") - .join("copilot") - .join("index.js"); - assert!( - cli_path.exists(), - "CLI not found at {}; run npm install in nodejs first", - cli_path.display() - ); - - let home_dir = tempfile::tempdir().expect("create home dir"); - let work_dir = tempfile::tempdir().expect("create work dir"); - let mut proxy = CapiProxy::start(&repo_root).expect("start replay proxy"); - proxy - .configure( - &repo_root - .join("test") - .join("snapshots") - .join("mode_handlers") - .join("should_invoke_exit_plan_mode_handler_when_model_uses_tool.yaml"), - work_dir.path(), - ) - .expect("configure replay proxy"); - proxy - .set_copilot_user_by_token( - MODE_HANDLER_TOKEN, - json!({ - "login": "mode-handler-user", - "copilot_plan": "individual_pro", - "endpoints": { - "api": proxy.url(), - "telemetry": "https://localhost:1/telemetry" - }, - "analytics_tracking_id": "mode-handler-tracking-id" - }), - ) - .expect("configure copilot user"); - - let mut env = proxy.proxy_env(); - env.extend([ - ("COPILOT_API_URL".into(), proxy.url().into()), - ("COPILOT_DEBUG_GITHUB_API_URL".into(), proxy.url().into()), - ( - "COPILOT_HOME".into(), - home_dir.path().as_os_str().to_owned(), - ), - ( - "GH_CONFIG_DIR".into(), - home_dir.path().as_os_str().to_owned(), - ), - ( - "XDG_CONFIG_HOME".into(), - home_dir.path().as_os_str().to_owned(), - ), - ( - "XDG_STATE_HOME".into(), - home_dir.path().as_os_str().to_owned(), - ), - ]); - - let client = Client::start( - ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from(node_program()))) - .with_prefix_args([cli_path.as_os_str().to_owned()]) - .with_cwd(work_dir.path()) - .with_env(env) - .with_use_logged_in_user(false), - ) - .await - .expect("start client"); - - let (request_tx, mut request_rx) = mpsc::unbounded_channel(); - let session = client - .create_session( - SessionConfig::default() - .with_github_token(MODE_HANDLER_TOKEN) - .with_handler(Arc::new(ModeHandler { - requests: request_tx, - })) - .approve_all_permissions(), - ) - .await - .expect("create session"); - - let requested_event = tokio::spawn(wait_for_event( - session.subscribe(), - "exit_plan_mode.requested event", - |event| { - event.parsed_type() == SessionEventType::ExitPlanModeRequested - && event - .typed_data::() - .is_some_and(|data| data.summary == PLAN_SUMMARY) - }, - )); - let completed_event = tokio::spawn(wait_for_event( - session.subscribe(), - "exit_plan_mode.completed event", - |event| { - event.parsed_type() == SessionEventType::ExitPlanModeCompleted - && event - .typed_data::() - .is_some_and(|data| { - data.approved == Some(true) - && data.selected_action.as_deref() == Some("interactive") - }) - }, - )); - let idle_event = tokio::spawn(wait_for_event( - session.subscribe(), - "session.idle event", - |event| event.parsed_type() == SessionEventType::SessionIdle, - )); - - let send_result = session - .client() - .call( - "session.send", - Some(json!({ - "sessionId": session.id().as_str(), - "prompt": PLAN_PROMPT, - "mode": "plan", - })), - ) - .await - .expect("send plan-mode prompt"); - assert!( - send_result.get("messageId").is_some(), - "expected messageId in send result" - ); - - let (session_id, request) = tokio::time::timeout(Duration::from_secs(10), request_rx.recv()) - .await - .expect("timed out waiting for exit-plan-mode request") - .expect("exit-plan-mode request channel closed"); - assert_eq!(session_id, session.id().clone()); - assert_eq!(request.summary, PLAN_SUMMARY); - assert_eq!( - request.actions, - ["interactive", "autopilot", "exit_only"].map(str::to_string) - ); - assert_eq!(request.recommended_action, "interactive"); - - let requested = requested_event - .await - .expect("requested task") - .expect("requested event"); - let requested_data = requested - .typed_data::() - .expect("typed requested event"); - assert_eq!(requested_data.summary, request.summary); - assert_eq!(requested_data.actions, request.actions); - assert_eq!( - requested_data.recommended_action, - request.recommended_action - ); - - let completed = completed_event - .await - .expect("completed task") - .expect("completed event"); - let completed_data = completed - .typed_data::() - .expect("typed completed event"); - assert_eq!(completed_data.approved, Some(true)); - assert_eq!( - completed_data.selected_action.as_deref(), - Some("interactive") - ); - assert_eq!( - completed_data.feedback.as_deref(), - Some("Approved by the Rust E2E test") - ); - idle_event.await.expect("idle task").expect("idle event"); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - proxy.stop(true).expect("stop replay proxy"); -} - -#[tokio::test] -#[ignore] // requires the Node CLI and shared replay proxy dependencies -async fn should_invoke_auto_mode_switch_handler_when_rate_limited() { - let repo_root = repo_root(); - let cli_path = repo_root - .join("nodejs") - .join("node_modules") - .join("@github") - .join("copilot") - .join("index.js"); - assert!( - cli_path.exists(), - "CLI not found at {}; run npm install in nodejs first", - cli_path.display() - ); - - let home_dir = tempfile::tempdir().expect("create home dir"); - let work_dir = tempfile::tempdir().expect("create work dir"); - let mut proxy = CapiProxy::start(&repo_root).expect("start replay proxy"); - proxy - .configure( - &repo_root - .join("test") - .join("snapshots") - .join("mode_handlers") - .join("should_invoke_auto_mode_switch_handler_when_rate_limited.yaml"), - work_dir.path(), - ) - .expect("configure replay proxy"); - proxy - .set_copilot_user_by_token( - MODE_HANDLER_TOKEN, - json!({ - "login": "mode-handler-user", - "copilot_plan": "individual_pro", - "endpoints": { - "api": proxy.url(), - "telemetry": "https://localhost:1/telemetry" - }, - "analytics_tracking_id": "mode-handler-tracking-id" - }), - ) - .expect("configure copilot user"); - - let mut env = proxy.proxy_env(); - env.extend([ - ("COPILOT_API_URL".into(), proxy.url().into()), - ("COPILOT_DEBUG_GITHUB_API_URL".into(), proxy.url().into()), - ( - "COPILOT_HOME".into(), - home_dir.path().as_os_str().to_owned(), - ), - ( - "GH_CONFIG_DIR".into(), - home_dir.path().as_os_str().to_owned(), - ), - ( - "XDG_CONFIG_HOME".into(), - home_dir.path().as_os_str().to_owned(), - ), - ( - "XDG_STATE_HOME".into(), - home_dir.path().as_os_str().to_owned(), - ), - ]); - - let client = Client::start( - ClientOptions::new() - .with_program(CliProgram::Path(PathBuf::from(node_program()))) - .with_prefix_args([cli_path.as_os_str().to_owned()]) - .with_cwd(work_dir.path()) - .with_env(env) - .with_use_logged_in_user(false), - ) - .await - .expect("start client"); - - let (request_tx, mut request_rx) = mpsc::unbounded_channel(); - let session = client - .create_session( - SessionConfig::default() - .with_github_token(MODE_HANDLER_TOKEN) - .with_handler(Arc::new(AutoModeHandler { - requests: request_tx, - })) - .approve_all_permissions(), - ) - .await - .expect("create session"); - - let requested_event = tokio::spawn(wait_for_event_allowing_rate_limit( - session.subscribe(), - "auto_mode_switch.requested event", - |event| { - event.parsed_type() == SessionEventType::AutoModeSwitchRequested - && event - .typed_data::() - .is_some_and(|data| { - data.error_code.as_deref() == Some("user_weekly_rate_limited") - && data.retry_after_seconds == Some(1.0) - }) - }, - )); - let completed_event = tokio::spawn(wait_for_event_allowing_rate_limit( - session.subscribe(), - "auto_mode_switch.completed event", - |event| { - event.parsed_type() == SessionEventType::AutoModeSwitchCompleted - && event - .typed_data::() - .is_some_and(|data| data.response == "yes") - }, - )); - let model_change_event = tokio::spawn(wait_for_event_allowing_rate_limit( - session.subscribe(), - "rate-limit auto-mode model change", - |event| { - event.parsed_type() == SessionEventType::SessionModelChange - && event - .typed_data::() - .is_some_and(|data| data.cause.as_deref() == Some("rate_limit_auto_switch")) - }, - )); - let idle_event = tokio::spawn(wait_for_event_allowing_rate_limit( - session.subscribe(), - "session.idle after auto-mode switch", - |event| event.parsed_type() == SessionEventType::SessionIdle, - )); - - let message_id = session - .send(AUTO_MODE_PROMPT) - .await - .expect("send auto-mode-switch prompt"); - assert!(!message_id.is_empty(), "expected message ID"); - - let (session_id, error_code, retry_after_seconds) = - tokio::time::timeout(Duration::from_secs(10), request_rx.recv()) - .await - .expect("timed out waiting for auto-mode-switch request") - .expect("auto-mode-switch request channel closed"); - assert_eq!(session_id, session.id().clone()); - assert_eq!(error_code.as_deref(), Some("user_weekly_rate_limited")); - assert_eq!(retry_after_seconds, Some(1.0)); - - let requested = requested_event - .await - .expect("requested task") - .expect("requested event"); - let requested_data = requested - .typed_data::() - .expect("typed requested event"); - assert_eq!(requested_data.error_code, error_code); - assert_eq!(requested_data.retry_after_seconds, retry_after_seconds); - - let completed = completed_event - .await - .expect("completed task") - .expect("completed event"); - let completed_data = completed - .typed_data::() - .expect("typed completed event"); - assert_eq!(completed_data.response, "yes"); - - let model_change = model_change_event - .await - .expect("model change task") - .expect("model change event"); - let model_change_data = model_change - .typed_data::() - .expect("typed model change event"); - assert_eq!( - model_change_data.cause.as_deref(), - Some("rate_limit_auto_switch") - ); - idle_event.await.expect("idle task").expect("idle event"); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - proxy.stop(true).expect("stop replay proxy"); -} - -async fn wait_for_event( - mut events: EventSubscription, - description: &'static str, - predicate: fn(&SessionEvent) -> bool, -) -> Result { - tokio::time::timeout(Duration::from_secs(30), async { - loop { - let event = events.recv().await.map_err(|err| { - format!("event stream closed while waiting for {description}: {err}") - })?; - if event.parsed_type() == SessionEventType::SessionError { - return Err(format!( - "session.error while waiting for {description}: {}", - event.data - )); - } - if predicate(&event) { - return Ok(event); - } - } - }) - .await - .map_err(|_| format!("timed out waiting for {description}"))? -} - -async fn wait_for_event_allowing_rate_limit( - mut events: EventSubscription, - description: &'static str, - predicate: fn(&SessionEvent) -> bool, -) -> Result { - tokio::time::timeout(Duration::from_secs(30), async { - loop { - let event = events.recv().await.map_err(|err| { - format!("event stream closed while waiting for {description}: {err}") - })?; - if event.parsed_type() == SessionEventType::SessionError - && event.data.get("errorType").and_then(|value| value.as_str()) - != Some("rate_limit") - { - return Err(format!( - "session.error while waiting for {description}: {}", - event.data - )); - } - if predicate(&event) { - return Ok(event); - } - } - }) - .await - .map_err(|_| format!("timed out waiting for {description}"))? -} - -fn repo_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("rust package has parent repo") - .to_path_buf() -} - -struct CapiProxy { - child: Option, - proxy_url: String, - connect_proxy_url: String, - ca_file_path: String, -} - -impl CapiProxy { - fn start(repo_root: &Path) -> std::io::Result { - let mut child = Command::new(npx_program()) - .args(["tsx", "server.ts"]) - .current_dir(repo_root.join("test").join("harness")) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn()?; - - let stdout = child.stdout.take().expect("proxy stdout"); - let reader = BufReader::new(stdout); - let re = regex::Regex::new(r"Listening: (http://[^\s]+)\s+(\{.*\})$").unwrap(); - for line in reader.lines() { - let line = line?; - if let Some(captures) = re.captures(&line) { - let metadata: serde_json::Value = - serde_json::from_str(captures.get(2).unwrap().as_str())?; - let connect_proxy_url = metadata - .get("connectProxyUrl") - .and_then(|value| value.as_str()) - .expect("connectProxyUrl") - .to_string(); - let ca_file_path = metadata - .get("caFilePath") - .and_then(|value| value.as_str()) - .expect("caFilePath") - .to_string(); - return Ok(Self { - child: Some(child), - proxy_url: captures.get(1).unwrap().as_str().to_string(), - connect_proxy_url, - ca_file_path, - }); - } - if line.contains("Listening: ") { - return Err(std::io::Error::other(format!( - "proxy startup line missing metadata: {line}" - ))); - } - } - - Err(std::io::Error::other("proxy exited before startup")) - } - - fn url(&self) -> &str { - &self.proxy_url - } - - fn configure(&self, file_path: &Path, work_dir: &Path) -> std::io::Result<()> { - self.post_json( - "/config", - &json!({ - "filePath": file_path, - "workDir": work_dir, - }) - .to_string(), - ) - } - - fn set_copilot_user_by_token( - &self, - token: &str, - response: serde_json::Value, - ) -> std::io::Result<()> { - self.post_json( - "/copilot-user-config", - &json!({ - "token": token, - "response": response, - }) - .to_string(), - ) - } - - fn stop(&mut self, skip_writing_cache: bool) -> std::io::Result<()> { - let path = if skip_writing_cache { - "/stop?skipWritingCache=true" - } else { - "/stop" - }; - let result = self.post_json(path, ""); - if let Some(mut child) = self.child.take() { - let _ = child.wait(); - } - result - } - - fn proxy_env(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)> { - let no_proxy = "127.0.0.1,localhost,::1"; - [ - ("HTTP_PROXY", self.connect_proxy_url.as_str()), - ("HTTPS_PROXY", self.connect_proxy_url.as_str()), - ("http_proxy", self.connect_proxy_url.as_str()), - ("https_proxy", self.connect_proxy_url.as_str()), - ("NO_PROXY", no_proxy), - ("no_proxy", no_proxy), - ("NODE_EXTRA_CA_CERTS", self.ca_file_path.as_str()), - ("SSL_CERT_FILE", self.ca_file_path.as_str()), - ("REQUESTS_CA_BUNDLE", self.ca_file_path.as_str()), - ("CURL_CA_BUNDLE", self.ca_file_path.as_str()), - ("GIT_SSL_CAINFO", self.ca_file_path.as_str()), - ("GH_TOKEN", ""), - ("GITHUB_TOKEN", ""), - ("GH_ENTERPRISE_TOKEN", ""), - ("GITHUB_ENTERPRISE_TOKEN", ""), - ] - .into_iter() - .map(|(key, value)| (key.into(), value.into())) - .collect() - } - - fn post_json(&self, path: &str, body: &str) -> std::io::Result<()> { - let (host, port) = parse_http_url(&self.proxy_url)?; - let mut stream = TcpStream::connect((host.as_str(), port))?; - write!( - stream, - "POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - )?; - - let mut response = String::new(); - stream.read_to_string(&mut response)?; - if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.1 204") { - return Err(std::io::Error::other(format!( - "proxy POST {path} failed: {response}" - ))); - } - Ok(()) - } -} - -impl Drop for CapiProxy { - fn drop(&mut self) { - if self.child.is_some() { - let _ = self.stop(true); - } - } -} - -fn node_program() -> &'static str { - if cfg!(windows) { "node.exe" } else { "node" } -} - -fn npx_program() -> &'static str { - if cfg!(windows) { "npx.cmd" } else { "npx" } -} - -fn parse_http_url(url: &str) -> std::io::Result<(String, u16)> { - let without_scheme = url - .strip_prefix("http://") - .ok_or_else(|| std::io::Error::other(format!("expected http URL, got {url}")))?; - let authority = without_scheme.split('/').next().unwrap_or(without_scheme); - let (host, port) = authority - .rsplit_once(':') - .ok_or_else(|| std::io::Error::other(format!("missing port in URL {url}")))?; - let port = port - .parse() - .map_err(|err| std::io::Error::other(format!("invalid port in URL {url}: {err}")))?; - Ok((host.to_string(), port)) -} diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 74c6eb90b..c98c04d89 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -137,12 +137,11 @@ async fn create_session_pair_with_capabilities( capabilities: Value, ) -> (github_copilot_sdk::session::Session, FakeServer) { let (client, server_read, server_write) = make_client(); - let session_id = format!("test-session-{}", rand_id()); let mut server = FakeServer { read: server_read, write: server_write, - session_id: session_id.clone(), + session_id: String::new(), }; let create_handle = tokio::spawn({ @@ -158,8 +157,9 @@ async fn create_session_pair_with_capabilities( let create_req = server.read_request().await; assert_eq!(create_req["method"], "session.create"); + server.session_id = requested_session_id(&create_req).to_string(); let mut result = serde_json::json!({ - "sessionId": session_id, + "sessionId": server.session_id.clone(), "workspacePath": "/tmp/workspace" }); if !capabilities.is_null() { @@ -176,6 +176,12 @@ fn rand_id() -> u64 { COUNTER.fetch_add(1, Ordering::Relaxed) as u64 } +fn requested_session_id(request: &Value) -> &str { + request["params"]["sessionId"] + .as_str() + .expect("session request should include sessionId") +} + #[tokio::test] async fn session_subscribe_yields_events_observe_only() { let (session, mut server) = create_session_pair(Arc::new(NoopHandler)).await; @@ -263,15 +269,16 @@ async fn create_session_sends_correct_rpc() { assert_eq!(request["params"]["model"], "gpt-4"); let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "result": { "sessionId": "s1", "workspacePath": "/ws" }, + "result": { "sessionId": session_id.clone(), "workspacePath": "/ws" }, }); write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; let session = timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); - assert_eq!(session.id(), "s1"); + assert_eq!(session.id(), session_id.as_str()); assert_eq!(session.workspace_path(), Some(Path::new("/ws"))); } @@ -1404,7 +1411,8 @@ async fn router_routes_to_correct_session() { // Create two sessions on the same client let mut sessions = Vec::new(); - for (tx, sid) in [(tx1, "s-one"), (tx2, "s-two")] { + let mut session_ids = Vec::new(); + for tx in [tx1, tx2] { let h = tokio::spawn({ let client = client.clone(); async move { @@ -1418,11 +1426,13 @@ async fn router_routes_to_correct_session() { }); let req = read_framed(&mut server_read).await; let id = req["id"].as_u64().unwrap(); + let session_id = requested_session_id(&req).to_string(); let resp = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "result": { "sessionId": sid }, + "result": { "sessionId": session_id.clone() }, }); write_framed(&mut server_write, &serde_json::to_vec(&resp).unwrap()).await; + session_ids.push(session_id); sessions.push(timeout(TIMEOUT, h).await.unwrap().unwrap()); } @@ -1431,7 +1441,7 @@ async fn router_routes_to_correct_session() { "jsonrpc": "2.0", "method": "session.event", "params": { - "sessionId": "s-two", + "sessionId": session_ids[1].clone(), "event": { "id": "e1", "timestamp": "2025-01-01T00:00:00Z", "type": "assistant.message", "data": {} }, }, }); @@ -1447,7 +1457,7 @@ async fn router_routes_to_correct_session() { "jsonrpc": "2.0", "method": "session.event", "params": { - "sessionId": "s-one", + "sessionId": session_ids[0].clone(), "event": { "id": "e2", "timestamp": "2025-01-01T00:00:00Z", "type": "session.idle", "data": {} }, }, }); @@ -1982,11 +1992,12 @@ async fn capabilities_captured_from_create_response() { let request = read_framed(&mut server_read).await; let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request); let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": { - "sessionId": "cap-session", + "sessionId": session_id, "capabilities": { "ui": { "elicitation": true } } @@ -2053,10 +2064,11 @@ async fn request_elicitation_sent_in_create_params() { assert_eq!(request["params"]["requestAutoModeSwitch"], true); let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request); let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "result": { "sessionId": "s-elicit" }, + "result": { "sessionId": session_id }, }); write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); @@ -2083,18 +2095,20 @@ async fn env_value_mode_hardcoded_direct_on_create_and_resume() { assert_eq!(request["params"]["envValueMode"], "direct"); let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "result": { "sessionId": "s-env-create" }, + "result": { "sessionId": session_id.clone() }, }); write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); let resume_handle = tokio::spawn({ let client = client.clone(); + let session_id = session_id.clone(); async move { - let cfg = ResumeSessionConfig::new(SessionId::from("s-env-create")) + let cfg = ResumeSessionConfig::new(SessionId::from(session_id)) .with_handler(Arc::new(NoopHandler)); client.resume_session(cfg).await.unwrap() } @@ -2108,7 +2122,7 @@ async fn env_value_mode_hardcoded_direct_on_create_and_resume() { let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "result": { "sessionId": "s-env-create" }, + "result": { "sessionId": session_id }, }); write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; @@ -2153,12 +2167,11 @@ async fn create_session_pair_with_hooks( hooks: Arc, ) -> (github_copilot_sdk::session::Session, FakeServer) { let (client, server_read, server_write) = make_client(); - let session_id = format!("test-session-{}", rand_id()); let mut server = FakeServer { read: server_read, write: server_write, - session_id: session_id.clone(), + session_id: String::new(), }; let create_handle = tokio::spawn({ @@ -2180,11 +2193,12 @@ async fn create_session_pair_with_hooks( assert_eq!(create_req["method"], "session.create"); // Verify hooks: true is auto-set in the config assert_eq!(create_req["params"]["hooks"], true); + server.session_id = requested_session_id(&create_req).to_string(); server .respond( &create_req, serde_json::json!({ - "sessionId": session_id, + "sessionId": server.session_id, "workspacePath": "/tmp/workspace" }), ) @@ -2286,12 +2300,11 @@ async fn create_session_pair_with_transforms( transforms: Arc, ) -> (github_copilot_sdk::session::Session, FakeServer) { let (client, server_read, server_write) = make_client(); - let session_id = format!("test-session-{}", rand_id()); let mut server = FakeServer { read: server_read, write: server_write, - session_id: session_id.clone(), + session_id: String::new(), }; let create_handle = tokio::spawn({ @@ -2313,11 +2326,12 @@ async fn create_session_pair_with_transforms( assert_eq!(create_req["method"], "session.create"); // Verify transforms inject customize mode and section overrides assert_eq!(create_req["params"]["systemMessage"]["mode"], "customize"); + server.session_id = requested_session_id(&create_req).to_string(); server .respond( &create_req, serde_json::json!({ - "sessionId": session_id, + "sessionId": server.session_id, "workspacePath": "/tmp/workspace" }), ) @@ -2473,13 +2487,11 @@ async fn client_stop_sends_session_destroy_for_each_active_session() { // One client, two registered sessions. Client::stop must send // session.destroy for each before returning Ok. let (client, server_read, server_write) = make_client(); - let session_id_a = format!("test-session-{}", rand_id()); - let session_id_b = format!("test-session-{}", rand_id()); let mut server = FakeServer { read: server_read, write: server_write, - session_id: session_id_a.clone(), + session_id: String::new(), }; // Spawn both create_session calls. @@ -2494,10 +2506,11 @@ async fn client_stop_sends_session_destroy_for_each_active_session() { }); let create_a_req = server.read_request().await; assert_eq!(create_a_req["method"], "session.create"); + let session_id_a = requested_session_id(&create_a_req).to_string(); server .respond( &create_a_req, - serde_json::json!({ "sessionId": session_id_a, "workspacePath": "/tmp/ws-a" }), + serde_json::json!({ "sessionId": session_id_a.clone(), "workspacePath": "/tmp/ws-a" }), ) .await; let _session_a = timeout(TIMEOUT, create_a).await.unwrap(); @@ -2513,10 +2526,11 @@ async fn client_stop_sends_session_destroy_for_each_active_session() { }); let create_b_req = server.read_request().await; assert_eq!(create_b_req["method"], "session.create"); + let session_id_b = requested_session_id(&create_b_req).to_string(); server .respond( &create_b_req, - serde_json::json!({ "sessionId": session_id_b, "workspacePath": "/tmp/ws-b" }), + serde_json::json!({ "sessionId": session_id_b.clone(), "workspacePath": "/tmp/ws-b" }), ) .await; let _session_b = timeout(TIMEOUT, create_b).await.unwrap(); @@ -2657,12 +2671,11 @@ async fn create_session_pair_with_commands( commands: Vec, ) -> (github_copilot_sdk::session::Session, FakeServer, Value) { let (client, server_read, server_write) = make_client(); - let session_id = format!("test-session-{}", rand_id()); let mut server = FakeServer { read: server_read, write: server_write, - session_id: session_id.clone(), + session_id: String::new(), }; let create_handle = tokio::spawn({ @@ -2682,11 +2695,12 @@ async fn create_session_pair_with_commands( let create_req = server.read_request().await; assert_eq!(create_req["method"], "session.create"); + server.session_id = requested_session_id(&create_req).to_string(); server .respond( &create_req, serde_json::json!({ - "sessionId": session_id, + "sessionId": server.session_id, "workspacePath": "/tmp/workspace" }), ) @@ -2954,12 +2968,11 @@ async fn create_session_pair_with_fs_provider( provider: Arc, ) -> (github_copilot_sdk::session::Session, FakeServer) { let (client, server_read, server_write) = make_client(); - let session_id = format!("test-session-{}", rand_id()); let mut server = FakeServer { read: server_read, write: server_write, - session_id: session_id.clone(), + session_id: String::new(), }; let create_handle = tokio::spawn({ @@ -2979,11 +2992,12 @@ async fn create_session_pair_with_fs_provider( let create_req = server.read_request().await; assert_eq!(create_req["method"], "session.create"); + server.session_id = requested_session_id(&create_req).to_string(); server .respond( &create_req, serde_json::json!({ - "sessionId": session_id, + "sessionId": server.session_id, "workspacePath": "/tmp/workspace" }), ) @@ -3217,10 +3231,11 @@ async fn on_get_trace_context_called_on_session_create() { assert_eq!(req["method"], "session.create"); assert_eq!(req["params"]["traceparent"], "00-aaaa-bbbb-01"); assert_eq!(req["params"]["tracestate"], "vendor=value"); + server.session_id = requested_session_id(&req).to_string(); server .respond( &req, - serde_json::json!({"sessionId": "trace-create", "workspacePath": "/tmp/ws"}), + serde_json::json!({"sessionId": server.session_id.clone(), "workspacePath": "/tmp/ws"}), ) .await; timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); @@ -3297,10 +3312,11 @@ async fn on_get_trace_context_called_on_session_send() { } }); let create_req = server.read_request().await; + server.session_id = requested_session_id(&create_req).to_string(); server .respond( &create_req, - serde_json::json!({"sessionId": "trace-send", "workspacePath": "/tmp/ws"}), + serde_json::json!({"sessionId": server.session_id.clone(), "workspacePath": "/tmp/ws"}), ) .await; let session = Arc::new(timeout(TIMEOUT, create_handle).await.unwrap().unwrap()); @@ -3349,10 +3365,11 @@ async fn message_options_trace_context_overrides_callback() { } }); let create_req = server.read_request().await; + server.session_id = requested_session_id(&create_req).to_string(); server .respond( &create_req, - serde_json::json!({"sessionId": "trace-override", "workspacePath": "/tmp/ws"}), + serde_json::json!({"sessionId": server.session_id.clone(), "workspacePath": "/tmp/ws"}), ) .await; let session = Arc::new(timeout(TIMEOUT, create_handle).await.unwrap().unwrap()); diff --git a/test/snapshots/client/listmodels_withcustomhandler_callshandler.yaml b/test/snapshots/client/listmodels_withcustomhandler_callshandler.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/listmodels_withcustomhandler_callshandler.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client/should_force_stop_client.yaml b/test/snapshots/client/should_force_stop_client.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/should_force_stop_client.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client/should_get_authenticated_status.yaml b/test/snapshots/client/should_get_authenticated_status.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/should_get_authenticated_status.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client/should_get_status.yaml b/test/snapshots/client/should_get_status.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/should_get_status.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client/should_list_models_when_authenticated.yaml b/test/snapshots/client/should_list_models_when_authenticated.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/should_list_models_when_authenticated.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client/should_start_ping_and_stop_stdio_client.yaml b/test/snapshots/client/should_start_ping_and_stop_stdio_client.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/should_start_ping_and_stop_stdio_client.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client/should_start_ping_and_stop_tcp_client.yaml b/test/snapshots/client/should_start_ping_and_stop_tcp_client.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/should_start_ping_and_stop_tcp_client.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client/should_stop_client_with_active_session.yaml b/test/snapshots/client/should_stop_client_with_active_session.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client/should_stop_client_with_active_session.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/client_options/should_listen_on_configured_tcp_port.yaml b/test/snapshots/client_options/should_listen_on_configured_tcp_port.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/client_options/should_listen_on_configured_tcp_port.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/commands/session_with_commands_creates_successfully.yaml b/test/snapshots/commands/session_with_commands_creates_successfully.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/commands/session_with_commands_creates_successfully.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/commands/session_with_commands_resumes_successfully.yaml b/test/snapshots/commands/session_with_commands_resumes_successfully.yaml new file mode 100644 index 000000000..0981462bf --- /dev/null +++ b/test/snapshots/commands/session_with_commands_resumes_successfully.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say OK. + - role: assistant + content: OK diff --git a/test/snapshots/commands/session_with_no_commands_creates_successfully.yaml b/test/snapshots/commands/session_with_no_commands_creates_successfully.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/commands/session_with_no_commands_creates_successfully.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/confirm_returns_false_when_handler_declines.yaml b/test/snapshots/elicitation/confirm_returns_false_when_handler_declines.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/confirm_returns_false_when_handler_declines.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/confirm_returns_true_when_handler_accepts.yaml b/test/snapshots/elicitation/confirm_returns_true_when_handler_accepts.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/confirm_returns_true_when_handler_accepts.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/defaults_capabilities_when_not_provided.yaml b/test/snapshots/elicitation/defaults_capabilities_when_not_provided.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/defaults_capabilities_when_not_provided.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/elicitation_returns_all_action_shapes.yaml b/test/snapshots/elicitation/elicitation_returns_all_action_shapes.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/elicitation_returns_all_action_shapes.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/elicitation_throws_when_capability_is_missing.yaml b/test/snapshots/elicitation/elicitation_throws_when_capability_is_missing.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/elicitation_throws_when_capability_is_missing.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/input_returns_freeform_value.yaml b/test/snapshots/elicitation/input_returns_freeform_value.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/input_returns_freeform_value.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/select_returns_selected_option.yaml b/test/snapshots/elicitation/select_returns_selected_option.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/select_returns_selected_option.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/sends_requestelicitation_when_handler_provided.yaml b/test/snapshots/elicitation/sends_requestelicitation_when_handler_provided.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/sends_requestelicitation_when_handler_provided.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/session_without_elicitationhandler_creates_successfully.yaml b/test/snapshots/elicitation/session_without_elicitationhandler_creates_successfully.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/session_without_elicitationhandler_creates_successfully.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/elicitation/should_report_elicitation_capability_based_on_handler_presence.yaml b/test/snapshots/elicitation/should_report_elicitation_capability_based_on_handler_presence.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/elicitation/should_report_elicitation_capability_based_on_handler_presence.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/mcp_and_agents/should_handle_custom_agent_with_mcp_servers.yaml b/test/snapshots/mcp_and_agents/should_handle_custom_agent_with_mcp_servers.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_handle_custom_agent_with_mcp_servers.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/mcp_and_agents/should_handle_custom_agent_with_tools_configuration.yaml b/test/snapshots/mcp_and_agents/should_handle_custom_agent_with_tools_configuration.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_handle_custom_agent_with_tools_configuration.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/mcp_and_agents/should_handle_multiple_custom_agents.yaml b/test/snapshots/mcp_and_agents/should_handle_multiple_custom_agents.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_handle_multiple_custom_agents.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/mcp_and_agents/should_handle_multiple_mcp_servers.yaml b/test/snapshots/mcp_and_agents/should_handle_multiple_mcp_servers.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_handle_multiple_mcp_servers.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/per-session-auth/session_auth_status_is_unauthenticated_without_token.yaml b/test/snapshots/per-session-auth/session_auth_status_is_unauthenticated_without_token.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/per-session-auth/session_auth_status_is_unauthenticated_without_token.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/per-session-auth/session_fails_with_invalid_token.yaml b/test/snapshots/per-session-auth/session_fails_with_invalid_token.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/per-session-auth/session_fails_with_invalid_token.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/per-session-auth/session_token_overrides_client_token.yaml b/test/snapshots/per-session-auth/session_token_overrides_client_token.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/per-session-auth/session_token_overrides_client_token.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/per-session-auth/session_uses_client_token_when_no_session_token_is_supplied.yaml b/test/snapshots/per-session-auth/session_uses_client_token_when_no_session_token_is_supplied.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/per-session-auth/session_uses_client_token_when_no_session_token_is_supplied.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/mode_set_to_same_value_multiple_times_stays_stable.yaml b/test/snapshots/rpc_additional_edge_cases/mode_set_to_same_value_multiple_times_stays_stable.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/mode_set_to_same_value_multiple_times_stays_stable.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/name_set_with_unicode_round_trips.yaml b/test/snapshots/rpc_additional_edge_cases/name_set_with_unicode_round_trips.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/name_set_with_unicode_round_trips.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/permissions_reset_session_approvals_on_fresh_session_is_noop.yaml b/test/snapshots/rpc_additional_edge_cases/permissions_reset_session_approvals_on_fresh_session_is_noop.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/permissions_reset_session_approvals_on_fresh_session_is_noop.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/permissions_set_approve_all_toggle_round_trips.yaml b/test/snapshots/rpc_additional_edge_cases/permissions_set_approve_all_toggle_round_trips.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/permissions_set_approve_all_toggle_round_trips.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/plan_delete_when_none_exists_is_idempotent.yaml b/test/snapshots/rpc_additional_edge_cases/plan_delete_when_none_exists_is_idempotent.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/plan_delete_when_none_exists_is_idempotent.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/plan_update_with_empty_content_then_read_returns_empty.yaml b/test/snapshots/rpc_additional_edge_cases/plan_update_with_empty_content_then_read_returns_empty.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/plan_update_with_empty_content_then_read_returns_empty.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/shell_exec_with_zero_timeout_does_not_kill_long_running_command.yaml b/test/snapshots/rpc_additional_edge_cases/shell_exec_with_zero_timeout_does_not_kill_long_running_command.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/shell_exec_with_zero_timeout_does_not_kill_long_running_command.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/usage_get_metrics_on_fresh_session_returns_zero_tokens.yaml b/test/snapshots/rpc_additional_edge_cases/usage_get_metrics_on_fresh_session_returns_zero_tokens.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/usage_get_metrics_on_fresh_session_returns_zero_tokens.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_empty_content_round_trips.yaml b/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_empty_content_round_trips.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_empty_content_round_trips.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_large_content_round_trips.yaml b/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_large_content_round_trips.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_large_content_round_trips.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_unicode_content_round_trips.yaml b/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_unicode_content_round_trips.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/workspaces_create_file_with_unicode_content_round_trips.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/workspaces_createfile_then_listfiles_returns_sorted_or_stable_order.yaml b/test/snapshots/rpc_additional_edge_cases/workspaces_createfile_then_listfiles_returns_sorted_or_stable_order.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/workspaces_createfile_then_listfiles_returns_sorted_or_stable_order.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_additional_edge_cases/workspaces_getworkspace_returns_stable_result_across_calls.yaml b/test/snapshots/rpc_additional_edge_cases/workspaces_getworkspace_returns_stable_result_across_calls.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_additional_edge_cases/workspaces_getworkspace_returns_stable_result_across_calls.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_agents/should_call_agent_reload.yaml b/test/snapshots/rpc_agents/should_call_agent_reload.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_agents/should_call_agent_reload.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_agents/should_deselect_current_agent.yaml b/test/snapshots/rpc_agents/should_deselect_current_agent.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_agents/should_deselect_current_agent.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_agents/should_emit_subagent_selected_and_deselected_events.yaml b/test/snapshots/rpc_agents/should_emit_subagent_selected_and_deselected_events.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_agents/should_emit_subagent_selected_and_deselected_events.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_agents/should_list_available_custom_agents.yaml b/test/snapshots/rpc_agents/should_list_available_custom_agents.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_agents/should_list_available_custom_agents.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_agents/should_return_empty_list_when_no_custom_agents_configured.yaml b/test/snapshots/rpc_agents/should_return_empty_list_when_no_custom_agents_configured.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_agents/should_return_empty_list_when_no_custom_agents_configured.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_agents/should_return_null_when_no_agent_is_selected.yaml b/test/snapshots/rpc_agents/should_return_null_when_no_agent_is_selected.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_agents/should_return_null_when_no_agent_is_selected.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_agents/should_select_and_get_current_agent.yaml b/test/snapshots/rpc_agents/should_select_and_get_current_agent.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_agents/should_select_and_get_current_agent.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_event_side_effects/should_emit_mode_changed_event_when_mode_set.yaml b/test/snapshots/rpc_event_side_effects/should_emit_mode_changed_event_when_mode_set.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_event_side_effects/should_emit_mode_changed_event_when_mode_set.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_event_side_effects/should_emit_plan_changed_event_for_update_and_delete.yaml b/test/snapshots/rpc_event_side_effects/should_emit_plan_changed_event_for_update_and_delete.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_event_side_effects/should_emit_plan_changed_event_for_update_and_delete.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_event_side_effects/should_emit_plan_changed_update_operation_on_second_update.yaml b/test/snapshots/rpc_event_side_effects/should_emit_plan_changed_update_operation_on_second_update.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_event_side_effects/should_emit_plan_changed_update_operation_on_second_update.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_event_side_effects/should_emit_title_changed_event_when_name_set.yaml b/test/snapshots/rpc_event_side_effects/should_emit_title_changed_event_when_name_set.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_event_side_effects/should_emit_title_changed_event_when_name_set.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_event_side_effects/should_emit_workspace_file_changed_event_when_file_created.yaml b/test/snapshots/rpc_event_side_effects/should_emit_workspace_file_changed_event_when_file_created.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_event_side_effects/should_emit_workspace_file_changed_event_when_file_created.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_list_and_toggle_session_skills.yaml b/test/snapshots/rpc_mcp_and_skills/should_list_and_toggle_session_skills.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_list_and_toggle_session_skills.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_list_extensions.yaml b/test/snapshots/rpc_mcp_and_skills/should_list_extensions.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_list_extensions.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_list_mcp_servers_with_configured_server.yaml b/test/snapshots/rpc_mcp_and_skills/should_list_mcp_servers_with_configured_server.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_list_mcp_servers_with_configured_server.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_list_plugins.yaml b/test/snapshots/rpc_mcp_and_skills/should_list_plugins.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_list_plugins.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_reload_session_skills.yaml b/test/snapshots/rpc_mcp_and_skills/should_reload_session_skills.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_reload_session_skills.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_report_error_when_extensions_are_not_available.yaml b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_extensions_are_not_available.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_extensions_are_not_available.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_host_is_not_initialized.yaml b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_host_is_not_initialized.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_host_is_not_initialized.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_configured.yaml b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_configured.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_configured.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_remote.yaml b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_remote.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_and_skills/should_report_error_when_mcp_oauth_server_is_not_remote.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_config/should_call_server_mcp_config_rpcs.yaml b/test/snapshots/rpc_mcp_config/should_call_server_mcp_config_rpcs.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_config/should_call_server_mcp_config_rpcs.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_config/should_round_trip_http_mcp_oauth_config_rpc.yaml b/test/snapshots/rpc_mcp_config/should_round_trip_http_mcp_oauth_config_rpc.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_config/should_round_trip_http_mcp_oauth_config_rpc.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server/should_call_rpc_account_get_quota_when_authenticated.yaml b/test/snapshots/rpc_server/should_call_rpc_account_get_quota_when_authenticated.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server/should_call_rpc_account_get_quota_when_authenticated.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server/should_call_rpc_models_list_with_typed_result.yaml b/test/snapshots/rpc_server/should_call_rpc_models_list_with_typed_result.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server/should_call_rpc_models_list_with_typed_result.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server/should_call_rpc_ping_with_typed_params_and_result.yaml b/test/snapshots/rpc_server/should_call_rpc_ping_with_typed_params_and_result.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server/should_call_rpc_ping_with_typed_params_and_result.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server/should_call_rpc_tools_list_with_typed_result.yaml b/test/snapshots/rpc_server/should_call_rpc_tools_list_with_typed_result.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server/should_call_rpc_tools_list_with_typed_result.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server/should_discover_server_mcp_and_skills.yaml b/test/snapshots/rpc_server/should_discover_server_mcp_and_skills.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server/should_discover_server_mcp_and_skills.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_call_session_rpc_model_getcurrent.yaml b/test/snapshots/rpc_session_state/should_call_session_rpc_model_getcurrent.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_call_session_rpc_model_getcurrent.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml b/test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_call_session_rpc_model_switchto.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_call_session_usage_and_permission_rpcs.yaml b/test/snapshots/rpc_session_state/should_call_session_usage_and_permission_rpcs.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_call_session_usage_and_permission_rpcs.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_call_workspace_file_rpc_methods.yaml b/test/snapshots/rpc_session_state/should_call_workspace_file_rpc_methods.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_call_workspace_file_rpc_methods.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_create_workspace_file_with_nested_path_auto_creating_dirs.yaml b/test/snapshots/rpc_session_state/should_create_workspace_file_with_nested_path_auto_creating_dirs.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_create_workspace_file_with_nested_path_auto_creating_dirs.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_emit_title_changed_event_each_time_name_set_is_called.yaml b/test/snapshots/rpc_session_state/should_emit_title_changed_event_each_time_name_set_is_called.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_emit_title_changed_event_each_time_name_set_is_called.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_get_and_set_session_metadata.yaml b/test/snapshots/rpc_session_state/should_get_and_set_session_metadata.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_get_and_set_session_metadata.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_get_and_set_session_mode.yaml b/test/snapshots/rpc_session_state/should_get_and_set_session_mode.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_get_and_set_session_mode.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_handle_forking_session_without_persisted_events.yaml b/test/snapshots/rpc_session_state/should_handle_forking_session_without_persisted_events.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_handle_forking_session_without_persisted_events.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_read_update_and_delete_plan.yaml b/test/snapshots/rpc_session_state/should_read_update_and_delete_plan.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_read_update_and_delete_plan.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_reject_empty_or_whitespace_session_name.yaml b/test/snapshots/rpc_session_state/should_reject_empty_or_whitespace_session_name.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_reject_empty_or_whitespace_session_name.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_reject_workspace_file_path_traversal.yaml b/test/snapshots/rpc_session_state/should_reject_workspace_file_path_traversal.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_reject_workspace_file_path_traversal.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_report_error_reading_nonexistent_workspace_file.yaml b/test/snapshots/rpc_session_state/should_report_error_reading_nonexistent_workspace_file.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_report_error_reading_nonexistent_workspace_file.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_report_implemented_errors_for_unsupported_session_rpc_paths.yaml b/test/snapshots/rpc_session_state/should_report_implemented_errors_for_unsupported_session_rpc_paths.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_report_implemented_errors_for_unsupported_session_rpc_paths.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_set_and_get_each_session_mode_value.yaml b/test/snapshots/rpc_session_state/should_set_and_get_each_session_mode_value.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_set_and_get_each_session_mode_value.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state/should_update_existing_workspace_file_with_update_operation.yaml b/test/snapshots/rpc_session_state/should_update_existing_workspace_file_with_update_operation.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state/should_update_existing_workspace_file_with_update_operation.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_and_fleet/should_execute_shell_command.yaml b/test/snapshots/rpc_shell_and_fleet/should_execute_shell_command.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_and_fleet/should_execute_shell_command.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_and_fleet/should_kill_shell_process.yaml b/test/snapshots/rpc_shell_and_fleet/should_kill_shell_process.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_and_fleet/should_kill_shell_process.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_edge_cases/shell_exec_with_custom_cwd_honors_override.yaml b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_custom_cwd_honors_override.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_custom_cwd_honors_override.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_edge_cases/shell_exec_with_large_stdout_cleans_up.yaml b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_large_stdout_cleans_up.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_large_stdout_cleans_up.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_edge_cases/shell_exec_with_nonexistent_command_returns_processid_and_cleans_up.yaml b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_nonexistent_command_returns_processid_and_cleans_up.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_nonexistent_command_returns_processid_and_cleans_up.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_edge_cases/shell_exec_with_stderr_output_cleans_up.yaml b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_stderr_output_cleans_up.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_stderr_output_cleans_up.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_edge_cases/shell_exec_with_timeout_kills_long_running_command.yaml b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_timeout_kills_long_running_command.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_edge_cases/shell_exec_with_timeout_kills_long_running_command.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_edge_cases/shell_kill_cleans_up_after_terminating_signal.yaml b/test/snapshots/rpc_shell_edge_cases/shell_kill_cleans_up_after_terminating_signal.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_edge_cases/shell_kill_cleans_up_after_terminating_signal.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_edge_cases/shell_kill_unknown_processid_returns_false.yaml b/test/snapshots/rpc_shell_edge_cases/shell_kill_unknown_processid_returns_false.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_edge_cases/shell_kill_unknown_processid_returns_false.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_tasks_and_handlers/should_list_task_state_and_return_false_for_missing_task_operations.yaml b/test/snapshots/rpc_tasks_and_handlers/should_list_task_state_and_return_false_for_missing_task_operations.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_tasks_and_handlers/should_list_task_state_and_return_false_for_missing_task_operations.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_invalid_task_agent_model.yaml b/test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_invalid_task_agent_model.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_invalid_task_agent_model.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_missing_task_agent_type.yaml b/test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_missing_task_agent_type.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_tasks_and_handlers/should_report_implemented_error_for_missing_task_agent_type.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_tasks_and_handlers/should_return_expected_results_for_missing_pending_handler_requestids.yaml b/test/snapshots/rpc_tasks_and_handlers/should_return_expected_results_for_missing_pending_handler_requestids.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_tasks_and_handlers/should_return_expected_results_for_missing_pending_handler_requestids.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rust_multi_client/both_clients_see_tool_request_and_completion_events.yaml b/test/snapshots/rust_multi_client/both_clients_see_tool_request_and_completion_events.yaml new file mode 100644 index 000000000..20eefc57a --- /dev/null +++ b/test/snapshots/rust_multi_client/both_clients_see_tool_request_and_completion_events.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the magic_number tool with seed 'hello' and tell me the result + - role: assistant + content: I'll use the magic_number tool with seed 'hello' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: magic_number + arguments: '{"seed":"hello"}' + - role: tool + tool_call_id: toolcall_0 + content: MAGIC_hello_42 + - role: assistant + content: The magic number for seed 'hello' is **MAGIC_hello_42**. diff --git a/test/snapshots/rust_multi_client/disconnecting_client_removes_its_tools.yaml b/test/snapshots/rust_multi_client/disconnecting_client_removes_its_tools.yaml new file mode 100644 index 000000000..192105ac7 --- /dev/null +++ b/test/snapshots/rust_multi_client/disconnecting_client_removes_its_tools.yaml @@ -0,0 +1,69 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - role: tool + tool_call_id: toolcall_0 + content: STABLE_test1 + - role: assistant + content: "The stable_tool returned: **STABLE_test1**" + - role: user + content: Use the ephemeral_tool with input 'test2' and tell me the result. + - role: assistant + content: I'll call the ephemeral_tool with input 'test2' for you. + tool_calls: + - id: toolcall_1 + type: function + function: + name: ephemeral_tool + arguments: '{"input":"test2"}' + - role: tool + tool_call_id: toolcall_1 + content: EPHEMERAL_test2 + - role: assistant + content: "The ephemeral_tool returned: **EPHEMERAL_test2**" + - role: user + content: >- + + + Tools no longer available: ephemeral_tool + + + Important: Do not attempt to call tools that are no longer available unless you've been notified that they're + available again. + + + + + Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available. + - role: assistant + content: I'll call the stable_tool with input 'still_here'. The ephemeral_tool is no longer available, so I can only use + the stable_tool. + tool_calls: + - id: toolcall_2 + type: function + function: + name: stable_tool + arguments: '{"input":"still_here"}' + - role: tool + tool_call_id: toolcall_2 + content: STABLE_still_here + - role: assistant + content: >- + The stable_tool returned: **STABLE_still_here** + + + The ephemeral_tool is not available anymore (it was removed as indicated in the tools_changed_notice), so I + could only call the stable_tool. diff --git a/test/snapshots/rust_multi_client/one_client_approves_permission_and_both_see_the_result.yaml b/test/snapshots/rust_multi_client/one_client_approves_permission_and_both_see_the_result.yaml new file mode 100644 index 000000000..e67357589 --- /dev/null +++ b/test/snapshots/rust_multi_client/one_client_approves_permission_and_both_see_the_result.yaml @@ -0,0 +1,50 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called hello.txt containing the text 'hello world' + - role: assistant + content: I'll create the hello.txt file for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating hello.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called hello.txt containing the text 'hello world' + - role: assistant + content: I'll create the hello.txt file for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating hello.txt file"}' + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Created file ${workdir}/hello.txt with 11 characters + - role: assistant + content: Done - I created hello.txt containing "hello world". diff --git a/test/snapshots/rust_multi_client/one_client_rejects_permission_and_both_see_the_result.yaml b/test/snapshots/rust_multi_client/one_client_rejects_permission_and_both_see_the_result.yaml new file mode 100644 index 000000000..ba9db87d0 --- /dev/null +++ b/test/snapshots/rust_multi_client/one_client_rejects_permission_and_both_see_the_result.yaml @@ -0,0 +1,25 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'protected' with 'hacked'. + - role: assistant + content: I'll help you edit protected.txt to replace 'protected' with 'hacked'. Let me first view the file and then make + the change. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' diff --git a/test/snapshots/rust_multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml b/test/snapshots/rust_multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml new file mode 100644 index 000000000..c97e969df --- /dev/null +++ b/test/snapshots/rust_multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml @@ -0,0 +1,36 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the city_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the city_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: city_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_0 + content: CITY_FOR_US + - role: assistant + content: The city_lookup tool returned **"CITY_FOR_US"** for the country code 'US'. + - role: user + content: Now use the currency_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the currency_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_1 + type: function + function: + name: currency_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_1 + content: CURRENCY_FOR_US + - role: assistant + content: The currency_lookup tool returned **"CURRENCY_FOR_US"** for the country code 'US'. diff --git a/test/snapshots/session_config/should_apply_all_reasoning_effort_values_on_session_create.yaml b/test/snapshots/session_config/should_apply_all_reasoning_effort_values_on_session_create.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/session_config/should_apply_all_reasoning_effort_values_on_session_create.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/session_config/should_apply_reasoning_effort_on_session_create.yaml b/test/snapshots/session_config/should_apply_reasoning_effort_on_session_create.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/session_config/should_apply_reasoning_effort_on_session_create.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/session_config/should_create_session_with_custom_provider_config.yaml b/test/snapshots/session_config/should_create_session_with_custom_provider_config.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/session_config/should_create_session_with_custom_provider_config.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/session_config/should_use_custom_session_id.yaml b/test/snapshots/session_config/should_use_custom_session_id.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/session_config/should_use_custom_session_id.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] From f72bf0fc5971eec797358c6fec35e845f740129c Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Mon, 11 May 2026 11:12:12 -0400 Subject: [PATCH 24/33] Add Maven Central badge to README (#1254) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7ae2b0972..dc19f47d5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![PyPI - Downloads](https://img.shields.io/pypi/dm/github-copilot-sdk?label=PyPI)](https://pypi.org/project/github-copilot-sdk/) [![NuGet Downloads](https://img.shields.io/nuget/dt/GitHub.Copilot.SDK?label=NuGet)](https://www.nuget.org/packages/GitHub.Copilot.SDK) [![Go Reference](https://pkg.go.dev/badge/github.com/github/copilot-sdk/go.svg)](https://pkg.go.dev/github.com/github/copilot-sdk/go) +[![Maven Central](https://img.shields.io/maven-central/v/com.github/copilot-sdk-java?label=Maven%20Central)](https://central.sonatype.com/artifact/com.github/copilot-sdk-java) Agents for every app. From 4a0437bb03a0b60a1867f14ae8e3faf053afa5aa Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 11 May 2026 12:36:34 -0400 Subject: [PATCH 25/33] Update README and guide for Rust SDK (#1259) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 21 ++- docs/getting-started.md | 400 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 401 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index dc19f47d5..277586b2d 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,13 @@ [![NPM Downloads](https://img.shields.io/npm/dm/%40github%2Fcopilot-sdk?label=npm)](https://www.npmjs.com/package/@github/copilot-sdk) [![PyPI - Downloads](https://img.shields.io/pypi/dm/github-copilot-sdk?label=PyPI)](https://pypi.org/project/github-copilot-sdk/) [![NuGet Downloads](https://img.shields.io/nuget/dt/GitHub.Copilot.SDK?label=NuGet)](https://www.nuget.org/packages/GitHub.Copilot.SDK) -[![Go Reference](https://pkg.go.dev/badge/github.com/github/copilot-sdk/go.svg)](https://pkg.go.dev/github.com/github/copilot-sdk/go) +[![Go Reference](https://img.shields.io/badge/Go-Reference-00ADD8?logo=go&logoColor=white)](https://pkg.go.dev/github.com/github/copilot-sdk/go) +[![crates.io](https://img.shields.io/crates/v/github-copilot-sdk?label=crates.io)](https://crates.io/crates/github-copilot-sdk) [![Maven Central](https://img.shields.io/maven-central/v/com.github/copilot-sdk-java?label=Maven%20Central)](https://central.sonatype.com/artifact/com.github/copilot-sdk-java) Agents for every app. -Embed Copilot's agentic workflows in your application—now available in public preview as a programmable SDK for Python, TypeScript, Go, .NET, and Java. +Embed Copilot's agentic workflows in your application—now available in public preview as a programmable SDK for Python, TypeScript, Go, .NET, and Java. A Rust SDK is also available in technical preview. The GitHub Copilot SDK exposes the same engine behind Copilot CLI: a production-tested agent runtime you can invoke programmatically. No need to build your own orchestration—you define agent behavior, Copilot handles planning, tool invocation, file edits, and more. @@ -22,6 +23,7 @@ The GitHub Copilot SDK exposes the same engine behind Copilot CLI: a production- | **Python** | [`python/`](./python/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/python/README.md) | `pip install github-copilot-sdk` | | **Go** | [`go/`](./go/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/go/README.md) | `go get github.com/github/copilot-sdk/go` | | **.NET** | [`dotnet/`](./dotnet/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/dotnet/README.md) | `dotnet add package GitHub.Copilot.SDK` | +| **Rust** | [`rust/`](./rust/) | — | `cargo add github-copilot-sdk` | | **Java** | [`github/copilot-sdk-java`](https://github.com/github/copilot-sdk-java) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/java/README.md) | Maven coordinates
`com.github:copilot-sdk-java`
See instructions for [Maven](https://github.com/github/copilot-sdk-java?tab=readme-ov-file#maven) and [Gradle](https://github.com/github/copilot-sdk-java?tab=readme-ov-file#gradle) | See the individual SDK READMEs for installation, usage examples, and API reference. @@ -35,7 +37,7 @@ Quick steps: 1. **(Optional) Install the Copilot CLI** For Node.js, Python, and .NET SDKs, the Copilot CLI is bundled automatically and no separate installation is required. -For the Go SDK, [install the CLI manually](https://github.com/features/copilot/cli) or ensure `copilot` is available in your PATH. +For the Go and Rust SDKs, [install the CLI manually](https://github.com/features/copilot/cli) or ensure `copilot` is available in your PATH unless you opt into their application-level CLI bundling features. 2. **Install your preferred SDK** using the commands above. @@ -86,26 +88,27 @@ See the **[Authentication documentation](./docs/auth/index.md)** for details on No — for Node.js, Python, and .NET SDKs, the Copilot CLI is bundled automatically as a dependency. You do not need to install it separately. -For Go SDK, you may still need to install the CLI manually. +For Go and Rust SDKs, the CLI is not bundled by default. Install the CLI manually, ensure `copilot` is available in your PATH, or opt into their application-level CLI bundling features. -Advanced: You can override the bundled CLI using `cliPath` or `cliUrl` if you want to use a custom CLI binary or connect to an external server. +Advanced: You can override the CLI binary or connect to an external server. See the individual SDK README for language-specific options. ### What tools are enabled by default? -By default, the SDK operates the Copilot CLI as if `--allow-all` were passed, enabling all first-party tools. This means that agents can perform a wide range of actions, including file system operations, Git operations, and web requests. You can customize tool availability by configuring the SDK client options to enable and disable specific tools. Refer to the individual SDK documentation for details on tool configuration and to the Copilot CLI documentation for the list of available tools. +By default, the SDK exposes the Copilot CLI's first-party tools, similar to running the CLI with `--allow-all`. Tool execution is still governed by each SDK's permission handler, so applications can approve, deny, or customize tool calls. You can customize tool availability by configuring the SDK client options to enable and disable specific tools. Refer to the individual SDK documentation for details on tool configuration and to the Copilot CLI documentation for the list of available tools. ### Can I use custom agents, skills or tools? Yes, the GitHub Copilot SDK allows you to define custom agents, skills, and tools. You can extend the functionality of the agents by implementing your own logic and integrating additional tools as needed. Refer to the SDK documentation of your preferred language for more details. -### Are there instructions for Copilot to speed up development with the SDK? +### Are there instructions or SDK guidance for Copilot to speed up development? -Yes, check out the custom instructions for each SDK: +Yes, check out the custom instructions and SDK-specific guidance: - **[Node.js / TypeScript](https://github.com/github/awesome-copilot/blob/main/instructions/copilot-sdk-nodejs.instructions.md)** - **[Python](https://github.com/github/awesome-copilot/blob/main/instructions/copilot-sdk-python.instructions.md)** - **[.NET](https://github.com/github/awesome-copilot/blob/main/instructions/copilot-sdk-csharp.instructions.md)** - **[Go](https://github.com/github/awesome-copilot/blob/main/instructions/copilot-sdk-go.instructions.md)** +- **[Rust](./rust/README.md)** (SDK guidance; custom instructions not yet published) - **[Java](https://github.com/github/copilot-sdk-java/blob/main/instructions/copilot-sdk-java.instructions.md)** ### What models are supported? @@ -137,11 +140,9 @@ Please use the [GitHub Issues](https://github.com/github/copilot-sdk/issues) pag | SDK | Location | | ----------- | -------------------------------------------------------- | -| **Rust** | [copilot-community-sdk/copilot-sdk-rust][sdk-rust] | | **Clojure** | [copilot-community-sdk/copilot-sdk-clojure][sdk-clojure] | | **C++** | [0xeb/copilot-sdk-cpp][sdk-cpp] | -[sdk-rust]: https://github.com/copilot-community-sdk/copilot-sdk-rust [sdk-cpp]: https://github.com/0xeb/copilot-sdk-cpp [sdk-clojure]: https://github.com/copilot-community-sdk/copilot-sdk-clojure diff --git a/docs/getting-started.md b/docs/getting-started.md index 8238dc772..4ee6bd298 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,7 @@ Before you begin, make sure you have: * **GitHub Copilot CLI** installed and authenticated ([Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)) * Your preferred language runtime: - * **Node.js** 18+ or **Python** 3.11+ or **Go** 1.21+ or **Java** 17+ or **.NET** 8.0+ + * **Node.js** 20+ or **Python** 3.11+ or **Go** 1.24+ or **Rust** 1.94+ or **Java** 17+ or **.NET** 8.0+ Verify the CLI is working: @@ -75,6 +75,28 @@ go get github.com/github/copilot-sdk/go

+
+Rust + +First, create a new binary crate: + +```bash +cargo new copilot-demo && cd copilot-demo +``` + +Then install the SDK and direct dependencies used by the examples: + +```bash +cargo add github-copilot-sdk --features derive +# Used by #[tokio::main] and tokio::spawn +cargo add tokio --features rt-multi-thread,macros +# Used by custom-tool parameter derives later in this guide +cargo add serde --features derive +cargo add schemars +``` + +
+
.NET @@ -226,6 +248,51 @@ go run main.go
+
+Rust + +Create `src/main.rs`: + +```rust +use std::sync::Arc; +use std::time::Duration; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::{Client, ClientOptions, MessageOptions, SessionConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::start(ClientOptions::default()).await?; + let session = client + .create_session(SessionConfig::default().with_handler(Arc::new(ApproveAllHandler))) + .await?; + + let response = session + .send_and_wait( + MessageOptions::new("What is 2 + 2?").with_wait_timeout(Duration::from_secs(120)), + ) + .await?; + + if let Some(event) = response { + if let Some(content) = event.data.get("content").and_then(|value| value.as_str()) { + println!("{content}"); + } + } + + session.disconnect().await?; + client.stop().await?; + Ok(()) +} +``` + +Run it: + +```bash +cargo run +``` + +
+
.NET @@ -427,6 +494,63 @@ func main() {
+
+Rust + +Update `src/main.rs`: + +```rust +use std::io::{self, Write}; +use std::sync::Arc; +use std::time::Duration; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::{Client, ClientOptions, MessageOptions, SessionConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::start(ClientOptions::default()).await?; + + let mut config = SessionConfig::default(); + config.streaming = Some(true); + let session = client + .create_session(config.with_handler(Arc::new(ApproveAllHandler))) + .await?; + + // Listen for response chunks + let mut events = session.subscribe(); + tokio::spawn(async move { + while let Ok(event) = events.recv().await { + match event.event_type.as_str() { + "assistant.message_delta" => { + if let Some(text) = + event.data.get("deltaContent").and_then(|value| value.as_str()) + { + print!("{text}"); + io::stdout().flush().ok(); + } + } + "assistant.message" => println!(), + _ => {} + } + } + }); + + session + .send_and_wait( + MessageOptions::new("Tell me a short joke") + .with_wait_timeout(Duration::from_secs(120)), + ) + .await?; + + session.disconnect().await?; + client.stop().await?; + Ok(()) +} +``` + +
+
.NET @@ -513,6 +637,7 @@ The SDK provides methods for subscribing to session events: |--------|-------------| | `on(handler)` | Subscribe to all events; returns unsubscribe function | | `on(eventType, handler)` | Subscribe to specific event type (Node.js/TypeScript only); returns unsubscribe function | +| `subscribe()` | Subscribe to all events (Rust); filter by `event_type` |
Node.js / TypeScript @@ -645,6 +770,31 @@ unsubscribe()
+
+Rust + +```rust +let mut events = session.subscribe(); + +tokio::spawn(async move { + while let Ok(event) = events.recv().await { + println!("Event: {}", event.event_type); + + match event.event_type.as_str() { + "session.idle" => println!("Session is idle"), + "assistant.message" => { + if let Some(content) = event.data.get("content").and_then(|value| value.as_str()) { + println!("Message: {content}"); + } + } + _ => {} + } + } +}); +``` + +
+
.NET @@ -925,6 +1075,84 @@ func main() {
+
+Rust + +Update `src/main.rs`: + +```rust +use std::io::{self, Write}; +use std::sync::Arc; +use std::time::Duration; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::tool::{JsonSchema, ToolHandlerRouter, define_tool}; +use github_copilot_sdk::{Client, ClientOptions, MessageOptions, SessionConfig, ToolResult}; +use serde::Deserialize; + +#[derive(Deserialize, JsonSchema)] +struct GetWeatherParams { + city: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Define a tool that Copilot can call + let router = ToolHandlerRouter::new( + vec![define_tool( + "get_weather", + "Get the current weather for a city", + |_inv, params: GetWeatherParams| async move { + Ok(ToolResult::Text(format!( + "{}: 62°F and sunny", + params.city + ))) + }, + )], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + + let client = Client::start(ClientOptions::default()).await?; + + let mut config = SessionConfig::default(); + config.streaming = Some(true); + config.tools = Some(tools); + let session = client.create_session(config.with_handler(Arc::new(router))).await?; + + let mut events = session.subscribe(); + tokio::spawn(async move { + while let Ok(event) = events.recv().await { + match event.event_type.as_str() { + "assistant.message_delta" => { + if let Some(text) = + event.data.get("deltaContent").and_then(|value| value.as_str()) + { + print!("{text}"); + io::stdout().flush().ok(); + } + } + "assistant.message" => println!(), + _ => {} + } + } + }); + + session + .send_and_wait( + MessageOptions::new("What's the weather like in Seattle and Tokyo?") + .with_wait_timeout(Duration::from_secs(120)), + ) + .await?; + + session.disconnect().await?; + client.stop().await?; + Ok(()) +} +``` + +
+
.NET @@ -1304,6 +1532,112 @@ go run weather-assistant.go
+
+Rust + +Create `src/main.rs`: + +```rust +use std::io::{self, BufRead, Write}; +use std::sync::Arc; +use std::time::Duration; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::tool::{JsonSchema, ToolHandlerRouter, define_tool}; +use github_copilot_sdk::{Client, ClientOptions, MessageOptions, SessionConfig, ToolResult}; +use serde::Deserialize; + +#[derive(Deserialize, JsonSchema)] +struct GetWeatherParams { + city: String, +} + +fn read_line() -> Option { + let stdin = io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line).ok()?; + if line.is_empty() { + return None; + } + Some(line.trim_end_matches(&['\n', '\r'][..]).to_string()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let router = ToolHandlerRouter::new( + vec![define_tool( + "get_weather", + "Get the current weather for a city", + |_inv, params: GetWeatherParams| async move { + Ok(ToolResult::Text(format!( + "{}: 62°F and sunny", + params.city + ))) + }, + )], + Arc::new(ApproveAllHandler), + ); + let tools = router.tools(); + + let client = Client::start(ClientOptions::default()).await?; + + let mut config = SessionConfig::default(); + config.streaming = Some(true); + config.tools = Some(tools); + let session = client.create_session(config.with_handler(Arc::new(router))).await?; + + let mut events = session.subscribe(); + tokio::spawn(async move { + while let Ok(event) = events.recv().await { + match event.event_type.as_str() { + "assistant.message_delta" => { + if let Some(text) = + event.data.get("deltaContent").and_then(|value| value.as_str()) + { + print!("{text}"); + io::stdout().flush().ok(); + } + } + "assistant.message" => println!(), + _ => {} + } + } + }); + + println!("Weather Assistant (type 'exit' to quit)"); + println!("Try: 'What's the weather in Paris?' or 'Compare weather in NYC and LA'\n"); + + loop { + print!("You: "); + io::stdout().flush().ok(); + + let Some(input) = read_line() else { break }; + if input.eq_ignore_ascii_case("exit") { + break; + } + + print!("Assistant: "); + io::stdout().flush().ok(); + session + .send_and_wait(MessageOptions::new(input).with_wait_timeout(Duration::from_secs(120))) + .await?; + println!(); + } + + session.disconnect().await?; + client.stop().await?; + Ok(()) +} +``` + +Run with: + +```bash +cargo run +``` + +
+
.NET @@ -1573,7 +1907,7 @@ Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_conte Each override supports four actions: `replace`, `remove`, `append`, and `prepend`. Unknown section IDs are handled gracefully—content is appended to additional instructions and a warning is emitted; `remove` on unknown sections is silently ignored. -See the language-specific SDK READMEs for examples in [TypeScript](../nodejs/README.md), [Python](../python/README.md), [Go](../go/README.md), [Java](../java/README.md), and [C#](../dotnet/README.md). +See the language-specific SDK READMEs for examples in [TypeScript](../nodejs/README.md), [Python](../python/README.md), [Go](../go/README.md), [Rust](../rust/README.md), [Java](../java/README.md), and [C#](../dotnet/README.md). ## Connecting to an external CLI server @@ -1698,6 +2032,31 @@ session, err := client.CreateSession(ctx, &copilot.SessionConfig{
+
+Rust + +```rust +use std::sync::Arc; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::{Client, ClientOptions, SessionConfig, Transport}; + +let mut options = ClientOptions::default(); +options.transport = Transport::External { + host: "localhost".to_string(), + port: 4321, +}; +let client = Client::start(options).await?; + +// Use the client normally +let session = client + .create_session(SessionConfig::default().with_handler(Arc::new(ApproveAllHandler))) + .await?; +// ... +``` + +
+
.NET @@ -1741,7 +2100,7 @@ var session = client.createSession(
-**Note:** When `cli_url` / `cliUrl` / `CLIUrl` is provided, the SDK will not spawn or manage a CLI process - it will only connect to the existing server at the specified URL. +**Note:** When `cli_url` / `cliUrl` / `CLIUrl` is provided, or Rust uses `Transport::External`, the SDK will not spawn or manage a CLI process - it will only connect to the existing server at the specified URL. ## Telemetry and observability @@ -1803,6 +2162,26 @@ Dependency: `go.opentelemetry.io/otel`
+
+Rust + + +```rust +use github_copilot_sdk::{Client, ClientOptions, OtelExporterType, TelemetryConfig}; + +let mut options = ClientOptions::default(); +options.telemetry = Some( + TelemetryConfig::new() + .with_exporter_type(OtelExporterType::OtlpHttp) + .with_otlp_endpoint("http://localhost:4318"), +); +let client = Client::start(options).await?; +``` + +No extra dependencies—the SDK injects telemetry environment variables for the spawned CLI process. + +
+
.NET @@ -1840,13 +2219,13 @@ Dependency: `io.opentelemetry:opentelemetry-api` ### TelemetryConfig options -| Option | Node.js | Python | Go | Java | .NET | Description | -|---|---|---|---|---|---|---| -| OTLP endpoint | `otlpEndpoint` | `otlp_endpoint` | `OTLPEndpoint` | `otlpEndpoint` | `OtlpEndpoint` | OTLP HTTP endpoint URL | -| File path | `filePath` | `file_path` | `FilePath` | `filePath` | `FilePath` | File path for JSON-lines trace output | -| Exporter type | `exporterType` | `exporter_type` | `ExporterType` | `exporterType` | `ExporterType` | `"otlp-http"` or `"file"` | -| Source name | `sourceName` | `source_name` | `SourceName` | `sourceName` | `SourceName` | Instrumentation scope name | -| Capture content | `captureContent` | `capture_content` | `CaptureContent` | `captureContent` | `CaptureContent` | Whether to capture message content | +| Option | Node.js | Python | Go | Rust | Java | .NET | Description | +|---|---|---|---|---|---|---|---| +| OTLP endpoint | `otlpEndpoint` | `otlp_endpoint` | `OTLPEndpoint` | `otlp_endpoint` | `otlpEndpoint` | `OtlpEndpoint` | OTLP HTTP endpoint URL | +| File path | `filePath` | `file_path` | `FilePath` | `file_path` | `filePath` | `FilePath` | File path for JSON-lines trace output | +| Exporter type | `exporterType` | `exporter_type` | `ExporterType` | `exporter_type` | `exporterType` | `ExporterType` | `"otlp-http"` or `"file"` | +| Source name | `sourceName` | `source_name` | `SourceName` | `source_name` | `sourceName` | `SourceName` | Instrumentation scope name | +| Capture content | `captureContent` | `capture_content` | `CaptureContent` | `capture_content` | `captureContent` | `CaptureContent` | Whether to capture message content | ### File export @@ -1878,6 +2257,7 @@ Trace context is propagated automatically—no manual instrumentation is needed: * [Node.js SDK Reference](../nodejs/README.md) * [Python SDK Reference](../python/README.md) * [Go SDK Reference](../go/README.md) +* [Rust SDK Reference](../rust/README.md) * [.NET SDK Reference](../dotnet/README.md) * [Java SDK Reference](../java/README.md) * [Using MCP Servers](./features/mcp.md) - Integrate external tools via Model Context Protocol From 99153433deb9e0e2c53b179a0ce061220c0365d8 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 11 May 2026 21:42:06 -0400 Subject: [PATCH 26/33] Fix C# listFiles E2E ordering assumption (#1261) * Fix listFiles E2E order assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Rust listFiles E2E order assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/E2E/RpcAdditionalEdgeCasesE2ETests.cs | 11 ++++++----- rust/tests/e2e/rpc_additional_edge_cases.rs | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/dotnet/test/E2E/RpcAdditionalEdgeCasesE2ETests.cs b/dotnet/test/E2E/RpcAdditionalEdgeCasesE2ETests.cs index d71fa20d8..463fdc96a 100644 --- a/dotnet/test/E2E/RpcAdditionalEdgeCasesE2ETests.cs +++ b/dotnet/test/E2E/RpcAdditionalEdgeCasesE2ETests.cs @@ -188,7 +188,7 @@ public async Task Permissions_SetApproveAll_Toggle_Round_Trips() } [Fact] - public async Task Workspaces_CreateFile_Then_ListFiles_Returns_Sorted_Or_Stable_Order() + public async Task Workspaces_CreateFile_Then_ListFiles_Returns_All_Files() { var session = await CreateSessionAsync(); var prefix = $"order-{Guid.NewGuid():N}-"; @@ -204,15 +204,16 @@ public async Task Workspaces_CreateFile_Then_ListFiles_Returns_Sorted_Or_Stable_ .Where(path => path.StartsWith(prefix, StringComparison.Ordinal)) .ToList(); - // The files this test created should be returned in sorted order. - Assert.Equal(paths, matchingFiles); + // The files this test created should all be returned; the runtime does not guarantee + // that workspace file enumeration is sorted. + Assert.Equal(paths, matchingFiles.OrderBy(path => path, StringComparer.Ordinal)); - // Calling list again immediately must preserve the same order. + // A repeated list should still include the files regardless of returned order. var listed2 = await session.Rpc.Workspaces.ListFilesAsync(); var matchingFiles2 = listed2.Files .Where(path => path.StartsWith(prefix, StringComparison.Ordinal)) .ToList(); - Assert.Equal(matchingFiles, matchingFiles2); + Assert.Equal(paths, matchingFiles2.OrderBy(path => path, StringComparer.Ordinal)); } [Fact] diff --git a/rust/tests/e2e/rpc_additional_edge_cases.rs b/rust/tests/e2e/rpc_additional_edge_cases.rs index bf35a2a87..a85da53f0 100644 --- a/rust/tests/e2e/rpc_additional_edge_cases.rs +++ b/rust/tests/e2e/rpc_additional_edge_cases.rs @@ -428,10 +428,10 @@ async fn permissions_set_approve_all_toggle_round_trips() { } #[tokio::test] -async fn workspaces_createfile_then_listfiles_returns_sorted_or_stable_order() { +async fn workspaces_createfile_then_listfiles_returns_all_files() { with_e2e_context( "rpc_additional_edge_cases", - "workspaces_createfile_then_listfiles_returns_sorted_or_stable_order", + "workspaces_createfile_then_listfiles_returns_all_files", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); @@ -465,9 +465,10 @@ async fn workspaces_createfile_then_listfiles_returns_sorted_or_stable_order() { .list_files() .await .expect("list files again"); - assert_eq!(first.files, second.files); - for expected in ["a-rust.txt", "b-rust.txt", "c-rust.txt"] { - assert!(first.files.iter().any(|file| file == expected)); + for files in [&first.files, &second.files] { + for expected in ["a-rust.txt", "b-rust.txt", "c-rust.txt"] { + assert!(files.iter().any(|file| file == expected)); + } } session.disconnect().await.expect("disconnect session"); From 7e7dac2abb7dd21624ff9a23082818879b18d3b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 08:50:09 -0400 Subject: [PATCH 27/33] Update @github/copilot to 1.0.45 (#1263) * Update @github/copilot to 1.0.45 - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code * Fix C# boolean discriminator generation Generate boolean-discriminated unions as flat DTOs so handled serializes as a JSON boolean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Stephen Toub Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 52 +++++++- dotnet/src/Generated/SessionEvents.cs | 44 +++++++ dotnet/test/Unit/SerializationTests.cs | 23 ++++ go/generated_session_events.go | 24 +++- go/rpc/generated_rpc.go | 59 +++++++++- nodejs/package-lock.json | 64 +++++----- nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/rpc.ts | 43 ++++++- nodejs/src/generated/session-events.ts | 36 +++++- python/copilot/generated/rpc.py | 131 ++++++++++++++++++++- python/copilot/generated/session_events.py | 27 +++++ rust/src/generated/api_types.rs | 47 +++++++- rust/src/generated/rpc.rs | 18 +++ rust/src/generated/session_events.rs | 44 +++++++ scripts/codegen/csharp.ts | 120 +++++++++++++++++-- test/harness/package-lock.json | 56 ++++----- test/harness/package.json | 2 +- 18 files changed, 695 insertions(+), 99 deletions(-) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 50965b901..e643cc8ef 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -846,10 +846,6 @@ public sealed class WorkspacesGetWorkspaceResultWorkspace [JsonPropertyName("repository")] public string? Repository { get; set; } - /// Gets or sets the summary value. - [JsonPropertyName("summary")] - public string? Summary { get; set; } - /// Gets or sets the summary_count value. [Range((double)0, (double)long.MaxValue)] [JsonPropertyName("summary_count")] @@ -1792,6 +1788,44 @@ internal sealed class CommandsHandlePendingCommandRequest public string SessionId { get; set; } = string.Empty; } +/// RPC data type for CommandsRespondToQueuedCommand operations. +public sealed class CommandsRespondToQueuedCommandResult +{ + /// Whether the response was accepted (false if the requestId was not found or already resolved). + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +/// Result of the queued command execution. +/// Data type discriminated by handled. +public partial class QueuedCommandResult +{ + /// The boolean discriminator. + [JsonPropertyName("handled")] + public bool Handled { get; set; } + + /// If true, stop processing remaining queued items. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("stopProcessingQueue")] + public bool? StopProcessingQueue { get; set; } +} + +/// RPC data type for CommandsRespondToQueuedCommand operations. +internal sealed class CommandsRespondToQueuedCommandRequest +{ + /// Request ID from the queued command event. + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + /// Result of the queued command execution. + [JsonPropertyName("result")] + public QueuedCommandResult Result { get => field ??= new(); set; } + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// The elicitation response (accept with form values, decline, or cancel). public sealed class UIElicitationResponse { @@ -5214,6 +5248,13 @@ public async Task HandlePendingCommandAsync( var request = new CommandsHandlePendingCommandRequest { SessionId = _sessionId, RequestId = requestId, Error = error }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.commands.handlePendingCommand", [request], cancellationToken); } + + /// Calls "session.commands.respondToQueuedCommand". + public async Task RespondToQueuedCommandAsync(string requestId, QueuedCommandResult result, CancellationToken cancellationToken = default) + { + var request = new CommandsRespondToQueuedCommandRequest { SessionId = _sessionId, RequestId = requestId, Result = result }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.commands.respondToQueuedCommand", [request], cancellationToken); + } } /// Provides session-scoped Ui APIs. @@ -5506,6 +5547,8 @@ public static void RegisterClientSessionApiHandlers(JsonRpc rpc, FuncAssistant response containing text content, optional tool requests, and interaction metadata. public partial class AssistantMessageData { + /// Raw Anthropic content array with advisor blocks (server_tool_use, advisor_tool_result) for verbatim round-tripping. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("anthropicAdvisorBlocks")] + public object[]? AnthropicAdvisorBlocks { get; set; } + + /// Anthropic advisor model ID used for this response, for timeline display on replay. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("anthropicAdvisorModel")] + public string? AnthropicAdvisorModel { get; set; } + /// The assistant's text response content. [JsonPropertyName("content")] public required string Content { get; set; } @@ -1945,6 +1955,11 @@ public partial class AssistantMessageData [JsonPropertyName("messageId")] public required string MessageId { get; set; } + /// Model that produced this assistant message, if known. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("model")] + public string? Model { get; set; } + /// Actual output token count from the API response (completion_tokens), used for accurate token accounting. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("outputTokens")] @@ -4645,6 +4660,31 @@ public partial class UserToolSessionApprovalCustomTool : UserToolSessionApproval public required string ToolName { get; set; } } +/// The extension-management variant of . +public partial class UserToolSessionApprovalExtensionManagement : UserToolSessionApproval +{ + /// + [JsonIgnore] + public override string Kind => "extension-management"; + + /// Optional operation identifier. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("operation")] + public string? Operation { get; set; } +} + +/// The extension-permission-access variant of . +public partial class UserToolSessionApprovalExtensionPermissionAccess : UserToolSessionApproval +{ + /// + [JsonIgnore] + public override string Kind => "extension-permission-access"; + + /// Extension name. + [JsonPropertyName("extensionName")] + public required string ExtensionName { get; set; } +} + /// The approval to add as a session-scoped rule. /// Polymorphic base type discriminated by kind. [JsonPolymorphic( @@ -4656,6 +4696,8 @@ public partial class UserToolSessionApprovalCustomTool : UserToolSessionApproval [JsonDerivedType(typeof(UserToolSessionApprovalMcp), "mcp")] [JsonDerivedType(typeof(UserToolSessionApprovalMemory), "memory")] [JsonDerivedType(typeof(UserToolSessionApprovalCustomTool), "custom-tool")] +[JsonDerivedType(typeof(UserToolSessionApprovalExtensionManagement), "extension-management")] +[JsonDerivedType(typeof(UserToolSessionApprovalExtensionPermissionAccess), "extension-permission-access")] public partial class UserToolSessionApproval { /// The type discriminator. @@ -6753,6 +6795,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(UserToolSessionApproval))] [JsonSerializable(typeof(UserToolSessionApprovalCommands))] [JsonSerializable(typeof(UserToolSessionApprovalCustomTool))] +[JsonSerializable(typeof(UserToolSessionApprovalExtensionManagement))] +[JsonSerializable(typeof(UserToolSessionApprovalExtensionPermissionAccess))] [JsonSerializable(typeof(UserToolSessionApprovalMcp))] [JsonSerializable(typeof(UserToolSessionApprovalMemory))] [JsonSerializable(typeof(UserToolSessionApprovalRead))] diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 10e45fcf8..fa579ac6b 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -5,6 +5,7 @@ using Xunit; using System.Text.Json; using System.Text.Json.Serialization; +using GitHub.Copilot.SDK.Rpc; namespace GitHub.Copilot.SDK.Test.Unit; @@ -241,6 +242,28 @@ public void McpHttpServerConfig_CanSerializeOauthOptions_WithSdkOptions() Assert.Equal(3000, httpConfig.Timeout); } + [Fact] + public void QueuedCommandResult_SerializesHandledAsBoolean_WithSdkOptions() + { + var options = GetSerializerOptions(); + var original = new QueuedCommandResult + { + Handled = true, + StopProcessingQueue = false + }; + + var json = JsonSerializer.Serialize(original, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal(JsonValueKind.True, root.GetProperty("handled").ValueKind); + Assert.Equal(JsonValueKind.False, root.GetProperty("stopProcessingQueue").ValueKind); + + var deserialized = JsonSerializer.Deserialize("""{"handled":false}""", options); + Assert.NotNull(deserialized); + Assert.False(deserialized.Handled); + Assert.Null(deserialized.StopProcessingQueue); + } + private static JsonSerializerOptions GetSerializerOptions() { var prop = typeof(CopilotClient) diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 7dc1f3a32..5cb73195f 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -699,6 +699,10 @@ func (*AssistantReasoningData) sessionEventData() {} // Assistant response containing text content, optional tool requests, and interaction metadata type AssistantMessageData struct { + // Raw Anthropic content array with advisor blocks (server_tool_use, advisor_tool_result) for verbatim round-tripping + AnthropicAdvisorBlocks []any `json:"anthropicAdvisorBlocks,omitempty"` + // Anthropic advisor model ID used for this response, for timeline display on replay + AnthropicAdvisorModel *string `json:"anthropicAdvisorModel,omitempty"` // The assistant's text response content Content string `json:"content"` // Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. @@ -707,6 +711,8 @@ type AssistantMessageData struct { InteractionID *string `json:"interactionId,omitempty"` // Unique identifier for this assistant message MessageID string `json:"messageId"` + // Model that produced this assistant message, if known + Model *string `json:"model,omitempty"` // Actual output token count from the API response (completion_tokens), used for accurate token accounting OutputTokens *float64 `json:"outputTokens,omitempty"` // Tool call ID of the parent tool invocation when this event originates from a sub-agent @@ -2468,8 +2474,12 @@ type UserMessageAttachmentSelectionDetailsStart struct { type UserToolSessionApproval struct { // Command identifiers approved by the user CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` + // Extension name + ExtensionName *string `json:"extensionName,omitempty"` // Kind discriminator Kind UserToolSessionApprovalKind `json:"kind"` + // Optional operation identifier + Operation *string `json:"operation,omitempty"` // MCP server name ServerName *string `json:"serverName,omitempty"` // Optional MCP tool name, or null for all tools on the server @@ -2791,12 +2801,14 @@ const ( type UserToolSessionApprovalKind string const ( - UserToolSessionApprovalKindCommands UserToolSessionApprovalKind = "commands" - UserToolSessionApprovalKindCustomTool UserToolSessionApprovalKind = "custom-tool" - UserToolSessionApprovalKindMcp UserToolSessionApprovalKind = "mcp" - UserToolSessionApprovalKindMemory UserToolSessionApprovalKind = "memory" - UserToolSessionApprovalKindRead UserToolSessionApprovalKind = "read" - UserToolSessionApprovalKindWrite UserToolSessionApprovalKind = "write" + UserToolSessionApprovalKindCommands UserToolSessionApprovalKind = "commands" + UserToolSessionApprovalKindCustomTool UserToolSessionApprovalKind = "custom-tool" + UserToolSessionApprovalKindExtensionManagement UserToolSessionApprovalKind = "extension-management" + UserToolSessionApprovalKindExtensionPermissionAccess UserToolSessionApprovalKind = "extension-permission-access" + UserToolSessionApprovalKindMcp UserToolSessionApprovalKind = "mcp" + UserToolSessionApprovalKindMemory UserToolSessionApprovalKind = "memory" + UserToolSessionApprovalKindRead UserToolSessionApprovalKind = "read" + UserToolSessionApprovalKindWrite UserToolSessionApprovalKind = "write" ) // Hosting platform type of the repository (github or ado) diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index 87f323814..cc099b8ea 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -105,6 +105,19 @@ type CommandsHandlePendingCommandResult struct { Success bool `json:"success"` } +type CommandsRespondToQueuedCommandRequest struct { + // Request ID from the queued command event + RequestID string `json:"requestId"` + // Result of the queued command execution + Result QueuedCommandResult `json:"result"` +} + +type CommandsRespondToQueuedCommandResult struct { + // Whether the response was accepted (false if the requestId was not found or already + // resolved) + Success bool `json:"success"` +} + // Internal: ConnectRequest is an internal SDK API and is not part of the public surface. type ConnectRequest struct { // Connection token; required when the server was started with COPILOT_CONNECTION_TOKEN @@ -1109,6 +1122,26 @@ type PluginList struct { Plugins []Plugin `json:"plugins"` } +type QueuedCommandHandled struct { + // The command was handled + Handled bool `json:"handled"` + // If true, stop processing remaining queued items + StopProcessingQueue *bool `json:"stopProcessingQueue,omitempty"` +} + +type QueuedCommandNotHandled struct { + // The command was not handled + Handled bool `json:"handled"` +} + +// Result of the queued command execution +type QueuedCommandResult struct { + // Handled discriminator + Handled QueuedCommandResultHandled `json:"handled"` + // If true, stop processing remaining queued items + StopProcessingQueue *bool `json:"stopProcessingQueue,omitempty"` +} + // Experimental: RemoteDisableResult is part of an experimental API and may change or be // removed. type RemoteDisableResult struct { @@ -1980,7 +2013,6 @@ type WorkspacesGetWorkspaceResultWorkspace struct { Name *string `json:"name,omitempty"` RemoteSteerable *bool `json:"remote_steerable,omitempty"` Repository *string `json:"repository,omitempty"` - Summary *string `json:"summary,omitempty"` SummaryCount *int64 `json:"summary_count,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` UserNamed *bool `json:"user_named,omitempty"` @@ -2396,6 +2428,14 @@ const ( PermissionDecisionUserNotAvailableKindUserNotAvailable PermissionDecisionUserNotAvailableKind = "user-not-available" ) +// Handled discriminator for QueuedCommandResult. +type QueuedCommandResultHandled string + +const ( + QueuedCommandResultHandledFalse QueuedCommandResultHandled = "false" + QueuedCommandResultHandledTrue QueuedCommandResultHandled = "true" +) + // Error classification type SessionFsErrorCode string @@ -2995,6 +3035,23 @@ func (a *CommandsApi) HandlePendingCommand(ctx context.Context, params *Commands return &result, nil } +func (a *CommandsApi) RespondToQueuedCommand(ctx context.Context, params *CommandsRespondToQueuedCommandRequest) (*CommandsRespondToQueuedCommandResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + req["requestId"] = params.RequestID + req["result"] = params.Result + } + raw, err := a.client.Request("session.commands.respondToQueuedCommand", req) + if err != nil { + return nil, err + } + var result CommandsRespondToQueuedCommandResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Experimental: ExtensionsApi contains experimental APIs that may change or be removed. type ExtensionsApi sessionApi diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index ef3bb3fcf..a2f495463 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.44-3", + "@github/copilot": "^1.0.45", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-3.tgz", - "integrity": "sha512-hTsNxnmtKDK3ymh+c6LrsXWc9TbbubUHSxPuAKc4CX0d1c9iI1R4ybzS5Ihe+GxlozHIyFANd58gAg3QH3uCkA==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.45.tgz", + "integrity": "sha512-2QADgQcw/d0GFqTq2+nHwX152ZRvZxW0CHONG5d1RCs6YJtdr/GdbnMYYeRH2BiBIhnfkcvF50ImCRvsS5Tnwg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.44-3", - "@github/copilot-darwin-x64": "1.0.44-3", - "@github/copilot-linux-arm64": "1.0.44-3", - "@github/copilot-linux-x64": "1.0.44-3", - "@github/copilot-win32-arm64": "1.0.44-3", - "@github/copilot-win32-x64": "1.0.44-3" + "@github/copilot-darwin-arm64": "1.0.45", + "@github/copilot-darwin-x64": "1.0.45", + "@github/copilot-linux-arm64": "1.0.45", + "@github/copilot-linux-x64": "1.0.45", + "@github/copilot-win32-arm64": "1.0.45", + "@github/copilot-win32-x64": "1.0.45" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-3.tgz", - "integrity": "sha512-59IXG1lGCf0Ni4TjNL6bqBul6G2FPFX2vh6pMnoRVtHvRrtFILIBMNRMNQFrYZo3eXYBqYXwVHu4R8zfELpK6A==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.45.tgz", + "integrity": "sha512-gCJy1nOIWL5lpLFJTRk2Kz7bS30emkA4p4gM+PJ5/dOwNRBOyUO0/2f03/m5vYL4DNd/T47cFIN6s82gISAIYQ==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-3.tgz", - "integrity": "sha512-I+aR9rBNzwn3OOd5oIDIpnUCkCtj3mL183Ml1LLUcJ3utxwxKVInckW/Jg36jSD2PhkbNX8gzq0l3dv0td6QYQ==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.45.tgz", + "integrity": "sha512-nLzC7C0i/WAY+4FukHuONBDNeKUAqBBab3n36aEdpqxVDP5h2Tbzg2yShqav2blR7KDJL7YMcYTVFxmwfQj+yQ==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-3.tgz", - "integrity": "sha512-Agz4tMiM0hy9zIPPxKF0SSjMZSYuLYoGMe5KbvNEwTrAApLSrSW6k8yhlOTVCiRHEBsfh69We3LCOmc8hX8jVg==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.45.tgz", + "integrity": "sha512-MdRNZUNMrI0dpQ+DiDoZQ7AbitQp9eN7ir176Za2Kf7dkUxPwmio32yhRbBS81McU6vBw8cCzEZviwv/jc8buQ==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-3.tgz", - "integrity": "sha512-Ev5/uZKqSOr6l2tcy9Xqx354tuxo8qE42Cnnd6JynGrvVc1NpzF1Kt5eCzzjxdZiRtPo6AdDXS16oAN8CVxCrg==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.45.tgz", + "integrity": "sha512-xSRUjWA+wrSSjktJSjNtiS/47Cy0PviPejj7RUmtChsPfDJB8wW2iZ6NfpdiAomtxAz5xx4AjbjT1I4b1FqnwA==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-3.tgz", - "integrity": "sha512-bV2JeRRNYTiTfqmCVeXdPpgYe8KY58diJFZdhYSQnQDowjKvRn59K0RBEYDGK8//AjN+NfaGPGikMq3CQm61cA==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.45.tgz", + "integrity": "sha512-lhcTlKs7MWMzIXv21hUSpL4aFW49jqVhNrQKaB8sYk2nzvGRJvNwTcBS1Tn5ndXlPzQ9P/p9B6B5uwwmZ1vHHw==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-3.tgz", - "integrity": "sha512-qR6q16UDC6bIO8cde62z0wwVweH351RzN1KZgMjBqQYUBJw521K8VK7p64XK0tQWoTG8uyCuqqu5djQq/4Ek+g==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.45.tgz", + "integrity": "sha512-XYZ983NQmooVr/n+pCnHIorBmf1hd3o1rMlSAodwG/VFlQaydGoOs1F1NntxWBoFAND+eM6N4PZfw8M8sRayfA==", "cpu": [ "x64" ], @@ -1260,7 +1260,6 @@ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1300,7 +1299,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -1629,7 +1627,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1957,7 +1954,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2866,7 +2862,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3285,7 +3280,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3319,7 +3313,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3387,7 +3380,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/nodejs/package.json b/nodejs/package.json index 9ed91794f..a9cd04a70 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.44-3", + "@github/copilot": "^1.0.45", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index b4b177fc7..1a019f262 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.44-3", + "@github/copilot": "^1.0.45", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index b52fecce9..ce95493cb 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -12,6 +12,13 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; * via the `definition` "AuthInfoType". */ export type AuthInfoType = "hmac" | "env" | "user" | "gh-cli" | "api-key" | "token" | "copilot-api-token"; +/** + * Result of the queued command execution + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "QueuedCommandResult". + */ +export type QueuedCommandResult = QueuedCommandHandled | QueuedCommandNotHandled; /** * Server transport type: stdio, http, sse, or memory (local configs are normalized to stdio) * @@ -399,6 +406,39 @@ export interface CommandsHandlePendingCommandResult { success: boolean; } +export interface CommandsRespondToQueuedCommandRequest { + /** + * Request ID from the queued command event + */ + requestId: string; + result: QueuedCommandResult; +} + +export interface QueuedCommandHandled { + /** + * The command was handled + */ + handled: true; + /** + * If true, stop processing remaining queued items + */ + stopProcessingQueue?: boolean; +} + +export interface QueuedCommandNotHandled { + /** + * The command was not handled + */ + handled: false; +} + +export interface CommandsRespondToQueuedCommandResult { + /** + * Whether the response was accepted (false if the requestId was not found or already resolved) + */ + success: boolean; +} + /** @internal */ export interface ConnectRequest { /** @@ -2520,7 +2560,6 @@ export interface WorkspacesGetWorkspaceResult { branch?: string; name?: string; user_named?: boolean; - summary?: string; summary_count?: number; created_at?: string; updated_at?: string; @@ -2752,6 +2791,8 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin commands: { handlePendingCommand: async (params: CommandsHandlePendingCommandRequest): Promise => connection.sendRequest("session.commands.handlePendingCommand", { sessionId, ...params }), + respondToQueuedCommand: async (params: CommandsRespondToQueuedCommandRequest): Promise => + connection.sendRequest("session.commands.respondToQueuedCommand", { sessionId, ...params }), }, ui: { elicitation: async (params: UIElicitationRequest): Promise => diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index a9155ea48..6f2bab31c 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -240,7 +240,9 @@ export type UserToolSessionApproval = | UserToolSessionApprovalWrite | UserToolSessionApprovalMcp | UserToolSessionApprovalMemory - | UserToolSessionApprovalCustomTool; + | UserToolSessionApprovalCustomTool + | UserToolSessionApprovalExtensionManagement + | UserToolSessionApprovalExtensionPermissionAccess; /** * Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to "form" when absent. */ @@ -1972,6 +1974,14 @@ export interface AssistantMessageEvent { * Assistant response containing text content, optional tool requests, and interaction metadata */ export interface AssistantMessageData { + /** + * Raw Anthropic content array with advisor blocks (server_tool_use, advisor_tool_result) for verbatim round-tripping + */ + anthropicAdvisorBlocks?: unknown[]; + /** + * Anthropic advisor model ID used for this response, for timeline display on replay + */ + anthropicAdvisorModel?: string; /** * The assistant's text response content */ @@ -1988,6 +1998,10 @@ export interface AssistantMessageData { * Unique identifier for this assistant message */ messageId: string; + /** + * Model that produced this assistant message, if known + */ + model?: string; /** * Actual output token count from the API response (completion_tokens), used for accurate token accounting */ @@ -4142,6 +4156,26 @@ export interface UserToolSessionApprovalCustomTool { */ toolName: string; } +export interface UserToolSessionApprovalExtensionManagement { + /** + * Extension management approval kind + */ + kind: "extension-management"; + /** + * Optional operation identifier + */ + operation?: string; +} +export interface UserToolSessionApprovalExtensionPermissionAccess { + /** + * Extension name + */ + extensionName: string; + /** + * Extension permission access approval kind + */ + kind: "extension-permission-access"; +} export interface PermissionApprovedForLocation { approval: UserToolSessionApproval; /** diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index ca8fbe494..91d3f7474 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -245,6 +245,50 @@ def to_dict(self) -> dict: result["success"] = from_bool(self.success) return result +@dataclass +class QueuedCommandResult: + """Result of the queued command execution""" + + handled: bool + """The command was handled + + The command was not handled + """ + stop_processing_queue: bool | None = None + """If true, stop processing remaining queued items""" + + @staticmethod + def from_dict(obj: Any) -> 'QueuedCommandResult': + assert isinstance(obj, dict) + handled = from_bool(obj.get("handled")) + stop_processing_queue = from_union([from_bool, from_none], obj.get("stopProcessingQueue")) + return QueuedCommandResult(handled, stop_processing_queue) + + def to_dict(self) -> dict: + result: dict = {} + result["handled"] = from_bool(self.handled) + if self.stop_processing_queue is not None: + result["stopProcessingQueue"] = from_union([from_bool, from_none], self.stop_processing_queue) + return result + +@dataclass +class CommandsRespondToQueuedCommandResult: + success: bool + """Whether the response was accepted (false if the requestId was not found or already + resolved) + """ + + @staticmethod + def from_dict(obj: Any) -> 'CommandsRespondToQueuedCommandResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return CommandsRespondToQueuedCommandResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + # Internal: this type is an internal SDK API and is not part of the public surface. @dataclass class ConnectRequest: @@ -1351,6 +1395,44 @@ def to_dict(self) -> dict: result["version"] = from_union([from_str, from_none], self.version) return result +@dataclass +class QueuedCommandHandled: + handled: bool + """The command was handled""" + + stop_processing_queue: bool | None = None + """If true, stop processing remaining queued items""" + + @staticmethod + def from_dict(obj: Any) -> 'QueuedCommandHandled': + assert isinstance(obj, dict) + handled = from_bool(obj.get("handled")) + stop_processing_queue = from_union([from_bool, from_none], obj.get("stopProcessingQueue")) + return QueuedCommandHandled(handled, stop_processing_queue) + + def to_dict(self) -> dict: + result: dict = {} + result["handled"] = from_bool(self.handled) + if self.stop_processing_queue is not None: + result["stopProcessingQueue"] = from_union([from_bool, from_none], self.stop_processing_queue) + return result + +@dataclass +class QueuedCommandNotHandled: + handled: bool + """The command was not handled""" + + @staticmethod + def from_dict(obj: Any) -> 'QueuedCommandNotHandled': + assert isinstance(obj, dict) + handled = from_bool(obj.get("handled")) + return QueuedCommandNotHandled(handled) + + def to_dict(self) -> dict: + result: dict = {} + result["handled"] = from_bool(self.handled) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class RemoteEnableResult: @@ -2677,6 +2759,27 @@ def to_dict(self) -> dict: result["statusMessage"] = from_union([from_str, from_none], self.status_message) return result +@dataclass +class CommandsRespondToQueuedCommandRequest: + request_id: str + """Request ID from the queued command event""" + + result: QueuedCommandResult + """Result of the queued command execution""" + + @staticmethod + def from_dict(obj: Any) -> 'CommandsRespondToQueuedCommandRequest': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + result = QueuedCommandResult.from_dict(obj.get("result")) + return CommandsRespondToQueuedCommandRequest(request_id, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + result["result"] = to_class(QueuedCommandResult, self.result) + return result + @dataclass class DiscoveredMCPServer: enabled: bool @@ -4372,7 +4475,6 @@ class Workspace: name: str | None = None remote_steerable: bool | None = None repository: str | None = None - summary: str | None = None summary_count: int | None = None updated_at: datetime | None = None user_named: bool | None = None @@ -4393,11 +4495,10 @@ def from_dict(obj: Any) -> 'Workspace': name = from_union([from_str, from_none], obj.get("name")) remote_steerable = from_union([from_bool, from_none], obj.get("remote_steerable")) repository = from_union([from_str, from_none], obj.get("repository")) - summary = from_union([from_str, from_none], obj.get("summary")) summary_count = from_union([from_int, from_none], obj.get("summary_count")) updated_at = from_union([from_datetime, from_none], obj.get("updated_at")) user_named = from_union([from_bool, from_none], obj.get("user_named")) - return Workspace(id, branch, chronicle_sync_dismissed, created_at, cwd, git_root, host_type, mc_last_event_id, mc_session_id, mc_task_id, name, remote_steerable, repository, summary, summary_count, updated_at, user_named) + return Workspace(id, branch, chronicle_sync_dismissed, created_at, cwd, git_root, host_type, mc_last_event_id, mc_session_id, mc_task_id, name, remote_steerable, repository, summary_count, updated_at, user_named) def to_dict(self) -> dict: result: dict = {} @@ -4426,8 +4527,6 @@ def to_dict(self) -> dict: result["remote_steerable"] = from_union([from_bool, from_none], self.remote_steerable) if self.repository is not None: result["repository"] = from_union([from_str, from_none], self.repository) - if self.summary is not None: - result["summary"] = from_union([from_str, from_none], self.summary) if self.summary_count is not None: result["summary_count"] = from_union([from_int, from_none], self.summary_count) if self.updated_at is not None: @@ -5779,6 +5878,8 @@ class RPC: auth_info_type: AuthInfoType commands_handle_pending_command_request: CommandsHandlePendingCommandRequest commands_handle_pending_command_result: CommandsHandlePendingCommandResult + commands_respond_to_queued_command_request: CommandsRespondToQueuedCommandRequest + commands_respond_to_queued_command_result: CommandsRespondToQueuedCommandResult connect_request: ConnectRequest connect_result: ConnectResult current_model: CurrentModel @@ -5901,6 +6002,9 @@ class RPC: plan_update_request: PlanUpdateRequest plugin: Plugin plugin_list: PluginList + queued_command_handled: QueuedCommandHandled + queued_command_not_handled: QueuedCommandNotHandled + queued_command_result: QueuedCommandResult remote_enable_result: RemoteEnableResult server_skill: ServerSkill server_skill_list: ServerSkillList @@ -6014,6 +6118,8 @@ def from_dict(obj: Any) -> 'RPC': auth_info_type = AuthInfoType(obj.get("AuthInfoType")) commands_handle_pending_command_request = CommandsHandlePendingCommandRequest.from_dict(obj.get("CommandsHandlePendingCommandRequest")) commands_handle_pending_command_result = CommandsHandlePendingCommandResult.from_dict(obj.get("CommandsHandlePendingCommandResult")) + commands_respond_to_queued_command_request = CommandsRespondToQueuedCommandRequest.from_dict(obj.get("CommandsRespondToQueuedCommandRequest")) + commands_respond_to_queued_command_result = CommandsRespondToQueuedCommandResult.from_dict(obj.get("CommandsRespondToQueuedCommandResult")) connect_request = ConnectRequest.from_dict(obj.get("ConnectRequest")) connect_result = ConnectResult.from_dict(obj.get("ConnectResult")) current_model = CurrentModel.from_dict(obj.get("CurrentModel")) @@ -6136,6 +6242,9 @@ def from_dict(obj: Any) -> 'RPC': plan_update_request = PlanUpdateRequest.from_dict(obj.get("PlanUpdateRequest")) plugin = Plugin.from_dict(obj.get("Plugin")) plugin_list = PluginList.from_dict(obj.get("PluginList")) + queued_command_handled = QueuedCommandHandled.from_dict(obj.get("QueuedCommandHandled")) + queued_command_not_handled = QueuedCommandNotHandled.from_dict(obj.get("QueuedCommandNotHandled")) + queued_command_result = QueuedCommandResult.from_dict(obj.get("QueuedCommandResult")) remote_enable_result = RemoteEnableResult.from_dict(obj.get("RemoteEnableResult")) server_skill = ServerSkill.from_dict(obj.get("ServerSkill")) server_skill_list = ServerSkillList.from_dict(obj.get("ServerSkillList")) @@ -6233,7 +6342,7 @@ def from_dict(obj: Any) -> 'RPC': workspaces_list_files_result = WorkspacesListFilesResult.from_dict(obj.get("WorkspacesListFilesResult")) workspaces_read_file_request = WorkspacesReadFileRequest.from_dict(obj.get("WorkspacesReadFileRequest")) workspaces_read_file_result = WorkspacesReadFileResult.from_dict(obj.get("WorkspacesReadFileResult")) - return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, remote_enable_result, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) + return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, queued_command_handled, queued_command_not_handled, queued_command_result, remote_enable_result, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) def to_dict(self) -> dict: result: dict = {} @@ -6249,6 +6358,8 @@ def to_dict(self) -> dict: result["AuthInfoType"] = to_enum(AuthInfoType, self.auth_info_type) result["CommandsHandlePendingCommandRequest"] = to_class(CommandsHandlePendingCommandRequest, self.commands_handle_pending_command_request) result["CommandsHandlePendingCommandResult"] = to_class(CommandsHandlePendingCommandResult, self.commands_handle_pending_command_result) + result["CommandsRespondToQueuedCommandRequest"] = to_class(CommandsRespondToQueuedCommandRequest, self.commands_respond_to_queued_command_request) + result["CommandsRespondToQueuedCommandResult"] = to_class(CommandsRespondToQueuedCommandResult, self.commands_respond_to_queued_command_result) result["ConnectRequest"] = to_class(ConnectRequest, self.connect_request) result["ConnectResult"] = to_class(ConnectResult, self.connect_result) result["CurrentModel"] = to_class(CurrentModel, self.current_model) @@ -6371,6 +6482,9 @@ def to_dict(self) -> dict: result["PlanUpdateRequest"] = to_class(PlanUpdateRequest, self.plan_update_request) result["Plugin"] = to_class(Plugin, self.plugin) result["PluginList"] = to_class(PluginList, self.plugin_list) + result["QueuedCommandHandled"] = to_class(QueuedCommandHandled, self.queued_command_handled) + result["QueuedCommandNotHandled"] = to_class(QueuedCommandNotHandled, self.queued_command_not_handled) + result["QueuedCommandResult"] = to_class(QueuedCommandResult, self.queued_command_result) result["RemoteEnableResult"] = to_class(RemoteEnableResult, self.remote_enable_result) result["ServerSkill"] = to_class(ServerSkill, self.server_skill) result["ServerSkillList"] = to_class(ServerSkillList, self.server_skill_list) @@ -6919,6 +7033,11 @@ async def handle_pending_command(self, params: CommandsHandlePendingCommandReque params_dict["sessionId"] = self._session_id return CommandsHandlePendingCommandResult.from_dict(await self._client.request("session.commands.handlePendingCommand", params_dict, **_timeout_kwargs(timeout))) + async def respond_to_queued_command(self, params: CommandsRespondToQueuedCommandRequest, *, timeout: float | None = None) -> CommandsRespondToQueuedCommandResult: + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return CommandsRespondToQueuedCommandResult.from_dict(await self._client.request("session.commands.respondToQueuedCommand", params_dict, **_timeout_kwargs(timeout))) + class UiApi: def __init__(self, client: "JsonRpcClient", session_id: str): diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index b55bd921a..4f3e791ce 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -300,8 +300,11 @@ class AssistantMessageData: "Assistant response containing text content, optional tool requests, and interaction metadata" content: str message_id: str + anthropic_advisor_blocks: list[Any] | None = None + anthropic_advisor_model: str | None = None encrypted_content: str | None = None interaction_id: str | None = None + model: str | None = None output_tokens: float | None = None # Deprecated: this field is deprecated. parent_tool_call_id: str | None = None @@ -317,8 +320,11 @@ def from_dict(obj: Any) -> "AssistantMessageData": assert isinstance(obj, dict) content = from_str(obj.get("content")) message_id = from_str(obj.get("messageId")) + anthropic_advisor_blocks = from_union([from_none, lambda x: from_list(lambda x: x, x)], obj.get("anthropicAdvisorBlocks")) + anthropic_advisor_model = from_union([from_none, from_str], obj.get("anthropicAdvisorModel")) encrypted_content = from_union([from_none, from_str], obj.get("encryptedContent")) interaction_id = from_union([from_none, from_str], obj.get("interactionId")) + model = from_union([from_none, from_str], obj.get("model")) output_tokens = from_union([from_none, from_float], obj.get("outputTokens")) parent_tool_call_id = from_union([from_none, from_str], obj.get("parentToolCallId")) phase = from_union([from_none, from_str], obj.get("phase")) @@ -330,8 +336,11 @@ def from_dict(obj: Any) -> "AssistantMessageData": return AssistantMessageData( content=content, message_id=message_id, + anthropic_advisor_blocks=anthropic_advisor_blocks, + anthropic_advisor_model=anthropic_advisor_model, encrypted_content=encrypted_content, interaction_id=interaction_id, + model=model, output_tokens=output_tokens, parent_tool_call_id=parent_tool_call_id, phase=phase, @@ -346,10 +355,16 @@ def to_dict(self) -> dict: result: dict = {} result["content"] = from_str(self.content) result["messageId"] = from_str(self.message_id) + if self.anthropic_advisor_blocks is not None: + result["anthropicAdvisorBlocks"] = from_union([from_none, lambda x: from_list(lambda x: x, x)], self.anthropic_advisor_blocks) + if self.anthropic_advisor_model is not None: + result["anthropicAdvisorModel"] = from_union([from_none, from_str], self.anthropic_advisor_model) if self.encrypted_content is not None: result["encryptedContent"] = from_union([from_none, from_str], self.encrypted_content) if self.interaction_id is not None: result["interactionId"] = from_union([from_none, from_str], self.interaction_id) + if self.model is not None: + result["model"] = from_union([from_none, from_str], self.model) if self.output_tokens is not None: result["outputTokens"] = from_union([from_none, to_float], self.output_tokens) if self.parent_tool_call_id is not None: @@ -4542,6 +4557,8 @@ class UserToolSessionApproval: "The approval to add as a session-scoped rule" kind: UserToolSessionApprovalKind command_identifiers: list[str] | None = None + extension_name: str | None = None + operation: str | None = None server_name: str | None = None tool_name: str | None = None @@ -4550,11 +4567,15 @@ def from_dict(obj: Any) -> "UserToolSessionApproval": assert isinstance(obj, dict) kind = parse_enum(UserToolSessionApprovalKind, obj.get("kind")) command_identifiers = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("commandIdentifiers")) + extension_name = from_union([from_none, from_str], obj.get("extensionName")) + operation = from_union([from_none, from_str], obj.get("operation")) server_name = from_union([from_none, from_str], obj.get("serverName")) tool_name = from_union([from_none, from_str], obj.get("toolName")) return UserToolSessionApproval( kind=kind, command_identifiers=command_identifiers, + extension_name=extension_name, + operation=operation, server_name=server_name, tool_name=tool_name, ) @@ -4564,6 +4585,10 @@ def to_dict(self) -> dict: result["kind"] = to_enum(UserToolSessionApprovalKind, self.kind) if self.command_identifiers is not None: result["commandIdentifiers"] = from_union([from_none, lambda x: from_list(from_str, x)], self.command_identifiers) + if self.extension_name is not None: + result["extensionName"] = from_union([from_none, from_str], self.extension_name) + if self.operation is not None: + result["operation"] = from_union([from_none, from_str], self.operation) if self.server_name is not None: result["serverName"] = from_union([from_none, from_str], self.server_name) if self.tool_name is not None: @@ -4854,6 +4879,8 @@ class UserToolSessionApprovalKind(Enum): MCP = "mcp" MEMORY = "memory" CUSTOM_TOOL = "custom-tool" + EXTENSION_MANAGEMENT = "extension-management" + EXTENSION_PERMISSION_ACCESS = "extension-permission-access" class WorkingDirectoryContextHostType(Enum): diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index fc5deb6c5..1c5ca509d 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -130,6 +130,9 @@ pub mod rpc_methods { pub const SESSION_TOOLS_HANDLEPENDINGTOOLCALL: &str = "session.tools.handlePendingToolCall"; /// `session.commands.handlePendingCommand` pub const SESSION_COMMANDS_HANDLEPENDINGCOMMAND: &str = "session.commands.handlePendingCommand"; + /// `session.commands.respondToQueuedCommand` + pub const SESSION_COMMANDS_RESPONDTOQUEUEDCOMMAND: &str = + "session.commands.respondToQueuedCommand"; /// `session.ui.elicitation` pub const SESSION_UI_ELICITATION: &str = "session.ui.elicitation"; /// `session.ui.handlePendingElicitation` @@ -283,6 +286,22 @@ pub struct CommandsHandlePendingCommandResult { pub success: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandsRespondToQueuedCommandRequest { + /// Request ID from the queued command event + pub request_id: RequestId, + /// Result of the queued command execution + pub result: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandsRespondToQueuedCommandResult { + /// Whether the response was accepted (false if the requestId was not found or already resolved) + pub success: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConnectRequest { @@ -1316,6 +1335,23 @@ pub struct PluginList { pub plugins: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueuedCommandHandled { + /// The command was handled + pub handled: bool, + /// If true, stop processing remaining queued items + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_processing_queue: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueuedCommandNotHandled { + /// The command was not handled + pub handled: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteEnableResult { @@ -2209,8 +2245,6 @@ pub struct WorkspacesGetWorkspaceResultWorkspace { pub remote_steerable: Option, #[serde(skip_serializing_if = "Option::is_none")] pub repository: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, #[serde(rename = "summary_count", skip_serializing_if = "Option::is_none")] pub summary_count: Option, #[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")] @@ -2418,8 +2452,6 @@ pub struct SessionWorkspacesGetWorkspaceResultWorkspace { pub remote_steerable: Option, #[serde(skip_serializing_if = "Option::is_none")] pub repository: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, #[serde(rename = "summary_count", skip_serializing_if = "Option::is_none")] pub summary_count: Option, #[serde(rename = "updated_at", skip_serializing_if = "Option::is_none")] @@ -2684,6 +2716,13 @@ pub struct SessionCommandsHandlePendingCommandResult { pub success: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCommandsRespondToQueuedCommandResult { + /// Whether the response was accepted (false if the requestId was not found or already resolved) + pub success: bool, +} + /// The elicitation response (accept with form values, decline, or cancel) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index ec958708c..5a0b48434 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -664,6 +664,24 @@ impl<'a> SessionRpcCommands<'a> { .await?; Ok(serde_json::from_value(_value)?) } + + /// Wire method: `session.commands.respondToQueuedCommand`. + pub async fn respond_to_queued_command( + &self, + params: CommandsRespondToQueuedCommandRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_COMMANDS_RESPONDTOQUEUEDCOMMAND, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } } /// `session.extensions.*` RPCs. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index d27ac5a62..85c523940 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -1089,6 +1089,12 @@ pub struct AssistantMessageToolRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AssistantMessageData { + /// Raw Anthropic content array with advisor blocks (server_tool_use, advisor_tool_result) for verbatim round-tripping + #[serde(default)] + pub anthropic_advisor_blocks: Vec, + /// Anthropic advisor model ID used for this response, for timeline display on replay + #[serde(skip_serializing_if = "Option::is_none")] + pub anthropic_advisor_model: Option, /// The assistant's text response content pub content: String, /// Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. @@ -1099,6 +1105,9 @@ pub struct AssistantMessageData { pub interaction_id: Option, /// Unique identifier for this assistant message pub message_id: String, + /// Model that produced this assistant message, if known + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, /// Actual output token count from the API response (completion_tokens), used for accurate token accounting #[serde(skip_serializing_if = "Option::is_none")] pub output_tokens: Option, @@ -2098,6 +2107,25 @@ pub struct UserToolSessionApprovalCustomTool { pub tool_name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserToolSessionApprovalExtensionManagement { + /// Extension management approval kind + pub kind: UserToolSessionApprovalExtensionManagementKind, + /// Optional operation identifier + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserToolSessionApprovalExtensionPermissionAccess { + /// Extension name + pub extension_name: String, + /// Extension permission access approval kind + pub kind: UserToolSessionApprovalExtensionPermissionAccessKind, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PermissionApprovedForSession { @@ -3042,6 +3070,20 @@ pub enum UserToolSessionApprovalCustomToolKind { CustomTool, } +/// Extension management approval kind +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum UserToolSessionApprovalExtensionManagementKind { + #[serde(rename = "extension-management")] + ExtensionManagement, +} + +/// Extension permission access approval kind +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum UserToolSessionApprovalExtensionPermissionAccessKind { + #[serde(rename = "extension-permission-access")] + ExtensionPermissionAccess, +} + /// The approval to add as a session-scoped rule #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -3052,6 +3094,8 @@ pub enum UserToolSessionApproval { Mcp(UserToolSessionApprovalMcp), Memory(UserToolSessionApprovalMemory), CustomTool(UserToolSessionApprovalCustomTool), + ExtensionManagement(UserToolSessionApprovalExtensionManagement), + ExtensionPermissionAccess(UserToolSessionApprovalExtensionPermissionAccess), } /// Approved and remembered for the rest of the session diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index b624abcc3..f82a5cd03 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -416,10 +416,20 @@ function extractEventVariants(schema: JSONSchema7): EventVariant[] { }); } +interface DiscriminatorVariant { + value: unknown; + schema: JSONSchema7; +} + +interface DiscriminatorInfo { + property: string; + mapping: Map; +} + /** * Find a discriminator property shared by all variants in an anyOf. */ -function findDiscriminator(variants: JSONSchema7[]): { property: string; mapping: Map } | null { +function findDiscriminator(variants: JSONSchema7[]): DiscriminatorInfo | null { if (variants.length === 0) return null; const firstVariant = variants[0]; if (!firstVariant.properties) return null; @@ -429,7 +439,7 @@ function findDiscriminator(variants: JSONSchema7[]): { property: string; mapping const schema = propSchema as JSONSchema7; if (schema.const === undefined) continue; - const mapping = new Map(); + const mapping = new Map(); let isValidDiscriminator = true; for (const variant of variants) { @@ -438,7 +448,9 @@ function findDiscriminator(variants: JSONSchema7[]): { property: string; mapping if (typeof variantProp !== "object") { isValidDiscriminator = false; break; } const variantSchema = variantProp as JSONSchema7; if (variantSchema.const === undefined) { isValidDiscriminator = false; break; } - mapping.set(String(variantSchema.const), variant); + const key = String(variantSchema.const); + if (mapping.has(key)) { isValidDiscriminator = false; break; } + mapping.set(key, { value: variantSchema.const, schema: variant }); } if (isValidDiscriminator && mapping.size === variants.length) { @@ -459,6 +471,94 @@ type PropertyTypeResolver = ( enumOutput: string[] ) => string; +function isBooleanDiscriminator(discriminatorInfo: DiscriminatorInfo): boolean { + return Array.from(discriminatorInfo.mapping.values()).every((variant) => typeof variant.value === "boolean"); +} + +function escapeCSharpStringLiteral(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function generateDiscriminatedUnionClass( + baseClassName: string, + discriminatorInfo: DiscriminatorInfo, + variants: JSONSchema7[], + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[], + description?: string, + propertyResolver?: PropertyTypeResolver +): string { + if (isBooleanDiscriminator(discriminatorInfo)) { + return generateFlattenedBooleanDiscriminatedClass(baseClassName, discriminatorInfo, knownTypes, nestedClasses, enumOutput, description, propertyResolver); + } + + return generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput, description, propertyResolver); +} + +function generateFlattenedBooleanDiscriminatedClass( + baseClassName: string, + discriminatorInfo: DiscriminatorInfo, + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[], + description?: string, + propertyResolver?: PropertyTypeResolver +): string { + const resolver = propertyResolver ?? resolveSessionPropertyType; + const renamedBase = applyTypeRename(baseClassName); + const lines: string[] = []; + const flattenedProperties = new Map(); + const variants = Array.from(discriminatorInfo.mapping.values()).map((variant) => variant.schema); + + for (const variant of variants) { + const required = new Set(variant.required || []); + for (const [propName, propSchema] of Object.entries(variant.properties || {})) { + if (typeof propSchema !== "object" || propName === discriminatorInfo.property) continue; + + const existing = flattenedProperties.get(propName); + if (existing) { + existing.variantCount++; + if (required.has(propName)) existing.requiredCount++; + continue; + } + + flattenedProperties.set(propName, { + schema: propSchema as JSONSchema7, + requiredCount: required.has(propName) ? 1 : 0, + variantCount: 1, + }); + } + } + + lines.push(...xmlDocCommentWithFallback(description, `Data type discriminated by ${escapeXml(discriminatorInfo.property)}.`, "")); + lines.push(`public partial class ${renamedBase}`); + lines.push(`{`); + lines.push(` /// The boolean discriminator.`); + lines.push(` [JsonPropertyName("${discriminatorInfo.property}")]`); + lines.push(` public bool ${toPascalCase(discriminatorInfo.property)} { get; set; }`); + + const propertyEntries = Array.from(flattenedProperties.entries()).sort(([a], [b]) => a.localeCompare(b)); + for (const [propName, info] of propertyEntries) { + const isReq = info.variantCount === variants.length && info.requiredCount === variants.length; + const csharpName = toPascalCase(propName); + const csharpType = resolver(info.schema, renamedBase, csharpName, isReq, knownTypes, nestedClasses, enumOutput); + + lines.push(""); + lines.push(...xmlDocPropertyComment(info.schema.description, propName, " ")); + lines.push(...emitDataAnnotations(info.schema, " ")); + if (isSchemaDeprecated(info.schema)) lines.push(` [Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isDurationProperty(info.schema)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`); + if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${propName}")]`); + const reqMod = isReq && !csharpType.endsWith("?") ? "required " : ""; + lines.push(` public ${reqMod}${csharpType} ${csharpName} { get; set; }`); + } + + lines.push(`}`); + return lines.join("\n"); +} + /** * Generate a polymorphic base class and derived classes for a discriminated union. */ @@ -482,9 +582,10 @@ function generatePolymorphicClasses( lines.push(` TypeDiscriminatorPropertyName = "${discriminatorProperty}",`); lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]`); - for (const [constValue] of discriminatorInfo.mapping) { + for (const { value } of discriminatorInfo.mapping.values()) { + const constValue = String(value); const derivedClassName = applyTypeRename(`${baseClassName}${toPascalCase(constValue)}`); - lines.push(`[JsonDerivedType(typeof(${derivedClassName}), "${constValue}")]`); + lines.push(`[JsonDerivedType(typeof(${derivedClassName}), "${escapeCSharpStringLiteral(constValue)}")]`); } lines.push(`public partial class ${renamedBase}`); @@ -495,9 +596,10 @@ function generatePolymorphicClasses( lines.push(`}`); lines.push(""); - for (const [constValue, variant] of discriminatorInfo.mapping) { + for (const { value, schema } of discriminatorInfo.mapping.values()) { + const constValue = String(value); const derivedClassName = applyTypeRename(`${baseClassName}${toPascalCase(constValue)}`); - const derivedCode = generateDerivedClass(derivedClassName, renamedBase, discriminatorProperty, constValue, variant, knownTypes, nestedClasses, enumOutput, resolver); + const derivedCode = generateDerivedClass(derivedClassName, renamedBase, discriminatorProperty, constValue, schema, knownTypes, nestedClasses, enumOutput, resolver); nestedClasses.set(derivedClassName, derivedCode); } @@ -641,7 +743,7 @@ function resolveSessionPropertyType( const hasNull = propSchema.anyOf.length > nonNull.length; const baseClassName = (propSchema.title as string) ?? `${parentClassName}${propName}`; const renamedBase = applyTypeRename(baseClassName); - const polymorphicCode = generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput, propSchema.description); + const polymorphicCode = generateDiscriminatedUnionClass(baseClassName, discriminatorInfo, variants, knownTypes, nestedClasses, enumOutput, propSchema.description); nestedClasses.set(renamedBase, polymorphicCode); return isRequired && !hasNull ? renamedBase : `${renamedBase}?`; } @@ -981,7 +1083,7 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam } return result; }; - const polymorphicCode = generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, rpcKnownTypes, nestedMap, rpcEnumOutput, schema.description, rpcPropertyResolver); + const polymorphicCode = generateDiscriminatedUnionClass(baseClassName, discriminatorInfo, variants, rpcKnownTypes, nestedMap, rpcEnumOutput, schema.description, rpcPropertyResolver); classes.push(polymorphicCode); for (const nested of nestedMap.values()) classes.push(nested); } diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 69a491670..2b06abfa4 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.44-3", + "@github/copilot": "^1.0.45", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -464,27 +464,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.44-3.tgz", - "integrity": "sha512-hTsNxnmtKDK3ymh+c6LrsXWc9TbbubUHSxPuAKc4CX0d1c9iI1R4ybzS5Ihe+GxlozHIyFANd58gAg3QH3uCkA==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.45.tgz", + "integrity": "sha512-2QADgQcw/d0GFqTq2+nHwX152ZRvZxW0CHONG5d1RCs6YJtdr/GdbnMYYeRH2BiBIhnfkcvF50ImCRvsS5Tnwg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.44-3", - "@github/copilot-darwin-x64": "1.0.44-3", - "@github/copilot-linux-arm64": "1.0.44-3", - "@github/copilot-linux-x64": "1.0.44-3", - "@github/copilot-win32-arm64": "1.0.44-3", - "@github/copilot-win32-x64": "1.0.44-3" + "@github/copilot-darwin-arm64": "1.0.45", + "@github/copilot-darwin-x64": "1.0.45", + "@github/copilot-linux-arm64": "1.0.45", + "@github/copilot-linux-x64": "1.0.45", + "@github/copilot-win32-arm64": "1.0.45", + "@github/copilot-win32-x64": "1.0.45" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.44-3.tgz", - "integrity": "sha512-59IXG1lGCf0Ni4TjNL6bqBul6G2FPFX2vh6pMnoRVtHvRrtFILIBMNRMNQFrYZo3eXYBqYXwVHu4R8zfELpK6A==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.45.tgz", + "integrity": "sha512-gCJy1nOIWL5lpLFJTRk2Kz7bS30emkA4p4gM+PJ5/dOwNRBOyUO0/2f03/m5vYL4DNd/T47cFIN6s82gISAIYQ==", "cpu": [ "arm64" ], @@ -499,9 +499,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.44-3.tgz", - "integrity": "sha512-I+aR9rBNzwn3OOd5oIDIpnUCkCtj3mL183Ml1LLUcJ3utxwxKVInckW/Jg36jSD2PhkbNX8gzq0l3dv0td6QYQ==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.45.tgz", + "integrity": "sha512-nLzC7C0i/WAY+4FukHuONBDNeKUAqBBab3n36aEdpqxVDP5h2Tbzg2yShqav2blR7KDJL7YMcYTVFxmwfQj+yQ==", "cpu": [ "x64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.44-3.tgz", - "integrity": "sha512-Agz4tMiM0hy9zIPPxKF0SSjMZSYuLYoGMe5KbvNEwTrAApLSrSW6k8yhlOTVCiRHEBsfh69We3LCOmc8hX8jVg==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.45.tgz", + "integrity": "sha512-MdRNZUNMrI0dpQ+DiDoZQ7AbitQp9eN7ir176Za2Kf7dkUxPwmio32yhRbBS81McU6vBw8cCzEZviwv/jc8buQ==", "cpu": [ "arm64" ], @@ -533,9 +533,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.44-3.tgz", - "integrity": "sha512-Ev5/uZKqSOr6l2tcy9Xqx354tuxo8qE42Cnnd6JynGrvVc1NpzF1Kt5eCzzjxdZiRtPo6AdDXS16oAN8CVxCrg==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.45.tgz", + "integrity": "sha512-xSRUjWA+wrSSjktJSjNtiS/47Cy0PviPejj7RUmtChsPfDJB8wW2iZ6NfpdiAomtxAz5xx4AjbjT1I4b1FqnwA==", "cpu": [ "x64" ], @@ -550,9 +550,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.44-3.tgz", - "integrity": "sha512-bV2JeRRNYTiTfqmCVeXdPpgYe8KY58diJFZdhYSQnQDowjKvRn59K0RBEYDGK8//AjN+NfaGPGikMq3CQm61cA==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.45.tgz", + "integrity": "sha512-lhcTlKs7MWMzIXv21hUSpL4aFW49jqVhNrQKaB8sYk2nzvGRJvNwTcBS1Tn5ndXlPzQ9P/p9B6B5uwwmZ1vHHw==", "cpu": [ "arm64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.44-3", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.44-3.tgz", - "integrity": "sha512-qR6q16UDC6bIO8cde62z0wwVweH351RzN1KZgMjBqQYUBJw521K8VK7p64XK0tQWoTG8uyCuqqu5djQq/4Ek+g==", + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.45.tgz", + "integrity": "sha512-XYZ983NQmooVr/n+pCnHIorBmf1hd3o1rMlSAodwG/VFlQaydGoOs1F1NntxWBoFAND+eM6N4PZfw8M8sRayfA==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 72e06265e..c40ae2aa7 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.44-3", + "@github/copilot": "^1.0.45", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From f48afdafe341b6c2e9222835bf4dae749445690e Mon Sep 17 00:00:00 2001 From: Quim Muntal Date: Tue, 12 May 2026 16:42:34 +0200 Subject: [PATCH 28/33] Generate typed Go union interfaces (#1252) * Generate typed Go union interfaces * Update Go image input docs for attachment variants * Preserve unknown permission decision kind * Simplify Go union codegen cleanup * Fix Go scenario typed union examples * Fix Go matcher codegen lint * Generate Go interfaces for shape-distinct unions * Refactor Go union codegen planning * Fix raw session event JSON fallback * Generate Go unions from required fields * regenerate --- docs/features/image-input.md | 26 +- go/README.md | 13 +- go/generated_session_events.go | 2018 +++++++++-------- go/internal/e2e/abort_e2e_test.go | 2 +- .../e2e/commands_and_elicitation_e2e_test.go | 34 +- go/internal/e2e/compaction_e2e_test.go | 9 +- go/internal/e2e/event_fidelity_e2e_test.go | 18 +- go/internal/e2e/mode_handlers_e2e_test.go | 4 +- go/internal/e2e/multi_client_e2e_test.go | 26 +- go/internal/e2e/multi_turn_e2e_test.go | 10 +- .../e2e/pending_work_resume_e2e_test.go | 24 +- go/internal/e2e/permissions_e2e_test.go | 59 +- .../e2e/rpc_event_side_effects_e2e_test.go | 4 +- go/internal/e2e/rpc_mcp_config_e2e_test.go | 85 +- .../e2e/rpc_tasks_and_handlers_e2e_test.go | 12 +- go/internal/e2e/session_config_e2e_test.go | 2 +- go/internal/e2e/session_e2e_test.go | 122 +- .../e2e/streaming_fidelity_e2e_test.go | 24 +- go/internal/e2e/suspend_e2e_test.go | 9 +- go/internal/e2e/testharness/helper.go | 10 +- go/internal/e2e/tool_results_e2e_test.go | 5 +- go/internal/e2e/tools_e2e_test.go | 6 +- go/rpc/generated_rpc.go | 1166 ++++------ go/rpc/generated_rpc_api_shape_test.go | 114 + go/rpc/generated_rpc_union_test.go | 186 +- go/rpc/zrpc_encoding.go | 1486 ++++++++++++ go/samples/chat.go | 6 +- go/samples/go.mod | 11 +- go/samples/go.sum | 23 + go/session.go | 169 +- go/session_event_serialization_test.go | 98 +- go/session_test.go | 44 +- go/zsession_encoding.go | 1991 ++++++++++++++++ scripts/codegen/go.ts | 1969 +++++++++++++--- test/scenarios/callbacks/hooks/go/main.go | 10 +- .../callbacks/permissions/go/main.go | 23 +- .../scenarios/callbacks/user-input/go/main.go | 10 +- test/scenarios/prompts/attachments/README.md | 4 +- test/scenarios/prompts/attachments/go/main.go | 10 +- test/scenarios/sessions/streaming/go/main.go | 10 +- test/scenarios/tools/skills/go/main.go | 10 +- .../tools/virtual-filesystem/go/main.go | 10 +- 42 files changed, 7598 insertions(+), 2274 deletions(-) create mode 100644 go/rpc/generated_rpc_api_shape_test.go create mode 100644 go/rpc/zrpc_encoding.go create mode 100644 go/zsession_encoding.go diff --git a/docs/features/image-input.md b/docs/features/image-input.md index 342ad3c8c..6a25b312e 100644 --- a/docs/features/image-input.md +++ b/docs/features/image-input.md @@ -120,9 +120,9 @@ func main() { session.Send(ctx, copilot.MessageOptions{ Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ - { - Type: copilot.AttachmentTypeFile, - Path: &path, + &copilot.UserMessageAttachmentFile{ + DisplayName: "screenshot.png", + Path: path, }, }, }) @@ -146,9 +146,9 @@ path := "/absolute/path/to/screenshot.png" session.Send(ctx, copilot.MessageOptions{ Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ - { - Type: copilot.AttachmentTypeFile, - Path: &path, + &copilot.UserMessageAttachmentFile{ + DisplayName: "screenshot.png", + Path: path, }, }, }) @@ -343,10 +343,9 @@ func main() { session.Send(ctx, copilot.MessageOptions{ Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ - { - Type: copilot.AttachmentTypeBlob, - Data: &base64ImageData, - MIMEType: &mimeType, + &copilot.UserMessageAttachmentBlob{ + Data: base64ImageData, + MIMEType: mimeType, DisplayName: &displayName, }, }, @@ -361,10 +360,9 @@ displayName := "screenshot.png" session.Send(ctx, copilot.MessageOptions{ Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ - { - Type: copilot.AttachmentTypeBlob, - Data: &base64ImageData, // base64-encoded string - MIMEType: &mimeType, + &copilot.UserMessageAttachmentBlob{ + Data: base64ImageData, // base64-encoded string + MIMEType: mimeType, DisplayName: &displayName, }, }, diff --git a/go/README.md b/go/README.md index bbed46f0f..29760064c 100644 --- a/go/README.md +++ b/go/README.md @@ -246,9 +246,9 @@ The SDK supports image attachments via the `Attachments` field in `MessageOption _, err = session.Send(context.Background(), copilot.MessageOptions{ Prompt: "What's in this image?", Attachments: []copilot.Attachment{ - { - Type: "file", - Path: "/path/to/image.jpg", + &copilot.UserMessageAttachmentFile{ + DisplayName: "image.jpg", + Path: "/path/to/image.jpg", }, }, }) @@ -258,10 +258,9 @@ mimeType := "image/png" _, err = session.Send(context.Background(), copilot.MessageOptions{ Prompt: "What's in this image?", Attachments: []copilot.Attachment{ - { - Type: copilot.AttachmentTypeBlob, - Data: &base64ImageData, - MIMEType: &mimeType, + &copilot.UserMessageAttachmentBlob{ + Data: base64ImageData, + MIMEType: mimeType, }, }, }) diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 5cb73195f..316cc7df8 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -5,25 +5,15 @@ package copilot import ( "encoding/json" - "errors" "time" ) // SessionEventData is the interface implemented by all per-event data types. type SessionEventData interface { sessionEventData() + Type() SessionEventType } -// RawSessionEventData holds unparsed JSON data for unrecognized event types. -type RawSessionEventData struct { - Raw json.RawMessage -} - -func (RawSessionEventData) sessionEventData() {} - -// MarshalJSON returns the original raw JSON so round-tripping preserves the payload. -func (r RawSessionEventData) MarshalJSON() ([]byte, error) { return r.Raw, nil } - // SessionEvent represents a single session event with a typed data payload. type SessionEvent struct { // Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. @@ -38,549 +28,25 @@ type SessionEvent struct { ParentID *string `json:"parentId"` // ISO 8601 timestamp when the event was created Timestamp time.Time `json:"timestamp"` - // The event type discriminator. - Type SessionEventType `json:"type"` } -// UnmarshalSessionEvent parses JSON bytes into a SessionEvent. -func UnmarshalSessionEvent(data []byte) (SessionEvent, error) { - var r SessionEvent - err := json.Unmarshal(data, &r) - return r, err +// Type returns the event type discriminator derived from Data. +func (e SessionEvent) Type() SessionEventType { + if e.Data == nil { + return "" + } + return e.Data.Type() } -// Marshal serializes the SessionEvent to JSON. -func (r *SessionEvent) Marshal() ([]byte, error) { - return json.Marshal(r) +// RawSessionEventData holds unparsed JSON data for unrecognized event types. +type RawSessionEventData struct { + EventType SessionEventType + Raw json.RawMessage } -func (e *SessionEvent) UnmarshalJSON(data []byte) error { - type rawEvent struct { - AgentID *string `json:"agentId,omitempty"` - Data json.RawMessage `json:"data"` - Ephemeral *bool `json:"ephemeral,omitempty"` - ID string `json:"id"` - ParentID *string `json:"parentId"` - Timestamp time.Time `json:"timestamp"` - Type SessionEventType `json:"type"` - } - var raw rawEvent - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - e.AgentID = raw.AgentID - e.Ephemeral = raw.Ephemeral - e.ID = raw.ID - e.ParentID = raw.ParentID - e.Timestamp = raw.Timestamp - e.Type = raw.Type - - switch raw.Type { - case SessionEventTypeAbort: - var d AbortData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantIntent: - var d AssistantIntentData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantMessage: - var d AssistantMessageData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantMessageDelta: - var d AssistantMessageDeltaData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantMessageStart: - var d AssistantMessageStartData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantReasoning: - var d AssistantReasoningData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantReasoningDelta: - var d AssistantReasoningDeltaData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantStreamingDelta: - var d AssistantStreamingDeltaData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantTurnEnd: - var d AssistantTurnEndData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantTurnStart: - var d AssistantTurnStartData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAssistantUsage: - var d AssistantUsageData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAutoModeSwitchCompleted: - var d AutoModeSwitchCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeAutoModeSwitchRequested: - var d AutoModeSwitchRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeCapabilitiesChanged: - var d CapabilitiesChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeCommandCompleted: - var d CommandCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeCommandExecute: - var d CommandExecuteData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeCommandQueued: - var d CommandQueuedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeCommandsChanged: - var d CommandsChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeElicitationCompleted: - var d ElicitationCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeElicitationRequested: - var d ElicitationRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeExitPlanModeCompleted: - var d ExitPlanModeCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeExitPlanModeRequested: - var d ExitPlanModeRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeExternalToolCompleted: - var d ExternalToolCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeExternalToolRequested: - var d ExternalToolRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeHookEnd: - var d HookEndData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeHookStart: - var d HookStartData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeMcpOauthCompleted: - var d McpOauthCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeMcpOauthRequired: - var d McpOauthRequiredData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeModelCallFailure: - var d ModelCallFailureData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypePendingMessagesModified: - var d PendingMessagesModifiedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypePermissionCompleted: - var d PermissionCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypePermissionRequested: - var d PermissionRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSamplingCompleted: - var d SamplingCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSamplingRequested: - var d SamplingRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionBackgroundTasksChanged: - var d SessionBackgroundTasksChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionCompactionComplete: - var d SessionCompactionCompleteData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionCompactionStart: - var d SessionCompactionStartData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionContextChanged: - var d SessionContextChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionCustomAgentsUpdated: - var d SessionCustomAgentsUpdatedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionError: - var d SessionErrorData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionExtensionsLoaded: - var d SessionExtensionsLoadedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionHandoff: - var d SessionHandoffData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionIdle: - var d SessionIdleData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionInfo: - var d SessionInfoData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionMcpServersLoaded: - var d SessionMcpServersLoadedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionMcpServerStatusChanged: - var d SessionMcpServerStatusChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionModeChanged: - var d SessionModeChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionModelChange: - var d SessionModelChangeData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionPlanChanged: - var d SessionPlanChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionRemoteSteerableChanged: - var d SessionRemoteSteerableChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionResume: - var d SessionResumeData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionScheduleCancelled: - var d SessionScheduleCancelledData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionScheduleCreated: - var d SessionScheduleCreatedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionShutdown: - var d SessionShutdownData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionSkillsLoaded: - var d SessionSkillsLoadedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionSnapshotRewind: - var d SessionSnapshotRewindData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionStart: - var d SessionStartData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionTaskComplete: - var d SessionTaskCompleteData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionTitleChanged: - var d SessionTitleChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionToolsUpdated: - var d SessionToolsUpdatedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionTruncation: - var d SessionTruncationData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionUsageInfo: - var d SessionUsageInfoData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionWarning: - var d SessionWarningData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSessionWorkspaceFileChanged: - var d SessionWorkspaceFileChangedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSkillInvoked: - var d SkillInvokedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSubagentCompleted: - var d SubagentCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSubagentDeselected: - var d SubagentDeselectedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSubagentFailed: - var d SubagentFailedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSubagentSelected: - var d SubagentSelectedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSubagentStarted: - var d SubagentStartedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSystemMessage: - var d SystemMessageData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeSystemNotification: - var d SystemNotificationData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeToolExecutionComplete: - var d ToolExecutionCompleteData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeToolExecutionPartialResult: - var d ToolExecutionPartialResultData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeToolExecutionProgress: - var d ToolExecutionProgressData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeToolExecutionStart: - var d ToolExecutionStartData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeToolUserRequested: - var d ToolUserRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeUserInputCompleted: - var d UserInputCompletedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeUserInputRequested: - var d UserInputRequestedData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - case SessionEventTypeUserMessage: - var d UserMessageData - if err := json.Unmarshal(raw.Data, &d); err != nil { - return err - } - e.Data = &d - default: - e.Data = &RawSessionEventData{Raw: raw.Data} - } - return nil -} - -func (e SessionEvent) MarshalJSON() ([]byte, error) { - type rawEvent struct { - AgentID *string `json:"agentId,omitempty"` - Data any `json:"data"` - Ephemeral *bool `json:"ephemeral,omitempty"` - ID string `json:"id"` - ParentID *string `json:"parentId"` - Timestamp time.Time `json:"timestamp"` - Type SessionEventType `json:"type"` - } - return json.Marshal(rawEvent{ - AgentID: e.AgentID, - Data: e.Data, - Ephemeral: e.Ephemeral, - ID: e.ID, - ParentID: e.ParentID, - Timestamp: e.Timestamp, - Type: e.Type, - }) +func (RawSessionEventData) sessionEventData() {} +func (r RawSessionEventData) Type() SessionEventType { + return r.EventType } // SessionEventType identifies the kind of session event. @@ -675,7 +141,8 @@ type AssistantIntentData struct { Intent string `json:"intent"` } -func (*AssistantIntentData) sessionEventData() {} +func (*AssistantIntentData) sessionEventData() {} +func (*AssistantIntentData) Type() SessionEventType { return SessionEventTypeAssistantIntent } // Agent mode change details including previous and new modes type SessionModeChangedData struct { @@ -685,7 +152,8 @@ type SessionModeChangedData struct { PreviousMode string `json:"previousMode"` } -func (*SessionModeChangedData) sessionEventData() {} +func (*SessionModeChangedData) sessionEventData() {} +func (*SessionModeChangedData) Type() SessionEventType { return SessionEventTypeSessionModeChanged } // Assistant reasoning content for timeline display with complete thinking text type AssistantReasoningData struct { @@ -695,7 +163,8 @@ type AssistantReasoningData struct { ReasoningID string `json:"reasoningId"` } -func (*AssistantReasoningData) sessionEventData() {} +func (*AssistantReasoningData) sessionEventData() {} +func (*AssistantReasoningData) Type() SessionEventType { return SessionEventTypeAssistantReasoning } // Assistant response containing text content, optional tool requests, and interaction metadata type AssistantMessageData struct { @@ -732,7 +201,8 @@ type AssistantMessageData struct { TurnID *string `json:"turnId,omitempty"` } -func (*AssistantMessageData) sessionEventData() {} +func (*AssistantMessageData) sessionEventData() {} +func (*AssistantMessageData) Type() SessionEventType { return SessionEventTypeAssistantMessage } // Auto mode switch completion notification type AutoModeSwitchCompletedData struct { @@ -743,6 +213,9 @@ type AutoModeSwitchCompletedData struct { } func (*AutoModeSwitchCompletedData) sessionEventData() {} +func (*AutoModeSwitchCompletedData) Type() SessionEventType { + return SessionEventTypeAutoModeSwitchCompleted +} // Auto mode switch request notification requiring user approval type AutoModeSwitchRequestedData struct { @@ -755,6 +228,9 @@ type AutoModeSwitchRequestedData struct { } func (*AutoModeSwitchRequestedData) sessionEventData() {} +func (*AutoModeSwitchRequestedData) Type() SessionEventType { + return SessionEventTypeAutoModeSwitchRequested +} // Context window breakdown at the start of LLM-powered conversation compaction type SessionCompactionStartData struct { @@ -767,6 +243,9 @@ type SessionCompactionStartData struct { } func (*SessionCompactionStartData) sessionEventData() {} +func (*SessionCompactionStartData) Type() SessionEventType { + return SessionEventTypeSessionCompactionStart +} // Conversation compaction results including success status, metrics, and optional error details type SessionCompactionCompleteData struct { @@ -803,6 +282,9 @@ type SessionCompactionCompleteData struct { } func (*SessionCompactionCompleteData) sessionEventData() {} +func (*SessionCompactionCompleteData) Type() SessionEventType { + return SessionEventTypeSessionCompactionComplete +} // Conversation truncation statistics including token counts and removed content metrics type SessionTruncationData struct { @@ -824,7 +306,8 @@ type SessionTruncationData struct { TokensRemovedDuringTruncation float64 `json:"tokensRemovedDuringTruncation"` } -func (*SessionTruncationData) sessionEventData() {} +func (*SessionTruncationData) sessionEventData() {} +func (*SessionTruncationData) Type() SessionEventType { return SessionEventTypeSessionTruncation } // Current context window usage statistics including token and message counts type SessionUsageInfoData struct { @@ -844,7 +327,8 @@ type SessionUsageInfoData struct { ToolDefinitionsTokens *float64 `json:"toolDefinitionsTokens,omitempty"` } -func (*SessionUsageInfoData) sessionEventData() {} +func (*SessionUsageInfoData) sessionEventData() {} +func (*SessionUsageInfoData) Type() SessionEventType { return SessionEventTypeSessionUsageInfo } // Custom agent selection details including name and available tools type SubagentSelectedData struct { @@ -856,19 +340,21 @@ type SubagentSelectedData struct { Tools []string `json:"tools"` } -func (*SubagentSelectedData) sessionEventData() {} +func (*SubagentSelectedData) sessionEventData() {} +func (*SubagentSelectedData) Type() SessionEventType { return SessionEventTypeSubagentSelected } // Elicitation request completion with the user's response type ElicitationCompletedData struct { // The user action: "accept" (submitted form), "decline" (explicitly refused), or "cancel" (dismissed) Action *ElicitationCompletedAction `json:"action,omitempty"` // The submitted form data when action is 'accept'; keys match the requested schema fields - Content map[string]*ElicitationCompletedContent `json:"content,omitempty"` + Content map[string]ElicitationCompletedContent `json:"content,omitempty"` // Request ID of the resolved elicitation request; clients should dismiss any UI for this request RequestID string `json:"requestId"` } -func (*ElicitationCompletedData) sessionEventData() {} +func (*ElicitationCompletedData) sessionEventData() {} +func (*ElicitationCompletedData) Type() SessionEventType { return SessionEventTypeElicitationCompleted } // Elicitation request; may be form-based (structured input) or URL-based (browser redirect) type ElicitationRequestedData struct { @@ -888,19 +374,24 @@ type ElicitationRequestedData struct { URL *string `json:"url,omitempty"` } -func (*ElicitationRequestedData) sessionEventData() {} +func (*ElicitationRequestedData) sessionEventData() {} +func (*ElicitationRequestedData) Type() SessionEventType { return SessionEventTypeElicitationRequested } // Empty payload; the event signals that the custom agent was deselected, returning to the default agent type SubagentDeselectedData struct { } -func (*SubagentDeselectedData) sessionEventData() {} +func (*SubagentDeselectedData) sessionEventData() {} +func (*SubagentDeselectedData) Type() SessionEventType { return SessionEventTypeSubagentDeselected } // Empty payload; the event signals that the pending message queue has changed type PendingMessagesModifiedData struct { } func (*PendingMessagesModifiedData) sessionEventData() {} +func (*PendingMessagesModifiedData) Type() SessionEventType { + return SessionEventTypePendingMessagesModified +} // Error details for timeline display including message and optional diagnostic information type SessionErrorData struct { @@ -922,7 +413,8 @@ type SessionErrorData struct { URL *string `json:"url,omitempty"` } -func (*SessionErrorData) sessionEventData() {} +func (*SessionErrorData) sessionEventData() {} +func (*SessionErrorData) Type() SessionEventType { return SessionEventTypeSessionError } // External tool completion notification signaling UI dismissal type ExternalToolCompletedData struct { @@ -931,6 +423,9 @@ type ExternalToolCompletedData struct { } func (*ExternalToolCompletedData) sessionEventData() {} +func (*ExternalToolCompletedData) Type() SessionEventType { + return SessionEventTypeExternalToolCompleted +} // External tool invocation request for client-side tool execution type ExternalToolRequestedData struct { @@ -951,6 +446,9 @@ type ExternalToolRequestedData struct { } func (*ExternalToolRequestedData) sessionEventData() {} +func (*ExternalToolRequestedData) Type() SessionEventType { + return SessionEventTypeExternalToolRequested +} // Failed LLM API call metadata for telemetry type ModelCallFailureData struct { @@ -972,7 +470,8 @@ type ModelCallFailureData struct { StatusCode *int64 `json:"statusCode,omitempty"` } -func (*ModelCallFailureData) sessionEventData() {} +func (*ModelCallFailureData) sessionEventData() {} +func (*ModelCallFailureData) Type() SessionEventType { return SessionEventTypeModelCallFailure } // Hook invocation completion details including output, success status, and error information type HookEndData struct { @@ -988,7 +487,8 @@ type HookEndData struct { Success bool `json:"success"` } -func (*HookEndData) sessionEventData() {} +func (*HookEndData) sessionEventData() {} +func (*HookEndData) Type() SessionEventType { return SessionEventTypeHookEnd } // Hook invocation start details including type and input data type HookStartData struct { @@ -1000,7 +500,8 @@ type HookStartData struct { Input any `json:"input,omitempty"` } -func (*HookStartData) sessionEventData() {} +func (*HookStartData) sessionEventData() {} +func (*HookStartData) Type() SessionEventType { return SessionEventTypeHookStart } // Informational message for timeline display with categorization type SessionInfoData struct { @@ -1014,7 +515,8 @@ type SessionInfoData struct { URL *string `json:"url,omitempty"` } -func (*SessionInfoData) sessionEventData() {} +func (*SessionInfoData) sessionEventData() {} +func (*SessionInfoData) Type() SessionEventType { return SessionEventTypeSessionInfo } // LLM API call usage metrics including tokens, costs, quotas, and billing information type AssistantUsageData struct { @@ -1055,7 +557,8 @@ type AssistantUsageData struct { TtftMs *float64 `json:"ttftMs,omitempty"` } -func (*AssistantUsageData) sessionEventData() {} +func (*AssistantUsageData) sessionEventData() {} +func (*AssistantUsageData) Type() SessionEventType { return SessionEventTypeAssistantUsage } // MCP OAuth request completion notification type McpOauthCompletedData struct { @@ -1063,7 +566,8 @@ type McpOauthCompletedData struct { RequestID string `json:"requestId"` } -func (*McpOauthCompletedData) sessionEventData() {} +func (*McpOauthCompletedData) sessionEventData() {} +func (*McpOauthCompletedData) Type() SessionEventType { return SessionEventTypeMcpOauthCompleted } // Model change details including previous and new model identifiers type SessionModelChangeData struct { @@ -1079,7 +583,8 @@ type SessionModelChangeData struct { ReasoningEffort *string `json:"reasoningEffort,omitempty"` } -func (*SessionModelChangeData) sessionEventData() {} +func (*SessionModelChangeData) sessionEventData() {} +func (*SessionModelChangeData) Type() SessionEventType { return SessionEventTypeSessionModelChange } // Notifies Mission Control that the session's remote steering capability has changed type SessionRemoteSteerableChangedData struct { @@ -1088,6 +593,9 @@ type SessionRemoteSteerableChangedData struct { } func (*SessionRemoteSteerableChangedData) sessionEventData() {} +func (*SessionRemoteSteerableChangedData) Type() SessionEventType { + return SessionEventTypeSessionRemoteSteerableChanged +} // OAuth authentication request for an MCP server type McpOauthRequiredData struct { @@ -1101,7 +609,8 @@ type McpOauthRequiredData struct { StaticClientConfig *McpOauthRequiredStaticClientConfig `json:"staticClientConfig,omitempty"` } -func (*McpOauthRequiredData) sessionEventData() {} +func (*McpOauthRequiredData) sessionEventData() {} +func (*McpOauthRequiredData) Type() SessionEventType { return SessionEventTypeMcpOauthRequired } // Payload indicating the session is idle with no background agents in flight type SessionIdleData struct { @@ -1109,7 +618,8 @@ type SessionIdleData struct { Aborted *bool `json:"aborted,omitempty"` } -func (*SessionIdleData) sessionEventData() {} +func (*SessionIdleData) sessionEventData() {} +func (*SessionIdleData) Type() SessionEventType { return SessionEventTypeSessionIdle } // Permission request completion notification signaling UI dismissal type PermissionCompletedData struct { @@ -1121,21 +631,23 @@ type PermissionCompletedData struct { ToolCallID *string `json:"toolCallId,omitempty"` } -func (*PermissionCompletedData) sessionEventData() {} +func (*PermissionCompletedData) sessionEventData() {} +func (*PermissionCompletedData) Type() SessionEventType { return SessionEventTypePermissionCompleted } // Permission request notification requiring client approval with request details type PermissionRequestedData struct { // Details of the permission being requested PermissionRequest PermissionRequest `json:"permissionRequest"` // Derived user-facing permission prompt details for UI consumers - PromptRequest *PermissionPromptRequest `json:"promptRequest,omitempty"` + PromptRequest PermissionPromptRequest `json:"promptRequest,omitempty"` // Unique identifier for this permission request; used to respond via session.respondToPermission() RequestID string `json:"requestId"` // When true, this permission was already resolved by a permissionRequest hook and requires no client action ResolvedByHook *bool `json:"resolvedByHook,omitempty"` } -func (*PermissionRequestedData) sessionEventData() {} +func (*PermissionRequestedData) sessionEventData() {} +func (*PermissionRequestedData) Type() SessionEventType { return SessionEventTypePermissionRequested } // Plan approval request with plan content and available user actions type ExitPlanModeRequestedData struct { @@ -1152,6 +664,9 @@ type ExitPlanModeRequestedData struct { } func (*ExitPlanModeRequestedData) sessionEventData() {} +func (*ExitPlanModeRequestedData) Type() SessionEventType { + return SessionEventTypeExitPlanModeRequested +} // Plan file operation details indicating what changed type SessionPlanChangedData struct { @@ -1159,7 +674,8 @@ type SessionPlanChangedData struct { Operation PlanChangedOperation `json:"operation"` } -func (*SessionPlanChangedData) sessionEventData() {} +func (*SessionPlanChangedData) sessionEventData() {} +func (*SessionPlanChangedData) Type() SessionEventType { return SessionEventTypeSessionPlanChanged } // Plan mode exit completion with the user's approval decision and optional feedback type ExitPlanModeCompletedData struct { @@ -1176,6 +692,9 @@ type ExitPlanModeCompletedData struct { } func (*ExitPlanModeCompletedData) sessionEventData() {} +func (*ExitPlanModeCompletedData) Type() SessionEventType { + return SessionEventTypeExitPlanModeCompleted +} // Queued command completion notification signaling UI dismissal type CommandCompletedData struct { @@ -1183,7 +702,8 @@ type CommandCompletedData struct { RequestID string `json:"requestId"` } -func (*CommandCompletedData) sessionEventData() {} +func (*CommandCompletedData) sessionEventData() {} +func (*CommandCompletedData) Type() SessionEventType { return SessionEventTypeCommandCompleted } // Queued slash command dispatch request for client execution type CommandQueuedData struct { @@ -1193,7 +713,8 @@ type CommandQueuedData struct { RequestID string `json:"requestId"` } -func (*CommandQueuedData) sessionEventData() {} +func (*CommandQueuedData) sessionEventData() {} +func (*CommandQueuedData) Type() SessionEventType { return SessionEventTypeCommandQueued } // Registered command dispatch request routed to the owning client type CommandExecuteData struct { @@ -1207,7 +728,8 @@ type CommandExecuteData struct { RequestID string `json:"requestId"` } -func (*CommandExecuteData) sessionEventData() {} +func (*CommandExecuteData) sessionEventData() {} +func (*CommandExecuteData) Type() SessionEventType { return SessionEventTypeCommandExecute } // SDK command registration change notification type CommandsChangedData struct { @@ -1215,7 +737,8 @@ type CommandsChangedData struct { Commands []CommandsChangedCommand `json:"commands"` } -func (*CommandsChangedData) sessionEventData() {} +func (*CommandsChangedData) sessionEventData() {} +func (*CommandsChangedData) Type() SessionEventType { return SessionEventTypeCommandsChanged } // Sampling request completion notification signaling UI dismissal type SamplingCompletedData struct { @@ -1223,7 +746,8 @@ type SamplingCompletedData struct { RequestID string `json:"requestId"` } -func (*SamplingCompletedData) sessionEventData() {} +func (*SamplingCompletedData) sessionEventData() {} +func (*SamplingCompletedData) Type() SessionEventType { return SessionEventTypeSamplingCompleted } // Sampling request from an MCP server; contains the server name and a requestId for correlation type SamplingRequestedData struct { @@ -1235,7 +759,8 @@ type SamplingRequestedData struct { ServerName string `json:"serverName"` } -func (*SamplingRequestedData) sessionEventData() {} +func (*SamplingRequestedData) sessionEventData() {} +func (*SamplingRequestedData) Type() SessionEventType { return SessionEventTypeSamplingRequested } // Scheduled prompt cancelled from the schedule manager dialog type SessionScheduleCancelledData struct { @@ -1244,6 +769,9 @@ type SessionScheduleCancelledData struct { } func (*SessionScheduleCancelledData) sessionEventData() {} +func (*SessionScheduleCancelledData) Type() SessionEventType { + return SessionEventTypeSessionScheduleCancelled +} // Scheduled prompt registered via /every type SessionScheduleCreatedData struct { @@ -1256,6 +784,9 @@ type SessionScheduleCreatedData struct { } func (*SessionScheduleCreatedData) sessionEventData() {} +func (*SessionScheduleCreatedData) Type() SessionEventType { + return SessionEventTypeSessionScheduleCreated +} // Session capability change notification type CapabilitiesChangedData struct { @@ -1263,7 +794,8 @@ type CapabilitiesChangedData struct { UI *CapabilitiesChangedUI `json:"ui,omitempty"` } -func (*CapabilitiesChangedData) sessionEventData() {} +func (*CapabilitiesChangedData) sessionEventData() {} +func (*CapabilitiesChangedData) Type() SessionEventType { return SessionEventTypeCapabilitiesChanged } // Session handoff metadata including source, context, and repository information type SessionHandoffData struct { @@ -1283,7 +815,8 @@ type SessionHandoffData struct { Summary *string `json:"summary,omitempty"` } -func (*SessionHandoffData) sessionEventData() {} +func (*SessionHandoffData) sessionEventData() {} +func (*SessionHandoffData) Type() SessionEventType { return SessionEventTypeSessionHandoff } // Session initialization metadata including context and configuration type SessionStartData struct { @@ -1311,7 +844,8 @@ type SessionStartData struct { Version float64 `json:"version"` } -func (*SessionStartData) sessionEventData() {} +func (*SessionStartData) sessionEventData() {} +func (*SessionStartData) Type() SessionEventType { return SessionEventTypeSessionStart } // Session resume metadata including current context and event count type SessionResumeData struct { @@ -1335,7 +869,8 @@ type SessionResumeData struct { SessionWasActive *bool `json:"sessionWasActive,omitempty"` } -func (*SessionResumeData) sessionEventData() {} +func (*SessionResumeData) sessionEventData() {} +func (*SessionResumeData) Type() SessionEventType { return SessionEventTypeSessionResume } // Session rewind details including target event and count of removed events type SessionSnapshotRewindData struct { @@ -1346,6 +881,9 @@ type SessionSnapshotRewindData struct { } func (*SessionSnapshotRewindData) sessionEventData() {} +func (*SessionSnapshotRewindData) Type() SessionEventType { + return SessionEventTypeSessionSnapshotRewind +} // Session termination metrics including usage statistics, code changes, and shutdown reason type SessionShutdownData struct { @@ -1379,7 +917,8 @@ type SessionShutdownData struct { TotalPremiumRequests float64 `json:"totalPremiumRequests"` } -func (*SessionShutdownData) sessionEventData() {} +func (*SessionShutdownData) sessionEventData() {} +func (*SessionShutdownData) Type() SessionEventType { return SessionEventTypeSessionShutdown } // Session title change payload containing the new display title type SessionTitleChangedData struct { @@ -1387,13 +926,17 @@ type SessionTitleChangedData struct { Title string `json:"title"` } -func (*SessionTitleChangedData) sessionEventData() {} +func (*SessionTitleChangedData) sessionEventData() {} +func (*SessionTitleChangedData) Type() SessionEventType { return SessionEventTypeSessionTitleChanged } // SessionBackgroundTasksChangedData holds the payload for session.background_tasks_changed events. type SessionBackgroundTasksChangedData struct { } func (*SessionBackgroundTasksChangedData) sessionEventData() {} +func (*SessionBackgroundTasksChangedData) Type() SessionEventType { + return SessionEventTypeSessionBackgroundTasksChanged +} // SessionCustomAgentsUpdatedData holds the payload for session.custom_agents_updated events. type SessionCustomAgentsUpdatedData struct { @@ -1406,6 +949,9 @@ type SessionCustomAgentsUpdatedData struct { } func (*SessionCustomAgentsUpdatedData) sessionEventData() {} +func (*SessionCustomAgentsUpdatedData) Type() SessionEventType { + return SessionEventTypeSessionCustomAgentsUpdated +} // SessionExtensionsLoadedData holds the payload for session.extensions_loaded events. type SessionExtensionsLoadedData struct { @@ -1414,6 +960,9 @@ type SessionExtensionsLoadedData struct { } func (*SessionExtensionsLoadedData) sessionEventData() {} +func (*SessionExtensionsLoadedData) Type() SessionEventType { + return SessionEventTypeSessionExtensionsLoaded +} // SessionMcpServerStatusChangedData holds the payload for session.mcp_server_status_changed events. type SessionMcpServerStatusChangedData struct { @@ -1424,6 +973,9 @@ type SessionMcpServerStatusChangedData struct { } func (*SessionMcpServerStatusChangedData) sessionEventData() {} +func (*SessionMcpServerStatusChangedData) Type() SessionEventType { + return SessionEventTypeSessionMcpServerStatusChanged +} // SessionMcpServersLoadedData holds the payload for session.mcp_servers_loaded events. type SessionMcpServersLoadedData struct { @@ -1432,6 +984,9 @@ type SessionMcpServersLoadedData struct { } func (*SessionMcpServersLoadedData) sessionEventData() {} +func (*SessionMcpServersLoadedData) Type() SessionEventType { + return SessionEventTypeSessionMcpServersLoaded +} // SessionSkillsLoadedData holds the payload for session.skills_loaded events. type SessionSkillsLoadedData struct { @@ -1439,14 +994,16 @@ type SessionSkillsLoadedData struct { Skills []SkillsLoadedSkill `json:"skills"` } -func (*SessionSkillsLoadedData) sessionEventData() {} +func (*SessionSkillsLoadedData) sessionEventData() {} +func (*SessionSkillsLoadedData) Type() SessionEventType { return SessionEventTypeSessionSkillsLoaded } // SessionToolsUpdatedData holds the payload for session.tools_updated events. type SessionToolsUpdatedData struct { Model string `json:"model"` } -func (*SessionToolsUpdatedData) sessionEventData() {} +func (*SessionToolsUpdatedData) sessionEventData() {} +func (*SessionToolsUpdatedData) Type() SessionEventType { return SessionEventTypeSessionToolsUpdated } // Skill invocation details including content, allowed tools, and plugin metadata type SkillInvokedData struct { @@ -1466,7 +1023,8 @@ type SkillInvokedData struct { PluginVersion *string `json:"pluginVersion,omitempty"` } -func (*SkillInvokedData) sessionEventData() {} +func (*SkillInvokedData) sessionEventData() {} +func (*SkillInvokedData) Type() SessionEventType { return SessionEventTypeSkillInvoked } // Streaming assistant message delta for incremental response updates type AssistantMessageDeltaData struct { @@ -1480,6 +1038,9 @@ type AssistantMessageDeltaData struct { } func (*AssistantMessageDeltaData) sessionEventData() {} +func (*AssistantMessageDeltaData) Type() SessionEventType { + return SessionEventTypeAssistantMessageDelta +} // Streaming assistant message start metadata type AssistantMessageStartData struct { @@ -1490,6 +1051,9 @@ type AssistantMessageStartData struct { } func (*AssistantMessageStartData) sessionEventData() {} +func (*AssistantMessageStartData) Type() SessionEventType { + return SessionEventTypeAssistantMessageStart +} // Streaming reasoning delta for incremental extended thinking updates type AssistantReasoningDeltaData struct { @@ -1500,6 +1064,9 @@ type AssistantReasoningDeltaData struct { } func (*AssistantReasoningDeltaData) sessionEventData() {} +func (*AssistantReasoningDeltaData) Type() SessionEventType { + return SessionEventTypeAssistantReasoningDelta +} // Streaming response progress with cumulative byte count type AssistantStreamingDeltaData struct { @@ -1508,6 +1075,9 @@ type AssistantStreamingDeltaData struct { } func (*AssistantStreamingDeltaData) sessionEventData() {} +func (*AssistantStreamingDeltaData) Type() SessionEventType { + return SessionEventTypeAssistantStreamingDelta +} // Streaming tool execution output for incremental result display type ToolExecutionPartialResultData struct { @@ -1518,6 +1088,9 @@ type ToolExecutionPartialResultData struct { } func (*ToolExecutionPartialResultData) sessionEventData() {} +func (*ToolExecutionPartialResultData) Type() SessionEventType { + return SessionEventTypeToolExecutionPartialResult +} // Sub-agent completion details for successful execution type SubagentCompletedData struct { @@ -1537,7 +1110,8 @@ type SubagentCompletedData struct { TotalToolCalls *float64 `json:"totalToolCalls,omitempty"` } -func (*SubagentCompletedData) sessionEventData() {} +func (*SubagentCompletedData) sessionEventData() {} +func (*SubagentCompletedData) Type() SessionEventType { return SessionEventTypeSubagentCompleted } // Sub-agent failure details including error message and agent information type SubagentFailedData struct { @@ -1559,7 +1133,8 @@ type SubagentFailedData struct { TotalToolCalls *float64 `json:"totalToolCalls,omitempty"` } -func (*SubagentFailedData) sessionEventData() {} +func (*SubagentFailedData) sessionEventData() {} +func (*SubagentFailedData) Type() SessionEventType { return SessionEventTypeSubagentFailed } // Sub-agent startup details including parent tool call and agent information type SubagentStartedData struct { @@ -1575,7 +1150,8 @@ type SubagentStartedData struct { ToolCallID string `json:"toolCallId"` } -func (*SubagentStartedData) sessionEventData() {} +func (*SubagentStartedData) sessionEventData() {} +func (*SubagentStartedData) Type() SessionEventType { return SessionEventTypeSubagentStarted } // System-generated notification for runtime events like background task completion type SystemNotificationData struct { @@ -1585,7 +1161,8 @@ type SystemNotificationData struct { Kind SystemNotification `json:"kind"` } -func (*SystemNotificationData) sessionEventData() {} +func (*SystemNotificationData) sessionEventData() {} +func (*SystemNotificationData) Type() SessionEventType { return SessionEventTypeSystemNotification } // System/developer instruction content with role and optional template metadata type SystemMessageData struct { @@ -1599,7 +1176,8 @@ type SystemMessageData struct { Role SystemMessageRole `json:"role"` } -func (*SystemMessageData) sessionEventData() {} +func (*SystemMessageData) sessionEventData() {} +func (*SystemMessageData) Type() SessionEventType { return SessionEventTypeSystemMessage } // Task completion notification with summary from the agent type SessionTaskCompleteData struct { @@ -1609,7 +1187,8 @@ type SessionTaskCompleteData struct { Summary *string `json:"summary,omitempty"` } -func (*SessionTaskCompleteData) sessionEventData() {} +func (*SessionTaskCompleteData) sessionEventData() {} +func (*SessionTaskCompleteData) Type() SessionEventType { return SessionEventTypeSessionTaskComplete } // Tool execution completion results including success status, detailed output, and error information type ToolExecutionCompleteData struct { @@ -1637,6 +1216,9 @@ type ToolExecutionCompleteData struct { } func (*ToolExecutionCompleteData) sessionEventData() {} +func (*ToolExecutionCompleteData) Type() SessionEventType { + return SessionEventTypeToolExecutionComplete +} // Tool execution progress notification with status message type ToolExecutionProgressData struct { @@ -1647,6 +1229,9 @@ type ToolExecutionProgressData struct { } func (*ToolExecutionProgressData) sessionEventData() {} +func (*ToolExecutionProgressData) Type() SessionEventType { + return SessionEventTypeToolExecutionProgress +} // Tool execution startup details including MCP server information when applicable type ToolExecutionStartData struct { @@ -1667,7 +1252,8 @@ type ToolExecutionStartData struct { TurnID *string `json:"turnId,omitempty"` } -func (*ToolExecutionStartData) sessionEventData() {} +func (*ToolExecutionStartData) sessionEventData() {} +func (*ToolExecutionStartData) Type() SessionEventType { return SessionEventTypeToolExecutionStart } // Turn abort information including the reason for termination type AbortData struct { @@ -1675,7 +1261,8 @@ type AbortData struct { Reason AbortReason `json:"reason"` } -func (*AbortData) sessionEventData() {} +func (*AbortData) sessionEventData() {} +func (*AbortData) Type() SessionEventType { return SessionEventTypeAbort } // Turn completion metadata including the turn identifier type AssistantTurnEndData struct { @@ -1683,7 +1270,8 @@ type AssistantTurnEndData struct { TurnID string `json:"turnId"` } -func (*AssistantTurnEndData) sessionEventData() {} +func (*AssistantTurnEndData) sessionEventData() {} +func (*AssistantTurnEndData) Type() SessionEventType { return SessionEventTypeAssistantTurnEnd } // Turn initialization metadata including identifier and interaction tracking type AssistantTurnStartData struct { @@ -1693,7 +1281,8 @@ type AssistantTurnStartData struct { TurnID string `json:"turnId"` } -func (*AssistantTurnStartData) sessionEventData() {} +func (*AssistantTurnStartData) sessionEventData() {} +func (*AssistantTurnStartData) Type() SessionEventType { return SessionEventTypeAssistantTurnStart } // User input request completion with the user's response type UserInputCompletedData struct { @@ -1705,7 +1294,8 @@ type UserInputCompletedData struct { WasFreeform *bool `json:"wasFreeform,omitempty"` } -func (*UserInputCompletedData) sessionEventData() {} +func (*UserInputCompletedData) sessionEventData() {} +func (*UserInputCompletedData) Type() SessionEventType { return SessionEventTypeUserInputCompleted } // User input request notification with question and optional predefined choices type UserInputRequestedData struct { @@ -1721,7 +1311,8 @@ type UserInputRequestedData struct { ToolCallID *string `json:"toolCallId,omitempty"` } -func (*UserInputRequestedData) sessionEventData() {} +func (*UserInputRequestedData) sessionEventData() {} +func (*UserInputRequestedData) Type() SessionEventType { return SessionEventTypeUserInputRequested } // User-initiated tool invocation request with tool name and arguments type ToolUserRequestedData struct { @@ -1733,7 +1324,8 @@ type ToolUserRequestedData struct { ToolName string `json:"toolName"` } -func (*ToolUserRequestedData) sessionEventData() {} +func (*ToolUserRequestedData) sessionEventData() {} +func (*ToolUserRequestedData) Type() SessionEventType { return SessionEventTypeToolUserRequested } // UserMessageData holds the payload for user.message events. type UserMessageData struct { @@ -1757,7 +1349,8 @@ type UserMessageData struct { TransformedContent *string `json:"transformedContent,omitempty"` } -func (*UserMessageData) sessionEventData() {} +func (*UserMessageData) sessionEventData() {} +func (*UserMessageData) Type() SessionEventType { return SessionEventTypeUserMessage } // Warning message for timeline display with categorization type SessionWarningData struct { @@ -1769,7 +1362,8 @@ type SessionWarningData struct { WarningType string `json:"warningType"` } -func (*SessionWarningData) sessionEventData() {} +func (*SessionWarningData) sessionEventData() {} +func (*SessionWarningData) Type() SessionEventType { return SessionEventTypeSessionWarning } // Working directory and git context at session start type SessionContextChangedData struct { @@ -1792,6 +1386,9 @@ type SessionContextChangedData struct { } func (*SessionContextChangedData) sessionEventData() {} +func (*SessionContextChangedData) Type() SessionEventType { + return SessionEventTypeSessionContextChanged +} // Workspace file change details including path and operation type type SessionWorkspaceFileChangedData struct { @@ -1802,6 +1399,9 @@ type SessionWorkspaceFileChangedData struct { } func (*SessionWorkspaceFileChangedData) sessionEventData() {} +func (*SessionWorkspaceFileChangedData) Type() SessionEventType { + return SessionEventTypeSessionWorkspaceFileChanged +} // A tool invocation request from the assistant type AssistantMessageToolRequest struct { @@ -1930,64 +1530,25 @@ type CustomAgentsUpdatedAgent struct { UserInvocable bool `json:"userInvocable"` } -type ElicitationCompletedContent struct { - Bool *bool - Double *float64 - String *string - StringArray []string +type ElicitationCompletedContent interface { + elicitationCompletedContent() } -func (r ElicitationCompletedContent) MarshalJSON() ([]byte, error) { - if r.Bool != nil { - return json.Marshal(r.Bool) - } - if r.Double != nil { - return json.Marshal(r.Double) - } - if r.String != nil { - return json.Marshal(r.String) - } - if r.StringArray != nil { - return json.Marshal(r.StringArray) - } - return []byte("null"), nil -} +type ElicitationCompletedBooleanContent bool -func (r *ElicitationCompletedContent) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - *r = ElicitationCompletedContent{} - return nil - } - { - var value bool - if err := json.Unmarshal(data, &value); err == nil { - *r = ElicitationCompletedContent{Bool: &value} - return nil - } - } - { - var value float64 - if err := json.Unmarshal(data, &value); err == nil { - *r = ElicitationCompletedContent{Double: &value} - return nil - } - } - { - var value string - if err := json.Unmarshal(data, &value); err == nil { - *r = ElicitationCompletedContent{String: &value} - return nil - } - } - { - var value []string - if err := json.Unmarshal(data, &value); err == nil { - *r = ElicitationCompletedContent{StringArray: value} - return nil - } - } - return errors.New("data did not match any union variant for ElicitationCompletedContent") -} +func (ElicitationCompletedBooleanContent) elicitationCompletedContent() {} + +type ElicitationCompletedNumberContent float64 + +func (ElicitationCompletedNumberContent) elicitationCompletedContent() {} + +type ElicitationCompletedStringArrayContent []string + +func (ElicitationCompletedStringArrayContent) elicitationCompletedContent() {} + +type ElicitationCompletedStringContent string + +func (ElicitationCompletedStringContent) elicitationCompletedContent() {} // JSON Schema describing the form fields to present to the user (form mode only) type ElicitationRequestedSchema struct { @@ -2050,216 +1611,581 @@ type McpServersLoadedServer struct { } // Derived user-facing permission prompt details for UI consumers -type PermissionPromptRequest struct { - // Underlying permission kind that needs path approval - AccessKind *PermissionPromptRequestPathAccessKind `json:"accessKind,omitempty"` - // Whether this is a store or vote memory operation - Action *PermissionPromptRequestMemoryAction `json:"action,omitempty"` - // Arguments to pass to the MCP tool - Args *any `json:"args,omitempty"` +type PermissionPromptRequest interface { + permissionPromptRequest() + Kind() PermissionPromptRequestKind +} + +type RawPermissionPromptRequest struct { + Discriminator PermissionPromptRequestKind + Raw json.RawMessage +} + +func (RawPermissionPromptRequest) permissionPromptRequest() {} +func (r RawPermissionPromptRequest) Kind() PermissionPromptRequestKind { + return r.Discriminator +} + +// Shell command permission prompt +type PermissionPromptRequestCommands struct { // Whether the UI can offer session-wide approval for this command pattern - CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` - // Capabilities the extension is requesting - Capabilities []string `json:"capabilities,omitempty"` - // Source references for the stored fact (store only) - Citations *string `json:"citations,omitempty"` + CanOfferSessionApproval bool `json:"canOfferSessionApproval"` // Command identifiers covered by this approval prompt - CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` - // Unified diff showing the proposed changes - Diff *string `json:"diff,omitempty"` - // Vote direction (vote only) - Direction *PermissionPromptRequestMemoryDirection `json:"direction,omitempty"` + CommandIdentifiers []string `json:"commandIdentifiers"` + // The complete shell command text to be executed + FullCommandText string `json:"fullCommandText"` + // Human-readable description of what the command intends to do + Intention string `json:"intention"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Optional warning message about risks of running this command + Warning *string `json:"warning,omitempty"` +} + +func (PermissionPromptRequestCommands) permissionPromptRequest() {} +func (PermissionPromptRequestCommands) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindCommands +} + +// Custom tool invocation permission prompt +type PermissionPromptRequestCustomTool struct { + // Arguments to pass to the custom tool + Args any `json:"args,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Description of what the custom tool does + ToolDescription string `json:"toolDescription"` + // Name of the custom tool + ToolName string `json:"toolName"` +} + +func (PermissionPromptRequestCustomTool) permissionPromptRequest() {} +func (PermissionPromptRequestCustomTool) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindCustomTool +} + +// Extension management permission prompt +type PermissionPromptRequestExtensionManagement struct { // Name of the extension being managed ExtensionName *string `json:"extensionName,omitempty"` - // The fact being stored or voted on - Fact *string `json:"fact,omitempty"` - // Path of the file being written to - FileName *string `json:"fileName,omitempty"` - // The complete shell command text to be executed - FullCommandText *string `json:"fullCommandText,omitempty"` + // The extension management operation (scaffold, reload) + Operation string `json:"operation"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionPromptRequestExtensionManagement) permissionPromptRequest() {} +func (PermissionPromptRequestExtensionManagement) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindExtensionManagement +} + +// Extension permission access prompt +type PermissionPromptRequestExtensionPermissionAccess struct { + // Capabilities the extension is requesting + Capabilities []string `json:"capabilities"` + // Name of the extension requesting permission access + ExtensionName string `json:"extensionName"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionPromptRequestExtensionPermissionAccess) permissionPromptRequest() {} +func (PermissionPromptRequestExtensionPermissionAccess) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindExtensionPermissionAccess +} + +// Hook confirmation permission prompt +type PermissionPromptRequestHook struct { // Optional message from the hook explaining why confirmation is needed HookMessage *string `json:"hookMessage,omitempty"` - // Human-readable description of what the command intends to do - Intention *string `json:"intention,omitempty"` - // Kind discriminator - Kind PermissionPromptRequestKind `json:"kind"` - // Complete new file contents for newly created files - NewFileContents *string `json:"newFileContents,omitempty"` - // The extension management operation (scaffold, reload) - Operation *string `json:"operation,omitempty"` - // Path of the file or directory being read - Path *string `json:"path,omitempty"` - // File paths that require explicit approval - Paths []string `json:"paths,omitempty"` - // Reason for the vote (vote only) - Reason *string `json:"reason,omitempty"` - // Name of the MCP server providing the tool - ServerName *string `json:"serverName,omitempty"` - // Topic or subject of the memory (store only) - Subject *string `json:"subject,omitempty"` // Arguments of the tool call being gated ToolArgs any `json:"toolArgs,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` - // Description of what the custom tool does - ToolDescription *string `json:"toolDescription,omitempty"` + // Name of the tool the hook is gating + ToolName string `json:"toolName"` +} + +func (PermissionPromptRequestHook) permissionPromptRequest() {} +func (PermissionPromptRequestHook) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindHook +} + +// MCP tool invocation permission prompt +type PermissionPromptRequestMcp struct { + // Arguments to pass to the MCP tool + Args *any `json:"args,omitempty"` + // Name of the MCP server providing the tool + ServerName string `json:"serverName"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` // Internal name of the MCP tool - ToolName *string `json:"toolName,omitempty"` + ToolName string `json:"toolName"` // Human-readable title of the MCP tool - ToolTitle *string `json:"toolTitle,omitempty"` - // URL to be fetched - URL *string `json:"url,omitempty"` - // Optional warning message about risks of running this command - Warning *string `json:"warning,omitempty"` + ToolTitle string `json:"toolTitle"` } -// Details of the permission being requested -type PermissionRequest struct { +func (PermissionPromptRequestMcp) permissionPromptRequest() {} +func (PermissionPromptRequestMcp) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindMcp +} + +// Memory operation permission prompt +type PermissionPromptRequestMemory struct { // Whether this is a store or vote memory operation - Action *PermissionRequestMemoryAction `json:"action,omitempty"` - // Arguments to pass to the MCP tool - Args any `json:"args,omitempty"` - // Whether the UI can offer session-wide approval for this command pattern - CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` - // Capabilities the extension is requesting - Capabilities []string `json:"capabilities,omitempty"` + Action *PermissionPromptRequestMemoryAction `json:"action,omitempty"` // Source references for the stored fact (store only) Citations *string `json:"citations,omitempty"` - // Parsed command identifiers found in the command text - Commands []PermissionRequestShellCommand `json:"commands,omitempty"` - // Unified diff showing the proposed changes - Diff *string `json:"diff,omitempty"` // Vote direction (vote only) - Direction *PermissionRequestMemoryDirection `json:"direction,omitempty"` - // Name of the extension being managed - ExtensionName *string `json:"extensionName,omitempty"` + Direction *PermissionPromptRequestMemoryDirection `json:"direction,omitempty"` // The fact being stored or voted on - Fact *string `json:"fact,omitempty"` - // Path of the file being written to - FileName *string `json:"fileName,omitempty"` - // The complete shell command text to be executed - FullCommandText *string `json:"fullCommandText,omitempty"` - // Whether the command includes a file write redirection (e.g., > or >>) - HasWriteFileRedirection *bool `json:"hasWriteFileRedirection,omitempty"` - // Optional message from the hook explaining why confirmation is needed - HookMessage *string `json:"hookMessage,omitempty"` - // Human-readable description of what the command intends to do - Intention *string `json:"intention,omitempty"` - // Kind discriminator - Kind PermissionRequestKind `json:"kind"` - // Complete new file contents for newly created files - NewFileContents *string `json:"newFileContents,omitempty"` - // The extension management operation (scaffold, reload) - Operation *string `json:"operation,omitempty"` - // Path of the file or directory being read - Path *string `json:"path,omitempty"` - // File paths that may be read or written by the command - PossiblePaths []string `json:"possiblePaths,omitempty"` - // URLs that may be accessed by the command - PossibleUrls []PermissionRequestShellPossibleURL `json:"possibleUrls,omitempty"` - // Whether this MCP tool is read-only (no side effects) - ReadOnly *bool `json:"readOnly,omitempty"` + Fact string `json:"fact"` // Reason for the vote (vote only) Reason *string `json:"reason,omitempty"` - // Name of the MCP server providing the tool - ServerName *string `json:"serverName,omitempty"` // Topic or subject of the memory (store only) Subject *string `json:"subject,omitempty"` - // Arguments of the tool call being gated - ToolArgs any `json:"toolArgs,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` - // Description of what the custom tool does - ToolDescription *string `json:"toolDescription,omitempty"` - // Internal name of the MCP tool - ToolName *string `json:"toolName,omitempty"` - // Human-readable title of the MCP tool - ToolTitle *string `json:"toolTitle,omitempty"` - // URL to be fetched - URL *string `json:"url,omitempty"` - // Optional warning message about risks of running this command - Warning *string `json:"warning,omitempty"` -} - -type PermissionRequestShellCommand struct { - // Command identifier (e.g., executable name) - Identifier string `json:"identifier"` - // Whether this command is read-only (no side effects) - ReadOnly bool `json:"readOnly"` } -type PermissionRequestShellPossibleURL struct { - // URL that may be accessed by the command - URL string `json:"url"` +func (PermissionPromptRequestMemory) permissionPromptRequest() {} +func (PermissionPromptRequestMemory) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindMemory } -// The result of the permission request -type PermissionResult struct { - // The approval to add as a session-scoped rule - Approval *UserToolSessionApproval `json:"approval,omitempty"` - // Optional feedback from the user explaining the denial - Feedback *string `json:"feedback,omitempty"` - // Whether to force-reject the current agent turn - ForceReject *bool `json:"forceReject,omitempty"` - // Whether to interrupt the current agent turn - Interrupt *bool `json:"interrupt,omitempty"` - // Kind discriminator - Kind PermissionResultKind `json:"kind"` - // The location key (git root or cwd) to persist the approval to - LocationKey *string `json:"locationKey,omitempty"` - // Human-readable explanation of why the path was excluded - Message *string `json:"message,omitempty"` - // File path that triggered the exclusion - Path *string `json:"path,omitempty"` - // Optional explanation of why the request was cancelled - Reason *string `json:"reason,omitempty"` - // Rules that denied the request - Rules []PermissionRule `json:"rules,omitempty"` +// Path access permission prompt +type PermissionPromptRequestPath struct { + // Underlying permission kind that needs path approval + AccessKind PermissionPromptRequestPathAccessKind `json:"accessKind"` + // File paths that require explicit approval + Paths []string `json:"paths"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` } -type PermissionRule struct { - // Optional rule argument matched against the request - Argument *string `json:"argument"` - // The rule kind, such as Shell or GitHubMCP - Kind string `json:"kind"` +func (PermissionPromptRequestPath) permissionPromptRequest() {} +func (PermissionPromptRequestPath) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindPath } -// Aggregate code change metrics for the session -type ShutdownCodeChanges struct { - // List of file paths that were modified during the session - FilesModified []string `json:"filesModified"` - // Total number of lines added during the session - LinesAdded float64 `json:"linesAdded"` - // Total number of lines removed during the session - LinesRemoved float64 `json:"linesRemoved"` +// File read permission prompt +type PermissionPromptRequestRead struct { + // Human-readable description of why the file is being read + Intention string `json:"intention"` + // Path of the file or directory being read + Path string `json:"path"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` } -type ShutdownModelMetric struct { - // Request count and cost metrics - Requests ShutdownModelMetricRequests `json:"requests"` - // Token count details per type - TokenDetails map[string]ShutdownModelMetricTokenDetail `json:"tokenDetails,omitempty"` - // Accumulated nano-AI units cost for this model - TotalNanoAiu *float64 `json:"totalNanoAiu,omitempty"` - // Token usage breakdown - Usage ShutdownModelMetricUsage `json:"usage"` +func (PermissionPromptRequestRead) permissionPromptRequest() {} +func (PermissionPromptRequestRead) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindRead } -// Request count and cost metrics -type ShutdownModelMetricRequests struct { - // Cumulative cost multiplier for requests to this model - Cost float64 `json:"cost"` - // Total number of API requests made to this model - Count float64 `json:"count"` +// URL access permission prompt +type PermissionPromptRequestURL struct { + // Human-readable description of why the URL is being accessed + Intention string `json:"intention"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // URL to be fetched + URL string `json:"url"` } -type ShutdownModelMetricTokenDetail struct { - // Accumulated token count for this token type - TokenCount float64 `json:"tokenCount"` +func (PermissionPromptRequestURL) permissionPromptRequest() {} +func (PermissionPromptRequestURL) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindURL } -// Token usage breakdown -type ShutdownModelMetricUsage struct { +// File write permission prompt +type PermissionPromptRequestWrite struct { + // Whether the UI can offer session-wide approval for file write operations + CanOfferSessionApproval bool `json:"canOfferSessionApproval"` + // Unified diff showing the proposed changes + Diff string `json:"diff"` + // Path of the file being written to + FileName string `json:"fileName"` + // Human-readable description of the intended file change + Intention string `json:"intention"` + // Complete new file contents for newly created files + NewFileContents *string `json:"newFileContents,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionPromptRequestWrite) permissionPromptRequest() {} +func (PermissionPromptRequestWrite) Kind() PermissionPromptRequestKind { + return PermissionPromptRequestKindWrite +} + +// Details of the permission being requested +type PermissionRequest interface { + permissionRequest() + Kind() PermissionRequestKind +} + +type RawPermissionRequest struct { + Discriminator PermissionRequestKind + Raw json.RawMessage +} + +func (RawPermissionRequest) permissionRequest() {} +func (r RawPermissionRequest) Kind() PermissionRequestKind { + return r.Discriminator +} + +// Custom tool invocation permission request +type PermissionRequestCustomTool struct { + // Arguments to pass to the custom tool + Args any `json:"args,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Description of what the custom tool does + ToolDescription string `json:"toolDescription"` + // Name of the custom tool + ToolName string `json:"toolName"` +} + +func (PermissionRequestCustomTool) permissionRequest() {} +func (PermissionRequestCustomTool) Kind() PermissionRequestKind { + return PermissionRequestKindCustomTool +} + +// Extension management permission request +type PermissionRequestExtensionManagement struct { + // Name of the extension being managed + ExtensionName *string `json:"extensionName,omitempty"` + // The extension management operation (scaffold, reload) + Operation string `json:"operation"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionRequestExtensionManagement) permissionRequest() {} +func (PermissionRequestExtensionManagement) Kind() PermissionRequestKind { + return PermissionRequestKindExtensionManagement +} + +// Extension permission access request +type PermissionRequestExtensionPermissionAccess struct { + // Capabilities the extension is requesting + Capabilities []string `json:"capabilities"` + // Name of the extension requesting permission access + ExtensionName string `json:"extensionName"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionRequestExtensionPermissionAccess) permissionRequest() {} +func (PermissionRequestExtensionPermissionAccess) Kind() PermissionRequestKind { + return PermissionRequestKindExtensionPermissionAccess +} + +// Hook confirmation permission request +type PermissionRequestHook struct { + // Optional message from the hook explaining why confirmation is needed + HookMessage *string `json:"hookMessage,omitempty"` + // Arguments of the tool call being gated + ToolArgs any `json:"toolArgs,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Name of the tool the hook is gating + ToolName string `json:"toolName"` +} + +func (PermissionRequestHook) permissionRequest() {} +func (PermissionRequestHook) Kind() PermissionRequestKind { + return PermissionRequestKindHook +} + +// MCP tool invocation permission request +type PermissionRequestMcp struct { + // Arguments to pass to the MCP tool + Args any `json:"args,omitempty"` + // Whether this MCP tool is read-only (no side effects) + ReadOnly bool `json:"readOnly"` + // Name of the MCP server providing the tool + ServerName string `json:"serverName"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Internal name of the MCP tool + ToolName string `json:"toolName"` + // Human-readable title of the MCP tool + ToolTitle string `json:"toolTitle"` +} + +func (PermissionRequestMcp) permissionRequest() {} +func (PermissionRequestMcp) Kind() PermissionRequestKind { + return PermissionRequestKindMcp +} + +// Memory operation permission request +type PermissionRequestMemory struct { + // Whether this is a store or vote memory operation + Action *PermissionRequestMemoryAction `json:"action,omitempty"` + // Source references for the stored fact (store only) + Citations *string `json:"citations,omitempty"` + // Vote direction (vote only) + Direction *PermissionRequestMemoryDirection `json:"direction,omitempty"` + // The fact being stored or voted on + Fact string `json:"fact"` + // Reason for the vote (vote only) + Reason *string `json:"reason,omitempty"` + // Topic or subject of the memory (store only) + Subject *string `json:"subject,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionRequestMemory) permissionRequest() {} +func (PermissionRequestMemory) Kind() PermissionRequestKind { + return PermissionRequestKindMemory +} + +// File or directory read permission request +type PermissionRequestRead struct { + // Human-readable description of why the file is being read + Intention string `json:"intention"` + // Path of the file or directory being read + Path string `json:"path"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionRequestRead) permissionRequest() {} +func (PermissionRequestRead) Kind() PermissionRequestKind { + return PermissionRequestKindRead +} + +// Shell command permission request +type PermissionRequestShell struct { + // Whether the UI can offer session-wide approval for this command pattern + CanOfferSessionApproval bool `json:"canOfferSessionApproval"` + // Parsed command identifiers found in the command text + Commands []PermissionRequestShellCommand `json:"commands"` + // The complete shell command text to be executed + FullCommandText string `json:"fullCommandText"` + // Whether the command includes a file write redirection (e.g., > or >>) + HasWriteFileRedirection bool `json:"hasWriteFileRedirection"` + // Human-readable description of what the command intends to do + Intention string `json:"intention"` + // File paths that may be read or written by the command + PossiblePaths []string `json:"possiblePaths"` + // URLs that may be accessed by the command + PossibleUrls []PermissionRequestShellPossibleURL `json:"possibleUrls"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Optional warning message about risks of running this command + Warning *string `json:"warning,omitempty"` +} + +func (PermissionRequestShell) permissionRequest() {} +func (PermissionRequestShell) Kind() PermissionRequestKind { + return PermissionRequestKindShell +} + +// URL access permission request +type PermissionRequestURL struct { + // Human-readable description of why the URL is being accessed + Intention string `json:"intention"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // URL to be fetched + URL string `json:"url"` +} + +func (PermissionRequestURL) permissionRequest() {} +func (PermissionRequestURL) Kind() PermissionRequestKind { + return PermissionRequestKindURL +} + +// File write permission request +type PermissionRequestWrite struct { + // Whether the UI can offer session-wide approval for file write operations + CanOfferSessionApproval bool `json:"canOfferSessionApproval"` + // Unified diff showing the proposed changes + Diff string `json:"diff"` + // Path of the file being written to + FileName string `json:"fileName"` + // Human-readable description of the intended file change + Intention string `json:"intention"` + // Complete new file contents for newly created files + NewFileContents *string `json:"newFileContents,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` +} + +func (PermissionRequestWrite) permissionRequest() {} +func (PermissionRequestWrite) Kind() PermissionRequestKind { + return PermissionRequestKindWrite +} + +type PermissionRequestShellCommand struct { + // Command identifier (e.g., executable name) + Identifier string `json:"identifier"` + // Whether this command is read-only (no side effects) + ReadOnly bool `json:"readOnly"` +} + +type PermissionRequestShellPossibleURL struct { + // URL that may be accessed by the command + URL string `json:"url"` +} + +// The result of the permission request +type PermissionResult interface { + permissionResult() + Kind() PermissionResultKind +} + +type RawPermissionResult struct { + Discriminator PermissionResultKind + Raw json.RawMessage +} + +func (RawPermissionResult) permissionResult() {} +func (r RawPermissionResult) Kind() PermissionResultKind { + return r.Discriminator +} + +type PermissionApproved struct { +} + +func (PermissionApproved) permissionResult() {} +func (PermissionApproved) Kind() PermissionResultKind { + return PermissionResultKindApproved +} + +type PermissionApprovedForLocation struct { + // The approval to persist for this location + Approval UserToolSessionApproval `json:"approval"` + // The location key (git root or cwd) to persist the approval to + LocationKey string `json:"locationKey"` +} + +func (PermissionApprovedForLocation) permissionResult() {} +func (PermissionApprovedForLocation) Kind() PermissionResultKind { + return PermissionResultKindApprovedForLocation +} + +type PermissionApprovedForSession struct { + // The approval to add as a session-scoped rule + Approval UserToolSessionApproval `json:"approval"` +} + +func (PermissionApprovedForSession) permissionResult() {} +func (PermissionApprovedForSession) Kind() PermissionResultKind { + return PermissionResultKindApprovedForSession +} + +type PermissionCancelled struct { + // Optional explanation of why the request was cancelled + Reason *string `json:"reason,omitempty"` +} + +func (PermissionCancelled) permissionResult() {} +func (PermissionCancelled) Kind() PermissionResultKind { + return PermissionResultKindCancelled +} + +type PermissionDeniedByContentExclusionPolicy struct { + // Human-readable explanation of why the path was excluded + Message string `json:"message"` + // File path that triggered the exclusion + Path string `json:"path"` +} + +func (PermissionDeniedByContentExclusionPolicy) permissionResult() {} +func (PermissionDeniedByContentExclusionPolicy) Kind() PermissionResultKind { + return PermissionResultKindDeniedByContentExclusionPolicy +} + +type PermissionDeniedByPermissionRequestHook struct { + // Whether to interrupt the current agent turn + Interrupt *bool `json:"interrupt,omitempty"` + // Optional message from the hook explaining the denial + Message *string `json:"message,omitempty"` +} + +func (PermissionDeniedByPermissionRequestHook) permissionResult() {} +func (PermissionDeniedByPermissionRequestHook) Kind() PermissionResultKind { + return PermissionResultKindDeniedByPermissionRequestHook +} + +type PermissionDeniedByRules struct { + // Rules that denied the request + Rules []PermissionRule `json:"rules"` +} + +func (PermissionDeniedByRules) permissionResult() {} +func (PermissionDeniedByRules) Kind() PermissionResultKind { + return PermissionResultKindDeniedByRules +} + +type PermissionDeniedInteractivelyByUser struct { + // Optional feedback from the user explaining the denial + Feedback *string `json:"feedback,omitempty"` + // Whether to force-reject the current agent turn + ForceReject *bool `json:"forceReject,omitempty"` +} + +func (PermissionDeniedInteractivelyByUser) permissionResult() {} +func (PermissionDeniedInteractivelyByUser) Kind() PermissionResultKind { + return PermissionResultKindDeniedInteractivelyByUser +} + +type PermissionDeniedNoApprovalRuleAndCouldNotRequestFromUser struct { +} + +func (PermissionDeniedNoApprovalRuleAndCouldNotRequestFromUser) permissionResult() {} +func (PermissionDeniedNoApprovalRuleAndCouldNotRequestFromUser) Kind() PermissionResultKind { + return PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser +} + +type PermissionRule struct { + // Optional rule argument matched against the request + Argument *string `json:"argument"` + // The rule kind, such as Shell or GitHubMCP + Kind string `json:"kind"` +} + +// Aggregate code change metrics for the session +type ShutdownCodeChanges struct { + // List of file paths that were modified during the session + FilesModified []string `json:"filesModified"` + // Total number of lines added during the session + LinesAdded float64 `json:"linesAdded"` + // Total number of lines removed during the session + LinesRemoved float64 `json:"linesRemoved"` +} + +type ShutdownModelMetric struct { + // Request count and cost metrics + Requests ShutdownModelMetricRequests `json:"requests"` + // Token count details per type + TokenDetails map[string]ShutdownModelMetricTokenDetail `json:"tokenDetails,omitempty"` + // Accumulated nano-AI units cost for this model + TotalNanoAiu *float64 `json:"totalNanoAiu,omitempty"` + // Token usage breakdown + Usage ShutdownModelMetricUsage `json:"usage"` +} + +// Request count and cost metrics +type ShutdownModelMetricRequests struct { + // Cumulative cost multiplier for requests to this model + Cost float64 `json:"cost"` + // Total number of API requests made to this model + Count float64 `json:"count"` +} + +type ShutdownModelMetricTokenDetail struct { + // Accumulated token count for this token type + TokenCount float64 `json:"tokenCount"` +} + +// Token usage breakdown +type ShutdownModelMetricUsage struct { // Total tokens read from prompt cache across all requests CacheReadTokens float64 `json:"cacheReadTokens"` // Total tokens written to prompt cache across all requests @@ -2301,81 +2227,246 @@ type SystemMessageMetadata struct { } // Structured metadata identifying what triggered this notification -type SystemNotification struct { +type SystemNotification interface { + systemNotification() + Type() SystemNotificationType +} + +type RawSystemNotification struct { + Discriminator SystemNotificationType + Raw json.RawMessage +} + +func (RawSystemNotification) systemNotification() {} +func (r RawSystemNotification) Type() SystemNotificationType { + return r.Discriminator +} + +type SystemNotificationAgentCompleted struct { // Unique identifier of the background agent - AgentID *string `json:"agentId,omitempty"` + AgentID string `json:"agentId"` // Type of the agent (e.g., explore, task, general-purpose) - AgentType *string `json:"agentType,omitempty"` + AgentType string `json:"agentType"` // Human-readable description of the agent task Description *string `json:"description,omitempty"` - // Unique identifier of the inbox entry - EntryID *string `json:"entryId,omitempty"` - // Exit code of the shell command, if available - ExitCode *float64 `json:"exitCode,omitempty"` // The full prompt given to the background agent Prompt *string `json:"prompt,omitempty"` - // Human-readable name of the sender - SenderName *string `json:"senderName,omitempty"` - // Category of the sender (e.g., sidekick-agent, plugin, hook) - SenderType *string `json:"senderType,omitempty"` - // Unique identifier of the shell session - ShellID *string `json:"shellId,omitempty"` - // Relative path to the discovered instruction file - SourcePath *string `json:"sourcePath,omitempty"` // Whether the agent completed successfully or failed - Status *SystemNotificationAgentCompletedStatus `json:"status,omitempty"` - // Short summary shown before the agent decides whether to read the inbox - Summary *string `json:"summary,omitempty"` + Status SystemNotificationAgentCompletedStatus `json:"status"` +} + +func (SystemNotificationAgentCompleted) systemNotification() {} +func (SystemNotificationAgentCompleted) Type() SystemNotificationType { + return SystemNotificationTypeAgentCompleted +} + +type SystemNotificationAgentIdle struct { + // Unique identifier of the background agent + AgentID string `json:"agentId"` + // Type of the agent (e.g., explore, task, general-purpose) + AgentType string `json:"agentType"` + // Human-readable description of the agent task + Description *string `json:"description,omitempty"` +} + +func (SystemNotificationAgentIdle) systemNotification() {} +func (SystemNotificationAgentIdle) Type() SystemNotificationType { + return SystemNotificationTypeAgentIdle +} + +type SystemNotificationInstructionDiscovered struct { + // Human-readable label for the timeline (e.g., 'AGENTS.md from packages/billing/') + Description *string `json:"description,omitempty"` + // Relative path to the discovered instruction file + SourcePath string `json:"sourcePath"` // Path of the file access that triggered discovery - TriggerFile *string `json:"triggerFile,omitempty"` + TriggerFile string `json:"triggerFile"` // Tool command that triggered discovery (currently always 'view') - TriggerTool *string `json:"triggerTool,omitempty"` - // Type discriminator - Type SystemNotificationType `json:"type"` + TriggerTool string `json:"triggerTool"` +} + +func (SystemNotificationInstructionDiscovered) systemNotification() {} +func (SystemNotificationInstructionDiscovered) Type() SystemNotificationType { + return SystemNotificationTypeInstructionDiscovered +} + +type SystemNotificationNewInboxMessage struct { + // Unique identifier of the inbox entry + EntryID string `json:"entryId"` + // Human-readable name of the sender + SenderName string `json:"senderName"` + // Category of the sender (e.g., sidekick-agent, plugin, hook) + SenderType string `json:"senderType"` + // Short summary shown before the agent decides whether to read the inbox + Summary string `json:"summary"` +} + +func (SystemNotificationNewInboxMessage) systemNotification() {} +func (SystemNotificationNewInboxMessage) Type() SystemNotificationType { + return SystemNotificationTypeNewInboxMessage +} + +type SystemNotificationShellCompleted struct { + // Human-readable description of the command + Description *string `json:"description,omitempty"` + // Exit code of the shell command, if available + ExitCode *float64 `json:"exitCode,omitempty"` + // Unique identifier of the shell session + ShellID string `json:"shellId"` +} + +func (SystemNotificationShellCompleted) systemNotification() {} +func (SystemNotificationShellCompleted) Type() SystemNotificationType { + return SystemNotificationTypeShellCompleted +} + +type SystemNotificationShellDetachedCompleted struct { + // Human-readable description of the command + Description *string `json:"description,omitempty"` + // Unique identifier of the detached shell session + ShellID string `json:"shellId"` +} + +func (SystemNotificationShellDetachedCompleted) systemNotification() {} +func (SystemNotificationShellDetachedCompleted) Type() SystemNotificationType { + return SystemNotificationTypeShellDetachedCompleted } // A content block within a tool result, which may be text, terminal output, image, audio, or a resource -type ToolExecutionCompleteContent struct { - // Working directory where the command was executed - Cwd *string `json:"cwd,omitempty"` +type ToolExecutionCompleteContent interface { + toolExecutionCompleteContent() + Type() ToolExecutionCompleteContentType +} + +type RawToolExecutionCompleteContent struct { + Discriminator ToolExecutionCompleteContentType + Raw json.RawMessage +} + +func (RawToolExecutionCompleteContent) toolExecutionCompleteContent() {} +func (r RawToolExecutionCompleteContent) Type() ToolExecutionCompleteContentType { + return r.Discriminator +} + +// Audio content block with base64-encoded data +type ToolExecutionCompleteContentAudio struct { + // Base64-encoded audio data + Data string `json:"data"` + // MIME type of the audio (e.g., audio/wav, audio/mpeg) + MIMEType string `json:"mimeType"` +} + +func (ToolExecutionCompleteContentAudio) toolExecutionCompleteContent() {} +func (ToolExecutionCompleteContentAudio) Type() ToolExecutionCompleteContentType { + return ToolExecutionCompleteContentTypeAudio +} + +// Image content block with base64-encoded data +type ToolExecutionCompleteContentImage struct { // Base64-encoded image data - Data *string `json:"data,omitempty"` + Data string `json:"data"` + // MIME type of the image (e.g., image/png, image/jpeg) + MIMEType string `json:"mimeType"` +} + +func (ToolExecutionCompleteContentImage) toolExecutionCompleteContent() {} +func (ToolExecutionCompleteContentImage) Type() ToolExecutionCompleteContentType { + return ToolExecutionCompleteContentTypeImage +} + +// Embedded resource content block with inline text or binary data +type ToolExecutionCompleteContentResource struct { + // The embedded resource contents, either text or base64-encoded binary + Resource ToolExecutionCompleteContentResourceDetails `json:"resource"` +} + +func (ToolExecutionCompleteContentResource) toolExecutionCompleteContent() {} +func (ToolExecutionCompleteContentResource) Type() ToolExecutionCompleteContentType { + return ToolExecutionCompleteContentTypeResource +} + +// Resource link content block referencing an external resource +type ToolExecutionCompleteContentResourceLink struct { // Human-readable description of the resource Description *string `json:"description,omitempty"` - // Process exit code, if the command has completed - ExitCode *float64 `json:"exitCode,omitempty"` // Icons associated with this resource Icons []ToolExecutionCompleteContentResourceLinkIcon `json:"icons,omitempty"` - // MIME type of the image (e.g., image/png, image/jpeg) + // MIME type of the resource content MIMEType *string `json:"mimeType,omitempty"` // Resource name identifier - Name *string `json:"name,omitempty"` - // The embedded resource contents, either text or base64-encoded binary - Resource *ToolExecutionCompleteContentResourceDetails `json:"resource,omitempty"` + Name string `json:"name"` // Size of the resource in bytes Size *float64 `json:"size,omitempty"` - // The text content - Text *string `json:"text,omitempty"` // Human-readable display title for the resource Title *string `json:"title,omitempty"` - // Type discriminator - Type ToolExecutionCompleteContentType `json:"type"` // URI identifying the resource - URI *string `json:"uri,omitempty"` + URI string `json:"uri"` +} + +func (ToolExecutionCompleteContentResourceLink) toolExecutionCompleteContent() {} +func (ToolExecutionCompleteContentResourceLink) Type() ToolExecutionCompleteContentType { + return ToolExecutionCompleteContentTypeResourceLink +} + +// Terminal/shell output content block with optional exit code and working directory +type ToolExecutionCompleteContentTerminal struct { + // Working directory where the command was executed + Cwd *string `json:"cwd,omitempty"` + // Process exit code, if the command has completed + ExitCode *float64 `json:"exitCode,omitempty"` + // Terminal/shell output text + Text string `json:"text"` +} + +func (ToolExecutionCompleteContentTerminal) toolExecutionCompleteContent() {} +func (ToolExecutionCompleteContentTerminal) Type() ToolExecutionCompleteContentType { + return ToolExecutionCompleteContentTypeTerminal +} + +// Plain text content block +type ToolExecutionCompleteContentText struct { + // The text content + Text string `json:"text"` +} + +func (ToolExecutionCompleteContentText) toolExecutionCompleteContent() {} +func (ToolExecutionCompleteContentText) Type() ToolExecutionCompleteContentType { + return ToolExecutionCompleteContentTypeText } // The embedded resource contents, either text or base64-encoded binary -type ToolExecutionCompleteContentResourceDetails struct { +type ToolExecutionCompleteContentResourceDetails interface { + toolExecutionCompleteContentResourceDetails() +} + +type RawToolExecutionCompleteContentResourceDetails struct { + Raw json.RawMessage +} + +func (RawToolExecutionCompleteContentResourceDetails) toolExecutionCompleteContentResourceDetails() {} + +type EmbeddedBlobResourceContents struct { // Base64-encoded binary content of the resource - Blob *string `json:"blob,omitempty"` + Blob string `json:"blob"` + // MIME type of the blob content + MIMEType *string `json:"mimeType,omitempty"` + // URI identifying the resource + URI string `json:"uri"` +} + +func (EmbeddedBlobResourceContents) toolExecutionCompleteContentResourceDetails() {} + +type EmbeddedTextResourceContents struct { // MIME type of the text content MIMEType *string `json:"mimeType,omitempty"` // Text content of the resource - Text *string `json:"text,omitempty"` + Text string `json:"text"` // URI identifying the resource URI string `json:"uri"` } +func (EmbeddedTextResourceContents) toolExecutionCompleteContentResourceDetails() {} + // Icon image for a resource type ToolExecutionCompleteContentResourceLinkIcon struct { // MIME type of the icon image @@ -2407,35 +2498,98 @@ type ToolExecutionCompleteResult struct { } // A user message attachment — a file, directory, code selection, blob, or GitHub reference -type UserMessageAttachment struct { +type UserMessageAttachment interface { + userMessageAttachment() + Type() UserMessageAttachmentType +} + +type RawUserMessageAttachment struct { + Discriminator UserMessageAttachmentType + Raw json.RawMessage +} + +func (RawUserMessageAttachment) userMessageAttachment() {} +func (r RawUserMessageAttachment) Type() UserMessageAttachmentType { + return r.Discriminator +} + +// Blob attachment with inline base64-encoded data +type UserMessageAttachmentBlob struct { // Base64-encoded content - Data *string `json:"data,omitempty"` + Data string `json:"data"` // User-facing display name for the attachment DisplayName *string `json:"displayName,omitempty"` - // Absolute path to the file containing the selection - FilePath *string `json:"filePath,omitempty"` + // MIME type of the inline data + MIMEType string `json:"mimeType"` +} + +func (UserMessageAttachmentBlob) userMessageAttachment() {} +func (UserMessageAttachmentBlob) Type() UserMessageAttachmentType { + return UserMessageAttachmentTypeBlob +} + +// Directory attachment +type UserMessageAttachmentDirectory struct { + // User-facing display name for the attachment + DisplayName string `json:"displayName"` + // Absolute directory path + Path string `json:"path"` +} + +func (UserMessageAttachmentDirectory) userMessageAttachment() {} +func (UserMessageAttachmentDirectory) Type() UserMessageAttachmentType { + return UserMessageAttachmentTypeDirectory +} + +// File attachment +type UserMessageAttachmentFile struct { + // User-facing display name for the attachment + DisplayName string `json:"displayName"` // Optional line range to scope the attachment to a specific section of the file LineRange *UserMessageAttachmentFileLineRange `json:"lineRange,omitempty"` - // MIME type of the inline data - MIMEType *string `json:"mimeType,omitempty"` - // Issue, pull request, or discussion number - Number *float64 `json:"number,omitempty"` // Absolute file path - Path *string `json:"path,omitempty"` + Path string `json:"path"` +} + +func (UserMessageAttachmentFile) userMessageAttachment() {} +func (UserMessageAttachmentFile) Type() UserMessageAttachmentType { + return UserMessageAttachmentTypeFile +} + +// GitHub issue, pull request, or discussion reference +type UserMessageAttachmentGithubReference struct { + // Issue, pull request, or discussion number + Number float64 `json:"number"` // Type of GitHub reference - ReferenceType *UserMessageAttachmentGithubReferenceType `json:"referenceType,omitempty"` - // Position range of the selection within the file - Selection *UserMessageAttachmentSelectionDetails `json:"selection,omitempty"` + ReferenceType UserMessageAttachmentGithubReferenceType `json:"referenceType"` // Current state of the referenced item (e.g., open, closed, merged) - State *string `json:"state,omitempty"` - // The selected text content - Text *string `json:"text,omitempty"` + State string `json:"state"` // Title of the referenced item - Title *string `json:"title,omitempty"` - // Type discriminator - Type UserMessageAttachmentType `json:"type"` + Title string `json:"title"` // URL to the referenced item on GitHub - URL *string `json:"url,omitempty"` + URL string `json:"url"` +} + +func (UserMessageAttachmentGithubReference) userMessageAttachment() {} +func (UserMessageAttachmentGithubReference) Type() UserMessageAttachmentType { + return UserMessageAttachmentTypeGithubReference +} + +// Code selection attachment from an editor +type UserMessageAttachmentSelection struct { + // User-facing display name for the selection + DisplayName string `json:"displayName"` + // Absolute path to the file containing the selection + FilePath string `json:"filePath"` + // Position range of the selection within the file + Selection UserMessageAttachmentSelectionDetails `json:"selection"` + // The selected text content + Text string `json:"text"` +} + +func (UserMessageAttachmentSelection) userMessageAttachment() {} +func (UserMessageAttachmentSelection) Type() UserMessageAttachmentType { + return UserMessageAttachmentTypeSelection } // Optional line range to scope the attachment to a specific section of the file @@ -2471,19 +2625,95 @@ type UserMessageAttachmentSelectionDetailsStart struct { } // The approval to add as a session-scoped rule -type UserToolSessionApproval struct { +type UserToolSessionApproval interface { + userToolSessionApproval() + Kind() UserToolSessionApprovalKind +} + +type RawUserToolSessionApproval struct { + Discriminator UserToolSessionApprovalKind + Raw json.RawMessage +} + +func (RawUserToolSessionApproval) userToolSessionApproval() {} +func (r RawUserToolSessionApproval) Kind() UserToolSessionApprovalKind { + return r.Discriminator +} + +type UserToolSessionApprovalCommands struct { // Command identifiers approved by the user - CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` - // Extension name - ExtensionName *string `json:"extensionName,omitempty"` - // Kind discriminator - Kind UserToolSessionApprovalKind `json:"kind"` + CommandIdentifiers []string `json:"commandIdentifiers"` +} + +func (UserToolSessionApprovalCommands) userToolSessionApproval() {} +func (UserToolSessionApprovalCommands) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindCommands +} + +type UserToolSessionApprovalCustomTool struct { + // Custom tool name + ToolName string `json:"toolName"` +} + +func (UserToolSessionApprovalCustomTool) userToolSessionApproval() {} +func (UserToolSessionApprovalCustomTool) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindCustomTool +} + +type UserToolSessionApprovalExtensionManagement struct { // Optional operation identifier Operation *string `json:"operation,omitempty"` +} + +func (UserToolSessionApprovalExtensionManagement) userToolSessionApproval() {} +func (UserToolSessionApprovalExtensionManagement) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindExtensionManagement +} + +type UserToolSessionApprovalExtensionPermissionAccess struct { + // Extension name + ExtensionName string `json:"extensionName"` +} + +func (UserToolSessionApprovalExtensionPermissionAccess) userToolSessionApproval() {} +func (UserToolSessionApprovalExtensionPermissionAccess) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindExtensionPermissionAccess +} + +type UserToolSessionApprovalMcp struct { // MCP server name - ServerName *string `json:"serverName,omitempty"` + ServerName string `json:"serverName"` // Optional MCP tool name, or null for all tools on the server - ToolName *string `json:"toolName,omitempty"` + ToolName *string `json:"toolName"` +} + +func (UserToolSessionApprovalMcp) userToolSessionApproval() {} +func (UserToolSessionApprovalMcp) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindMcp +} + +type UserToolSessionApprovalMemory struct { +} + +func (UserToolSessionApprovalMemory) userToolSessionApproval() {} +func (UserToolSessionApprovalMemory) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindMemory +} + +type UserToolSessionApprovalRead struct { +} + +func (UserToolSessionApprovalRead) userToolSessionApproval() {} +func (UserToolSessionApprovalRead) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindRead +} + +type UserToolSessionApprovalWrite struct { +} + +func (UserToolSessionApprovalWrite) userToolSessionApproval() {} +func (UserToolSessionApprovalWrite) Kind() UserToolSessionApprovalKind { + return UserToolSessionApprovalKindWrite } // Working directory and git context at session start diff --git a/go/internal/e2e/abort_e2e_test.go b/go/internal/e2e/abort_e2e_test.go index 10514b5db..d71af962e 100644 --- a/go/internal/e2e/abort_e2e_test.go +++ b/go/internal/e2e/abort_e2e_test.go @@ -76,7 +76,7 @@ func TestAbortE2E(t *testing.T) { // Key contract: at least one delta arrived before abort hasDelta := false for _, e := range snapshot { - if e.Type == copilot.SessionEventTypeAssistantMessageDelta { + if _, ok := e.Data.(*copilot.AssistantMessageDeltaData); ok { hasDelta = true break } diff --git a/go/internal/e2e/commands_and_elicitation_e2e_test.go b/go/internal/e2e/commands_and_elicitation_e2e_test.go index 3ae14d649..501e13813 100644 --- a/go/internal/e2e/commands_and_elicitation_e2e_test.go +++ b/go/internal/e2e/commands_and_elicitation_e2e_test.go @@ -53,7 +53,7 @@ func TestCommandsE2E(t *testing.T) { // Listen for commands.changed event on client1 commandsChangedCh := make(chan copilot.SessionEvent, 1) unsubscribe := session1.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeCommandsChanged { + if _, ok := event.Data.(*copilot.CommandsChangedData); ok { select { case commandsChangedCh <- event: default: @@ -416,7 +416,7 @@ func TestUIElicitationCallbackE2E(t *testing.T) { schema := rpc.UIElicitationSchema{ Type: rpc.UIElicitationSchemaTypeObject, Properties: map[string]rpc.UIElicitationSchemaProperty{ - "name": {Type: rpc.UIElicitationSchemaPropertyTypeString}, + "name": &rpc.UIElicitationSchemaPropertyString{}, }, Required: []string{"name"}, } @@ -549,12 +549,10 @@ func TestUIElicitationMultiClientE2E(t *testing.T) { // Listen for capabilities.changed with elicitation enabled capEnabledCh := make(chan copilot.SessionEvent, 1) unsubscribe := session1.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeCapabilitiesChanged { - if d, ok := event.Data.(*copilot.CapabilitiesChangedData); ok && d.UI != nil && d.UI.Elicitation != nil && *d.UI.Elicitation { - select { - case capEnabledCh <- event: - default: - } + if d, ok := event.Data.(*copilot.CapabilitiesChangedData); ok && d.UI != nil && d.UI.Elicitation != nil && *d.UI.Elicitation { + select { + case capEnabledCh <- event: + default: } } }) @@ -612,12 +610,10 @@ func TestUIElicitationMultiClientE2E(t *testing.T) { // Listen for capability enabled capEnabledCh := make(chan struct{}, 1) unsubEnabled := session1.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeCapabilitiesChanged { - if d, ok := event.Data.(*copilot.CapabilitiesChangedData); ok && d.UI != nil && d.UI.Elicitation != nil && *d.UI.Elicitation { - select { - case capEnabledCh <- struct{}{}: - default: - } + if d, ok := event.Data.(*copilot.CapabilitiesChangedData); ok && d.UI != nil && d.UI.Elicitation != nil && *d.UI.Elicitation { + select { + case capEnabledCh <- struct{}{}: + default: } } }) @@ -652,12 +648,10 @@ func TestUIElicitationMultiClientE2E(t *testing.T) { // Now listen for elicitation to become disabled capDisabledCh := make(chan struct{}, 1) unsubDisabled := session1.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeCapabilitiesChanged { - if d, ok := event.Data.(*copilot.CapabilitiesChangedData); ok && d.UI != nil && d.UI.Elicitation != nil && !*d.UI.Elicitation { - select { - case capDisabledCh <- struct{}{}: - default: - } + if d, ok := event.Data.(*copilot.CapabilitiesChangedData); ok && d.UI != nil && d.UI.Elicitation != nil && !*d.UI.Elicitation { + select { + case capDisabledCh <- struct{}{}: + default: } } }) diff --git a/go/internal/e2e/compaction_e2e_test.go b/go/internal/e2e/compaction_e2e_test.go index 61081773c..e09a33b4f 100644 --- a/go/internal/e2e/compaction_e2e_test.go +++ b/go/internal/e2e/compaction_e2e_test.go @@ -37,10 +37,10 @@ func TestCompactionE2E(t *testing.T) { var compactionCompleteEvents []copilot.SessionEvent session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeSessionCompactionStart { + switch event.Data.(type) { + case *copilot.SessionCompactionStartData: compactionStartEvents = append(compactionStartEvents, event) - } - if event.Type == copilot.SessionEventTypeSessionCompactionComplete { + case *copilot.SessionCompactionCompleteData: compactionCompleteEvents = append(compactionCompleteEvents, event) } }) @@ -107,7 +107,8 @@ func TestCompactionE2E(t *testing.T) { var compactionEvents []copilot.SessionEvent session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeSessionCompactionStart || event.Type == copilot.SessionEventTypeSessionCompactionComplete { + switch event.Data.(type) { + case *copilot.SessionCompactionStartData, *copilot.SessionCompactionCompleteData: compactionEvents = append(compactionEvents, event) } }) diff --git a/go/internal/e2e/event_fidelity_e2e_test.go b/go/internal/e2e/event_fidelity_e2e_test.go index 54ba39060..759a1f413 100644 --- a/go/internal/e2e/event_fidelity_e2e_test.go +++ b/go/internal/e2e/event_fidelity_e2e_test.go @@ -61,7 +61,7 @@ func TestEventFidelityE2E(t *testing.T) { // Verify the event itself has a valid ID and timestamp for _, evt := range snapshot { - if evt.Type == copilot.SessionEventTypeAssistantUsage { + if _, ok := evt.Data.(*copilot.AssistantUsageData); ok { if evt.ID == "" { t.Error("Expected assistant.usage event to have a non-empty ID") } @@ -135,7 +135,7 @@ func TestEventFidelityE2E(t *testing.T) { pendingModified := make(chan *copilot.SessionEvent, 1) session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypePendingMessagesModified { + if _, ok := event.Data.(*copilot.PendingMessagesModifiedData); ok { select { case pendingModified <- &event: default: @@ -195,7 +195,7 @@ func TestEventFidelityE2E(t *testing.T) { types := make([]copilot.SessionEventType, 0, len(messages)) for _, m := range messages { - types = append(types, m.Type) + types = append(types, m.Type()) } sessionStartIdx := -1 @@ -253,7 +253,7 @@ func TestEventFidelityE2E(t *testing.T) { // Verify user.message mentions the file for _, msg := range messages { - if msg.Type == copilot.SessionEventTypeUserMessage { + if msg.Type() == copilot.SessionEventTypeUserMessage { if d, ok := msg.Data.(*copilot.UserMessageData); ok { if !strings.Contains(d.Content, "order.txt") { t.Errorf("Expected user.message to mention 'order.txt', got %q", d.Content) @@ -265,7 +265,7 @@ func TestEventFidelityE2E(t *testing.T) { // Verify assistant.message references the number for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Type == copilot.SessionEventTypeAssistantMessage { + if messages[i].Type() == copilot.SessionEventTypeAssistantMessage { if d, ok := messages[i].Data.(*copilot.AssistantMessageData); ok { if !strings.Contains(d.Content, "42") { t.Errorf("Expected assistant.message to contain '42', got %q", d.Content) @@ -308,7 +308,7 @@ func TestEventFidelityE2E(t *testing.T) { snapshot := snapshotEventFidelityEvents(&mu, &events) types := make([]copilot.SessionEventType, 0, len(snapshot)) for _, event := range snapshot { - types = append(types, event.Type) + types = append(types, event.Type()) } if !containsEventFidelityType(types, copilot.SessionEventTypeUserMessage) { @@ -358,10 +358,10 @@ func TestEventFidelityE2E(t *testing.T) { snapshot := snapshotEventFidelityEvents(&mu, &events) for _, event := range snapshot { if event.ID == "" { - t.Fatalf("Expected event id to be populated for %q", event.Type) + t.Fatalf("Expected event id to be populated for %q", event.Type()) } if event.Timestamp.IsZero() { - t.Fatalf("Expected event timestamp to be populated for %q", event.Type) + t.Fatalf("Expected event timestamp to be populated for %q", event.Type()) } } @@ -482,7 +482,7 @@ func snapshotEventFidelityEvents(mu *sync.Mutex, events *[]copilot.SessionEvent) func eventFidelityTypes(events []copilot.SessionEvent) []copilot.SessionEventType { types := make([]copilot.SessionEventType, 0, len(events)) for _, event := range events { - types = append(types, event.Type) + types = append(types, event.Type()) } return types } diff --git a/go/internal/e2e/mode_handlers_e2e_test.go b/go/internal/e2e/mode_handlers_e2e_test.go index d4ed134ff..cdf6800a1 100644 --- a/go/internal/e2e/mode_handlers_e2e_test.go +++ b/go/internal/e2e/mode_handlers_e2e_test.go @@ -235,12 +235,12 @@ func waitForMatchingEventAllowingRateLimit(session *copilot.Session, eventType c result := make(chan *copilot.SessionEvent, 1) errCh := make(chan error, 1) unsubscribe := session.On(func(event copilot.SessionEvent) { - if event.Type == eventType && predicate(event) { + if event.Type() == eventType && predicate(event) { select { case result <- &event: default: } - } else if event.Type == copilot.SessionEventTypeSessionError { + } else if event.Type() == copilot.SessionEventTypeSessionError { if data, ok := event.Data.(*copilot.SessionErrorData); ok && data.ErrorType == "rate_limit" { return } diff --git a/go/internal/e2e/multi_client_e2e_test.go b/go/internal/e2e/multi_client_e2e_test.go index 7638d3212..c60c3ff6f 100644 --- a/go/internal/e2e/multi_client_e2e_test.go +++ b/go/internal/e2e/multi_client_e2e_test.go @@ -78,13 +78,13 @@ func TestMultiClientE2E(t *testing.T) { client2Completed := make(chan struct{}, 1) session1.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeExternalToolRequested { + switch event.Data.(type) { + case *copilot.ExternalToolRequestedData: select { case client1Requested <- struct{}{}: default: } - } - if event.Type == copilot.SessionEventTypeExternalToolCompleted { + case *copilot.ExternalToolCompletedData: select { case client1Completed <- struct{}{}: default: @@ -92,13 +92,13 @@ func TestMultiClientE2E(t *testing.T) { } }) session2.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeExternalToolRequested { + switch event.Data.(type) { + case *copilot.ExternalToolRequestedData: select { case client2Requested <- struct{}{}: default: } - } - if event.Type == copilot.SessionEventTypeExternalToolCompleted { + case *copilot.ExternalToolCompletedData: select { case client2Completed <- struct{}{}: default: @@ -224,7 +224,11 @@ func TestMultiClientE2E(t *testing.T) { } for _, event := range append(c1PermCompleted, c2PermCompleted...) { d, ok := event.Data.(*copilot.PermissionCompletedData) - if !ok || string(d.Result.Kind) != "approved" { + if !ok { + t.Errorf("Expected permission.completed result kind 'approved', got %v", event.Data) + continue + } + if _, ok := d.Result.(*copilot.PermissionApproved); !ok { t.Errorf("Expected permission.completed result kind 'approved', got %v", event.Data) } } @@ -317,7 +321,11 @@ func TestMultiClientE2E(t *testing.T) { } for _, event := range append(c1PermCompleted, c2PermCompleted...) { d, ok := event.Data.(*copilot.PermissionCompletedData) - if !ok || string(d.Result.Kind) != "denied-interactively-by-user" { + if !ok { + t.Errorf("Expected permission.completed result kind 'denied-interactively-by-user', got %v", event.Data) + continue + } + if _, ok := d.Result.(*copilot.PermissionDeniedInteractivelyByUser); !ok { t.Errorf("Expected permission.completed result kind 'denied-interactively-by-user', got %v", event.Data) } } @@ -507,7 +515,7 @@ func TestMultiClientE2E(t *testing.T) { func filterEventsByType(events []copilot.SessionEvent, eventType copilot.SessionEventType) []copilot.SessionEvent { var filtered []copilot.SessionEvent for _, e := range events { - if e.Type == eventType { + if e.Type() == eventType { filtered = append(filtered, e) } } diff --git a/go/internal/e2e/multi_turn_e2e_test.go b/go/internal/e2e/multi_turn_e2e_test.go index 8a91a359f..563de49c3 100644 --- a/go/internal/e2e/multi_turn_e2e_test.go +++ b/go/internal/e2e/multi_turn_e2e_test.go @@ -125,7 +125,7 @@ func assertToolTurnOrdering(t *testing.T, events []copilot.SessionEvent, turnDes observedTypes := make([]copilot.SessionEventType, 0, len(events)) for _, e := range events { - observedTypes = append(observedTypes, e.Type) + observedTypes = append(observedTypes, e.Type()) } userMessageIdx := indexOfEventType(events, copilot.SessionEventTypeUserMessage, 0) @@ -155,14 +155,14 @@ func assertToolTurnOrdering(t *testing.T, events []copilot.SessionEvent, turnDes // Match each tool.execution_complete to a preceding tool.execution_start with the same ToolCallID. starts := make(map[string]int) for i, e := range events { - if e.Type == copilot.SessionEventTypeToolExecutionStart { + if e.Type() == copilot.SessionEventTypeToolExecutionStart { if d, ok := e.Data.(*copilot.ToolExecutionStartData); ok { starts[d.ToolCallID] = i } } } for _, e := range events { - if e.Type == copilot.SessionEventTypeToolExecutionComplete { + if e.Type() == copilot.SessionEventTypeToolExecutionComplete { if d, ok := e.Data.(*copilot.ToolExecutionCompleteData); ok { if _, found := starts[d.ToolCallID]; !found { t.Errorf("[%s] tool.execution_complete for %q has no matching tool.execution_start; types=%v", @@ -188,7 +188,7 @@ func assertToolTurnOrdering(t *testing.T, events []copilot.SessionEvent, turnDes func indexOfEventType(events []copilot.SessionEvent, typ copilot.SessionEventType, startIdx int) int { for i := startIdx; i < len(events); i++ { - if events[i].Type == typ { + if events[i].Type() == typ { return i } } @@ -197,7 +197,7 @@ func indexOfEventType(events []copilot.SessionEvent, typ copilot.SessionEventTyp func lastIndexOfEventType(events []copilot.SessionEvent, typ copilot.SessionEventType) int { for i := len(events) - 1; i >= 0; i-- { - if events[i].Type == typ { + if events[i].Type() == typ { return i } } diff --git a/go/internal/e2e/pending_work_resume_e2e_test.go b/go/internal/e2e/pending_work_resume_e2e_test.go index dde7c0bd0..c4cc18c40 100644 --- a/go/internal/e2e/pending_work_resume_e2e_test.go +++ b/go/internal/e2e/pending_work_resume_e2e_test.go @@ -65,7 +65,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { // Subscribe to the permission.requested event before sending the prompt. permissionEventCh := make(chan *copilot.SessionEvent, 1) unsub := session1.On(func(evt copilot.SessionEvent) { - if evt.Type == copilot.SessionEventTypePermissionRequested { + if evt.Type() == copilot.SessionEventTypePermissionRequested { select { case permissionEventCh <- &evt: default: @@ -129,9 +129,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { permResult, err := session2.RPC.Permissions.HandlePendingPermissionRequest(t.Context(), &rpc.PermissionDecisionRequest{ RequestID: permData.RequestID, - Result: rpc.PermissionDecision{ - Kind: rpc.PermissionDecisionKindApproveOnce, - }, + Result: &rpc.PermissionDecisionApproveOnce{}, }) if err != nil { t.Fatalf("Failed to handle pending permission request: %v", err) @@ -243,9 +241,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { toolResult, err := session2.RPC.Tools.HandlePendingToolCall(t.Context(), &rpc.HandlePendingToolCallRequest{ RequestID: toolEvent.RequestID, - Result: &rpc.ExternalToolResult{ - String: copilot.String("EXTERNAL_RESUMED_BETA"), - }, + Result: rpc.ExternalToolStringResult("EXTERNAL_RESUMED_BETA"), }) if err != nil { t.Fatalf("Failed to handle pending tool call: %v", err) @@ -365,14 +361,14 @@ func TestPendingWorkResumeE2E(t *testing.T) { // Resolve B first to verify ordering doesn't matter. resB, err := session2.RPC.Tools.HandlePendingToolCall(t.Context(), &rpc.HandlePendingToolCallRequest{ RequestID: toolEvents["pending_lookup_b"].RequestID, - Result: &rpc.ExternalToolResult{String: copilot.String("PARALLEL_B_BETA")}, + Result: rpc.ExternalToolStringResult("PARALLEL_B_BETA"), }) if err != nil || !resB.Success { t.Fatalf("HandlePendingToolCall(B) failed: err=%v result=%+v", err, resB) } resA, err := session2.RPC.Tools.HandlePendingToolCall(t.Context(), &rpc.HandlePendingToolCallRequest{ RequestID: toolEvents["pending_lookup_a"].RequestID, - Result: &rpc.ExternalToolResult{String: copilot.String("PARALLEL_A_ALPHA")}, + Result: rpc.ExternalToolStringResult("PARALLEL_A_ALPHA"), }) if err != nil || !resA.Success { t.Fatalf("HandlePendingToolCall(A) failed: err=%v result=%+v", err, resA) @@ -534,7 +530,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { } var resumeEvent *copilot.SessionResumeData for _, msg := range messages { - if msg.Type == copilot.SessionEventTypeSessionResume { + if msg.Type() == copilot.SessionEventTypeSessionResume { if d, ok := msg.Data.(*copilot.SessionResumeData); ok { resumeEvent = d break @@ -555,9 +551,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { // handleable via HandlePendingToolCall. toolResult, err := session2.RPC.Tools.HandlePendingToolCall(t.Context(), &rpc.HandlePendingToolCallRequest{ RequestID: toolEvent.RequestID, - Result: &rpc.ExternalToolResult{ - String: copilot.String("EXTERNAL_RESUMED_BETA"), - }, + Result: rpc.ExternalToolStringResult("EXTERNAL_RESUMED_BETA"), }) if err != nil { t.Fatalf("Failed to handle pending tool call: %v", err) @@ -631,7 +625,7 @@ func TestPendingWorkResumeE2E(t *testing.T) { } var resumeEvent *copilot.SessionResumeData for _, msg := range messages { - if msg.Type == copilot.SessionEventTypeSessionResume { + if msg.Type() == copilot.SessionEventTypeSessionResume { if d, ok := msg.Data.(*copilot.SessionResumeData); ok { resumeEvent = d break @@ -720,7 +714,7 @@ func waitForExternalToolRequests(session *copilot.Session, names []string) *coll c.want[n] = struct{}{} } session.On(func(evt copilot.SessionEvent) { - if evt.Type != copilot.SessionEventTypeExternalToolRequested { + if evt.Type() != copilot.SessionEventTypeExternalToolRequested { return } d, ok := evt.Data.(*copilot.ExternalToolRequestedData) diff --git a/go/internal/e2e/permissions_e2e_test.go b/go/internal/e2e/permissions_e2e_test.go index 14116dd58..e7f309435 100644 --- a/go/internal/e2e/permissions_e2e_test.go +++ b/go/internal/e2e/permissions_e2e_test.go @@ -63,7 +63,7 @@ func TestPermissionsE2E(t *testing.T) { } writeCount := 0 for _, req := range permissionRequests { - if req.Kind == "write" { + if _, ok := req.(*copilot.PermissionRequestWrite); ok { writeCount++ } } @@ -105,7 +105,7 @@ func TestPermissionsE2E(t *testing.T) { mu.Lock() shellCount := 0 for _, req := range permissionRequests { - if req.Kind == "shell" { + if _, ok := req.(*copilot.PermissionRequestShell); ok { shellCount++ } } @@ -176,15 +176,13 @@ func TestPermissionsE2E(t *testing.T) { permissionDenied := false session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeToolExecutionComplete { - if d, ok := event.Data.(*copilot.ToolExecutionCompleteData); ok && - !d.Success && - d.Error != nil && - strings.Contains(d.Error.Message, "Permission denied") { - mu.Lock() - permissionDenied = true - mu.Unlock() - } + if d, ok := event.Data.(*copilot.ToolExecutionCompleteData); ok && + !d.Success && + d.Error != nil && + strings.Contains(d.Error.Message, "Permission denied") { + mu.Lock() + permissionDenied = true + mu.Unlock() } }) @@ -228,15 +226,13 @@ func TestPermissionsE2E(t *testing.T) { permissionDenied := false session2.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeToolExecutionComplete { - if d, ok := event.Data.(*copilot.ToolExecutionCompleteData); ok && - !d.Success && - d.Error != nil && - strings.Contains(d.Error.Message, "Permission denied") { - mu.Lock() - permissionDenied = true - mu.Unlock() - } + if d, ok := event.Data.(*copilot.ToolExecutionCompleteData); ok && + !d.Success && + d.Error != nil && + strings.Contains(d.Error.Message, "Permission denied") { + mu.Lock() + permissionDenied = true + mu.Unlock() } }) @@ -388,7 +384,7 @@ func TestPermissionsE2E(t *testing.T) { var receivedToolCallID atomicBool session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - if req.Kind == copilot.PermissionRequestKindShell && req.ToolCallID != nil && *req.ToolCallID != "" { + if shellReq, ok := req.(*copilot.PermissionRequestShell); ok && shellReq.ToolCallID != nil && *shellReq.ToolCallID != "" { receivedToolCallID.Set(true) } return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil @@ -429,12 +425,13 @@ func TestPermissionsE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - if req.Kind != copilot.PermissionRequestKindShell { + shellReq, ok := req.(*copilot.PermissionRequestShell) + if !ok { return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil } toolCallID := "" - if req.ToolCallID != nil { - toolCallID = *req.ToolCallID + if shellReq.ToolCallID != nil { + toolCallID = *shellReq.ToolCallID } addLifecycle("permission-start", toolCallID) select { @@ -655,14 +652,12 @@ func TestPermissionsE2E(t *testing.T) { hasFirst := false hasSecond := false for _, req := range reqs { - if req.Kind == copilot.PermissionRequestKindCustomTool { - if req.ToolName != nil { - if *req.ToolName == "first_permission_tool" { - hasFirst = true - } - if *req.ToolName == "second_permission_tool" { - hasSecond = true - } + if customReq, ok := req.(*copilot.PermissionRequestCustomTool); ok { + if customReq.ToolName == "first_permission_tool" { + hasFirst = true + } + if customReq.ToolName == "second_permission_tool" { + hasSecond = true } } } diff --git a/go/internal/e2e/rpc_event_side_effects_e2e_test.go b/go/internal/e2e/rpc_event_side_effects_e2e_test.go index 169e22bc2..e5b691f08 100644 --- a/go/internal/e2e/rpc_event_side_effects_e2e_test.go +++ b/go/internal/e2e/rpc_event_side_effects_e2e_test.go @@ -272,12 +272,12 @@ func waitForMatchingEvent(session *copilot.Session, eventType copilot.SessionEve result := make(chan *copilot.SessionEvent, 1) errCh := make(chan error, 1) unsubscribe := session.On(func(event copilot.SessionEvent) { - if event.Type == eventType && predicate(event) { + if event.Type() == eventType && predicate(event) { select { case result <- &event: default: } - } else if event.Type == copilot.SessionEventTypeSessionError { + } else if event.Type() == copilot.SessionEventTypeSessionError { msg := "session error" if data, ok := event.Data.(*copilot.SessionErrorData); ok { msg = data.Message diff --git a/go/internal/e2e/rpc_mcp_config_e2e_test.go b/go/internal/e2e/rpc_mcp_config_e2e_test.go index 34134c68a..e87e5c91e 100644 --- a/go/internal/e2e/rpc_mcp_config_e2e_test.go +++ b/go/internal/e2e/rpc_mcp_config_e2e_test.go @@ -21,13 +21,12 @@ func TestRpcMcpConfigE2E(t *testing.T) { serverName := fmt.Sprintf("sdk-test-%s", randomHex(t)) - nodeCmd := "node" - baseConfig := rpc.McpServerConfig{ - Command: &nodeCmd, + baseConfig := &rpc.McpServerConfigLocal{ + Command: "node", Args: []string{"-v"}, } - updatedConfig := rpc.McpServerConfig{ - Command: &nodeCmd, + updatedConfig := &rpc.McpServerConfigLocal{ + Command: "node", Args: []string{"--version"}, } @@ -74,11 +73,15 @@ func TestRpcMcpConfigE2E(t *testing.T) { if !present { t.Fatalf("Expected %q to still be present after Update", serverName) } - if updated.Command == nil || *updated.Command != "node" { - t.Errorf("Expected command='node', got %v", updated.Command) + updatedLocal, ok := updated.(*rpc.McpServerConfigLocal) + if !ok { + t.Fatalf("Expected local MCP config, got %T", updated) } - if len(updated.Args) == 0 || updated.Args[0] != "--version" { - t.Errorf("Expected args[0]='--version', got %v", updated.Args) + if updatedLocal.Command != "node" { + t.Errorf("Expected command='node', got %q", updatedLocal.Command) + } + if len(updatedLocal.Args) == 0 || updatedLocal.Args[0] != "--version" { + t.Errorf("Expected args[0]='--version', got %v", updatedLocal.Args) } if _, err := client.RPC.Mcp.Config().Disable(t.Context(), &rpc.McpConfigDisableRequest{Names: []string{serverName}}); err != nil { @@ -111,7 +114,7 @@ func TestRpcMcpConfigE2E(t *testing.T) { serverName := fmt.Sprintf("sdk-http-oauth-%s", randomHex(t)) - httpType := rpc.McpServerConfigTypeHTTP + httpType := rpc.McpServerConfigHTTPTypeHTTP urlBase := "https://example.com/mcp" urlUpdated := "https://example.com/updated-mcp" clientID := "client-id" @@ -123,9 +126,9 @@ func TestRpcMcpConfigE2E(t *testing.T) { var timeoutBase int64 = 3000 var timeoutUpdated int64 = 4000 - baseConfig := rpc.McpServerConfig{ + baseConfig := &rpc.McpServerConfigHTTP{ Type: &httpType, - URL: &urlBase, + URL: urlBase, Headers: map[string]string{"Authorization": "Bearer token"}, OauthClientID: &clientID, OauthPublicClient: &publicFalse, @@ -133,9 +136,9 @@ func TestRpcMcpConfigE2E(t *testing.T) { Tools: []string{"*"}, Timeout: &timeoutBase, } - updatedConfig := rpc.McpServerConfig{ + updatedConfig := &rpc.McpServerConfigHTTP{ Type: &httpType, - URL: &urlUpdated, + URL: urlUpdated, OauthClientID: &clientIDUpdated, OauthPublicClient: &publicTrue, OauthGrantType: &grantAuthCode, @@ -162,23 +165,27 @@ func TestRpcMcpConfigE2E(t *testing.T) { if !present { t.Fatalf("Expected %q to be present after Add", serverName) } - if added.Type == nil || *added.Type != "http" { - t.Errorf("Expected type='http', got %v", added.Type) + addedHTTP, ok := added.(*rpc.McpServerConfigHTTP) + if !ok { + t.Fatalf("Expected HTTP MCP config, got %T", added) + } + if addedHTTP.Type == nil || *addedHTTP.Type != "http" { + t.Errorf("Expected type='http', got %v", addedHTTP.Type) } - if added.URL == nil || *added.URL != "https://example.com/mcp" { - t.Errorf("Expected url='https://example.com/mcp', got %v", added.URL) + if addedHTTP.URL != "https://example.com/mcp" { + t.Errorf("Expected url='https://example.com/mcp', got %q", addedHTTP.URL) } - if got := added.Headers["Authorization"]; got != "Bearer token" { + if got := addedHTTP.Headers["Authorization"]; got != "Bearer token" { t.Errorf("Expected Authorization='Bearer token', got %q", got) } - if added.OauthClientID == nil || *added.OauthClientID != "client-id" { - t.Errorf("Expected oauthClientId='client-id', got %v", added.OauthClientID) + if addedHTTP.OauthClientID == nil || *addedHTTP.OauthClientID != "client-id" { + t.Errorf("Expected oauthClientId='client-id', got %v", addedHTTP.OauthClientID) } - if added.OauthPublicClient == nil || *added.OauthPublicClient { - t.Errorf("Expected oauthPublicClient=false, got %v", added.OauthPublicClient) + if addedHTTP.OauthPublicClient == nil || *addedHTTP.OauthPublicClient { + t.Errorf("Expected oauthPublicClient=false, got %v", addedHTTP.OauthPublicClient) } - if added.OauthGrantType == nil || *added.OauthGrantType != "client_credentials" { - t.Errorf("Expected oauthGrantType='client_credentials', got %v", added.OauthGrantType) + if addedHTTP.OauthGrantType == nil || *addedHTTP.OauthGrantType != "client_credentials" { + t.Errorf("Expected oauthGrantType='client_credentials', got %v", addedHTTP.OauthGrantType) } if _, err := client.RPC.Mcp.Config().Update(t.Context(), &rpc.McpConfigUpdateRequest{ @@ -195,23 +202,27 @@ func TestRpcMcpConfigE2E(t *testing.T) { if !present { t.Fatalf("Expected %q to still be present after Update", serverName) } - if updated.URL == nil || *updated.URL != "https://example.com/updated-mcp" { - t.Errorf("Expected url='https://example.com/updated-mcp', got %v", updated.URL) + updatedHTTP, ok := updated.(*rpc.McpServerConfigHTTP) + if !ok { + t.Fatalf("Expected HTTP MCP config, got %T", updated) + } + if updatedHTTP.URL != "https://example.com/updated-mcp" { + t.Errorf("Expected url='https://example.com/updated-mcp', got %q", updatedHTTP.URL) } - if updated.OauthClientID == nil || *updated.OauthClientID != "updated-client-id" { - t.Errorf("Expected oauthClientId='updated-client-id', got %v", updated.OauthClientID) + if updatedHTTP.OauthClientID == nil || *updatedHTTP.OauthClientID != "updated-client-id" { + t.Errorf("Expected oauthClientId='updated-client-id', got %v", updatedHTTP.OauthClientID) } - if updated.OauthPublicClient == nil || !*updated.OauthPublicClient { - t.Errorf("Expected oauthPublicClient=true, got %v", updated.OauthPublicClient) + if updatedHTTP.OauthPublicClient == nil || !*updatedHTTP.OauthPublicClient { + t.Errorf("Expected oauthPublicClient=true, got %v", updatedHTTP.OauthPublicClient) } - if updated.OauthGrantType == nil || *updated.OauthGrantType != "authorization_code" { - t.Errorf("Expected oauthGrantType='authorization_code', got %v", updated.OauthGrantType) + if updatedHTTP.OauthGrantType == nil || *updatedHTTP.OauthGrantType != "authorization_code" { + t.Errorf("Expected oauthGrantType='authorization_code', got %v", updatedHTTP.OauthGrantType) } - if len(updated.Tools) == 0 || updated.Tools[0] != "updated-tool" { - t.Errorf("Expected tools[0]='updated-tool', got %v", updated.Tools) + if len(updatedHTTP.Tools) == 0 || updatedHTTP.Tools[0] != "updated-tool" { + t.Errorf("Expected tools[0]='updated-tool', got %v", updatedHTTP.Tools) } - if updated.Timeout == nil || *updated.Timeout != 4000 { - t.Errorf("Expected timeout=4000, got %v", updated.Timeout) + if updatedHTTP.Timeout == nil || *updatedHTTP.Timeout != 4000 { + t.Errorf("Expected timeout=4000, got %v", updatedHTTP.Timeout) } if _, err := client.RPC.Mcp.Config().Remove(t.Context(), &rpc.McpConfigRemoveRequest{Name: serverName}); err != nil { diff --git a/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go b/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go index ee6d6600f..e3f3bd007 100644 --- a/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go +++ b/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go @@ -90,7 +90,7 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { tool, err := session.RPC.Tools.HandlePendingToolCall(t.Context(), &rpc.HandlePendingToolCallRequest{ RequestID: "missing-tool-request", - Result: &rpc.ExternalToolResult{String: copilot.String("tool result")}, + Result: rpc.ExternalToolStringResult("tool result"), }) if err != nil { t.Fatalf("Tools.HandlePendingToolCall failed: %v", err) @@ -126,10 +126,7 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { feedback := "not approved" permission, err := session.RPC.Permissions.HandlePendingPermissionRequest(t.Context(), &rpc.PermissionDecisionRequest{ RequestID: "missing-permission-request", - Result: rpc.PermissionDecision{ - Kind: rpc.PermissionDecisionKindReject, - Feedback: &feedback, - }, + Result: &rpc.PermissionDecisionReject{Feedback: &feedback}, }) if err != nil { t.Fatalf("Permissions.HandlePendingPermissionRequest (reject) failed: %v", err) @@ -141,10 +138,7 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { domain := "example.com" permanent, err := session.RPC.Permissions.HandlePendingPermissionRequest(t.Context(), &rpc.PermissionDecisionRequest{ RequestID: "missing-permanent-permission-request", - Result: rpc.PermissionDecision{ - Kind: rpc.PermissionDecisionKindApprovePermanently, - Domain: &domain, - }, + Result: &rpc.PermissionDecisionApprovePermanently{Domain: domain}, }) if err != nil { t.Fatalf("Permissions.HandlePendingPermissionRequest (approve-permanently) failed: %v", err) diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index d3af7f6c0..de9dad9e2 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -206,7 +206,7 @@ func TestSessionConfigExtrasE2E(t *testing.T) { if err != nil { t.Fatalf("GetMessages failed: %v", err) } - if len(messages) == 0 || messages[0].Type != copilot.SessionEventTypeSessionStart { + if len(messages) == 0 || messages[0].Type() != copilot.SessionEventTypeSessionStart { t.Fatalf("Expected first event to be session.start, got %+v", messages) } startData := messages[0].Data.(*copilot.SessionStartData) diff --git a/go/internal/e2e/session_e2e_test.go b/go/internal/e2e/session_e2e_test.go index fa2500fe5..7ac451e8d 100644 --- a/go/internal/e2e/session_e2e_test.go +++ b/go/internal/e2e/session_e2e_test.go @@ -38,7 +38,7 @@ func TestSessionE2E(t *testing.T) { t.Fatalf("Failed to get messages: %v", err) } - if len(messages) == 0 || messages[0].Type != "session.start" { + if len(messages) == 0 || messages[0].Type() != "session.start" { t.Fatalf("Expected first message to be session.start, got %v", messages) } @@ -533,10 +533,10 @@ func TestSessionE2E(t *testing.T) { hasUserMessage := false hasSessionResume := false for _, msg := range messages { - if msg.Type == "user.message" { + if msg.Type() == "user.message" { hasUserMessage = true } - if msg.Type == "session.resume" { + if msg.Type() == "session.resume" { hasSessionResume = true } } @@ -671,7 +671,7 @@ func TestSessionE2E(t *testing.T) { // Verify messages contain an abort event hasAbortEvent := false for _, msg := range messages { - if msg.Type == copilot.SessionEventTypeAbort { + if msg.Type() == copilot.SessionEventTypeAbort { hasAbortEvent = true break } @@ -701,7 +701,7 @@ func TestSessionE2E(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnEvent: func(event copilot.SessionEvent) { - if event.Type == "session.start" { + if event.Type() == "session.start" { select { case sessionStartCh <- true: default: @@ -727,7 +727,7 @@ func TestSessionE2E(t *testing.T) { receivedEventsMu.Lock() receivedEvents = append(receivedEvents, event) receivedEventsMu.Unlock() - if event.Type == "session.idle" { + if event.Type() == "session.idle" { select { case idle <- true: default: @@ -760,7 +760,7 @@ func TestSessionE2E(t *testing.T) { hasAssistantMessage := false hasSessionIdle := false for _, evt := range eventsSnapshot { - switch evt.Type { + switch evt.Type() { case "user.message": hasUserMessage = true case "assistant.message": @@ -1082,7 +1082,7 @@ func TestSetModelWithReasoningEffortE2E(t *testing.T) { modelChanged := make(chan copilot.SessionEvent, 1) session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeSessionModelChange { + if event.Type() == copilot.SessionEventTypeSessionModelChange { select { case modelChanged <- event: default: @@ -1139,10 +1139,9 @@ func TestSessionBlobAttachmentE2E(t *testing.T) { _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Describe this image", Attachments: []copilot.Attachment{ - { - Type: copilot.AttachmentTypeBlob, - Data: &data, - MIMEType: &mimeType, + &copilot.UserMessageAttachmentBlob{ + Data: data, + MIMEType: mimeType, DisplayName: &displayName, }, }, @@ -1266,7 +1265,7 @@ func waitForEvent(t *testing.T, mu *sync.Mutex, events *[]copilot.SessionEvent, for time.Now().Before(deadline) { mu.Lock() for _, evt := range *events { - if evt.Type == eventType && getEventMessage(evt) == message { + if evt.Type() == eventType && getEventMessage(evt) == message { mu.Unlock() return evt } @@ -1323,10 +1322,9 @@ func TestSessionAttachmentsE2E(t *testing.T) { path := filePath _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Read the attached file and reply with its contents.", - Attachments: []copilot.Attachment{{ - Type: copilot.AttachmentTypeFile, - DisplayName: &displayName, - Path: &path, + Attachments: []copilot.Attachment{&copilot.UserMessageAttachmentFile{ + DisplayName: displayName, + Path: path, LineRange: &copilot.UserMessageAttachmentFileLineRange{Start: 1, End: 1}, }}, }) @@ -1334,14 +1332,14 @@ func TestSessionAttachmentsE2E(t *testing.T) { t.Fatalf("SendAndWait failed: %v", err) } - attachment := lastUserAttachment(t, session) - if attachment.Type != copilot.AttachmentTypeFile { - t.Errorf("Expected attachment type %q, got %q", copilot.AttachmentTypeFile, attachment.Type) + attachment, ok := lastUserAttachment(t, session).(*copilot.UserMessageAttachmentFile) + if !ok { + t.Fatalf("Expected file attachment, got %T", lastUserAttachment(t, session)) } - if attachment.DisplayName == nil || *attachment.DisplayName != "attached-file.txt" { + if attachment.DisplayName != "attached-file.txt" { t.Errorf("Expected DisplayName 'attached-file.txt', got %v", attachment.DisplayName) } - if attachment.Path == nil || *attachment.Path != filePath { + if attachment.Path != filePath { t.Errorf("Expected Path %q, got %v", filePath, attachment.Path) } if attachment.LineRange == nil || attachment.LineRange.Start != 1 || attachment.LineRange.End != 1 { @@ -1371,24 +1369,23 @@ func TestSessionAttachmentsE2E(t *testing.T) { path := directoryPath _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "List the attached directory.", - Attachments: []copilot.Attachment{{ - Type: copilot.AttachmentTypeDirectory, - DisplayName: &displayName, - Path: &path, + Attachments: []copilot.Attachment{&copilot.UserMessageAttachmentDirectory{ + DisplayName: displayName, + Path: path, }}, }) if err != nil { t.Fatalf("SendAndWait failed: %v", err) } - attachment := lastUserAttachment(t, session) - if attachment.Type != copilot.AttachmentTypeDirectory { - t.Errorf("Expected attachment type %q, got %q", copilot.AttachmentTypeDirectory, attachment.Type) + attachment, ok := lastUserAttachment(t, session).(*copilot.UserMessageAttachmentDirectory) + if !ok { + t.Fatalf("Expected directory attachment, got %T", lastUserAttachment(t, session)) } - if attachment.DisplayName == nil || *attachment.DisplayName != "attached-directory" { + if attachment.DisplayName != "attached-directory" { t.Errorf("Expected DisplayName 'attached-directory', got %v", attachment.DisplayName) } - if attachment.Path == nil || *attachment.Path != directoryPath { + if attachment.Path != directoryPath { t.Errorf("Expected Path %q, got %v", directoryPath, attachment.Path) } }) @@ -1413,12 +1410,11 @@ func TestSessionAttachmentsE2E(t *testing.T) { text := `string Value = "SELECTION_SENTINEL";` _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Summarize the selected code.", - Attachments: []copilot.Attachment{{ - Type: copilot.AttachmentTypeSelection, - DisplayName: &displayName, - FilePath: &filePathCopy, - Text: &text, - Selection: &copilot.UserMessageAttachmentSelectionDetails{ + Attachments: []copilot.Attachment{&copilot.UserMessageAttachmentSelection{ + DisplayName: displayName, + FilePath: filePathCopy, + Text: text, + Selection: copilot.UserMessageAttachmentSelectionDetails{ Start: copilot.UserMessageAttachmentSelectionDetailsStart{Line: 1, Character: 10}, End: copilot.UserMessageAttachmentSelectionDetailsEnd{Line: 1, Character: 45}, }, @@ -1428,22 +1424,19 @@ func TestSessionAttachmentsE2E(t *testing.T) { t.Fatalf("SendAndWait failed: %v", err) } - attachment := lastUserAttachment(t, session) - if attachment.Type != copilot.AttachmentTypeSelection { - t.Errorf("Expected attachment type %q, got %q", copilot.AttachmentTypeSelection, attachment.Type) + attachment, ok := lastUserAttachment(t, session).(*copilot.UserMessageAttachmentSelection) + if !ok { + t.Fatalf("Expected selection attachment, got %T", lastUserAttachment(t, session)) } - if attachment.DisplayName == nil || *attachment.DisplayName != "selected-file.cs" { + if attachment.DisplayName != "selected-file.cs" { t.Errorf("Expected DisplayName 'selected-file.cs', got %v", attachment.DisplayName) } - if attachment.FilePath == nil || *attachment.FilePath != filePath { + if attachment.FilePath != filePath { t.Errorf("Expected FilePath %q, got %v", filePath, attachment.FilePath) } - if attachment.Text == nil || *attachment.Text != text { + if attachment.Text != text { t.Errorf("Expected Text %q, got %v", text, attachment.Text) } - if attachment.Selection == nil { - t.Fatal("Expected non-nil Selection") - } if attachment.Selection.Start.Line != 1 || attachment.Selection.Start.Character != 10 { t.Errorf("Expected Selection.Start {1,10}, got %+v", attachment.Selection.Start) } @@ -1469,36 +1462,35 @@ func TestSessionAttachmentsE2E(t *testing.T) { url := "https://github.com/github/copilot-sdk/issues/1234" _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ Prompt: "Using only the GitHub reference metadata in this message, summarize the reference. Do not call any tools.", - Attachments: []copilot.Attachment{{ - Type: copilot.AttachmentTypeGithubReference, - Number: &number, - ReferenceType: &referenceType, - State: &state, - Title: &title, - URL: &url, + Attachments: []copilot.Attachment{&copilot.UserMessageAttachmentGithubReference{ + Number: number, + ReferenceType: referenceType, + State: state, + Title: title, + URL: url, }}, }) if err != nil { t.Fatalf("SendAndWait failed: %v", err) } - attachment := lastUserAttachment(t, session) - if attachment.Type != copilot.AttachmentTypeGithubReference { - t.Errorf("Expected attachment type %q, got %q", copilot.AttachmentTypeGithubReference, attachment.Type) + attachment, ok := lastUserAttachment(t, session).(*copilot.UserMessageAttachmentGithubReference) + if !ok { + t.Fatalf("Expected GitHub reference attachment, got %T", lastUserAttachment(t, session)) } - if attachment.Number == nil || *attachment.Number != 1234 { + if attachment.Number != 1234 { t.Errorf("Expected Number=1234, got %v", attachment.Number) } - if attachment.ReferenceType == nil || *attachment.ReferenceType != copilot.UserMessageAttachmentGithubReferenceTypeIssue { + if attachment.ReferenceType != copilot.UserMessageAttachmentGithubReferenceTypeIssue { t.Errorf("Expected ReferenceType=Issue, got %v", attachment.ReferenceType) } - if attachment.State == nil || *attachment.State != "open" { + if attachment.State != "open" { t.Errorf("Expected State='open', got %v", attachment.State) } - if attachment.Title == nil || *attachment.Title != title { + if attachment.Title != title { t.Errorf("Expected Title=%q, got %v", title, attachment.Title) } - if attachment.URL == nil || *attachment.URL != url { + if attachment.URL != url { t.Errorf("Expected URL=%q, got %v", url, attachment.URL) } }) @@ -1512,7 +1504,7 @@ func lastUserAttachment(t *testing.T, session *copilot.Session) copilot.Attachme t.Fatalf("GetMessages failed: %v", err) } for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Type != copilot.SessionEventTypeUserMessage { + if messages[i].Type() != copilot.SessionEventTypeUserMessage { continue } data, ok := messages[i].Data.(*copilot.UserMessageData) @@ -1525,7 +1517,7 @@ func lastUserAttachment(t *testing.T, session *copilot.Session) copilot.Attachme return data.Attachments[0] } t.Fatal("No user.message event with attachments found") - return copilot.Attachment{} + return nil } // TestSessionMessageOptions mirrors C# Should_Send_With_Mode_Property and Should_Send_With_Custom_RequestHeaders. @@ -1562,7 +1554,7 @@ func TestSessionMessageOptionsE2E(t *testing.T) { } var userMsg *copilot.UserMessageData for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Type == copilot.SessionEventTypeUserMessage { + if messages[i].Type() == copilot.SessionEventTypeUserMessage { userMsg = messages[i].Data.(*copilot.UserMessageData) break } @@ -1650,7 +1642,7 @@ func TestSessionSetModelOnExistingE2E(t *testing.T) { modelChanged := make(chan copilot.SessionEvent, 1) session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionEventTypeSessionModelChange { + if event.Type() == copilot.SessionEventTypeSessionModelChange { select { case modelChanged <- event: default: diff --git a/go/internal/e2e/streaming_fidelity_e2e_test.go b/go/internal/e2e/streaming_fidelity_e2e_test.go index 99c85ce63..2684306d7 100644 --- a/go/internal/e2e/streaming_fidelity_e2e_test.go +++ b/go/internal/e2e/streaming_fidelity_e2e_test.go @@ -46,7 +46,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // Should have streaming deltas before the final message var deltaEvents []copilot.SessionEvent for _, e := range snapshot { - if e.Type == "assistant.message_delta" { + if e.Type() == "assistant.message_delta" { deltaEvents = append(deltaEvents, e) } } @@ -64,7 +64,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // Should still have a final assistant.message hasAssistantMessage := false for _, e := range snapshot { - if e.Type == "assistant.message" { + if e.Type() == "assistant.message" { hasAssistantMessage = true break } @@ -77,10 +77,10 @@ func TestStreamingFidelityE2E(t *testing.T) { firstDeltaIdx := -1 lastAssistantIdx := -1 for i, e := range snapshot { - if e.Type == "assistant.message_delta" && firstDeltaIdx == -1 { + if e.Type() == "assistant.message_delta" && firstDeltaIdx == -1 { firstDeltaIdx = i } - if e.Type == "assistant.message" { + if e.Type() == "assistant.message" { lastAssistantIdx = i } } @@ -121,7 +121,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // No deltas when streaming is off var deltaEvents []copilot.SessionEvent for _, e := range snapshot { - if e.Type == "assistant.message_delta" { + if e.Type() == "assistant.message_delta" { deltaEvents = append(deltaEvents, e) } } @@ -132,7 +132,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // But should still have a final assistant.message var assistantEvents []copilot.SessionEvent for _, e := range snapshot { - if e.Type == "assistant.message" { + if e.Type() == "assistant.message" { assistantEvents = append(assistantEvents, e) } } @@ -195,7 +195,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // Should have streaming deltas before the final message var deltaEvents []copilot.SessionEvent for _, e := range snapshot { - if e.Type == "assistant.message_delta" { + if e.Type() == "assistant.message_delta" { deltaEvents = append(deltaEvents, e) } } @@ -263,7 +263,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // No deltas when streaming is toggled off for _, e := range snapshot { - if e.Type == "assistant.message_delta" { + if e.Type() == "assistant.message_delta" { t.Errorf("Expected no delta events after resume with streaming disabled; got delta at index %d", len(snapshot)) break } @@ -272,7 +272,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // But should still have a final assistant.message hasAssistantMessage := false for _, e := range snapshot { - if e.Type == "assistant.message" { + if e.Type() == "assistant.message" { hasAssistantMessage = true break } @@ -319,7 +319,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // With streaming + reasoning effort, we should still get content deltas var deltaEvents []copilot.SessionEvent for _, e := range snapshot { - if e.Type == "assistant.message_delta" { + if e.Type() == "assistant.message_delta" { deltaEvents = append(deltaEvents, e) } } @@ -330,7 +330,7 @@ func TestStreamingFidelityE2E(t *testing.T) { // And a final assistant.message with the answer var lastAssistantContent string for _, e := range snapshot { - if e.Type == "assistant.message" { + if e.Type() == "assistant.message" { if ad, ok := e.Data.(*copilot.AssistantMessageData); ok { lastAssistantContent = ad.Content } @@ -350,7 +350,7 @@ func TestStreamingFidelityE2E(t *testing.T) { } var sessionStartReasoningEffort string for _, msg := range messages { - if msg.Type == copilot.SessionEventTypeSessionStart { + if msg.Type() == copilot.SessionEventTypeSessionStart { if d, ok := msg.Data.(*copilot.SessionStartData); ok { if d.ReasoningEffort != nil { sessionStartReasoningEffort = *d.ReasoningEffort diff --git a/go/internal/e2e/suspend_e2e_test.go b/go/internal/e2e/suspend_e2e_test.go index 3c70874a5..957fb58c6 100644 --- a/go/internal/e2e/suspend_e2e_test.go +++ b/go/internal/e2e/suspend_e2e_test.go @@ -153,11 +153,12 @@ func TestSuspendE2E(t *testing.T) { case <-time.After(suspendTimeout): t.Fatal("Timed out waiting for permission request") } - if request.Kind != copilot.PermissionRequestKindCustomTool { - t.Fatalf("Expected custom-tool permission request, got %q", request.Kind) + customReq, ok := request.(*copilot.PermissionRequestCustomTool) + if !ok { + t.Fatalf("Expected custom-tool permission request, got %#v", request) } - if request.ToolName == nil || *request.ToolName != "suspend_cancel_permission_tool" { - t.Fatalf("Expected permission request for suspend_cancel_permission_tool, got %#v", request.ToolName) + if customReq.ToolName != "suspend_cancel_permission_tool" { + t.Fatalf("Expected permission request for suspend_cancel_permission_tool, got %#v", request) } if err := suspendSession(t.Context(), session); err != nil { diff --git a/go/internal/e2e/testharness/helper.go b/go/internal/e2e/testharness/helper.go index 0960b659d..27cf77cb5 100644 --- a/go/internal/e2e/testharness/helper.go +++ b/go/internal/e2e/testharness/helper.go @@ -60,7 +60,7 @@ func GetNextEventOfType(session *copilot.Session, eventType copilot.SessionEvent errCh := make(chan error, 1) unsubscribe := session.On(func(event copilot.SessionEvent) { - switch event.Type { + switch event.Type() { case eventType: select { case result <- &event: @@ -98,7 +98,7 @@ func getExistingFinalResponse(ctx context.Context, session *copilot.Session, alr // Find last user message finalUserMessageIndex := -1 for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Type == "user.message" { + if messages[i].Type() == "user.message" { finalUserMessageIndex = i break } @@ -113,7 +113,7 @@ func getExistingFinalResponse(ctx context.Context, session *copilot.Session, alr // Check for errors for _, msg := range currentTurnMessages { - if msg.Type == "session.error" { + if msg.Type() == "session.error" { errMsg := "session error" if d, ok := msg.Data.(*copilot.SessionErrorData); ok { errMsg = d.Message @@ -128,7 +128,7 @@ func getExistingFinalResponse(ctx context.Context, session *copilot.Session, alr sessionIdleIndex = len(currentTurnMessages) } else { for i, msg := range currentTurnMessages { - if msg.Type == "session.idle" { + if msg.Type() == "session.idle" { sessionIdleIndex = i break } @@ -138,7 +138,7 @@ func getExistingFinalResponse(ctx context.Context, session *copilot.Session, alr if sessionIdleIndex != -1 { // Find last assistant.message before session.idle for i := sessionIdleIndex - 1; i >= 0; i-- { - if currentTurnMessages[i].Type == "assistant.message" { + if currentTurnMessages[i].Type() == "assistant.message" { return ¤tTurnMessages[i], nil } } diff --git a/go/internal/e2e/tool_results_e2e_test.go b/go/internal/e2e/tool_results_e2e_test.go index 0ae0ec08e..8908ffcda 100644 --- a/go/internal/e2e/tool_results_e2e_test.go +++ b/go/internal/e2e/tool_results_e2e_test.go @@ -210,12 +210,13 @@ func TestToolResultsE2E(t *testing.T) { } session.On(func(event copilot.SessionEvent) { - if d, ok := event.Data.(*copilot.ToolExecutionCompleteData); ok { + switch d := event.Data.(type) { + case *copilot.ToolExecutionCompleteData: select { case toolCompleted <- d: default: } - } else if event.Type == copilot.SessionEventTypeSessionIdle { + case *copilot.SessionIdleData: select { case idle <- struct{}{}: default: diff --git a/go/internal/e2e/tools_e2e_test.go b/go/internal/e2e/tools_e2e_test.go index 4f2fbf802..43e439bf8 100644 --- a/go/internal/e2e/tools_e2e_test.go +++ b/go/internal/e2e/tools_e2e_test.go @@ -524,10 +524,10 @@ func TestToolsE2E(t *testing.T) { mu.Lock() customToolReqs := 0 for _, req := range permissionRequests { - if req.Kind == "custom-tool" { + if customReq, ok := req.(*copilot.PermissionRequestCustomTool); ok { customToolReqs++ - if req.ToolName == nil || *req.ToolName != "encrypt_string" { - t.Errorf("Expected toolName 'encrypt_string', got '%v'", req.ToolName) + if customReq.ToolName != "encrypt_string" { + t.Errorf("Expected toolName 'encrypt_string', got '%v'", req) } } } diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index cc099b8ea..e83d3aec3 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -150,24 +150,6 @@ type DiscoveredMcpServer struct { Type *DiscoveredMcpServerType `json:"type,omitempty"` } -type EmbeddedBlobResourceContents struct { - // Base64-encoded binary content of the resource - Blob string `json:"blob"` - // MIME type of the blob content - MIMEType *string `json:"mimeType,omitempty"` - // URI identifying the resource - URI string `json:"uri"` -} - -type EmbeddedTextResourceContents struct { - // MIME type of the text content - MIMEType *string `json:"mimeType,omitempty"` - // Text content of the resource - Text string `json:"text"` - // URI identifying the resource - URI string `json:"uri"` -} - type Extension struct { // Source-qualified ID (e.g., 'project:my-ext', 'user:auth-helper') ID string `json:"id"` @@ -217,42 +199,15 @@ type ExtensionsReloadResult struct { } // Tool call result (string or expanded result object) -type ExternalToolResult struct { - ExternalToolTextResultForLlm *ExternalToolTextResultForLlm - String *string +type ExternalToolResult interface { + externalToolResult() } -func (r ExternalToolResult) MarshalJSON() ([]byte, error) { - if r.ExternalToolTextResultForLlm != nil { - return json.Marshal(r.ExternalToolTextResultForLlm) - } - if r.String != nil { - return json.Marshal(r.String) - } - return []byte("null"), nil -} +type ExternalToolStringResult string -func (r *ExternalToolResult) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - *r = ExternalToolResult{} - return nil - } - { - var value ExternalToolTextResultForLlm - if err := json.Unmarshal(data, &value); err == nil { - *r = ExternalToolResult{ExternalToolTextResultForLlm: &value} - return nil - } - } - { - var value string - if err := json.Unmarshal(data, &value); err == nil { - *r = ExternalToolResult{String: &value} - return nil - } - } - return errors.New("data did not match any union variant for ExternalToolResult") -} +func (ExternalToolStringResult) externalToolResult() {} + +func (ExternalToolTextResultForLlm) externalToolResult() {} // Expanded external tool result payload type ExternalToolTextResultForLlm struct { @@ -273,33 +228,19 @@ type ExternalToolTextResultForLlm struct { // A content block within a tool result, which may be text, terminal output, image, audio, // or a resource -type ExternalToolTextResultForLlmContent struct { - // Working directory where the command was executed - Cwd *string `json:"cwd,omitempty"` - // Base64-encoded image data - Data *string `json:"data,omitempty"` - // Human-readable description of the resource - Description *string `json:"description,omitempty"` - // Process exit code, if the command has completed - ExitCode *float64 `json:"exitCode,omitempty"` - // Icons associated with this resource - Icons []ExternalToolTextResultForLlmContentResourceLinkIcon `json:"icons,omitempty"` - // MIME type of the image (e.g., image/png, image/jpeg) - MIMEType *string `json:"mimeType,omitempty"` - // Resource name identifier - Name *string `json:"name,omitempty"` - // The embedded resource contents, either text or base64-encoded binary - Resource *ExternalToolTextResultForLlmContentResourceDetails `json:"resource,omitempty"` - // Size of the resource in bytes - Size *float64 `json:"size,omitempty"` - // The text content - Text *string `json:"text,omitempty"` - // Human-readable display title for the resource - Title *string `json:"title,omitempty"` - // Type discriminator - Type ExternalToolTextResultForLlmContentType `json:"type"` - // URI identifying the resource - URI *string `json:"uri,omitempty"` +type ExternalToolTextResultForLlmContent interface { + externalToolTextResultForLlmContent() + Type() ExternalToolTextResultForLlmContentType +} + +type RawExternalToolTextResultForLlmContentData struct { + Discriminator ExternalToolTextResultForLlmContentType + Raw json.RawMessage +} + +func (RawExternalToolTextResultForLlmContentData) externalToolTextResultForLlmContent() {} +func (r RawExternalToolTextResultForLlmContentData) Type() ExternalToolTextResultForLlmContentType { + return r.Discriminator } // Audio content block with base64-encoded data @@ -308,8 +249,11 @@ type ExternalToolTextResultForLlmContentAudio struct { Data string `json:"data"` // MIME type of the audio (e.g., audio/wav, audio/mpeg) MIMEType string `json:"mimeType"` - // Content block type discriminator - Type ExternalToolTextResultForLlmContentAudioType `json:"type"` +} + +func (ExternalToolTextResultForLlmContentAudio) externalToolTextResultForLlmContent() {} +func (ExternalToolTextResultForLlmContentAudio) Type() ExternalToolTextResultForLlmContentType { + return ExternalToolTextResultForLlmContentTypeAudio } // Image content block with base64-encoded data @@ -318,28 +262,22 @@ type ExternalToolTextResultForLlmContentImage struct { Data string `json:"data"` // MIME type of the image (e.g., image/png, image/jpeg) MIMEType string `json:"mimeType"` - // Content block type discriminator - Type ExternalToolTextResultForLlmContentImageType `json:"type"` +} + +func (ExternalToolTextResultForLlmContentImage) externalToolTextResultForLlmContent() {} +func (ExternalToolTextResultForLlmContentImage) Type() ExternalToolTextResultForLlmContentType { + return ExternalToolTextResultForLlmContentTypeImage } // Embedded resource content block with inline text or binary data type ExternalToolTextResultForLlmContentResource struct { // The embedded resource contents, either text or base64-encoded binary Resource ExternalToolTextResultForLlmContentResourceDetails `json:"resource"` - // Content block type discriminator - Type ExternalToolTextResultForLlmContentResourceType `json:"type"` } -// The embedded resource contents, either text or base64-encoded binary -type ExternalToolTextResultForLlmContentResourceDetails struct { - // Base64-encoded binary content of the resource - Blob *string `json:"blob,omitempty"` - // MIME type of the text content - MIMEType *string `json:"mimeType,omitempty"` - // Text content of the resource - Text *string `json:"text,omitempty"` - // URI identifying the resource - URI string `json:"uri"` +func (ExternalToolTextResultForLlmContentResource) externalToolTextResultForLlmContent() {} +func (ExternalToolTextResultForLlmContentResource) Type() ExternalToolTextResultForLlmContentType { + return ExternalToolTextResultForLlmContentTypeResource } // Resource link content block referencing an external resource @@ -356,22 +294,13 @@ type ExternalToolTextResultForLlmContentResourceLink struct { Size *float64 `json:"size,omitempty"` // Human-readable display title for the resource Title *string `json:"title,omitempty"` - // Content block type discriminator - Type ExternalToolTextResultForLlmContentResourceLinkType `json:"type"` // URI identifying the resource URI string `json:"uri"` } -// Icon image for a resource -type ExternalToolTextResultForLlmContentResourceLinkIcon struct { - // MIME type of the icon image - MIMEType *string `json:"mimeType,omitempty"` - // Available icon sizes (e.g., ['16x16', '32x32']) - Sizes []string `json:"sizes,omitempty"` - // URL or path to the icon image - Src string `json:"src"` - // Theme variant this icon is intended for - Theme *ExternalToolTextResultForLlmContentResourceLinkIconTheme `json:"theme,omitempty"` +func (ExternalToolTextResultForLlmContentResourceLink) externalToolTextResultForLlmContent() {} +func (ExternalToolTextResultForLlmContentResourceLink) Type() ExternalToolTextResultForLlmContentType { + return ExternalToolTextResultForLlmContentTypeResourceLink } // Terminal/shell output content block with optional exit code and working directory @@ -382,55 +311,80 @@ type ExternalToolTextResultForLlmContentTerminal struct { ExitCode *float64 `json:"exitCode,omitempty"` // Terminal/shell output text Text string `json:"text"` - // Content block type discriminator - Type ExternalToolTextResultForLlmContentTerminalType `json:"type"` +} + +func (ExternalToolTextResultForLlmContentTerminal) externalToolTextResultForLlmContent() {} +func (ExternalToolTextResultForLlmContentTerminal) Type() ExternalToolTextResultForLlmContentType { + return ExternalToolTextResultForLlmContentTypeTerminal } // Plain text content block type ExternalToolTextResultForLlmContentText struct { // The text content Text string `json:"text"` - // Content block type discriminator - Type ExternalToolTextResultForLlmContentTextType `json:"type"` } -type FilterMapping struct { - Enum *FilterMappingString - EnumMap map[string]FilterMappingValue +func (ExternalToolTextResultForLlmContentText) externalToolTextResultForLlmContent() {} +func (ExternalToolTextResultForLlmContentText) Type() ExternalToolTextResultForLlmContentType { + return ExternalToolTextResultForLlmContentTypeText } -func (r FilterMapping) MarshalJSON() ([]byte, error) { - if r.Enum != nil { - return json.Marshal(r.Enum) - } - if r.EnumMap != nil { - return json.Marshal(r.EnumMap) - } - return []byte("null"), nil +// The embedded resource contents, either text or base64-encoded binary +type ExternalToolTextResultForLlmContentResourceDetails interface { + externalToolTextResultForLlmContentResourceDetails() } -func (r *FilterMapping) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - *r = FilterMapping{} - return nil - } - { - var value FilterMappingString - if err := json.Unmarshal(data, &value); err == nil { - *r = FilterMapping{Enum: &value} - return nil - } - } - { - var value map[string]FilterMappingValue - if err := json.Unmarshal(data, &value); err == nil { - *r = FilterMapping{EnumMap: value} - return nil - } - } - return errors.New("data did not match any union variant for FilterMapping") +type RawExternalToolTextResultForLlmContentResourceDetailsData struct { + Raw json.RawMessage } +func (RawExternalToolTextResultForLlmContentResourceDetailsData) externalToolTextResultForLlmContentResourceDetails() { +} + +type EmbeddedBlobResourceContents struct { + // Base64-encoded binary content of the resource + Blob string `json:"blob"` + // MIME type of the blob content + MIMEType *string `json:"mimeType,omitempty"` + // URI identifying the resource + URI string `json:"uri"` +} + +func (EmbeddedBlobResourceContents) externalToolTextResultForLlmContentResourceDetails() {} + +type EmbeddedTextResourceContents struct { + // MIME type of the text content + MIMEType *string `json:"mimeType,omitempty"` + // Text content of the resource + Text string `json:"text"` + // URI identifying the resource + URI string `json:"uri"` +} + +func (EmbeddedTextResourceContents) externalToolTextResultForLlmContentResourceDetails() {} + +// Icon image for a resource +type ExternalToolTextResultForLlmContentResourceLinkIcon struct { + // MIME type of the icon image + MIMEType *string `json:"mimeType,omitempty"` + // Available icon sizes (e.g., ['16x16', '32x32']) + Sizes []string `json:"sizes,omitempty"` + // URL or path to the icon image + Src string `json:"src"` + // Theme variant this icon is intended for + Theme *ExternalToolTextResultForLlmContentResourceLinkIconTheme `json:"theme,omitempty"` +} + +type FilterMapping interface { + filterMapping() +} + +type FilterMappingEnumMap map[string]FilterMappingValue + +func (FilterMappingEnumMap) filterMapping() {} + +func (FilterMappingString) filterMapping() {} + // Experimental: FleetStartRequest is part of an experimental API and may change or be // removed. type FleetStartRequest struct { @@ -451,7 +405,7 @@ type HandlePendingToolCallRequest struct { // Request ID of the pending tool call RequestID string `json:"requestId"` // Tool call result (string or expanded result object) - Result *ExternalToolResult `json:"result,omitempty"` + Result ExternalToolResult `json:"result,omitempty"` } type HandlePendingToolCallResult struct { @@ -676,28 +630,18 @@ type McpServer struct { } // MCP server configuration (local/stdio or remote/http) -type McpServerConfig struct { - Args []string `json:"args,omitempty"` - Command *string `json:"command,omitempty"` - Cwd *string `json:"cwd,omitempty"` - Env map[string]string `json:"env,omitempty"` - FilterMapping *FilterMapping `json:"filterMapping,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - IsDefaultServer *bool `json:"isDefaultServer,omitempty"` - OauthClientID *string `json:"oauthClientId,omitempty"` - OauthGrantType *McpServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` - OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` - // Timeout in milliseconds for tool calls to this server. - Timeout *int64 `json:"timeout,omitempty"` - // Tools to include. Defaults to all tools if not specified. - Tools []string `json:"tools,omitempty"` - // Remote transport type. Defaults to "http" when omitted. - Type *McpServerConfigType `json:"type,omitempty"` - URL *string `json:"url,omitempty"` +type McpServerConfig interface { + mcpServerConfig() +} + +type RawMcpServerConfigData struct { + Raw json.RawMessage } +func (RawMcpServerConfigData) mcpServerConfig() {} + type McpServerConfigHTTP struct { - FilterMapping *FilterMapping `json:"filterMapping,omitempty"` + FilterMapping FilterMapping `json:"filterMapping,omitempty"` Headers map[string]string `json:"headers,omitempty"` IsDefaultServer *bool `json:"isDefaultServer,omitempty"` OauthClientID *string `json:"oauthClientId,omitempty"` @@ -712,12 +656,14 @@ type McpServerConfigHTTP struct { URL string `json:"url"` } +func (McpServerConfigHTTP) mcpServerConfig() {} + type McpServerConfigLocal struct { Args []string `json:"args"` Command string `json:"command"` Cwd *string `json:"cwd,omitempty"` Env map[string]string `json:"env,omitempty"` - FilterMapping *FilterMapping `json:"filterMapping,omitempty"` + FilterMapping FilterMapping `json:"filterMapping,omitempty"` IsDefaultServer *bool `json:"isDefaultServer,omitempty"` // Timeout in milliseconds for tool calls to this server. Timeout *int64 `json:"timeout,omitempty"` @@ -726,6 +672,8 @@ type McpServerConfigLocal struct { Type *McpServerConfigLocalType `json:"type,omitempty"` } +func (McpServerConfigLocal) mcpServerConfig() {} + // Experimental: McpServerList is part of an experimental API and may change or be removed. type McpServerList struct { // Configured MCP servers @@ -879,162 +827,288 @@ type NameSetRequest struct { type NameSetResult struct { } -type PermissionDecision struct { - // The approval to add as a session-scoped rule - Approval *PermissionDecisionApproveForSessionApproval `json:"approval,omitempty"` - // The URL domain to approve for this session - Domain *string `json:"domain,omitempty"` - // Optional feedback from the user explaining the denial - Feedback *string `json:"feedback,omitempty"` - // Kind discriminator - Kind PermissionDecisionKind `json:"kind"` - // The location key (git root or cwd) to persist the approval to - LocationKey *string `json:"locationKey,omitempty"` +type PermissionDecision interface { + permissionDecision() + Kind() PermissionDecisionKind +} + +type RawPermissionDecisionData struct { + Discriminator PermissionDecisionKind + Raw json.RawMessage +} + +func (RawPermissionDecisionData) permissionDecision() {} +func (r RawPermissionDecisionData) Kind() PermissionDecisionKind { + return r.Discriminator } type PermissionDecisionApproveForLocation struct { // The approval to persist for this location Approval PermissionDecisionApproveForLocationApproval `json:"approval"` - // Approved and persisted for this project location - Kind PermissionDecisionApproveForLocationKind `json:"kind"` // The location key (git root or cwd) to persist the approval to LocationKey string `json:"locationKey"` } +func (PermissionDecisionApproveForLocation) permissionDecision() {} +func (PermissionDecisionApproveForLocation) Kind() PermissionDecisionKind { + return PermissionDecisionKindApproveForLocation +} + +type PermissionDecisionApproveForSession struct { + // The approval to add as a session-scoped rule + Approval PermissionDecisionApproveForSessionApproval `json:"approval,omitempty"` + // The URL domain to approve for this session + Domain *string `json:"domain,omitempty"` +} + +func (PermissionDecisionApproveForSession) permissionDecision() {} +func (PermissionDecisionApproveForSession) Kind() PermissionDecisionKind { + return PermissionDecisionKindApproveForSession +} + +type PermissionDecisionApproveOnce struct { +} + +func (PermissionDecisionApproveOnce) permissionDecision() {} +func (PermissionDecisionApproveOnce) Kind() PermissionDecisionKind { + return PermissionDecisionKindApproveOnce +} + +type PermissionDecisionApprovePermanently struct { + // The URL domain to approve permanently + Domain string `json:"domain"` +} + +func (PermissionDecisionApprovePermanently) permissionDecision() {} +func (PermissionDecisionApprovePermanently) Kind() PermissionDecisionKind { + return PermissionDecisionKindApprovePermanently +} + +type PermissionDecisionReject struct { + // Optional feedback from the user explaining the denial + Feedback *string `json:"feedback,omitempty"` +} + +func (PermissionDecisionReject) permissionDecision() {} +func (PermissionDecisionReject) Kind() PermissionDecisionKind { + return PermissionDecisionKindReject +} + +type PermissionDecisionUserNotAvailable struct { +} + +func (PermissionDecisionUserNotAvailable) permissionDecision() {} +func (PermissionDecisionUserNotAvailable) Kind() PermissionDecisionKind { + return PermissionDecisionKindUserNotAvailable +} + // The approval to persist for this location -type PermissionDecisionApproveForLocationApproval struct { - CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` - ExtensionName *string `json:"extensionName,omitempty"` - // Kind discriminator - Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` - Operation *string `json:"operation,omitempty"` - ServerName *string `json:"serverName,omitempty"` - ToolName *string `json:"toolName,omitempty"` +type PermissionDecisionApproveForLocationApproval interface { + permissionDecisionApproveForLocationApproval() + Kind() PermissionDecisionApproveForLocationApprovalKind +} + +type RawPermissionDecisionApproveForLocationApprovalData struct { + Discriminator PermissionDecisionApproveForLocationApprovalKind + Raw json.RawMessage +} + +func (RawPermissionDecisionApproveForLocationApprovalData) permissionDecisionApproveForLocationApproval() { +} +func (r RawPermissionDecisionApproveForLocationApprovalData) Kind() PermissionDecisionApproveForLocationApprovalKind { + return r.Discriminator } type PermissionDecisionApproveForLocationApprovalCommands struct { - CommandIdentifiers []string `json:"commandIdentifiers"` - Kind PermissionDecisionApproveForLocationApprovalCommandsKind `json:"kind"` + CommandIdentifiers []string `json:"commandIdentifiers"` +} + +func (PermissionDecisionApproveForLocationApprovalCommands) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalCommands) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindCommands } type PermissionDecisionApproveForLocationApprovalCustomTool struct { - Kind PermissionDecisionApproveForLocationApprovalCustomToolKind `json:"kind"` - ToolName string `json:"toolName"` + ToolName string `json:"toolName"` +} + +func (PermissionDecisionApproveForLocationApprovalCustomTool) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalCustomTool) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindCustomTool } type PermissionDecisionApproveForLocationApprovalExtensionManagement struct { - Kind PermissionDecisionApproveForLocationApprovalExtensionManagementKind `json:"kind"` - Operation *string `json:"operation,omitempty"` + Operation *string `json:"operation,omitempty"` +} + +func (PermissionDecisionApproveForLocationApprovalExtensionManagement) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalExtensionManagement) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindExtensionManagement } type PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess struct { - ExtensionName string `json:"extensionName"` - Kind PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind `json:"kind"` + ExtensionName string `json:"extensionName"` +} + +func (PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindExtensionPermissionAccess } type PermissionDecisionApproveForLocationApprovalMcp struct { - Kind PermissionDecisionApproveForLocationApprovalMcpKind `json:"kind"` - ServerName string `json:"serverName"` - ToolName *string `json:"toolName"` + ServerName string `json:"serverName"` + ToolName *string `json:"toolName"` +} + +func (PermissionDecisionApproveForLocationApprovalMcp) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalMcp) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindMcp } type PermissionDecisionApproveForLocationApprovalMcpSampling struct { - Kind PermissionDecisionApproveForLocationApprovalMcpSamplingKind `json:"kind"` - ServerName string `json:"serverName"` + ServerName string `json:"serverName"` +} + +func (PermissionDecisionApproveForLocationApprovalMcpSampling) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalMcpSampling) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindMcpSampling } type PermissionDecisionApproveForLocationApprovalMemory struct { - Kind PermissionDecisionApproveForLocationApprovalMemoryKind `json:"kind"` +} + +func (PermissionDecisionApproveForLocationApprovalMemory) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalMemory) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindMemory } type PermissionDecisionApproveForLocationApprovalRead struct { - Kind PermissionDecisionApproveForLocationApprovalReadKind `json:"kind"` +} + +func (PermissionDecisionApproveForLocationApprovalRead) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalRead) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindRead } type PermissionDecisionApproveForLocationApprovalWrite struct { - Kind PermissionDecisionApproveForLocationApprovalWriteKind `json:"kind"` } -type PermissionDecisionApproveForSession struct { - // The approval to add as a session-scoped rule - Approval *PermissionDecisionApproveForSessionApproval `json:"approval,omitempty"` - // The URL domain to approve for this session - Domain *string `json:"domain,omitempty"` - // Approved and remembered for the rest of the session - Kind PermissionDecisionApproveForSessionKind `json:"kind"` +func (PermissionDecisionApproveForLocationApprovalWrite) permissionDecisionApproveForLocationApproval() { +} +func (PermissionDecisionApproveForLocationApprovalWrite) Kind() PermissionDecisionApproveForLocationApprovalKind { + return PermissionDecisionApproveForLocationApprovalKindWrite } // The approval to add as a session-scoped rule -type PermissionDecisionApproveForSessionApproval struct { - CommandIdentifiers []string `json:"commandIdentifiers,omitempty"` - ExtensionName *string `json:"extensionName,omitempty"` - // Kind discriminator - Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` - Operation *string `json:"operation,omitempty"` - ServerName *string `json:"serverName,omitempty"` - ToolName *string `json:"toolName,omitempty"` +type PermissionDecisionApproveForSessionApproval interface { + permissionDecisionApproveForSessionApproval() + Kind() PermissionDecisionApproveForSessionApprovalKind +} + +type RawPermissionDecisionApproveForSessionApprovalData struct { + Discriminator PermissionDecisionApproveForSessionApprovalKind + Raw json.RawMessage +} + +func (RawPermissionDecisionApproveForSessionApprovalData) permissionDecisionApproveForSessionApproval() { +} +func (r RawPermissionDecisionApproveForSessionApprovalData) Kind() PermissionDecisionApproveForSessionApprovalKind { + return r.Discriminator } type PermissionDecisionApproveForSessionApprovalCommands struct { - CommandIdentifiers []string `json:"commandIdentifiers"` - Kind PermissionDecisionApproveForSessionApprovalCommandsKind `json:"kind"` + CommandIdentifiers []string `json:"commandIdentifiers"` +} + +func (PermissionDecisionApproveForSessionApprovalCommands) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalCommands) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindCommands } type PermissionDecisionApproveForSessionApprovalCustomTool struct { - Kind PermissionDecisionApproveForSessionApprovalCustomToolKind `json:"kind"` - ToolName string `json:"toolName"` + ToolName string `json:"toolName"` +} + +func (PermissionDecisionApproveForSessionApprovalCustomTool) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalCustomTool) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindCustomTool } type PermissionDecisionApproveForSessionApprovalExtensionManagement struct { - Kind PermissionDecisionApproveForSessionApprovalExtensionManagementKind `json:"kind"` - Operation *string `json:"operation,omitempty"` + Operation *string `json:"operation,omitempty"` +} + +func (PermissionDecisionApproveForSessionApprovalExtensionManagement) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalExtensionManagement) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindExtensionManagement } type PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess struct { - ExtensionName string `json:"extensionName"` - Kind PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind `json:"kind"` + ExtensionName string `json:"extensionName"` +} + +func (PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindExtensionPermissionAccess } type PermissionDecisionApproveForSessionApprovalMcp struct { - Kind PermissionDecisionApproveForSessionApprovalMcpKind `json:"kind"` - ServerName string `json:"serverName"` - ToolName *string `json:"toolName"` + ServerName string `json:"serverName"` + ToolName *string `json:"toolName"` +} + +func (PermissionDecisionApproveForSessionApprovalMcp) permissionDecisionApproveForSessionApproval() {} +func (PermissionDecisionApproveForSessionApprovalMcp) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindMcp } type PermissionDecisionApproveForSessionApprovalMcpSampling struct { - Kind PermissionDecisionApproveForSessionApprovalMcpSamplingKind `json:"kind"` - ServerName string `json:"serverName"` + ServerName string `json:"serverName"` +} + +func (PermissionDecisionApproveForSessionApprovalMcpSampling) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalMcpSampling) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindMcpSampling } type PermissionDecisionApproveForSessionApprovalMemory struct { - Kind PermissionDecisionApproveForSessionApprovalMemoryKind `json:"kind"` } -type PermissionDecisionApproveForSessionApprovalRead struct { - Kind PermissionDecisionApproveForSessionApprovalReadKind `json:"kind"` +func (PermissionDecisionApproveForSessionApprovalMemory) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalMemory) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindMemory } -type PermissionDecisionApproveForSessionApprovalWrite struct { - Kind PermissionDecisionApproveForSessionApprovalWriteKind `json:"kind"` +type PermissionDecisionApproveForSessionApprovalRead struct { } -type PermissionDecisionApproveOnce struct { - // The permission request was approved for this one instance - Kind PermissionDecisionApproveOnceKind `json:"kind"` +func (PermissionDecisionApproveForSessionApprovalRead) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalRead) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindRead } -type PermissionDecisionApprovePermanently struct { - // The URL domain to approve permanently - Domain string `json:"domain"` - // Approved and persisted across sessions - Kind PermissionDecisionApprovePermanentlyKind `json:"kind"` +type PermissionDecisionApproveForSessionApprovalWrite struct { } -type PermissionDecisionReject struct { - // Optional feedback from the user explaining the denial - Feedback *string `json:"feedback,omitempty"` - // Denied by the user during an interactive prompt - Kind PermissionDecisionRejectKind `json:"kind"` +func (PermissionDecisionApproveForSessionApprovalWrite) permissionDecisionApproveForSessionApproval() { +} +func (PermissionDecisionApproveForSessionApprovalWrite) Kind() PermissionDecisionApproveForSessionApprovalKind { + return PermissionDecisionApproveForSessionApprovalKindWrite } type PermissionDecisionRequest struct { @@ -1043,11 +1117,6 @@ type PermissionDecisionRequest struct { Result PermissionDecision `json:"result"` } -type PermissionDecisionUserNotAvailable struct { - // Denied because user confirmation was unavailable - Kind PermissionDecisionUserNotAvailableKind `json:"kind"` -} - type PermissionRequestResult struct { // Whether the permission request was handled successfully Success bool `json:"success"` @@ -1136,8 +1205,8 @@ type QueuedCommandNotHandled struct { // Result of the queued command execution type QueuedCommandResult struct { - // Handled discriminator - Handled QueuedCommandResultHandled `json:"handled"` + // The command was handled + Handled any `json:"handled"` // If true, stop processing remaining queued items StopProcessingQueue *bool `json:"stopProcessingQueue,omitempty"` } @@ -1462,6 +1531,21 @@ type SkillsReloadResult struct { type SuspendResult struct { } +type TaskInfo interface { + taskInfo() + Type() TaskInfoType +} + +type RawTaskInfoData struct { + Discriminator TaskInfoType + Raw json.RawMessage +} + +func (RawTaskInfoData) taskInfo() {} +func (r RawTaskInfoData) Type() TaskInfoType { + return r.Discriminator +} + type TaskAgentInfo struct { // ISO 8601 timestamp when the current active period began ActiveStartedAt *time.Time `json:"activeStartedAt,omitempty"` @@ -1499,58 +1583,42 @@ type TaskAgentInfo struct { Status TaskAgentInfoStatus `json:"status"` // Tool call ID associated with this agent task ToolCallID string `json:"toolCallId"` - // Task kind - Type TaskAgentInfoType `json:"type"` } -type TaskInfo struct { - // ISO 8601 timestamp when the current active period began - ActiveStartedAt *time.Time `json:"activeStartedAt,omitempty"` - // Accumulated active execution time in milliseconds - ActiveTimeMs *int64 `json:"activeTimeMs,omitempty"` - // Type of agent running this task - AgentType *string `json:"agentType,omitempty"` +func (TaskAgentInfo) taskInfo() {} +func (TaskAgentInfo) Type() TaskInfoType { + return TaskInfoTypeAgent +} + +type TaskShellInfo struct { // Whether the shell runs inside a managed PTY session or as an independent background // process - AttachmentMode *TaskShellInfoAttachmentMode `json:"attachmentMode,omitempty"` - // Whether the task is currently in the original sync wait and can be moved to background - // mode. False once it is already backgrounded, idle, finished, or no longer has a - // promotable sync waiter. + AttachmentMode TaskShellInfoAttachmentMode `json:"attachmentMode"` + // Whether this shell task can be promoted to background mode CanPromoteToBackground *bool `json:"canPromoteToBackground,omitempty"` // Command being executed - Command *string `json:"command,omitempty"` + Command string `json:"command"` // ISO 8601 timestamp when the task finished CompletedAt *time.Time `json:"completedAt,omitempty"` // Short description of the task Description string `json:"description"` - // Error message when the task failed - Error *string `json:"error,omitempty"` - // How the agent is currently being managed by the runtime - ExecutionMode *TaskAgentInfoExecutionMode `json:"executionMode,omitempty"` + // Whether the shell command is currently sync-waited or background-managed + ExecutionMode *TaskShellInfoExecutionMode `json:"executionMode,omitempty"` // Unique task identifier ID string `json:"id"` - // ISO 8601 timestamp when the agent entered idle state - IdleSince *time.Time `json:"idleSince,omitempty"` - // Most recent response text from the agent - LatestResponse *string `json:"latestResponse,omitempty"` // Path to the detached shell log, when available LogPath *string `json:"logPath,omitempty"` - // Model used for the task when specified - Model *string `json:"model,omitempty"` // Process ID when available Pid *int64 `json:"pid,omitempty"` - // Prompt passed to the agent - Prompt *string `json:"prompt,omitempty"` - // Result text from the task when available - Result *string `json:"result,omitempty"` // ISO 8601 timestamp when the task was started StartedAt time.Time `json:"startedAt"` // Current lifecycle status of the task - Status TaskAgentInfoStatus `json:"status"` - // Tool call ID associated with this agent task - ToolCallID *string `json:"toolCallId,omitempty"` - // Type discriminator - Type TaskInfoType `json:"type"` + Status TaskShellInfoStatus `json:"status"` +} + +func (TaskShellInfo) taskInfo() {} +func (TaskShellInfo) Type() TaskInfoType { + return TaskInfoTypeShell } // Experimental: TaskList is part of an experimental API and may change or be removed. @@ -1562,43 +1630,15 @@ type TaskList struct { // Experimental: TasksCancelRequest is part of an experimental API and may change or be // removed. type TasksCancelRequest struct { - // Task identifier - ID string `json:"id"` -} - -// Experimental: TasksCancelResult is part of an experimental API and may change or be -// removed. -type TasksCancelResult struct { - // Whether the task was successfully cancelled - Cancelled bool `json:"cancelled"` -} - -type TaskShellInfo struct { - // Whether the shell runs inside a managed PTY session or as an independent background - // process - AttachmentMode TaskShellInfoAttachmentMode `json:"attachmentMode"` - // Whether this shell task can be promoted to background mode - CanPromoteToBackground *bool `json:"canPromoteToBackground,omitempty"` - // Command being executed - Command string `json:"command"` - // ISO 8601 timestamp when the task finished - CompletedAt *time.Time `json:"completedAt,omitempty"` - // Short description of the task - Description string `json:"description"` - // Whether the shell command is currently sync-waited or background-managed - ExecutionMode *TaskShellInfoExecutionMode `json:"executionMode,omitempty"` - // Unique task identifier - ID string `json:"id"` - // Path to the detached shell log, when available - LogPath *string `json:"logPath,omitempty"` - // Process ID when available - Pid *int64 `json:"pid,omitempty"` - // ISO 8601 timestamp when the task was started - StartedAt time.Time `json:"startedAt"` - // Current lifecycle status of the task - Status TaskShellInfoStatus `json:"status"` - // Task kind - Type TaskShellInfoType `json:"type"` + // Task identifier + ID string `json:"id"` +} + +// Experimental: TasksCancelResult is part of an experimental API and may change or be +// removed. +type TasksCancelResult struct { + // Whether the task was successfully cancelled + Cancelled bool `json:"cancelled"` } // Experimental: TasksPromoteToBackgroundRequest is part of an experimental API and may @@ -1697,16 +1737,6 @@ type ToolsListRequest struct { Model *string `json:"model,omitempty"` } -type UIElicitationArrayAnyOfField struct { - Default []string `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Items UIElicitationArrayAnyOfFieldItems `json:"items"` - MaxItems *float64 `json:"maxItems,omitempty"` - MinItems *float64 `json:"minItems,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationArrayAnyOfFieldType `json:"type"` -} - type UIElicitationArrayAnyOfFieldItems struct { AnyOf []UIElicitationArrayAnyOfFieldItemsAnyOf `json:"anyOf"` } @@ -1716,79 +1746,30 @@ type UIElicitationArrayAnyOfFieldItemsAnyOf struct { Title string `json:"title"` } -type UIElicitationArrayEnumField struct { - Default []string `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Items UIElicitationArrayEnumFieldItems `json:"items"` - MaxItems *float64 `json:"maxItems,omitempty"` - MinItems *float64 `json:"minItems,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationArrayEnumFieldType `json:"type"` -} - type UIElicitationArrayEnumFieldItems struct { Enum []string `json:"enum"` Type UIElicitationArrayEnumFieldItemsType `json:"type"` } -type UIElicitationFieldValue struct { - Bool *bool - Double *float64 - String *string - StringArray []string +type UIElicitationFieldValue interface { + uIElicitationFieldValue() } -func (r UIElicitationFieldValue) MarshalJSON() ([]byte, error) { - if r.Bool != nil { - return json.Marshal(r.Bool) - } - if r.Double != nil { - return json.Marshal(r.Double) - } - if r.String != nil { - return json.Marshal(r.String) - } - if r.StringArray != nil { - return json.Marshal(r.StringArray) - } - return []byte("null"), nil -} +type UIElicitationBooleanValue bool -func (r *UIElicitationFieldValue) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - *r = UIElicitationFieldValue{} - return nil - } - { - var value bool - if err := json.Unmarshal(data, &value); err == nil { - *r = UIElicitationFieldValue{Bool: &value} - return nil - } - } - { - var value float64 - if err := json.Unmarshal(data, &value); err == nil { - *r = UIElicitationFieldValue{Double: &value} - return nil - } - } - { - var value string - if err := json.Unmarshal(data, &value); err == nil { - *r = UIElicitationFieldValue{String: &value} - return nil - } - } - { - var value []string - if err := json.Unmarshal(data, &value); err == nil { - *r = UIElicitationFieldValue{StringArray: value} - return nil - } - } - return errors.New("data did not match any union variant for UIElicitationFieldValue") -} +func (UIElicitationBooleanValue) uIElicitationFieldValue() {} + +type UIElicitationNumberValue float64 + +func (UIElicitationNumberValue) uIElicitationFieldValue() {} + +type UIElicitationStringArrayValue []string + +func (UIElicitationStringArrayValue) uIElicitationFieldValue() {} + +type UIElicitationStringValue string + +func (UIElicitationStringValue) uIElicitationFieldValue() {} type UIElicitationRequest struct { // Message describing what information is needed from the user @@ -1802,11 +1783,11 @@ type UIElicitationResponse struct { // The user's response: accept (submitted), decline (rejected), or cancel (dismissed) Action UIElicitationResponseAction `json:"action"` // The form values submitted by the user (present when action is 'accept') - Content map[string]*UIElicitationFieldValue `json:"content,omitempty"` + Content map[string]UIElicitationFieldValue `json:"content,omitempty"` } // The form values submitted by the user (present when action is 'accept') -type UIElicitationResponseContent map[string]*UIElicitationFieldValue +type UIElicitationResponseContent map[string]UIElicitationFieldValue type UIElicitationResult struct { // Whether the response was accepted. False if the request was already resolved by another @@ -1824,44 +1805,75 @@ type UIElicitationSchema struct { Type UIElicitationSchemaType `json:"type"` } -type UIElicitationSchemaProperty struct { - Default *UIElicitationFieldValue `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` - EnumNames []string `json:"enumNames,omitempty"` - Format *UIElicitationSchemaPropertyStringFormat `json:"format,omitempty"` - Items *UIElicitationSchemaPropertyItems `json:"items,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - MaxItems *float64 `json:"maxItems,omitempty"` - MaxLength *float64 `json:"maxLength,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - MinItems *float64 `json:"minItems,omitempty"` - MinLength *float64 `json:"minLength,omitempty"` - OneOf []UIElicitationStringOneOfFieldOneOf `json:"oneOf,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationSchemaPropertyType `json:"type"` +type UIElicitationSchemaProperty interface { + uIElicitationSchemaProperty() + Type() UIElicitationSchemaPropertyType +} + +type RawUIElicitationSchemaPropertyData struct { + Discriminator UIElicitationSchemaPropertyType + Raw json.RawMessage +} + +func (RawUIElicitationSchemaPropertyData) uIElicitationSchemaProperty() {} +func (r RawUIElicitationSchemaPropertyData) Type() UIElicitationSchemaPropertyType { + return r.Discriminator +} + +type UIElicitationArrayAnyOfField struct { + Default []string `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Items UIElicitationArrayAnyOfFieldItems `json:"items"` + MaxItems *float64 `json:"maxItems,omitempty"` + MinItems *float64 `json:"minItems,omitempty"` + Title *string `json:"title,omitempty"` +} + +func (UIElicitationArrayAnyOfField) uIElicitationSchemaProperty() {} +func (UIElicitationArrayAnyOfField) Type() UIElicitationSchemaPropertyType { + return UIElicitationSchemaPropertyTypeArray +} + +type UIElicitationArrayEnumField struct { + Default []string `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Items UIElicitationArrayEnumFieldItems `json:"items"` + MaxItems *float64 `json:"maxItems,omitempty"` + MinItems *float64 `json:"minItems,omitempty"` + Title *string `json:"title,omitempty"` +} + +func (UIElicitationArrayEnumField) uIElicitationSchemaProperty() {} +func (UIElicitationArrayEnumField) Type() UIElicitationSchemaPropertyType { + return UIElicitationSchemaPropertyTypeArray } type UIElicitationSchemaPropertyBoolean struct { - Default *bool `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationSchemaPropertyBooleanType `json:"type"` + Default *bool `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Title *string `json:"title,omitempty"` } -type UIElicitationSchemaPropertyItems struct { - AnyOf []UIElicitationArrayAnyOfFieldItemsAnyOf `json:"anyOf,omitempty"` - Enum []string `json:"enum,omitempty"` - Type *UIElicitationSchemaPropertyItemsType `json:"type,omitempty"` +func (UIElicitationSchemaPropertyBoolean) uIElicitationSchemaProperty() {} +func (UIElicitationSchemaPropertyBoolean) Type() UIElicitationSchemaPropertyType { + return UIElicitationSchemaPropertyTypeBoolean } type UIElicitationSchemaPropertyNumber struct { - Default *float64 `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationSchemaPropertyNumberType `json:"type"` + Default *float64 `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Title *string `json:"title,omitempty"` + Discriminator UIElicitationSchemaPropertyNumberType `json:"type,omitempty"` +} + +func (UIElicitationSchemaPropertyNumber) uIElicitationSchemaProperty() {} +func (r UIElicitationSchemaPropertyNumber) Type() UIElicitationSchemaPropertyType { + if r.Discriminator == "" { + return UIElicitationSchemaPropertyTypeNumber + } + return UIElicitationSchemaPropertyType(r.Discriminator) } type UIElicitationSchemaPropertyString struct { @@ -1871,16 +1883,24 @@ type UIElicitationSchemaPropertyString struct { MaxLength *float64 `json:"maxLength,omitempty"` MinLength *float64 `json:"minLength,omitempty"` Title *string `json:"title,omitempty"` - Type UIElicitationSchemaPropertyStringType `json:"type"` +} + +func (UIElicitationSchemaPropertyString) uIElicitationSchemaProperty() {} +func (UIElicitationSchemaPropertyString) Type() UIElicitationSchemaPropertyType { + return UIElicitationSchemaPropertyTypeString } type UIElicitationStringEnumField struct { - Default *string `json:"default,omitempty"` - Description *string `json:"description,omitempty"` - Enum []string `json:"enum"` - EnumNames []string `json:"enumNames,omitempty"` - Title *string `json:"title,omitempty"` - Type UIElicitationStringEnumFieldType `json:"type"` + Default *string `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + Enum []string `json:"enum"` + EnumNames []string `json:"enumNames,omitempty"` + Title *string `json:"title,omitempty"` +} + +func (UIElicitationStringEnumField) uIElicitationSchemaProperty() {} +func (UIElicitationStringEnumField) Type() UIElicitationSchemaPropertyType { + return UIElicitationSchemaPropertyTypeString } type UIElicitationStringOneOfField struct { @@ -1888,7 +1908,11 @@ type UIElicitationStringOneOfField struct { Description *string `json:"description,omitempty"` OneOf []UIElicitationStringOneOfFieldOneOf `json:"oneOf"` Title *string `json:"title,omitempty"` - Type UIElicitationStringOneOfFieldType `json:"type"` +} + +func (UIElicitationStringOneOfField) uIElicitationSchemaProperty() {} +func (UIElicitationStringOneOfField) Type() UIElicitationSchemaPropertyType { + return UIElicitationSchemaPropertyTypeString } type UIElicitationStringOneOfFieldOneOf struct { @@ -2084,20 +2108,6 @@ const ( ExtensionStatusStarting ExtensionStatus = "starting" ) -// Content block type discriminator -type ExternalToolTextResultForLlmContentAudioType string - -const ( - ExternalToolTextResultForLlmContentAudioTypeAudio ExternalToolTextResultForLlmContentAudioType = "audio" -) - -// Content block type discriminator -type ExternalToolTextResultForLlmContentImageType string - -const ( - ExternalToolTextResultForLlmContentImageTypeImage ExternalToolTextResultForLlmContentImageType = "image" -) - // Theme variant this icon is intended for type ExternalToolTextResultForLlmContentResourceLinkIconTheme string @@ -2106,34 +2116,6 @@ const ( ExternalToolTextResultForLlmContentResourceLinkIconThemeLight ExternalToolTextResultForLlmContentResourceLinkIconTheme = "light" ) -// Content block type discriminator -type ExternalToolTextResultForLlmContentResourceLinkType string - -const ( - ExternalToolTextResultForLlmContentResourceLinkTypeResourceLink ExternalToolTextResultForLlmContentResourceLinkType = "resource_link" -) - -// Content block type discriminator -type ExternalToolTextResultForLlmContentResourceType string - -const ( - ExternalToolTextResultForLlmContentResourceTypeResource ExternalToolTextResultForLlmContentResourceType = "resource" -) - -// Content block type discriminator -type ExternalToolTextResultForLlmContentTerminalType string - -const ( - ExternalToolTextResultForLlmContentTerminalTypeTerminal ExternalToolTextResultForLlmContentTerminalType = "terminal" -) - -// Content block type discriminator -type ExternalToolTextResultForLlmContentTextType string - -const ( - ExternalToolTextResultForLlmContentTextTypeText ExternalToolTextResultForLlmContentTextType = "text" -) - // Type discriminator for ExternalToolTextResultForLlmContent. type ExternalToolTextResultForLlmContentType string @@ -2205,15 +2187,6 @@ const ( McpServerConfigLocalTypeStdio McpServerConfigLocalType = "stdio" ) -type McpServerConfigType string - -const ( - McpServerConfigTypeHTTP McpServerConfigType = "http" - McpServerConfigTypeLocal McpServerConfigType = "local" - McpServerConfigTypeSse McpServerConfigType = "sse" - McpServerConfigTypeStdio McpServerConfigType = "stdio" -) - // Configuration source: user, workspace, plugin, or builtin type McpServerSource string @@ -2236,30 +2209,6 @@ const ( McpServerStatusPending McpServerStatus = "pending" ) -type PermissionDecisionApproveForLocationApprovalCommandsKind string - -const ( - PermissionDecisionApproveForLocationApprovalCommandsKindCommands PermissionDecisionApproveForLocationApprovalCommandsKind = "commands" -) - -type PermissionDecisionApproveForLocationApprovalCustomToolKind string - -const ( - PermissionDecisionApproveForLocationApprovalCustomToolKindCustomTool PermissionDecisionApproveForLocationApprovalCustomToolKind = "custom-tool" -) - -type PermissionDecisionApproveForLocationApprovalExtensionManagementKind string - -const ( - PermissionDecisionApproveForLocationApprovalExtensionManagementKindExtensionManagement PermissionDecisionApproveForLocationApprovalExtensionManagementKind = "extension-management" -) - -type PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind string - -const ( - PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKindExtensionPermissionAccess PermissionDecisionApproveForLocationApprovalExtensionPermissionAccessKind = "extension-permission-access" -) - // Kind discriminator for PermissionDecisionApproveForLocationApproval. type PermissionDecisionApproveForLocationApprovalKind string @@ -2275,67 +2224,6 @@ const ( PermissionDecisionApproveForLocationApprovalKindWrite PermissionDecisionApproveForLocationApprovalKind = "write" ) -type PermissionDecisionApproveForLocationApprovalMcpKind string - -const ( - PermissionDecisionApproveForLocationApprovalMcpKindMcp PermissionDecisionApproveForLocationApprovalMcpKind = "mcp" -) - -type PermissionDecisionApproveForLocationApprovalMcpSamplingKind string - -const ( - PermissionDecisionApproveForLocationApprovalMcpSamplingKindMcpSampling PermissionDecisionApproveForLocationApprovalMcpSamplingKind = "mcp-sampling" -) - -type PermissionDecisionApproveForLocationApprovalMemoryKind string - -const ( - PermissionDecisionApproveForLocationApprovalMemoryKindMemory PermissionDecisionApproveForLocationApprovalMemoryKind = "memory" -) - -type PermissionDecisionApproveForLocationApprovalReadKind string - -const ( - PermissionDecisionApproveForLocationApprovalReadKindRead PermissionDecisionApproveForLocationApprovalReadKind = "read" -) - -type PermissionDecisionApproveForLocationApprovalWriteKind string - -const ( - PermissionDecisionApproveForLocationApprovalWriteKindWrite PermissionDecisionApproveForLocationApprovalWriteKind = "write" -) - -// Approved and persisted for this project location -type PermissionDecisionApproveForLocationKind string - -const ( - PermissionDecisionApproveForLocationKindApproveForLocation PermissionDecisionApproveForLocationKind = "approve-for-location" -) - -type PermissionDecisionApproveForSessionApprovalCommandsKind string - -const ( - PermissionDecisionApproveForSessionApprovalCommandsKindCommands PermissionDecisionApproveForSessionApprovalCommandsKind = "commands" -) - -type PermissionDecisionApproveForSessionApprovalCustomToolKind string - -const ( - PermissionDecisionApproveForSessionApprovalCustomToolKindCustomTool PermissionDecisionApproveForSessionApprovalCustomToolKind = "custom-tool" -) - -type PermissionDecisionApproveForSessionApprovalExtensionManagementKind string - -const ( - PermissionDecisionApproveForSessionApprovalExtensionManagementKindExtensionManagement PermissionDecisionApproveForSessionApprovalExtensionManagementKind = "extension-management" -) - -type PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind string - -const ( - PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKindExtensionPermissionAccess PermissionDecisionApproveForSessionApprovalExtensionPermissionAccessKind = "extension-permission-access" -) - // Kind discriminator for PermissionDecisionApproveForSessionApproval. type PermissionDecisionApproveForSessionApprovalKind string @@ -2351,57 +2239,6 @@ const ( PermissionDecisionApproveForSessionApprovalKindWrite PermissionDecisionApproveForSessionApprovalKind = "write" ) -type PermissionDecisionApproveForSessionApprovalMcpKind string - -const ( - PermissionDecisionApproveForSessionApprovalMcpKindMcp PermissionDecisionApproveForSessionApprovalMcpKind = "mcp" -) - -type PermissionDecisionApproveForSessionApprovalMcpSamplingKind string - -const ( - PermissionDecisionApproveForSessionApprovalMcpSamplingKindMcpSampling PermissionDecisionApproveForSessionApprovalMcpSamplingKind = "mcp-sampling" -) - -type PermissionDecisionApproveForSessionApprovalMemoryKind string - -const ( - PermissionDecisionApproveForSessionApprovalMemoryKindMemory PermissionDecisionApproveForSessionApprovalMemoryKind = "memory" -) - -type PermissionDecisionApproveForSessionApprovalReadKind string - -const ( - PermissionDecisionApproveForSessionApprovalReadKindRead PermissionDecisionApproveForSessionApprovalReadKind = "read" -) - -type PermissionDecisionApproveForSessionApprovalWriteKind string - -const ( - PermissionDecisionApproveForSessionApprovalWriteKindWrite PermissionDecisionApproveForSessionApprovalWriteKind = "write" -) - -// Approved and remembered for the rest of the session -type PermissionDecisionApproveForSessionKind string - -const ( - PermissionDecisionApproveForSessionKindApproveForSession PermissionDecisionApproveForSessionKind = "approve-for-session" -) - -// The permission request was approved for this one instance -type PermissionDecisionApproveOnceKind string - -const ( - PermissionDecisionApproveOnceKindApproveOnce PermissionDecisionApproveOnceKind = "approve-once" -) - -// Approved and persisted across sessions -type PermissionDecisionApprovePermanentlyKind string - -const ( - PermissionDecisionApprovePermanentlyKindApprovePermanently PermissionDecisionApprovePermanentlyKind = "approve-permanently" -) - // Kind discriminator for PermissionDecision. type PermissionDecisionKind string @@ -2414,28 +2251,6 @@ const ( PermissionDecisionKindUserNotAvailable PermissionDecisionKind = "user-not-available" ) -// Denied by the user during an interactive prompt -type PermissionDecisionRejectKind string - -const ( - PermissionDecisionRejectKindReject PermissionDecisionRejectKind = "reject" -) - -// Denied because user confirmation was unavailable -type PermissionDecisionUserNotAvailableKind string - -const ( - PermissionDecisionUserNotAvailableKindUserNotAvailable PermissionDecisionUserNotAvailableKind = "user-not-available" -) - -// Handled discriminator for QueuedCommandResult. -type QueuedCommandResultHandled string - -const ( - QueuedCommandResultHandledFalse QueuedCommandResultHandled = "false" - QueuedCommandResultHandledTrue QueuedCommandResultHandled = "true" -) - // Error classification type SessionFsErrorCode string @@ -2507,13 +2322,6 @@ const ( TaskAgentInfoStatusRunning TaskAgentInfoStatus = "running" ) -// Task kind -type TaskAgentInfoType string - -const ( - TaskAgentInfoTypeAgent TaskAgentInfoType = "agent" -) - // Type discriminator for TaskInfo. type TaskInfoType string @@ -2550,31 +2358,12 @@ const ( TaskShellInfoStatusRunning TaskShellInfoStatus = "running" ) -// Task kind -type TaskShellInfoType string - -const ( - TaskShellInfoTypeShell TaskShellInfoType = "shell" -) - -type UIElicitationArrayAnyOfFieldType string - -const ( - UIElicitationArrayAnyOfFieldTypeArray UIElicitationArrayAnyOfFieldType = "array" -) - type UIElicitationArrayEnumFieldItemsType string const ( UIElicitationArrayEnumFieldItemsTypeString UIElicitationArrayEnumFieldItemsType = "string" ) -type UIElicitationArrayEnumFieldType string - -const ( - UIElicitationArrayEnumFieldTypeArray UIElicitationArrayEnumFieldType = "array" -) - // The user's response: accept (submitted), decline (rejected), or cancel (dismissed) type UIElicitationResponseAction string @@ -2584,18 +2373,6 @@ const ( UIElicitationResponseActionDecline UIElicitationResponseAction = "decline" ) -type UIElicitationSchemaPropertyBooleanType string - -const ( - UIElicitationSchemaPropertyBooleanTypeBoolean UIElicitationSchemaPropertyBooleanType = "boolean" -) - -type UIElicitationSchemaPropertyItemsType string - -const ( - UIElicitationSchemaPropertyItemsTypeString UIElicitationSchemaPropertyItemsType = "string" -) - type UIElicitationSchemaPropertyNumberType string const ( @@ -2612,12 +2389,7 @@ const ( UIElicitationSchemaPropertyStringFormatURI UIElicitationSchemaPropertyStringFormat = "uri" ) -type UIElicitationSchemaPropertyStringType string - -const ( - UIElicitationSchemaPropertyStringTypeString UIElicitationSchemaPropertyStringType = "string" -) - +// Type discriminator for UIElicitationSchemaProperty. type UIElicitationSchemaPropertyType string const ( @@ -2635,18 +2407,6 @@ const ( UIElicitationSchemaTypeObject UIElicitationSchemaType = "object" ) -type UIElicitationStringEnumFieldType string - -const ( - UIElicitationStringEnumFieldTypeString UIElicitationStringEnumFieldType = "string" -) - -type UIElicitationStringOneOfFieldType string - -const ( - UIElicitationStringOneOfFieldTypeString UIElicitationStringOneOfFieldType = "string" -) - type WorkspacesGetWorkspaceResultWorkspaceHostType string const ( @@ -3733,7 +3493,7 @@ func (a *ToolsApi) HandlePendingToolCall(ctx context.Context, params *HandlePend } req["requestId"] = params.RequestID if params.Result != nil { - req["result"] = *params.Result + req["result"] = params.Result } } raw, err := a.client.Request("session.tools.handlePendingToolCall", req) diff --git a/go/rpc/generated_rpc_api_shape_test.go b/go/rpc/generated_rpc_api_shape_test.go new file mode 100644 index 000000000..33674db18 --- /dev/null +++ b/go/rpc/generated_rpc_api_shape_test.go @@ -0,0 +1,114 @@ +package rpc + +import ( + "bytes" + "go/ast" + "go/format" + "go/parser" + "go/token" + "path/filepath" + "runtime" + "testing" +) + +var ( + _ ExternalToolResult = ExternalToolStringResult("") + _ ExternalToolResult = (*ExternalToolTextResultForLlm)(nil) + _ FilterMapping = FilterMappingEnumMap{} + _ FilterMapping = FilterMappingStringMarkdown + _ McpServerConfig = (*McpServerConfigHTTP)(nil) + _ McpServerConfig = (*McpServerConfigLocal)(nil) + _ UIElicitationFieldValue = UIElicitationStringValue("") + _ UIElicitationFieldValue = UIElicitationStringArrayValue(nil) + _ UIElicitationFieldValue = UIElicitationBooleanValue(false) + _ UIElicitationFieldValue = UIElicitationNumberValue(0) +) + +func TestGeneratedRPCAPIShape(t *testing.T) { + file, fileSet := parseGeneratedRPC(t) + + assertInterfaceType(t, file, "ExternalToolResult") + assertTypeExpr(t, fileSet, findTypeSpec(t, file, "ExternalToolStringResult").Type, "string") + assertStructFieldType(t, file, fileSet, "HandlePendingToolCallRequest", "Result", "ExternalToolResult") + + assertInterfaceType(t, file, "FilterMapping") + assertTypeExpr(t, fileSet, findTypeSpec(t, file, "FilterMappingEnumMap").Type, "map[string]FilterMappingValue") + + assertInterfaceType(t, file, "McpServerConfig") + assertStructFieldType(t, file, fileSet, "McpConfigAddRequest", "Config", "McpServerConfig") + assertStructFieldType(t, file, fileSet, "McpConfigList", "Servers", "map[string]McpServerConfig") + assertStructFieldType(t, file, fileSet, "McpConfigUpdateRequest", "Config", "McpServerConfig") + assertStructFieldType(t, file, fileSet, "McpServerConfigHTTP", "FilterMapping", "FilterMapping") + assertStructFieldType(t, file, fileSet, "McpServerConfigLocal", "FilterMapping", "FilterMapping") + + assertInterfaceType(t, file, "UIElicitationFieldValue") + assertTypeExpr(t, fileSet, findTypeSpec(t, file, "UIElicitationStringArrayValue").Type, "[]string") + assertStructFieldType(t, file, fileSet, "UIElicitationResponse", "Content", "map[string]UIElicitationFieldValue") +} + +func parseGeneratedRPC(t *testing.T) (*ast.File, *token.FileSet) { + t.Helper() + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate test file") + } + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, filepath.Join(filepath.Dir(currentFile), "generated_rpc.go"), nil, 0) + if err != nil { + t.Fatalf("parse generated_rpc.go: %v", err) + } + return file, fileSet +} + +func findTypeSpec(t *testing.T, file *ast.File, typeName string) *ast.TypeSpec { + t.Helper() + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if ok && typeSpec.Name.Name == typeName { + return typeSpec + } + } + } + t.Fatalf("type %s not found", typeName) + return nil +} + +func assertInterfaceType(t *testing.T, file *ast.File, typeName string) { + t.Helper() + if _, ok := findTypeSpec(t, file, typeName).Type.(*ast.InterfaceType); !ok { + t.Fatalf("type %s has unexpected AST node %T", typeName, findTypeSpec(t, file, typeName).Type) + } +} + +func assertStructFieldType(t *testing.T, file *ast.File, fileSet *token.FileSet, structName, fieldName, want string) { + t.Helper() + structType, ok := findTypeSpec(t, file, structName).Type.(*ast.StructType) + if !ok { + t.Fatalf("type %s is %T, want struct", structName, findTypeSpec(t, file, structName).Type) + } + for _, field := range structType.Fields.List { + for _, name := range field.Names { + if name.Name == fieldName { + assertTypeExpr(t, fileSet, field.Type, want) + return + } + } + } + t.Fatalf("field %s.%s not found", structName, fieldName) +} + +func assertTypeExpr(t *testing.T, fileSet *token.FileSet, expr ast.Expr, want string) { + t.Helper() + var buffer bytes.Buffer + if err := format.Node(&buffer, fileSet, expr); err != nil { + t.Fatalf("format type expression: %v", err) + } + if got := buffer.String(); got != want { + t.Fatalf("type expression = %s, want %s", got, want) + } +} diff --git a/go/rpc/generated_rpc_union_test.go b/go/rpc/generated_rpc_union_test.go index c0afbe911..e2ca093df 100644 --- a/go/rpc/generated_rpc_union_test.go +++ b/go/rpc/generated_rpc_union_test.go @@ -6,7 +6,7 @@ import ( ) func TestExternalToolResultJSONUnion(t *testing.T) { - stringResult := ExternalToolResult{String: stringPtr("tool result")} + var stringResult ExternalToolResult = ExternalToolStringResult("tool result") raw, err := json.Marshal(stringResult) if err != nil { t.Fatalf("marshal string result: %v", err) @@ -15,15 +15,16 @@ func TestExternalToolResultJSONUnion(t *testing.T) { t.Fatalf("marshal string result = %s", raw) } - var decodedString ExternalToolResult - if err := json.Unmarshal([]byte(`"tool result"`), &decodedString); err != nil { + decodedString, err := unmarshalExternalToolResult([]byte(`"tool result"`)) + if err != nil { t.Fatalf("unmarshal string result: %v", err) } - if decodedString.String == nil || *decodedString.String != "tool result" { + decodedStringValue, ok := decodedString.(ExternalToolStringResult) + if !ok || string(decodedStringValue) != "tool result" { t.Fatalf("unmarshal string result = %#v", decodedString) } - objectResult := ExternalToolResult{ExternalToolTextResultForLlm: &ExternalToolTextResultForLlm{TextResultForLlm: "expanded"}} + var objectResult ExternalToolResult = &ExternalToolTextResultForLlm{TextResultForLlm: "expanded"} raw, err = json.Marshal(objectResult) if err != nil { t.Fatalf("marshal object result: %v", err) @@ -32,17 +33,18 @@ func TestExternalToolResultJSONUnion(t *testing.T) { t.Fatalf("marshal object result = %s", raw) } - var decodedObject ExternalToolResult - if err := json.Unmarshal([]byte(`{"textResultForLlm":"expanded"}`), &decodedObject); err != nil { + decodedObject, err := unmarshalExternalToolResult([]byte(`{"textResultForLlm":"expanded"}`)) + if err != nil { t.Fatalf("unmarshal object result: %v", err) } - if decodedObject.ExternalToolTextResultForLlm == nil || decodedObject.ExternalToolTextResultForLlm.TextResultForLlm != "expanded" { + decodedObjectValue, ok := decodedObject.(*ExternalToolTextResultForLlm) + if !ok || decodedObjectValue.TextResultForLlm != "expanded" { t.Fatalf("unmarshal object result = %#v", decodedObject) } } func TestFilterMappingJSONUnion(t *testing.T) { - mapping := FilterMapping{EnumMap: map[string]FilterMappingValue{"secret": FilterMappingValueHiddenCharacters}} + var mapping FilterMapping = FilterMappingEnumMap{"secret": FilterMappingValueHiddenCharacters} raw, err := json.Marshal(mapping) if err != nil { t.Fatalf("marshal filter mapping map: %v", err) @@ -51,16 +53,17 @@ func TestFilterMappingJSONUnion(t *testing.T) { t.Fatalf("marshal filter mapping map = %s", raw) } - var decodedMap FilterMapping - if err := json.Unmarshal([]byte(`{"secret":"hidden_characters"}`), &decodedMap); err != nil { + decodedMap, err := unmarshalFilterMapping([]byte(`{"secret":"hidden_characters"}`)) + if err != nil { t.Fatalf("unmarshal filter mapping map: %v", err) } - if decodedMap.EnumMap["secret"] != FilterMappingValueHiddenCharacters { + decodedMapValue, ok := decodedMap.(FilterMappingEnumMap) + if !ok || decodedMapValue["secret"] != FilterMappingValueHiddenCharacters { t.Fatalf("unmarshal filter mapping map = %#v", decodedMap) } - enumValue := FilterMappingStringMarkdown - raw, err = json.Marshal(FilterMapping{Enum: &enumValue}) + var enumValue FilterMapping = FilterMappingStringMarkdown + raw, err = json.Marshal(enumValue) if err != nil { t.Fatalf("marshal filter mapping enum: %v", err) } @@ -68,18 +71,67 @@ func TestFilterMappingJSONUnion(t *testing.T) { t.Fatalf("marshal filter mapping enum = %s", raw) } - var decodedEnum FilterMapping - if err := json.Unmarshal([]byte(`"markdown"`), &decodedEnum); err != nil { + decodedEnum, err := unmarshalFilterMapping([]byte(`"markdown"`)) + if err != nil { t.Fatalf("unmarshal filter mapping enum: %v", err) } - if decodedEnum.Enum == nil || *decodedEnum.Enum != FilterMappingStringMarkdown { + decodedEnumValue, ok := decodedEnum.(FilterMappingString) + if !ok || decodedEnumValue != FilterMappingStringMarkdown { t.Fatalf("unmarshal filter mapping enum = %#v", decodedEnum) } } +func TestMcpServerConfigJSONUnion(t *testing.T) { + var localConfig McpServerConfig = &McpServerConfigLocal{ + Args: []string{"-v"}, + Command: "node", + } + raw, err := json.Marshal(localConfig) + if err != nil { + t.Fatalf("marshal local config: %v", err) + } + if string(raw) != `{"args":["-v"],"command":"node"}` { + t.Fatalf("marshal local config = %s", raw) + } + + decodedLocal, err := unmarshalMcpServerConfig([]byte(`{"args":["-v"],"command":"node"}`)) + if err != nil { + t.Fatalf("unmarshal local config: %v", err) + } + decodedLocalValue, ok := decodedLocal.(*McpServerConfigLocal) + if !ok || decodedLocalValue.Command != "node" || len(decodedLocalValue.Args) != 1 || decodedLocalValue.Args[0] != "-v" { + t.Fatalf("unmarshal local config = %#v", decodedLocal) + } + + var httpConfig McpServerConfig = &McpServerConfigHTTP{URL: "https://example.com/mcp"} + raw, err = json.Marshal(httpConfig) + if err != nil { + t.Fatalf("marshal HTTP config: %v", err) + } + if string(raw) != `{"url":"https://example.com/mcp"}` { + t.Fatalf("marshal HTTP config = %s", raw) + } + + decodedHTTP, err := unmarshalMcpServerConfig([]byte(`{"url":"https://example.com/mcp"}`)) + if err != nil { + t.Fatalf("unmarshal HTTP config: %v", err) + } + decodedHTTPValue, ok := decodedHTTP.(*McpServerConfigHTTP) + if !ok || decodedHTTPValue.URL != "https://example.com/mcp" { + t.Fatalf("unmarshal HTTP config = %#v", decodedHTTP) + } + + decodedRaw, err := unmarshalMcpServerConfig([]byte(`{"name":"future"}`)) + if err != nil { + t.Fatalf("unmarshal raw config: %v", err) + } + if _, ok := decodedRaw.(*RawMcpServerConfigData); !ok { + t.Fatalf("unmarshal raw config = %T, want *RawMcpServerConfigData", decodedRaw) + } +} + func TestUIElicitationFieldValueJSONUnion(t *testing.T) { - boolValue := true - raw, err := json.Marshal(UIElicitationFieldValue{Bool: &boolValue}) + raw, err := json.Marshal(UIElicitationBooleanValue(true)) if err != nil { t.Fatalf("marshal bool value: %v", err) } @@ -87,15 +139,99 @@ func TestUIElicitationFieldValueJSONUnion(t *testing.T) { t.Fatalf("marshal bool value = %s", raw) } - var decodedArray UIElicitationFieldValue - if err := json.Unmarshal([]byte(`["a","b"]`), &decodedArray); err != nil { - t.Fatalf("unmarshal string array value: %v", err) + var response UIElicitationResponse + if err := json.Unmarshal([]byte(`{"action":"accept","content":{"choices":["a","b"]}}`), &response); err != nil { + t.Fatalf("unmarshal response with string array value: %v", err) } - if len(decodedArray.StringArray) != 2 || decodedArray.StringArray[0] != "a" || decodedArray.StringArray[1] != "b" { + decodedArray, ok := response.Content["choices"].(UIElicitationStringArrayValue) + if !ok { + t.Fatalf("unmarshal string array value = %T, want UIElicitationStringArrayValue", response.Content["choices"]) + } + if len(decodedArray) != 2 || decodedArray[0] != "a" || decodedArray[1] != "b" { t.Fatalf("unmarshal string array value = %#v", decodedArray) } } -func stringPtr(value string) *string { - return &value +func TestUIElicitationSchemaPropertyJSONUnion(t *testing.T) { + var schema UIElicitationSchema + if err := json.Unmarshal([]byte(`{ + "type":"object", + "properties":{ + "confirmed":{"type":"boolean","default":true}, + "choice":{"type":"string","enum":["a","b"]}, + "freeform":{"type":"string","minLength":1}, + "count":{"type":"integer","minimum":0}, + "arrayChoice":{"type":"array","items":{"type":"string","enum":["a","b"]}}, + "arrayAnyOf":{"type":"array","items":{"anyOf":[{"const":"a","title":"A"}]}} + }, + "required":["confirmed"] + }`), &schema); err != nil { + t.Fatalf("unmarshal elicitation schema: %v", err) + } + + confirmed, ok := schema.Properties["confirmed"].(*UIElicitationSchemaPropertyBoolean) + if !ok { + t.Fatalf("confirmed property = %T, want *UIElicitationSchemaPropertyBoolean", schema.Properties["confirmed"]) + } + if confirmed.Default == nil || !*confirmed.Default { + t.Fatalf("confirmed default = %v, want true", confirmed.Default) + } + + choice, ok := schema.Properties["choice"].(*UIElicitationStringEnumField) + if !ok { + t.Fatalf("choice property = %T, want *UIElicitationStringEnumField", schema.Properties["choice"]) + } + if len(choice.Enum) != 2 || choice.Enum[0] != "a" || choice.Enum[1] != "b" { + t.Fatalf("choice enum = %#v", choice.Enum) + } + + freeform, ok := schema.Properties["freeform"].(*UIElicitationSchemaPropertyString) + if !ok { + t.Fatalf("freeform property = %T, want *UIElicitationSchemaPropertyString", schema.Properties["freeform"]) + } + if freeform.MinLength == nil || *freeform.MinLength != 1 { + t.Fatalf("freeform minLength = %v, want 1", freeform.MinLength) + } + + count, ok := schema.Properties["count"].(*UIElicitationSchemaPropertyNumber) + if !ok { + t.Fatalf("count property = %T, want *UIElicitationSchemaPropertyNumber", schema.Properties["count"]) + } + if count.Type() != UIElicitationSchemaPropertyTypeInteger { + t.Fatalf("count type = %q, want %q", count.Type(), UIElicitationSchemaPropertyTypeInteger) + } + + arrayChoice, ok := schema.Properties["arrayChoice"].(*UIElicitationArrayEnumField) + if !ok { + t.Fatalf("arrayChoice property = %T, want *UIElicitationArrayEnumField", schema.Properties["arrayChoice"]) + } + if len(arrayChoice.Items.Enum) != 2 || arrayChoice.Items.Enum[0] != "a" || arrayChoice.Items.Enum[1] != "b" { + t.Fatalf("arrayChoice items enum = %#v", arrayChoice.Items.Enum) + } + + arrayAnyOf, ok := schema.Properties["arrayAnyOf"].(*UIElicitationArrayAnyOfField) + if !ok { + t.Fatalf("arrayAnyOf property = %T, want *UIElicitationArrayAnyOfField", schema.Properties["arrayAnyOf"]) + } + if len(arrayAnyOf.Items.AnyOf) != 1 || arrayAnyOf.Items.AnyOf[0].Const != "a" || arrayAnyOf.Items.AnyOf[0].Title != "A" { + t.Fatalf("arrayAnyOf items anyOf = %#v", arrayAnyOf.Items.AnyOf) + } + + defaultValue := true + encoded, err := json.Marshal(UIElicitationSchema{ + Type: UIElicitationSchemaTypeObject, + Properties: map[string]UIElicitationSchemaProperty{ + "confirmed": &UIElicitationSchemaPropertyBoolean{Default: &defaultValue}, + }, + }) + if err != nil { + t.Fatalf("marshal elicitation schema: %v", err) + } + var roundTrip UIElicitationSchema + if err := json.Unmarshal(encoded, &roundTrip); err != nil { + t.Fatalf("unmarshal marshaled elicitation schema: %v", err) + } + if _, ok := roundTrip.Properties["confirmed"].(*UIElicitationSchemaPropertyBoolean); !ok { + t.Fatalf("round-trip confirmed property = %T, want *UIElicitationSchemaPropertyBoolean", roundTrip.Properties["confirmed"]) + } } diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go new file mode 100644 index 000000000..f4e21a465 --- /dev/null +++ b/go/rpc/zrpc_encoding.go @@ -0,0 +1,1486 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package rpc + +import ( + "encoding/json" + "errors" +) + +func unmarshalExternalToolTextResultForLlmContent(data []byte) (ExternalToolTextResultForLlmContent, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Type { + case ExternalToolTextResultForLlmContentTypeAudio: + var d ExternalToolTextResultForLlmContentAudio + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ExternalToolTextResultForLlmContentTypeImage: + var d ExternalToolTextResultForLlmContentImage + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ExternalToolTextResultForLlmContentTypeResource: + var d ExternalToolTextResultForLlmContentResource + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ExternalToolTextResultForLlmContentTypeResourceLink: + var d ExternalToolTextResultForLlmContentResourceLink + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ExternalToolTextResultForLlmContentTypeTerminal: + var d ExternalToolTextResultForLlmContentTerminal + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ExternalToolTextResultForLlmContentTypeText: + var d ExternalToolTextResultForLlmContentText + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawExternalToolTextResultForLlmContentData{Discriminator: raw.Type, Raw: data}, nil + } +} + +func (r RawExternalToolTextResultForLlmContentData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + }{ + Type: r.Discriminator, + }) +} + +func (r ExternalToolTextResultForLlmContentAudio) MarshalJSON() ([]byte, error) { + type alias ExternalToolTextResultForLlmContentAudio + return json.Marshal(struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ExternalToolTextResultForLlmContentImage) MarshalJSON() ([]byte, error) { + type alias ExternalToolTextResultForLlmContentImage + return json.Marshal(struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func matchesEmbeddedBlobResourceContents(data []byte) bool { + var rawGroup0 struct { + Blob json.RawMessage `json:"blob"` + Text json.RawMessage `json:"text"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Blob == nil { + return false + } + return rawGroup0.Text == nil +} + +func matchesEmbeddedTextResourceContents(data []byte) bool { + var rawGroup0 struct { + Blob json.RawMessage `json:"blob"` + Text json.RawMessage `json:"text"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Text == nil { + return false + } + return rawGroup0.Blob == nil +} + +func unmarshalExternalToolTextResultForLlmContentResourceDetails(data []byte) (ExternalToolTextResultForLlmContentResourceDetails, error) { + if string(data) == "null" { + return nil, nil + } + if matchesEmbeddedBlobResourceContents(data) { + var d EmbeddedBlobResourceContents + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + if matchesEmbeddedTextResourceContents(data) { + var d EmbeddedTextResourceContents + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + return &RawExternalToolTextResultForLlmContentResourceDetailsData{Raw: data}, nil +} + +func (r RawExternalToolTextResultForLlmContentResourceDetailsData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return []byte("null"), nil +} + +func (r *ExternalToolTextResultForLlmContentResource) UnmarshalJSON(data []byte) error { + type rawExternalToolTextResultForLlmContentResource struct { + Resource json.RawMessage `json:"resource"` + } + var raw rawExternalToolTextResultForLlmContentResource + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Resource != nil { + value, err := unmarshalExternalToolTextResultForLlmContentResourceDetails(raw.Resource) + if err != nil { + return err + } + r.Resource = value + } + return nil +} + +func (r ExternalToolTextResultForLlmContentResource) MarshalJSON() ([]byte, error) { + type alias ExternalToolTextResultForLlmContentResource + return json.Marshal(struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ExternalToolTextResultForLlmContentResourceLink) MarshalJSON() ([]byte, error) { + type alias ExternalToolTextResultForLlmContentResourceLink + return json.Marshal(struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ExternalToolTextResultForLlmContentTerminal) MarshalJSON() ([]byte, error) { + type alias ExternalToolTextResultForLlmContentTerminal + return json.Marshal(struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ExternalToolTextResultForLlmContentText) MarshalJSON() ([]byte, error) { + type alias ExternalToolTextResultForLlmContentText + return json.Marshal(struct { + Type ExternalToolTextResultForLlmContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r *ExternalToolTextResultForLlm) UnmarshalJSON(data []byte) error { + type rawExternalToolTextResultForLlm struct { + Contents []json.RawMessage `json:"contents,omitempty"` + Error *string `json:"error,omitempty"` + ResultType *string `json:"resultType,omitempty"` + SessionLog *string `json:"sessionLog,omitempty"` + TextResultForLlm string `json:"textResultForLlm"` + ToolTelemetry map[string]any `json:"toolTelemetry,omitempty"` + } + var raw rawExternalToolTextResultForLlm + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Contents != nil { + r.Contents = make([]ExternalToolTextResultForLlmContent, 0, len(raw.Contents)) + for _, rawItem := range raw.Contents { + value, err := unmarshalExternalToolTextResultForLlmContent(rawItem) + if err != nil { + return err + } + r.Contents = append(r.Contents, value) + } + } + r.Error = raw.Error + r.ResultType = raw.ResultType + r.SessionLog = raw.SessionLog + r.TextResultForLlm = raw.TextResultForLlm + r.ToolTelemetry = raw.ToolTelemetry + return nil +} + +func unmarshalExternalToolResult(data []byte) (ExternalToolResult, error) { + if string(data) == "null" { + return nil, nil + } + { + var value string + if err := json.Unmarshal(data, &value); err == nil { + return ExternalToolStringResult(value), nil + } + } + { + var value ExternalToolTextResultForLlm + if err := json.Unmarshal(data, &value); err == nil { + return &value, nil + } + } + return nil, errors.New("data did not match any union variant for ExternalToolResult") +} + +func unmarshalFilterMapping(data []byte) (FilterMapping, error) { + if string(data) == "null" { + return nil, nil + } + { + var value FilterMappingEnumMap + if err := json.Unmarshal(data, &value); err == nil { + return value, nil + } + } + { + var value FilterMappingString + if err := json.Unmarshal(data, &value); err == nil { + return value, nil + } + } + return nil, errors.New("data did not match any union variant for FilterMapping") +} + +func (r *HandlePendingToolCallRequest) UnmarshalJSON(data []byte) error { + type rawHandlePendingToolCallRequest struct { + Error *string `json:"error,omitempty"` + RequestID string `json:"requestId"` + Result json.RawMessage `json:"result,omitempty"` + } + var raw rawHandlePendingToolCallRequest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.Error = raw.Error + r.RequestID = raw.RequestID + if raw.Result != nil { + value, err := unmarshalExternalToolResult(raw.Result) + if err != nil { + return err + } + r.Result = value + } + return nil +} + +func matchesMcpServerConfigHTTP(data []byte) bool { + var rawGroup0 struct { + Args json.RawMessage `json:"args"` + Command json.RawMessage `json:"command"` + URL json.RawMessage `json:"url"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.URL == nil { + return false + } + if rawGroup0.Args != nil { + return false + } + return rawGroup0.Command == nil +} + +func matchesMcpServerConfigLocal(data []byte) bool { + var rawGroup0 struct { + Args json.RawMessage `json:"args"` + Command json.RawMessage `json:"command"` + URL json.RawMessage `json:"url"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Args == nil { + return false + } + if rawGroup0.Command == nil { + return false + } + return rawGroup0.URL == nil +} + +func unmarshalMcpServerConfig(data []byte) (McpServerConfig, error) { + if string(data) == "null" { + return nil, nil + } + if matchesMcpServerConfigHTTP(data) { + var d McpServerConfigHTTP + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + if matchesMcpServerConfigLocal(data) { + var d McpServerConfigLocal + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + return &RawMcpServerConfigData{Raw: data}, nil +} + +func (r RawMcpServerConfigData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return []byte("null"), nil +} + +func (r *McpServerConfigHTTP) UnmarshalJSON(data []byte) error { + type rawMcpServerConfigHTTP struct { + FilterMapping json.RawMessage `json:"filterMapping,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthGrantType *McpServerConfigHTTPOauthGrantType `json:"oauthGrantType,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + Timeout *int64 `json:"timeout,omitempty"` + Tools []string `json:"tools,omitempty"` + Type *McpServerConfigHTTPType `json:"type,omitempty"` + URL string `json:"url"` + } + var raw rawMcpServerConfigHTTP + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.FilterMapping != nil { + value, err := unmarshalFilterMapping(raw.FilterMapping) + if err != nil { + return err + } + r.FilterMapping = value + } + r.Headers = raw.Headers + r.IsDefaultServer = raw.IsDefaultServer + r.OauthClientID = raw.OauthClientID + r.OauthGrantType = raw.OauthGrantType + r.OauthPublicClient = raw.OauthPublicClient + r.Timeout = raw.Timeout + r.Tools = raw.Tools + r.Type = raw.Type + r.URL = raw.URL + return nil +} + +func (r *McpServerConfigLocal) UnmarshalJSON(data []byte) error { + type rawMcpServerConfigLocal struct { + Args []string `json:"args"` + Command string `json:"command"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping json.RawMessage `json:"filterMapping,omitempty"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Timeout *int64 `json:"timeout,omitempty"` + Tools []string `json:"tools,omitempty"` + Type *McpServerConfigLocalType `json:"type,omitempty"` + } + var raw rawMcpServerConfigLocal + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.Args = raw.Args + r.Command = raw.Command + r.Cwd = raw.Cwd + r.Env = raw.Env + if raw.FilterMapping != nil { + value, err := unmarshalFilterMapping(raw.FilterMapping) + if err != nil { + return err + } + r.FilterMapping = value + } + r.IsDefaultServer = raw.IsDefaultServer + r.Timeout = raw.Timeout + r.Tools = raw.Tools + r.Type = raw.Type + return nil +} + +func (r *McpConfigAddRequest) UnmarshalJSON(data []byte) error { + type rawMcpConfigAddRequest struct { + Config json.RawMessage `json:"config"` + Name string `json:"name"` + } + var raw rawMcpConfigAddRequest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Config != nil { + value, err := unmarshalMcpServerConfig(raw.Config) + if err != nil { + return err + } + r.Config = value + } + r.Name = raw.Name + return nil +} + +func (r *McpConfigList) UnmarshalJSON(data []byte) error { + type rawMcpConfigList struct { + Servers map[string]json.RawMessage `json:"servers"` + } + var raw rawMcpConfigList + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Servers != nil { + r.Servers = make(map[string]McpServerConfig, len(raw.Servers)) + for key, rawValue := range raw.Servers { + value, err := unmarshalMcpServerConfig(rawValue) + if err != nil { + return err + } + r.Servers[key] = value + } + } + return nil +} + +func (r *McpConfigUpdateRequest) UnmarshalJSON(data []byte) error { + type rawMcpConfigUpdateRequest struct { + Config json.RawMessage `json:"config"` + Name string `json:"name"` + } + var raw rawMcpConfigUpdateRequest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Config != nil { + value, err := unmarshalMcpServerConfig(raw.Config) + if err != nil { + return err + } + r.Config = value + } + r.Name = raw.Name + return nil +} + +func unmarshalPermissionDecision(data []byte) (PermissionDecision, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind PermissionDecisionKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case PermissionDecisionKindApproveForLocation: + var d PermissionDecisionApproveForLocation + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionKindApproveForSession: + var d PermissionDecisionApproveForSession + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionKindApproveOnce: + var d PermissionDecisionApproveOnce + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionKindApprovePermanently: + var d PermissionDecisionApprovePermanently + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionKindReject: + var d PermissionDecisionReject + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionKindUserNotAvailable: + var d PermissionDecisionUserNotAvailable + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawPermissionDecisionData{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawPermissionDecisionData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind PermissionDecisionKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func unmarshalPermissionDecisionApproveForLocationApproval(data []byte) (PermissionDecisionApproveForLocationApproval, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case PermissionDecisionApproveForLocationApprovalKindCommands: + var d PermissionDecisionApproveForLocationApprovalCommands + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindCustomTool: + var d PermissionDecisionApproveForLocationApprovalCustomTool + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindExtensionManagement: + var d PermissionDecisionApproveForLocationApprovalExtensionManagement + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindExtensionPermissionAccess: + var d PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindMcp: + var d PermissionDecisionApproveForLocationApprovalMcp + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindMcpSampling: + var d PermissionDecisionApproveForLocationApprovalMcpSampling + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindMemory: + var d PermissionDecisionApproveForLocationApprovalMemory + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindRead: + var d PermissionDecisionApproveForLocationApprovalRead + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForLocationApprovalKindWrite: + var d PermissionDecisionApproveForLocationApprovalWrite + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawPermissionDecisionApproveForLocationApprovalData{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawPermissionDecisionApproveForLocationApprovalData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func (r PermissionDecisionApproveForLocationApprovalCommands) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalCommands + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalCustomTool) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalCustomTool + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalExtensionManagement) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalExtensionManagement + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalExtensionPermissionAccess + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalMcp) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalMcp + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalMcpSampling) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalMcpSampling + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalMemory) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalMemory + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalRead) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalRead + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForLocationApprovalWrite) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocationApprovalWrite + return json.Marshal(struct { + Kind PermissionDecisionApproveForLocationApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *PermissionDecisionApproveForLocation) UnmarshalJSON(data []byte) error { + type rawPermissionDecisionApproveForLocation struct { + Approval json.RawMessage `json:"approval"` + LocationKey string `json:"locationKey"` + } + var raw rawPermissionDecisionApproveForLocation + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Approval != nil { + value, err := unmarshalPermissionDecisionApproveForLocationApproval(raw.Approval) + if err != nil { + return err + } + r.Approval = value + } + r.LocationKey = raw.LocationKey + return nil +} + +func (r PermissionDecisionApproveForLocation) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForLocation + return json.Marshal(struct { + Kind PermissionDecisionKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func unmarshalPermissionDecisionApproveForSessionApproval(data []byte) (PermissionDecisionApproveForSessionApproval, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case PermissionDecisionApproveForSessionApprovalKindCommands: + var d PermissionDecisionApproveForSessionApprovalCommands + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindCustomTool: + var d PermissionDecisionApproveForSessionApprovalCustomTool + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindExtensionManagement: + var d PermissionDecisionApproveForSessionApprovalExtensionManagement + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindExtensionPermissionAccess: + var d PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindMcp: + var d PermissionDecisionApproveForSessionApprovalMcp + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindMcpSampling: + var d PermissionDecisionApproveForSessionApprovalMcpSampling + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindMemory: + var d PermissionDecisionApproveForSessionApprovalMemory + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindRead: + var d PermissionDecisionApproveForSessionApprovalRead + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionDecisionApproveForSessionApprovalKindWrite: + var d PermissionDecisionApproveForSessionApprovalWrite + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawPermissionDecisionApproveForSessionApprovalData{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawPermissionDecisionApproveForSessionApprovalData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func (r PermissionDecisionApproveForSessionApprovalCommands) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalCommands + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalCustomTool) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalCustomTool + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalExtensionManagement) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalExtensionManagement + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalExtensionPermissionAccess + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalMcp) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalMcp + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalMcpSampling) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalMcpSampling + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalMemory) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalMemory + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalRead) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalRead + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveForSessionApprovalWrite) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSessionApprovalWrite + return json.Marshal(struct { + Kind PermissionDecisionApproveForSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *PermissionDecisionApproveForSession) UnmarshalJSON(data []byte) error { + type rawPermissionDecisionApproveForSession struct { + Approval json.RawMessage `json:"approval,omitempty"` + Domain *string `json:"domain,omitempty"` + } + var raw rawPermissionDecisionApproveForSession + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Approval != nil { + value, err := unmarshalPermissionDecisionApproveForSessionApproval(raw.Approval) + if err != nil { + return err + } + r.Approval = value + } + r.Domain = raw.Domain + return nil +} + +func (r PermissionDecisionApproveForSession) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveForSession + return json.Marshal(struct { + Kind PermissionDecisionKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApproveOnce) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApproveOnce + return json.Marshal(struct { + Kind PermissionDecisionKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionApprovePermanently) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionApprovePermanently + return json.Marshal(struct { + Kind PermissionDecisionKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionReject) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionReject + return json.Marshal(struct { + Kind PermissionDecisionKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDecisionUserNotAvailable) MarshalJSON() ([]byte, error) { + type alias PermissionDecisionUserNotAvailable + return json.Marshal(struct { + Kind PermissionDecisionKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *PermissionDecisionRequest) UnmarshalJSON(data []byte) error { + type rawPermissionDecisionRequest struct { + RequestID string `json:"requestId"` + Result json.RawMessage `json:"result"` + } + var raw rawPermissionDecisionRequest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.RequestID = raw.RequestID + if raw.Result != nil { + value, err := unmarshalPermissionDecision(raw.Result) + if err != nil { + return err + } + r.Result = value + } + return nil +} + +func unmarshalTaskInfo(data []byte) (TaskInfo, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Type TaskInfoType `json:"type"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Type { + case TaskInfoTypeAgent: + var d TaskAgentInfo + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case TaskInfoTypeShell: + var d TaskShellInfo + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawTaskInfoData{Discriminator: raw.Type, Raw: data}, nil + } +} + +func (r RawTaskInfoData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Type TaskInfoType `json:"type"` + }{ + Type: r.Discriminator, + }) +} + +func (r TaskAgentInfo) MarshalJSON() ([]byte, error) { + type alias TaskAgentInfo + return json.Marshal(struct { + Type TaskInfoType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r TaskShellInfo) MarshalJSON() ([]byte, error) { + type alias TaskShellInfo + return json.Marshal(struct { + Type TaskInfoType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r *TaskList) UnmarshalJSON(data []byte) error { + type rawTaskList struct { + Tasks []json.RawMessage `json:"tasks"` + } + var raw rawTaskList + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Tasks != nil { + r.Tasks = make([]TaskInfo, 0, len(raw.Tasks)) + for _, rawItem := range raw.Tasks { + value, err := unmarshalTaskInfo(rawItem) + if err != nil { + return err + } + r.Tasks = append(r.Tasks, value) + } + } + return nil +} + +func unmarshalUIElicitationFieldValue(data []byte) (UIElicitationFieldValue, error) { + if string(data) == "null" { + return nil, nil + } + { + var value string + if err := json.Unmarshal(data, &value); err == nil { + return UIElicitationStringValue(value), nil + } + } + { + var value float64 + if err := json.Unmarshal(data, &value); err == nil { + return UIElicitationNumberValue(value), nil + } + } + { + var value bool + if err := json.Unmarshal(data, &value); err == nil { + return UIElicitationBooleanValue(value), nil + } + } + { + var value []string + if err := json.Unmarshal(data, &value); err == nil { + return UIElicitationStringArrayValue(value), nil + } + } + return nil, errors.New("data did not match any union variant for UIElicitationFieldValue") +} + +func matchesUIElicitationArrayAnyOfField(data []byte) bool { + var rawGroup0 struct { + Items json.RawMessage `json:"items"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Items == nil { + return false + } + var rawGroup0Items struct { + AnyOf json.RawMessage `json:"anyOf"` + Enum json.RawMessage `json:"enum"` + Type json.RawMessage `json:"type"` + } + if err := json.Unmarshal(rawGroup0.Items, &rawGroup0Items); err != nil { + return false + } + if rawGroup0Items.AnyOf == nil { + return false + } + if rawGroup0Items.Enum != nil { + return false + } + return rawGroup0Items.Type == nil +} + +func matchesUIElicitationArrayEnumField(data []byte) bool { + var rawGroup0 struct { + Items json.RawMessage `json:"items"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Items == nil { + return false + } + var rawGroup0Items struct { + AnyOf json.RawMessage `json:"anyOf"` + Enum json.RawMessage `json:"enum"` + Type json.RawMessage `json:"type"` + } + if err := json.Unmarshal(rawGroup0.Items, &rawGroup0Items); err != nil { + return false + } + if rawGroup0Items.Enum == nil { + return false + } + if rawGroup0Items.Type == nil { + return false + } + var rawGroup0String string + if err := json.Unmarshal(rawGroup0Items.Type, &rawGroup0String); err != nil { + return false + } + switch rawGroup0String { + case "string": + default: + return false + } + return rawGroup0Items.AnyOf == nil +} + +func matchesUIElicitationSchemaPropertyString(data []byte) bool { + var rawGroup0 struct { + Enum json.RawMessage `json:"enum"` + OneOf json.RawMessage `json:"oneOf"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Enum != nil { + return false + } + return rawGroup0.OneOf == nil +} + +func matchesUIElicitationStringEnumField(data []byte) bool { + var rawGroup0 struct { + Enum json.RawMessage `json:"enum"` + OneOf json.RawMessage `json:"oneOf"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Enum == nil { + return false + } + return rawGroup0.OneOf == nil +} + +func matchesUIElicitationStringOneOfField(data []byte) bool { + var rawGroup0 struct { + Enum json.RawMessage `json:"enum"` + OneOf json.RawMessage `json:"oneOf"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.OneOf == nil { + return false + } + return rawGroup0.Enum == nil +} + +func unmarshalUIElicitationSchemaProperty(data []byte) (UIElicitationSchemaProperty, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Type UIElicitationSchemaPropertyType `json:"type"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Type { + case UIElicitationSchemaPropertyTypeArray: + if matchesUIElicitationArrayAnyOfField(data) { + var d UIElicitationArrayAnyOfField + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + if matchesUIElicitationArrayEnumField(data) { + var d UIElicitationArrayEnumField + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + return &RawUIElicitationSchemaPropertyData{Discriminator: raw.Type, Raw: data}, nil + case UIElicitationSchemaPropertyTypeBoolean: + var d UIElicitationSchemaPropertyBoolean + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UIElicitationSchemaPropertyTypeInteger: + var d UIElicitationSchemaPropertyNumber + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UIElicitationSchemaPropertyTypeNumber: + var d UIElicitationSchemaPropertyNumber + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UIElicitationSchemaPropertyTypeString: + if matchesUIElicitationSchemaPropertyString(data) { + var d UIElicitationSchemaPropertyString + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + if matchesUIElicitationStringEnumField(data) { + var d UIElicitationStringEnumField + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + if matchesUIElicitationStringOneOfField(data) { + var d UIElicitationStringOneOfField + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + return &RawUIElicitationSchemaPropertyData{Discriminator: raw.Type, Raw: data}, nil + default: + return &RawUIElicitationSchemaPropertyData{Discriminator: raw.Type, Raw: data}, nil + } +} + +func (r RawUIElicitationSchemaPropertyData) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + }{ + Type: r.Discriminator, + }) +} + +func (r UIElicitationArrayAnyOfField) MarshalJSON() ([]byte, error) { + type alias UIElicitationArrayAnyOfField + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UIElicitationArrayEnumField) MarshalJSON() ([]byte, error) { + type alias UIElicitationArrayEnumField + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UIElicitationSchemaPropertyBoolean) MarshalJSON() ([]byte, error) { + type alias UIElicitationSchemaPropertyBoolean + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UIElicitationSchemaPropertyNumber) MarshalJSON() ([]byte, error) { + type alias UIElicitationSchemaPropertyNumber + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UIElicitationSchemaPropertyString) MarshalJSON() ([]byte, error) { + type alias UIElicitationSchemaPropertyString + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UIElicitationStringEnumField) MarshalJSON() ([]byte, error) { + type alias UIElicitationStringEnumField + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UIElicitationStringOneOfField) MarshalJSON() ([]byte, error) { + type alias UIElicitationStringOneOfField + return json.Marshal(struct { + Type UIElicitationSchemaPropertyType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r *UIElicitationSchema) UnmarshalJSON(data []byte) error { + type rawUIElicitationSchema struct { + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required,omitempty"` + Type UIElicitationSchemaType `json:"type"` + } + var raw rawUIElicitationSchema + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Properties != nil { + r.Properties = make(map[string]UIElicitationSchemaProperty, len(raw.Properties)) + for key, rawValue := range raw.Properties { + value, err := unmarshalUIElicitationSchemaProperty(rawValue) + if err != nil { + return err + } + r.Properties[key] = value + } + } + r.Required = raw.Required + r.Type = raw.Type + return nil +} + +func (r *UIElicitationResponse) UnmarshalJSON(data []byte) error { + type rawUIElicitationResponse struct { + Action UIElicitationResponseAction `json:"action"` + Content map[string]json.RawMessage `json:"content,omitempty"` + } + var raw rawUIElicitationResponse + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.Action = raw.Action + if raw.Content != nil { + r.Content = make(map[string]UIElicitationFieldValue, len(raw.Content)) + for key, rawValue := range raw.Content { + value, err := unmarshalUIElicitationFieldValue(rawValue) + if err != nil { + return err + } + r.Content[key] = value + } + } + return nil +} diff --git a/go/samples/chat.go b/go/samples/chat.go index 62faaca72..1a1b7e203 100644 --- a/go/samples/chat.go +++ b/go/samples/chat.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/github/copilot-sdk/go" + copilot "github.com/github/copilot-sdk/go" ) const blue = "\033[34m" @@ -24,7 +24,6 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - CLIPath: cliPath, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) if err != nil { @@ -45,7 +44,8 @@ func main() { } }) - fmt.Println("Chat with Copilot (Ctrl+C to exit)\n") + fmt.Println("Chat with Copilot (Ctrl+C to exit)") + fmt.Println() scanner := bufio.NewScanner(os.Stdin) for { diff --git a/go/samples/go.mod b/go/samples/go.mod index 889070f67..ec905229a 100644 --- a/go/samples/go.mod +++ b/go/samples/go.mod @@ -4,6 +4,15 @@ go 1.24 require github.com/github/copilot-sdk/go v0.0.0 -require github.com/google/jsonschema-go v0.4.2 // indirect +require ( + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect +) replace github.com/github/copilot-sdk/go => ../ diff --git a/go/samples/go.sum b/go/samples/go.sum index 6e171099c..605b1f5d2 100644 --- a/go/samples/go.sum +++ b/go/samples/go.sum @@ -1,4 +1,27 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/session.go b/go/session.go index 0b70950c4..4016ce8e5 100644 --- a/go/session.go +++ b/go/session.go @@ -127,7 +127,7 @@ func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) // messageID, err := session.Send(context.Background(), copilot.MessageOptions{ // Prompt: "Explain this code", // Attachments: []copilot.Attachment{ -// {Type: "file", Path: "./main.go"}, +// &copilot.UserMessageAttachmentFile{DisplayName: "main.go", Path: "./main.go"}, // }, // }) // if err != nil { @@ -644,9 +644,19 @@ func (s *Session) handleElicitationRequest(elicitCtx ElicitationContext, request return } - rpcContent := make(map[string]*rpc.UIElicitationFieldValue) + rpcContent := make(map[string]rpc.UIElicitationFieldValue) for k, v := range result.Content { - rpcContent[k] = toRPCContent(v) + contentValue, err := toRPCContent(v) + if err != nil { + s.RPC.UI.HandlePendingElicitation(ctx, &rpc.UIHandlePendingElicitationRequest{ + RequestID: requestID, + Result: rpc.UIElicitationResponse{ + Action: rpc.UIElicitationResponseActionCancel, + }, + }) + return + } + rpcContent[k] = contentValue } s.RPC.UI.HandlePendingElicitation(ctx, &rpc.UIHandlePendingElicitationRequest{ @@ -658,37 +668,61 @@ func (s *Session) handleElicitationRequest(elicitCtx ElicitationContext, request }) } -// toRPCContent converts an arbitrary value to a *rpc.UIElicitationFieldValue for elicitation responses. -func toRPCContent(v any) *rpc.UIElicitationFieldValue { +// toRPCContent converts an SDK content value to an RPC elicitation response value. +func toRPCContent(v any) (rpc.UIElicitationFieldValue, error) { if v == nil { - return nil + return nil, nil } - c := &rpc.UIElicitationFieldValue{} switch val := v.(type) { case bool: - c.Bool = &val + return rpc.UIElicitationBooleanValue(val), nil case float64: - c.Double = &val + return rpc.UIElicitationNumberValue(val), nil + case float32: + return rpc.UIElicitationNumberValue(float64(val)), nil case int: - f := float64(val) - c.Double = &f + return rpc.UIElicitationNumberValue(float64(val)), nil + case int8: + return rpc.UIElicitationNumberValue(float64(val)), nil + case int16: + return rpc.UIElicitationNumberValue(float64(val)), nil + case int32: + return rpc.UIElicitationNumberValue(float64(val)), nil + case int64: + return rpc.UIElicitationNumberValue(float64(val)), nil + case uint: + return rpc.UIElicitationNumberValue(float64(val)), nil + case uint8: + return rpc.UIElicitationNumberValue(float64(val)), nil + case uint16: + return rpc.UIElicitationNumberValue(float64(val)), nil + case uint32: + return rpc.UIElicitationNumberValue(float64(val)), nil + case uint64: + return rpc.UIElicitationNumberValue(float64(val)), nil + case json.Number: + f, err := val.Float64() + if err != nil { + return nil, err + } + return rpc.UIElicitationNumberValue(f), nil case string: - c.String = &val + return rpc.UIElicitationStringValue(val), nil case []string: - c.StringArray = val + return rpc.UIElicitationStringArrayValue(val), nil case []any: - strs := make([]string, 0, len(val)) - for _, item := range val { - if s, ok := item.(string); ok { - strs = append(strs, s) + strs := make([]string, len(val)) + for i, item := range val { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("unsupported elicitation string array item type %T", item) } + strs[i] = s } - c.StringArray = strs + return rpc.UIElicitationStringArrayValue(strs), nil default: - s := fmt.Sprintf("%v", val) - c.String = &s + return nil, fmt.Errorf("unsupported elicitation content value type %T", v) } - return c } // Capabilities returns the session capabilities reported by the server. @@ -751,9 +785,8 @@ func (ui *SessionUI) Confirm(ctx context.Context, message string) (bool, error) RequestedSchema: rpc.UIElicitationSchema{ Type: rpc.UIElicitationSchemaTypeObject, Properties: map[string]rpc.UIElicitationSchemaProperty{ - "confirmed": { - Type: rpc.UIElicitationSchemaPropertyTypeBoolean, - Default: toRPCContent(true), + "confirmed": &rpc.UIElicitationSchemaPropertyBoolean{ + Default: Bool(true), }, }, Required: []string{"confirmed"}, @@ -763,8 +796,8 @@ func (ui *SessionUI) Confirm(ctx context.Context, message string) (bool, error) return false, err } if rpcResult.Action == rpc.UIElicitationResponseActionAccept { - if c, ok := rpcResult.Content["confirmed"]; ok && c != nil && c.Bool != nil { - return *c.Bool, nil + if value, ok := rpcResult.Content["confirmed"].(rpc.UIElicitationBooleanValue); ok { + return bool(value), nil } } return false, nil @@ -781,8 +814,7 @@ func (ui *SessionUI) Select(ctx context.Context, message string, options []strin RequestedSchema: rpc.UIElicitationSchema{ Type: rpc.UIElicitationSchemaTypeObject, Properties: map[string]rpc.UIElicitationSchemaProperty{ - "selection": { - Type: rpc.UIElicitationSchemaPropertyTypeString, + "selection": &rpc.UIElicitationStringEnumField{ Enum: options, }, }, @@ -793,8 +825,8 @@ func (ui *SessionUI) Select(ctx context.Context, message string, options []strin return "", false, err } if rpcResult.Action == rpc.UIElicitationResponseActionAccept { - if c, ok := rpcResult.Content["selection"]; ok && c != nil && c.String != nil { - return *c.String, true, nil + if value, ok := rpcResult.Content["selection"].(rpc.UIElicitationStringValue); ok { + return string(value), true, nil } } return "", false, nil @@ -806,7 +838,7 @@ func (ui *SessionUI) Input(ctx context.Context, message string, opts *InputOptio if err := ui.session.assertElicitation(); err != nil { return "", false, err } - prop := rpc.UIElicitationSchemaProperty{Type: rpc.UIElicitationSchemaPropertyTypeString} + prop := &rpc.UIElicitationSchemaPropertyString{} if opts != nil { if opts.Title != "" { prop.Title = &opts.Title @@ -827,7 +859,7 @@ func (ui *SessionUI) Input(ctx context.Context, message string, opts *InputOptio prop.Format = &format } if opts.Default != "" { - prop.Default = toRPCContent(opts.Default) + prop.Default = String(opts.Default) } } rpcResult, err := ui.session.RPC.UI.Elicitation(ctx, &rpc.UIElicitationRequest{ @@ -844,8 +876,8 @@ func (ui *SessionUI) Input(ctx context.Context, message string, opts *InputOptio return "", false, err } if rpcResult.Action == rpc.UIElicitationResponseActionAccept { - if c, ok := rpcResult.Content["value"]; ok && c != nil && c.String != nil { - return *c.String, true, nil + if value, ok := rpcResult.Content["value"].(rpc.UIElicitationStringValue); ok { + return string(value), true, nil } } return "", false, nil @@ -858,19 +890,7 @@ func fromRPCElicitationResult(r *rpc.UIElicitationResponse) *ElicitationResult { } content := make(map[string]any) for k, v := range r.Content { - if v == nil { - content[k] = nil - continue - } - if v.Bool != nil { - content[k] = *v.Bool - } else if v.Double != nil { - content[k] = *v.Double - } else if v.String != nil { - content[k] = *v.String - } else if v.StringArray != nil { - content[k] = v.StringArray - } + content[k] = fromRPCContent(v) } return &ElicitationResult{ Action: string(r.Action), @@ -878,6 +898,22 @@ func fromRPCElicitationResult(r *rpc.UIElicitationResponse) *ElicitationResult { } } +func fromRPCContent(value rpc.UIElicitationFieldValue) any { + switch v := value.(type) { + case nil: + return nil + case rpc.UIElicitationBooleanValue: + return bool(v) + case rpc.UIElicitationNumberValue: + return float64(v) + case rpc.UIElicitationStringValue: + return string(v) + case rpc.UIElicitationStringArrayValue: + return []string(v) + } + return nil +} + // dispatchEvent enqueues an event for delivery to user handlers and fires // broadcast handlers concurrently. // @@ -1051,19 +1087,17 @@ func (s *Session) executeToolAndRespond(requestID, toolName, toolCallID string, } } - rpcResult := rpc.ExternalToolResult{ - ExternalToolTextResultForLlm: &rpc.ExternalToolTextResultForLlm{ - TextResultForLlm: textResultForLLM, - ToolTelemetry: result.ToolTelemetry, - ResultType: &effectiveResultType, - }, + rpcResult := &rpc.ExternalToolTextResultForLlm{ + TextResultForLlm: textResultForLLM, + ToolTelemetry: result.ToolTelemetry, + ResultType: &effectiveResultType, } if result.Error != "" { - rpcResult.ExternalToolTextResultForLlm.Error = &result.Error + rpcResult.Error = &result.Error } s.RPC.Tools.HandlePendingToolCall(ctx, &rpc.HandlePendingToolCallRequest{ RequestID: requestID, - Result: &rpcResult, + Result: rpcResult, }) } @@ -1073,9 +1107,7 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques if r := recover(); r != nil { s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.PermissionDecisionRequest{ RequestID: requestID, - Result: rpc.PermissionDecision{ - Kind: rpc.PermissionDecisionKindUserNotAvailable, - }, + Result: &rpc.PermissionDecisionUserNotAvailable{}, }) } }() @@ -1088,9 +1120,7 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques if err != nil { s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.PermissionDecisionRequest{ RequestID: requestID, - Result: rpc.PermissionDecision{ - Kind: rpc.PermissionDecisionKindUserNotAvailable, - }, + Result: &rpc.PermissionDecisionUserNotAvailable{}, }) return } @@ -1100,12 +1130,23 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.PermissionDecisionRequest{ RequestID: requestID, - Result: rpc.PermissionDecision{ - Kind: rpc.PermissionDecisionKind(result.Kind), - }, + Result: rpcPermissionDecisionFromKind(rpc.PermissionDecisionKind(result.Kind)), }) } +func rpcPermissionDecisionFromKind(kind rpc.PermissionDecisionKind) rpc.PermissionDecision { + switch kind { + case rpc.PermissionDecisionKindApproveOnce: + return &rpc.PermissionDecisionApproveOnce{} + case rpc.PermissionDecisionKindReject: + return &rpc.PermissionDecisionReject{} + case rpc.PermissionDecisionKindUserNotAvailable: + return &rpc.PermissionDecisionUserNotAvailable{} + default: + return &rpc.RawPermissionDecisionData{Discriminator: kind} + } +} + // GetMessages retrieves all events and messages from this session's history. // // This returns the complete conversation history including user messages, diff --git a/go/session_event_serialization_test.go b/go/session_event_serialization_test.go index bf4846570..b64a79975 100644 --- a/go/session_event_serialization_test.go +++ b/go/session_event_serialization_test.go @@ -6,7 +6,8 @@ import ( ) func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) { - event, err := UnmarshalSessionEvent([]byte(`{ + var event SessionEvent + if err := json.Unmarshal([]byte(`{ "id": "00000000-0000-0000-0000-000000000001", "timestamp": "2026-01-01T00:00:00Z", "parentId": null, @@ -15,8 +16,7 @@ func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) { "data": { "content": "Hello" } - }`)) - if err != nil { + }`), &event); err != nil { t.Fatalf("failed to unmarshal session event: %v", err) } @@ -26,6 +26,9 @@ func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) { if _, ok := event.Data.(*UserMessageData); !ok { t.Fatalf("expected user message data, got %T", event.Data) } + if event.Type() != SessionEventTypeUserMessage { + t.Fatalf("expected user message type, got %q", event.Type()) + } data, err := event.Marshal() if err != nil { @@ -41,8 +44,32 @@ func TestSessionEventAgentIDRoundTripsKnownEvent(t *testing.T) { } } +func TestSessionEventTypeDerivedFromData(t *testing.T) { + event := SessionEvent{ + Data: &UserMessageData{Content: "Hello"}, + } + + if event.Type() != SessionEventTypeUserMessage { + t.Fatalf("expected user message type, got %q", event.Type()) + } + + data, err := event.Marshal() + if err != nil { + t.Fatalf("failed to marshal session event: %v", err) + } + + var serialized map[string]any + if err := json.Unmarshal(data, &serialized); err != nil { + t.Fatalf("failed to unmarshal serialized session event: %v", err) + } + if serialized["type"] != string(SessionEventTypeUserMessage) { + t.Fatalf("expected serialized type to be derived from data, got %v", serialized["type"]) + } +} + func TestSessionEventAgentIDRoundTripsUnknownEvent(t *testing.T) { - event, err := UnmarshalSessionEvent([]byte(`{ + var event SessionEvent + if err := json.Unmarshal([]byte(`{ "id": "00000000-0000-0000-0000-000000000002", "timestamp": "2026-01-01T00:00:00Z", "parentId": null, @@ -51,17 +78,36 @@ func TestSessionEventAgentIDRoundTripsUnknownEvent(t *testing.T) { "data": { "key": "value" } - }`)) - if err != nil { + }`), &event); err != nil { t.Fatalf("failed to unmarshal session event: %v", err) } if event.AgentID == nil || *event.AgentID != "future-agent" { t.Fatalf("expected agent ID to round-trip, got %v", event.AgentID) } - if _, ok := event.Data.(*RawSessionEventData); !ok { + rawData, ok := event.Data.(*RawSessionEventData) + if !ok { t.Fatalf("expected raw session event data, got %T", event.Data) } + if event.Type() != "future.feature_from_server" { + t.Fatalf("expected unknown event type to be derived from raw event type, got %q", event.Type()) + } + if rawData.EventType != "future.feature_from_server" { + t.Fatalf("expected raw event type to round-trip, got %q", rawData.EventType) + } + if rawData.Type() != event.Type() { + t.Fatalf("expected raw data type to match event type, got %q", rawData.Type()) + } + var rawPayload map[string]any + if err := json.Unmarshal(rawData.Raw, &rawPayload); err != nil { + t.Fatalf("failed to unmarshal raw payload: %v", err) + } + if rawPayload["key"] != "value" { + t.Fatalf("expected raw payload to preserve data, got %v", rawPayload) + } + if _, ok := rawPayload["type"]; ok { + t.Fatalf("expected raw payload to exclude event type, got %v", rawPayload) + } data, err := event.Marshal() if err != nil { @@ -75,4 +121,42 @@ func TestSessionEventAgentIDRoundTripsUnknownEvent(t *testing.T) { if serialized["agentId"] != "future-agent" { t.Fatalf("expected serialized agentId to round-trip, got %v", serialized["agentId"]) } + if serialized["type"] != "future.feature_from_server" { + t.Fatalf("expected serialized type to round-trip, got %v", serialized["type"]) + } + serializedData, ok := serialized["data"].(map[string]any) + if !ok { + t.Fatalf("expected serialized data payload to be an object, got %T", serialized["data"]) + } + if serializedData["key"] != "value" { + t.Fatalf("expected serialized data payload to round-trip, got %v", serializedData) + } + if _, ok := serializedData["type"]; ok { + t.Fatalf("expected serialized data to contain only the payload, got nested event object: %v", serializedData) + } +} + +func TestRawSessionEventDataWithNilRawMarshalsAsNull(t *testing.T) { + event := SessionEvent{ + Data: &RawSessionEventData{EventType: "future.event"}, + } + + data, err := event.Marshal() + if err != nil { + t.Fatalf("failed to marshal session event: %v", err) + } + if !json.Valid(data) { + t.Fatalf("expected valid JSON, got %s", data) + } + + var serialized map[string]any + if err := json.Unmarshal(data, &serialized); err != nil { + t.Fatalf("failed to unmarshal serialized session event: %v", err) + } + if serialized["type"] != "future.event" { + t.Fatalf("expected serialized type to round-trip, got %v", serialized["type"]) + } + if serialized["data"] != nil { + t.Fatalf("expected missing raw data to marshal as null, got %v", serialized["data"]) + } } diff --git a/go/session_test.go b/go/session_test.go index d17945369..0b7de5ac9 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -8,6 +8,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/github/copilot-sdk/go/rpc" ) // newTestSession creates a session with an event channel and starts the consumer goroutine. @@ -22,6 +24,28 @@ func newTestSession() (*Session, func()) { return s, func() { close(s.eventCh) } } +func newTestEvent() SessionEvent { + return SessionEvent{Data: &SessionIdleData{}} +} + +func TestRPCPermissionDecisionFromKindPreservesUnknownKind(t *testing.T) { + kind := rpc.PermissionDecisionKind("future-decision") + decision := rpcPermissionDecisionFromKind(kind) + + data, err := json.Marshal(decision) + if err != nil { + t.Fatalf("marshal permission decision: %v", err) + } + + var serialized map[string]any + if err := json.Unmarshal(data, &serialized); err != nil { + t.Fatalf("unmarshal serialized permission decision: %v", err) + } + if serialized["kind"] != string(kind) { + t.Fatalf("expected kind %q to round-trip, got %v in %s", kind, serialized["kind"], data) + } +} + func TestSession_On(t *testing.T) { t.Run("multiple handlers all receive events", func(t *testing.T) { session, cleanup := newTestSession() @@ -34,7 +58,7 @@ func TestSession_On(t *testing.T) { session.On(func(event SessionEvent) { received2 = true; wg.Done() }) session.On(func(event SessionEvent) { received3 = true; wg.Done() }) - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) wg.Wait() if !received1 || !received2 || !received3 { @@ -56,7 +80,7 @@ func TestSession_On(t *testing.T) { session.On(func(event SessionEvent) { count3.Add(1); wg.Done() }) // First event - all handlers receive it - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) wg.Wait() // Unsubscribe handler 2 @@ -64,7 +88,7 @@ func TestSession_On(t *testing.T) { // Second event - only handlers 1 and 3 should receive it wg.Add(2) - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) wg.Wait() if count1.Load() != 2 { @@ -88,7 +112,7 @@ func TestSession_On(t *testing.T) { wg.Add(1) unsub := session.On(func(event SessionEvent) { count.Add(1); wg.Done() }) - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) wg.Wait() unsub() @@ -98,7 +122,7 @@ func TestSession_On(t *testing.T) { // Dispatch again and wait for it to be processed via a sentinel handler wg.Add(1) session.On(func(event SessionEvent) { wg.Done() }) - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) wg.Wait() if count.Load() != 1 { @@ -117,7 +141,7 @@ func TestSession_On(t *testing.T) { session.On(func(event SessionEvent) { order = append(order, 2); wg.Done() }) session.On(func(event SessionEvent) { order = append(order, 3); wg.Done() }) - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) wg.Wait() if len(order) != 3 || order[0] != 1 || order[1] != 2 || order[2] != 3 { @@ -172,7 +196,7 @@ func TestSession_On(t *testing.T) { }) for i := 0; i < totalEvents; i++ { - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) } done.Wait() @@ -198,8 +222,8 @@ func TestSession_On(t *testing.T) { } }) - session.dispatchEvent(SessionEvent{Type: "test"}) - session.dispatchEvent(SessionEvent{Type: "test"}) + session.dispatchEvent(newTestEvent()) + session.dispatchEvent(newTestEvent()) done.Wait() @@ -401,7 +425,6 @@ func TestSession_Capabilities(t *testing.T) { // Dispatch a capabilities.changed event with elicitation=true elicitTrue := true session.dispatchEvent(SessionEvent{ - Type: SessionEventTypeCapabilitiesChanged, Data: &CapabilitiesChangedData{ UI: &CapabilitiesChangedUI{Elicitation: &elicitTrue}, }, @@ -420,7 +443,6 @@ func TestSession_Capabilities(t *testing.T) { // Dispatch with elicitation=false elicitFalse := false session.dispatchEvent(SessionEvent{ - Type: SessionEventTypeCapabilitiesChanged, Data: &CapabilitiesChangedData{ UI: &CapabilitiesChangedUI{Elicitation: &elicitFalse}, }, diff --git a/go/zsession_encoding.go b/go/zsession_encoding.go new file mode 100644 index 000000000..f3c18bfaa --- /dev/null +++ b/go/zsession_encoding.go @@ -0,0 +1,1991 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package copilot + +import ( + "encoding/json" + "errors" + "time" +) + +// Marshal serializes the SessionEvent to JSON. +func (r *SessionEvent) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func (e *SessionEvent) UnmarshalJSON(data []byte) error { + type rawEvent struct { + AgentID *string `json:"agentId,omitempty"` + Data json.RawMessage `json:"data"` + Ephemeral *bool `json:"ephemeral,omitempty"` + ID string `json:"id"` + ParentID *string `json:"parentId"` + Timestamp time.Time `json:"timestamp"` + Type SessionEventType `json:"type"` + } + var raw rawEvent + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + e.AgentID = raw.AgentID + e.Ephemeral = raw.Ephemeral + e.ID = raw.ID + e.ParentID = raw.ParentID + e.Timestamp = raw.Timestamp + + switch raw.Type { + case SessionEventTypeAbort: + var d AbortData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantIntent: + var d AssistantIntentData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantMessage: + var d AssistantMessageData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantMessageDelta: + var d AssistantMessageDeltaData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantMessageStart: + var d AssistantMessageStartData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantReasoning: + var d AssistantReasoningData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantReasoningDelta: + var d AssistantReasoningDeltaData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantStreamingDelta: + var d AssistantStreamingDeltaData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantTurnEnd: + var d AssistantTurnEndData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantTurnStart: + var d AssistantTurnStartData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAssistantUsage: + var d AssistantUsageData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAutoModeSwitchCompleted: + var d AutoModeSwitchCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeAutoModeSwitchRequested: + var d AutoModeSwitchRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeCapabilitiesChanged: + var d CapabilitiesChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeCommandCompleted: + var d CommandCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeCommandExecute: + var d CommandExecuteData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeCommandQueued: + var d CommandQueuedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeCommandsChanged: + var d CommandsChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeElicitationCompleted: + var d ElicitationCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeElicitationRequested: + var d ElicitationRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeExitPlanModeCompleted: + var d ExitPlanModeCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeExitPlanModeRequested: + var d ExitPlanModeRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeExternalToolCompleted: + var d ExternalToolCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeExternalToolRequested: + var d ExternalToolRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeHookEnd: + var d HookEndData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeHookStart: + var d HookStartData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeMcpOauthCompleted: + var d McpOauthCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeMcpOauthRequired: + var d McpOauthRequiredData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeModelCallFailure: + var d ModelCallFailureData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypePendingMessagesModified: + var d PendingMessagesModifiedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypePermissionCompleted: + var d PermissionCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypePermissionRequested: + var d PermissionRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSamplingCompleted: + var d SamplingCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSamplingRequested: + var d SamplingRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionBackgroundTasksChanged: + var d SessionBackgroundTasksChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionCompactionComplete: + var d SessionCompactionCompleteData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionCompactionStart: + var d SessionCompactionStartData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionContextChanged: + var d SessionContextChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionCustomAgentsUpdated: + var d SessionCustomAgentsUpdatedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionError: + var d SessionErrorData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionExtensionsLoaded: + var d SessionExtensionsLoadedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionHandoff: + var d SessionHandoffData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionIdle: + var d SessionIdleData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionInfo: + var d SessionInfoData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionMcpServersLoaded: + var d SessionMcpServersLoadedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionMcpServerStatusChanged: + var d SessionMcpServerStatusChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionModeChanged: + var d SessionModeChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionModelChange: + var d SessionModelChangeData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionPlanChanged: + var d SessionPlanChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionRemoteSteerableChanged: + var d SessionRemoteSteerableChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionResume: + var d SessionResumeData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionScheduleCancelled: + var d SessionScheduleCancelledData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionScheduleCreated: + var d SessionScheduleCreatedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionShutdown: + var d SessionShutdownData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionSkillsLoaded: + var d SessionSkillsLoadedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionSnapshotRewind: + var d SessionSnapshotRewindData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionStart: + var d SessionStartData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionTaskComplete: + var d SessionTaskCompleteData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionTitleChanged: + var d SessionTitleChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionToolsUpdated: + var d SessionToolsUpdatedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionTruncation: + var d SessionTruncationData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionUsageInfo: + var d SessionUsageInfoData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionWarning: + var d SessionWarningData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSessionWorkspaceFileChanged: + var d SessionWorkspaceFileChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSkillInvoked: + var d SkillInvokedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSubagentCompleted: + var d SubagentCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSubagentDeselected: + var d SubagentDeselectedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSubagentFailed: + var d SubagentFailedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSubagentSelected: + var d SubagentSelectedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSubagentStarted: + var d SubagentStartedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSystemMessage: + var d SystemMessageData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeSystemNotification: + var d SystemNotificationData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeToolExecutionComplete: + var d ToolExecutionCompleteData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeToolExecutionPartialResult: + var d ToolExecutionPartialResultData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeToolExecutionProgress: + var d ToolExecutionProgressData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeToolExecutionStart: + var d ToolExecutionStartData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeToolUserRequested: + var d ToolUserRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeUserInputCompleted: + var d UserInputCompletedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeUserInputRequested: + var d UserInputRequestedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeUserMessage: + var d UserMessageData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + default: + e.Data = &RawSessionEventData{EventType: raw.Type, Raw: raw.Data} + } + return nil +} + +func (e SessionEvent) MarshalJSON() ([]byte, error) { + type rawEvent struct { + AgentID *string `json:"agentId,omitempty"` + Data any `json:"data"` + Ephemeral *bool `json:"ephemeral,omitempty"` + ID string `json:"id"` + ParentID *string `json:"parentId"` + Timestamp time.Time `json:"timestamp"` + Type SessionEventType `json:"type"` + } + return json.Marshal(rawEvent{ + AgentID: e.AgentID, + Data: e.Data, + Ephemeral: e.Ephemeral, + ID: e.ID, + ParentID: e.ParentID, + Timestamp: e.Timestamp, + Type: e.Type(), + }) +} + +// MarshalJSON returns the original raw JSON so round-tripping preserves the payload. +func (r RawSessionEventData) MarshalJSON() ([]byte, error) { + if r.Raw == nil { + return []byte("null"), nil + } + return r.Raw, nil +} + +func unmarshalUserMessageAttachment(data []byte) (UserMessageAttachment, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Type UserMessageAttachmentType `json:"type"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Type { + case UserMessageAttachmentTypeBlob: + var d UserMessageAttachmentBlob + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserMessageAttachmentTypeDirectory: + var d UserMessageAttachmentDirectory + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserMessageAttachmentTypeFile: + var d UserMessageAttachmentFile + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserMessageAttachmentTypeGithubReference: + var d UserMessageAttachmentGithubReference + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserMessageAttachmentTypeSelection: + var d UserMessageAttachmentSelection + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawUserMessageAttachment{Discriminator: raw.Type, Raw: data}, nil + } +} + +func (r RawUserMessageAttachment) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Type UserMessageAttachmentType `json:"type"` + }{ + Type: r.Discriminator, + }) +} + +func (r UserMessageAttachmentBlob) MarshalJSON() ([]byte, error) { + type alias UserMessageAttachmentBlob + return json.Marshal(struct { + Type UserMessageAttachmentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UserMessageAttachmentDirectory) MarshalJSON() ([]byte, error) { + type alias UserMessageAttachmentDirectory + return json.Marshal(struct { + Type UserMessageAttachmentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UserMessageAttachmentFile) MarshalJSON() ([]byte, error) { + type alias UserMessageAttachmentFile + return json.Marshal(struct { + Type UserMessageAttachmentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UserMessageAttachmentGithubReference) MarshalJSON() ([]byte, error) { + type alias UserMessageAttachmentGithubReference + return json.Marshal(struct { + Type UserMessageAttachmentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r UserMessageAttachmentSelection) MarshalJSON() ([]byte, error) { + type alias UserMessageAttachmentSelection + return json.Marshal(struct { + Type UserMessageAttachmentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r *UserMessageData) UnmarshalJSON(data []byte) error { + type rawUserMessageData struct { + AgentMode *UserMessageAgentMode `json:"agentMode,omitempty"` + Attachments []json.RawMessage `json:"attachments,omitempty"` + Content string `json:"content"` + InteractionID *string `json:"interactionId,omitempty"` + NativeDocumentPathFallbackPaths []string `json:"nativeDocumentPathFallbackPaths,omitempty"` + ParentAgentTaskID *string `json:"parentAgentTaskId,omitempty"` + Source *string `json:"source,omitempty"` + SupportedNativeDocumentMIMETypes []string `json:"supportedNativeDocumentMimeTypes,omitempty"` + TransformedContent *string `json:"transformedContent,omitempty"` + } + var raw rawUserMessageData + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.AgentMode = raw.AgentMode + if raw.Attachments != nil { + r.Attachments = make([]UserMessageAttachment, 0, len(raw.Attachments)) + for _, rawItem := range raw.Attachments { + value, err := unmarshalUserMessageAttachment(rawItem) + if err != nil { + return err + } + r.Attachments = append(r.Attachments, value) + } + } + r.Content = raw.Content + r.InteractionID = raw.InteractionID + r.NativeDocumentPathFallbackPaths = raw.NativeDocumentPathFallbackPaths + r.ParentAgentTaskID = raw.ParentAgentTaskID + r.Source = raw.Source + r.SupportedNativeDocumentMIMETypes = raw.SupportedNativeDocumentMIMETypes + r.TransformedContent = raw.TransformedContent + return nil +} + +func unmarshalToolExecutionCompleteContent(data []byte) (ToolExecutionCompleteContent, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Type ToolExecutionCompleteContentType `json:"type"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Type { + case ToolExecutionCompleteContentTypeAudio: + var d ToolExecutionCompleteContentAudio + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ToolExecutionCompleteContentTypeImage: + var d ToolExecutionCompleteContentImage + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ToolExecutionCompleteContentTypeResource: + var d ToolExecutionCompleteContentResource + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ToolExecutionCompleteContentTypeResourceLink: + var d ToolExecutionCompleteContentResourceLink + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ToolExecutionCompleteContentTypeTerminal: + var d ToolExecutionCompleteContentTerminal + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case ToolExecutionCompleteContentTypeText: + var d ToolExecutionCompleteContentText + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawToolExecutionCompleteContent{Discriminator: raw.Type, Raw: data}, nil + } +} + +func (r RawToolExecutionCompleteContent) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Type ToolExecutionCompleteContentType `json:"type"` + }{ + Type: r.Discriminator, + }) +} + +func (r ToolExecutionCompleteContentAudio) MarshalJSON() ([]byte, error) { + type alias ToolExecutionCompleteContentAudio + return json.Marshal(struct { + Type ToolExecutionCompleteContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ToolExecutionCompleteContentImage) MarshalJSON() ([]byte, error) { + type alias ToolExecutionCompleteContentImage + return json.Marshal(struct { + Type ToolExecutionCompleteContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func matchesEmbeddedBlobResourceContents(data []byte) bool { + var rawGroup0 struct { + Blob json.RawMessage `json:"blob"` + Text json.RawMessage `json:"text"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Blob == nil { + return false + } + return rawGroup0.Text == nil +} + +func matchesEmbeddedTextResourceContents(data []byte) bool { + var rawGroup0 struct { + Blob json.RawMessage `json:"blob"` + Text json.RawMessage `json:"text"` + } + if err := json.Unmarshal(data, &rawGroup0); err != nil { + return false + } + if rawGroup0.Text == nil { + return false + } + return rawGroup0.Blob == nil +} + +func unmarshalToolExecutionCompleteContentResourceDetails(data []byte) (ToolExecutionCompleteContentResourceDetails, error) { + if string(data) == "null" { + return nil, nil + } + if matchesEmbeddedBlobResourceContents(data) { + var d EmbeddedBlobResourceContents + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + if matchesEmbeddedTextResourceContents(data) { + var d EmbeddedTextResourceContents + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + } + return &RawToolExecutionCompleteContentResourceDetails{Raw: data}, nil +} + +func (r RawToolExecutionCompleteContentResourceDetails) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return []byte("null"), nil +} + +func (r *ToolExecutionCompleteContentResource) UnmarshalJSON(data []byte) error { + type rawToolExecutionCompleteContentResource struct { + Resource json.RawMessage `json:"resource"` + } + var raw rawToolExecutionCompleteContentResource + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Resource != nil { + value, err := unmarshalToolExecutionCompleteContentResourceDetails(raw.Resource) + if err != nil { + return err + } + r.Resource = value + } + return nil +} + +func (r ToolExecutionCompleteContentResource) MarshalJSON() ([]byte, error) { + type alias ToolExecutionCompleteContentResource + return json.Marshal(struct { + Type ToolExecutionCompleteContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ToolExecutionCompleteContentResourceLink) MarshalJSON() ([]byte, error) { + type alias ToolExecutionCompleteContentResourceLink + return json.Marshal(struct { + Type ToolExecutionCompleteContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ToolExecutionCompleteContentTerminal) MarshalJSON() ([]byte, error) { + type alias ToolExecutionCompleteContentTerminal + return json.Marshal(struct { + Type ToolExecutionCompleteContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r ToolExecutionCompleteContentText) MarshalJSON() ([]byte, error) { + type alias ToolExecutionCompleteContentText + return json.Marshal(struct { + Type ToolExecutionCompleteContentType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r *ToolExecutionCompleteResult) UnmarshalJSON(data []byte) error { + type rawToolExecutionCompleteResult struct { + Content string `json:"content"` + Contents []json.RawMessage `json:"contents,omitempty"` + DetailedContent *string `json:"detailedContent,omitempty"` + } + var raw rawToolExecutionCompleteResult + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.Content = raw.Content + if raw.Contents != nil { + r.Contents = make([]ToolExecutionCompleteContent, 0, len(raw.Contents)) + for _, rawItem := range raw.Contents { + value, err := unmarshalToolExecutionCompleteContent(rawItem) + if err != nil { + return err + } + r.Contents = append(r.Contents, value) + } + } + r.DetailedContent = raw.DetailedContent + return nil +} + +func unmarshalSystemNotification(data []byte) (SystemNotification, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Type SystemNotificationType `json:"type"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Type { + case SystemNotificationTypeAgentCompleted: + var d SystemNotificationAgentCompleted + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case SystemNotificationTypeAgentIdle: + var d SystemNotificationAgentIdle + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case SystemNotificationTypeInstructionDiscovered: + var d SystemNotificationInstructionDiscovered + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case SystemNotificationTypeNewInboxMessage: + var d SystemNotificationNewInboxMessage + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case SystemNotificationTypeShellCompleted: + var d SystemNotificationShellCompleted + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case SystemNotificationTypeShellDetachedCompleted: + var d SystemNotificationShellDetachedCompleted + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawSystemNotification{Discriminator: raw.Type, Raw: data}, nil + } +} + +func (r RawSystemNotification) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Type SystemNotificationType `json:"type"` + }{ + Type: r.Discriminator, + }) +} + +func (r SystemNotificationAgentCompleted) MarshalJSON() ([]byte, error) { + type alias SystemNotificationAgentCompleted + return json.Marshal(struct { + Type SystemNotificationType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r SystemNotificationAgentIdle) MarshalJSON() ([]byte, error) { + type alias SystemNotificationAgentIdle + return json.Marshal(struct { + Type SystemNotificationType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r SystemNotificationInstructionDiscovered) MarshalJSON() ([]byte, error) { + type alias SystemNotificationInstructionDiscovered + return json.Marshal(struct { + Type SystemNotificationType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r SystemNotificationNewInboxMessage) MarshalJSON() ([]byte, error) { + type alias SystemNotificationNewInboxMessage + return json.Marshal(struct { + Type SystemNotificationType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r SystemNotificationShellCompleted) MarshalJSON() ([]byte, error) { + type alias SystemNotificationShellCompleted + return json.Marshal(struct { + Type SystemNotificationType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r SystemNotificationShellDetachedCompleted) MarshalJSON() ([]byte, error) { + type alias SystemNotificationShellDetachedCompleted + return json.Marshal(struct { + Type SystemNotificationType `json:"type"` + alias + }{ + Type: r.Type(), + alias: alias(r), + }) +} + +func (r *SystemNotificationData) UnmarshalJSON(data []byte) error { + type rawSystemNotificationData struct { + Content string `json:"content"` + Kind json.RawMessage `json:"kind"` + } + var raw rawSystemNotificationData + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.Content = raw.Content + if raw.Kind != nil { + value, err := unmarshalSystemNotification(raw.Kind) + if err != nil { + return err + } + r.Kind = value + } + return nil +} + +func unmarshalPermissionRequest(data []byte) (PermissionRequest, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind PermissionRequestKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case PermissionRequestKindCustomTool: + var d PermissionRequestCustomTool + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindExtensionManagement: + var d PermissionRequestExtensionManagement + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindExtensionPermissionAccess: + var d PermissionRequestExtensionPermissionAccess + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindHook: + var d PermissionRequestHook + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindMcp: + var d PermissionRequestMcp + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindMemory: + var d PermissionRequestMemory + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindRead: + var d PermissionRequestRead + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindShell: + var d PermissionRequestShell + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindURL: + var d PermissionRequestURL + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionRequestKindWrite: + var d PermissionRequestWrite + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawPermissionRequest{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawPermissionRequest) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func (r PermissionRequestCustomTool) MarshalJSON() ([]byte, error) { + type alias PermissionRequestCustomTool + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestExtensionManagement) MarshalJSON() ([]byte, error) { + type alias PermissionRequestExtensionManagement + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestExtensionPermissionAccess) MarshalJSON() ([]byte, error) { + type alias PermissionRequestExtensionPermissionAccess + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestHook) MarshalJSON() ([]byte, error) { + type alias PermissionRequestHook + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestMcp) MarshalJSON() ([]byte, error) { + type alias PermissionRequestMcp + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestMemory) MarshalJSON() ([]byte, error) { + type alias PermissionRequestMemory + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestRead) MarshalJSON() ([]byte, error) { + type alias PermissionRequestRead + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestShell) MarshalJSON() ([]byte, error) { + type alias PermissionRequestShell + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestURL) MarshalJSON() ([]byte, error) { + type alias PermissionRequestURL + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionRequestWrite) MarshalJSON() ([]byte, error) { + type alias PermissionRequestWrite + return json.Marshal(struct { + Kind PermissionRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func unmarshalPermissionPromptRequest(data []byte) (PermissionPromptRequest, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind PermissionPromptRequestKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case PermissionPromptRequestKindCommands: + var d PermissionPromptRequestCommands + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindCustomTool: + var d PermissionPromptRequestCustomTool + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindExtensionManagement: + var d PermissionPromptRequestExtensionManagement + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindExtensionPermissionAccess: + var d PermissionPromptRequestExtensionPermissionAccess + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindHook: + var d PermissionPromptRequestHook + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindMcp: + var d PermissionPromptRequestMcp + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindMemory: + var d PermissionPromptRequestMemory + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindPath: + var d PermissionPromptRequestPath + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindRead: + var d PermissionPromptRequestRead + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindURL: + var d PermissionPromptRequestURL + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionPromptRequestKindWrite: + var d PermissionPromptRequestWrite + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawPermissionPromptRequest{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawPermissionPromptRequest) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func (r PermissionPromptRequestCommands) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestCommands + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestCustomTool) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestCustomTool + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestExtensionManagement) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestExtensionManagement + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestExtensionPermissionAccess) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestExtensionPermissionAccess + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestHook) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestHook + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestMcp) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestMcp + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestMemory) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestMemory + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestPath) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestPath + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestRead) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestRead + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestURL) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestURL + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionPromptRequestWrite) MarshalJSON() ([]byte, error) { + type alias PermissionPromptRequestWrite + return json.Marshal(struct { + Kind PermissionPromptRequestKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *PermissionRequestedData) UnmarshalJSON(data []byte) error { + type rawPermissionRequestedData struct { + PermissionRequest json.RawMessage `json:"permissionRequest"` + PromptRequest json.RawMessage `json:"promptRequest,omitempty"` + RequestID string `json:"requestId"` + ResolvedByHook *bool `json:"resolvedByHook,omitempty"` + } + var raw rawPermissionRequestedData + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.PermissionRequest != nil { + value, err := unmarshalPermissionRequest(raw.PermissionRequest) + if err != nil { + return err + } + r.PermissionRequest = value + } + if raw.PromptRequest != nil { + value, err := unmarshalPermissionPromptRequest(raw.PromptRequest) + if err != nil { + return err + } + r.PromptRequest = value + } + r.RequestID = raw.RequestID + r.ResolvedByHook = raw.ResolvedByHook + return nil +} + +func unmarshalPermissionResult(data []byte) (PermissionResult, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind PermissionResultKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case PermissionResultKindApproved: + var d PermissionApproved + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindApprovedForLocation: + var d PermissionApprovedForLocation + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindApprovedForSession: + var d PermissionApprovedForSession + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindCancelled: + var d PermissionCancelled + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindDeniedByContentExclusionPolicy: + var d PermissionDeniedByContentExclusionPolicy + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindDeniedByPermissionRequestHook: + var d PermissionDeniedByPermissionRequestHook + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindDeniedByRules: + var d PermissionDeniedByRules + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindDeniedInteractivelyByUser: + var d PermissionDeniedInteractivelyByUser + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser: + var d PermissionDeniedNoApprovalRuleAndCouldNotRequestFromUser + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawPermissionResult{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawPermissionResult) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func (r PermissionApproved) MarshalJSON() ([]byte, error) { + type alias PermissionApproved + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func unmarshalUserToolSessionApproval(data []byte) (UserToolSessionApproval, error) { + if string(data) == "null" { + return nil, nil + } + type rawUnion struct { + Kind UserToolSessionApprovalKind `json:"kind"` + } + var raw rawUnion + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + switch raw.Kind { + case UserToolSessionApprovalKindCommands: + var d UserToolSessionApprovalCommands + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserToolSessionApprovalKindCustomTool: + var d UserToolSessionApprovalCustomTool + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserToolSessionApprovalKindExtensionManagement: + var d UserToolSessionApprovalExtensionManagement + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserToolSessionApprovalKindExtensionPermissionAccess: + var d UserToolSessionApprovalExtensionPermissionAccess + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserToolSessionApprovalKindMcp: + var d UserToolSessionApprovalMcp + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserToolSessionApprovalKindMemory: + var d UserToolSessionApprovalMemory + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserToolSessionApprovalKindRead: + var d UserToolSessionApprovalRead + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + case UserToolSessionApprovalKindWrite: + var d UserToolSessionApprovalWrite + if err := json.Unmarshal(data, &d); err != nil { + return nil, err + } + return &d, nil + default: + return &RawUserToolSessionApproval{Discriminator: raw.Kind, Raw: data}, nil + } +} + +func (r RawUserToolSessionApproval) MarshalJSON() ([]byte, error) { + if r.Raw != nil { + return r.Raw, nil + } + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + }{ + Kind: r.Discriminator, + }) +} + +func (r UserToolSessionApprovalCommands) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalCommands + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r UserToolSessionApprovalCustomTool) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalCustomTool + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r UserToolSessionApprovalExtensionManagement) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalExtensionManagement + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r UserToolSessionApprovalExtensionPermissionAccess) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalExtensionPermissionAccess + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r UserToolSessionApprovalMcp) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalMcp + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r UserToolSessionApprovalMemory) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalMemory + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r UserToolSessionApprovalRead) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalRead + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r UserToolSessionApprovalWrite) MarshalJSON() ([]byte, error) { + type alias UserToolSessionApprovalWrite + return json.Marshal(struct { + Kind UserToolSessionApprovalKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *PermissionApprovedForLocation) UnmarshalJSON(data []byte) error { + type rawPermissionApprovedForLocation struct { + Approval json.RawMessage `json:"approval"` + LocationKey string `json:"locationKey"` + } + var raw rawPermissionApprovedForLocation + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Approval != nil { + value, err := unmarshalUserToolSessionApproval(raw.Approval) + if err != nil { + return err + } + r.Approval = value + } + r.LocationKey = raw.LocationKey + return nil +} + +func (r PermissionApprovedForLocation) MarshalJSON() ([]byte, error) { + type alias PermissionApprovedForLocation + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *PermissionApprovedForSession) UnmarshalJSON(data []byte) error { + type rawPermissionApprovedForSession struct { + Approval json.RawMessage `json:"approval"` + } + var raw rawPermissionApprovedForSession + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Approval != nil { + value, err := unmarshalUserToolSessionApproval(raw.Approval) + if err != nil { + return err + } + r.Approval = value + } + return nil +} + +func (r PermissionApprovedForSession) MarshalJSON() ([]byte, error) { + type alias PermissionApprovedForSession + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionCancelled) MarshalJSON() ([]byte, error) { + type alias PermissionCancelled + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDeniedByContentExclusionPolicy) MarshalJSON() ([]byte, error) { + type alias PermissionDeniedByContentExclusionPolicy + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDeniedByPermissionRequestHook) MarshalJSON() ([]byte, error) { + type alias PermissionDeniedByPermissionRequestHook + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDeniedByRules) MarshalJSON() ([]byte, error) { + type alias PermissionDeniedByRules + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDeniedInteractivelyByUser) MarshalJSON() ([]byte, error) { + type alias PermissionDeniedInteractivelyByUser + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r PermissionDeniedNoApprovalRuleAndCouldNotRequestFromUser) MarshalJSON() ([]byte, error) { + type alias PermissionDeniedNoApprovalRuleAndCouldNotRequestFromUser + return json.Marshal(struct { + Kind PermissionResultKind `json:"kind"` + alias + }{ + Kind: r.Kind(), + alias: alias(r), + }) +} + +func (r *PermissionCompletedData) UnmarshalJSON(data []byte) error { + type rawPermissionCompletedData struct { + RequestID string `json:"requestId"` + Result json.RawMessage `json:"result"` + ToolCallID *string `json:"toolCallId,omitempty"` + } + var raw rawPermissionCompletedData + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.RequestID = raw.RequestID + if raw.Result != nil { + value, err := unmarshalPermissionResult(raw.Result) + if err != nil { + return err + } + r.Result = value + } + r.ToolCallID = raw.ToolCallID + return nil +} + +func unmarshalElicitationCompletedContent(data []byte) (ElicitationCompletedContent, error) { + if string(data) == "null" { + return nil, nil + } + { + var value string + if err := json.Unmarshal(data, &value); err == nil { + return ElicitationCompletedStringContent(value), nil + } + } + { + var value float64 + if err := json.Unmarshal(data, &value); err == nil { + return ElicitationCompletedNumberContent(value), nil + } + } + { + var value bool + if err := json.Unmarshal(data, &value); err == nil { + return ElicitationCompletedBooleanContent(value), nil + } + } + { + var value []string + if err := json.Unmarshal(data, &value); err == nil { + return ElicitationCompletedStringArrayContent(value), nil + } + } + return nil, errors.New("data did not match any union variant for ElicitationCompletedContent") +} + +func (r *ElicitationCompletedData) UnmarshalJSON(data []byte) error { + type rawElicitationCompletedData struct { + Action *ElicitationCompletedAction `json:"action,omitempty"` + Content map[string]json.RawMessage `json:"content,omitempty"` + RequestID string `json:"requestId"` + } + var raw rawElicitationCompletedData + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.Action = raw.Action + if raw.Content != nil { + r.Content = make(map[string]ElicitationCompletedContent, len(raw.Content)) + for key, rawValue := range raw.Content { + value, err := unmarshalElicitationCompletedContent(rawValue) + if err != nil { + return err + } + r.Content[key] = value + } + } + r.RequestID = raw.RequestID + return nil +} diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index c72d91e27..ad6552f9f 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -79,6 +79,10 @@ function sortByPascalName(entries: [string, T][]): [string, T][] { return entries.sort(([left], [right]) => toPascalCase(left).localeCompare(toPascalCase(right))); } +function compareGoTypeNames(left: string, right: string): number { + return left.localeCompare(right); +} + function compareRpcMethodsByGoName(left: RpcMethod, right: RpcMethod): number { return clientHandlerMethodName(left.rpcMethod).localeCompare(clientHandlerMethodName(right.rpcMethod)); } @@ -137,27 +141,58 @@ function wrapGeneratedGoComments(code: string): string { .join("\n"); } +interface GoExtractedField { + name: string; + type: string; +} + /** - * Extract a mapping from (structName, jsonFieldName) → goFieldName - * so the wrapper code references the generated Go field names. + * Extract a mapping from (structName, jsonFieldName) to generated Go field + * metadata so wrapper code can reference emitted field names and nil behavior. */ -function extractFieldNames(generatedTypeCode: string): Map> { - const result = new Map>(); +function extractFields(generatedTypeCode: string): Map> { + const result = new Map>(); const structRe = /^type\s+(\w+)\s+struct\s*\{([^}]*)\}/gm; let sm; while ((sm = structRe.exec(generatedTypeCode)) !== null) { const [, structName, body] = sm; - const fields = new Map(); - const fieldRe = /^\s+(\w+)\s+[^`\n]+`json:"([^",]+)/gm; + const fields = new Map(); + const fieldRe = /^\s+(\w+)\s+([^\s`]+)\s+`json:"([^",]+)/gm; let fm; while ((fm = fieldRe.exec(body)) !== null) { - fields.set(fm[2], fm[1]); + fields.set(fm[3], { name: fm[1], type: fm[2] }); } result.set(structName, fields); } return result; } +function goTypeIsPointer(goType: string | undefined): boolean { + return goType?.startsWith("*") ?? false; +} + +function goTypeIsSlice(goType: string | undefined): boolean { + return goType?.startsWith("[]") ?? false; +} + +function goTypeIsMap(goType: string | undefined): boolean { + return goType?.startsWith("map[") ?? false; +} + +function goTypeIsNilable(goType: string | undefined, ctx?: GoCodegenCtx): boolean { + if (!goType) return false; + if (goTypeIsPointer(goType) || goTypeIsSlice(goType) || goTypeIsMap(goType)) return true; + return ctx ? goDiscriminatedUnionInfoForType(goType, ctx) !== undefined : false; +} + +function goOptionalFieldNeedsDereference(goType: string | undefined): boolean { + return goType === undefined || goTypeIsPointer(goType); +} + +function goTypeWithOptionalPointer(goType: string, ctx?: GoCodegenCtx): string { + return goTypeIsNilable(goType, ctx) ? goType : `*${goType}`; +} + async function formatGoFile(filePath: string): Promise { try { await execFileAsync("go", ["fmt", filePath]); @@ -258,13 +293,59 @@ interface GoEventEnvelopeProperty extends SessionEventEnvelopeProperty { description?: string; } +interface GoDiscriminatedUnionInfo { + typeName: string; + unmarshalFuncName: string; +} + +interface GoDiscriminatedUnionVariant { + schema: JSONSchema7; + typeName: string; + discriminatorValues: string[]; +} + +interface GoDiscriminatorInfo { + property: string; + mapping: Map; + variants: GoDiscriminatedUnionVariant[]; +} + +interface GoRequiredFieldDiscriminatorInfo { + variants: GoDiscriminatedUnionVariant[]; +} + +interface GoPrimitiveUnionVariant { + typeName: string; + goType: string; +} + +interface GoUntaggedUnionVariant { + typeName: string; + goType: string; + jsonKind: string; + typeDefinition?: string; + returnExpr: string; +} + +type GoUnionPlan = + | { kind: "discriminated"; typeName: string; schema: JSONSchema7; description?: string; discriminator: GoDiscriminatorInfo } + | { kind: "requiredFieldDiscriminated"; typeName: string; schema: JSONSchema7; description?: string; discriminator: GoRequiredFieldDiscriminatorInfo } + | { kind: "primitive"; typeName: string; schema: JSONSchema7; description?: string; variants: GoPrimitiveUnionVariant[] } + | { kind: "flattenedObject"; typeName: string; schema: JSONSchema7; description?: string; variants: JSONSchema7[] } + | { kind: "untagged"; typeName: string; schema: JSONSchema7; description?: string; variants: GoUntaggedUnionVariant[] } + | { kind: "wrapper"; typeName: string; schema: JSONSchema7; description?: string }; + interface GoCodegenCtx { structs: string[]; + encoding: string[]; enums: string[]; enumsByName: Map; // enumName → enumName (dedup by type name, not values) + discriminatedUnions: Map; generatedNames: Set; definitions?: DefinitionCollections; wrapComments?: boolean; + discriminatedUnionRawVariantSuffix?: string; + skipDefinitionTypeNames?: Set; } function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { @@ -320,34 +401,92 @@ function sortedGoEventEnvelopeProperties(properties: GoEventEnvelopeProperty[]): } /** - * Find a const-valued discriminator property shared by all anyOf variants. + * Find a string-valued discriminator property shared by all anyOf variants. */ function findGoDiscriminator( - variants: JSONSchema7[] -): { property: string; mapping: Map } | null { + variants: JSONSchema7[], + ctx: GoCodegenCtx, + unionTypeName: string +): GoDiscriminatorInfo | null { if (variants.length === 0) return null; - const firstVariant = variants[0]; + const firstVariant = resolveGoUnionMember(variants[0], ctx.definitions); if (!firstVariant.properties) return null; for (const [propName, propSchema] of Object.entries(firstVariant.properties)) { if (typeof propSchema !== "object") continue; - if ((propSchema as JSONSchema7).const === undefined) continue; + const firstValues = goStringEnumValues(propSchema as JSONSchema7, ctx); + if (!firstValues || firstValues.length === 0) continue; - const mapping = new Map(); + const mapping = new Map(); + const unionVariants: GoDiscriminatedUnionVariant[] = []; let valid = true; - for (const variant of variants) { + for (const variantSource of variants) { + const variant = resolveGoUnionMember(variantSource, ctx.definitions); if (!variant.properties) { valid = false; break; } + if (!(variant.required || []).includes(propName)) { valid = false; break; } const vp = variant.properties[propName]; - if (typeof vp !== "object" || (vp as JSONSchema7).const === undefined) { valid = false; break; } - mapping.set(String((vp as JSONSchema7).const), variant); + if (typeof vp !== "object") { valid = false; break; } + const discriminatorValues = goStringEnumValues(vp as JSONSchema7, ctx); + if (!discriminatorValues || discriminatorValues.length === 0) { valid = false; break; } + const dedupedValues = [...new Set(discriminatorValues)]; + const unionVariant = { + schema: variant, + typeName: goDiscriminatedUnionVariantTypeName(unionTypeName, dedupedValues[0], variantSource, variant, ctx), + discriminatorValues: dedupedValues, + }; + unionVariants.push(unionVariant); + for (const discriminatorValue of dedupedValues) { + const existing = mapping.get(discriminatorValue) ?? []; + existing.push(unionVariant); + mapping.set(discriminatorValue, existing); + } } - if (valid && mapping.size === variants.length) { - return { property: propName, mapping }; + if (valid && mapping.size > 0 && unionVariants.length === variants.length) { + return { property: propName, mapping, variants: unionVariants }; } } return null; } +function findGoRequiredFieldDiscriminator( + variants: JSONSchema7[], + ctx: GoCodegenCtx, + unionTypeName: string +): GoRequiredFieldDiscriminatorInfo | null { + if (variants.length === 0) return null; + + const objectVariants = variants.map((variantSource) => ({ + source: variantSource, + schema: goObjectUnionMemberSchema(variantSource, ctx), + })); + if (objectVariants.some((variant) => variant.schema === undefined)) return null; + + const requiredSets = objectVariants.map((variant) => new Set(variant.schema!.required || [])); + const propertySets = objectVariants.map((variant) => new Set(Object.keys(variant.schema!.properties || {}))); + const unionVariants: GoDiscriminatedUnionVariant[] = []; + const seenTypeNames = new Set(); + for (const [index, variant] of objectVariants.entries()) { + const required = requiredSets[index]; + if (required.size === 0) return null; + + const uniqueRequired = [...required] + .filter((propName) => !propertySets.some((peerProperties, peerIndex) => peerIndex !== index && peerProperties.has(propName))) + .sort(compareGoFieldNames); + if (uniqueRequired.length === 0) return null; + + const typeName = goDiscriminatedUnionVariantTypeName(unionTypeName, uniqueRequired[0], variant.source, variant.schema!, ctx); + if (seenTypeNames.has(typeName)) return null; + seenTypeNames.add(typeName); + unionVariants.push({ + schema: variant.schema!, + typeName, + discriminatorValues: uniqueRequired, + }); + } + + return { variants: unionVariants }; +} + /** * Get or create a Go enum type, deduplicating by type name (not by value set). * Two enums with the same values but different names are distinct types. @@ -396,6 +535,23 @@ function goEnumConstSuffix(value: string): string { .join(""); } +function goDiscriminatedUnionVariantTypeName( + unionTypeName: string, + discriminatorValue: string, + variantSource: JSONSchema7, + variant: JSONSchema7, + ctx: GoCodegenCtx +): string { + if (variantSource.$ref && typeof variantSource.$ref === "string") { + return goDefinitionName(refTypeName(variantSource.$ref, ctx.definitions)); + } + const definitionRef = goDefinitionRefForEquivalentSchema(variant, ctx); + if (definitionRef) { + return goDefinitionName(refTypeName(definitionRef, ctx.definitions)); + } + return `${unionTypeName}${goEnumConstSuffix(discriminatorValue)}`; +} + function schemaForConstValue(value: unknown): JSONSchema7 { if (value === null) return { type: "null" }; if (Array.isArray(value)) return { type: "array", items: {} }; @@ -440,8 +596,12 @@ function resolveGoPropertyType( emitGoStruct(typeName, resolved, ctx); return isRequired ? typeName : `*${typeName}`; } - if (resolved.anyOf || resolved.oneOf) { + const resolvedUnion = resolved as JSONSchema7; + if (resolvedUnion.anyOf || resolvedUnion.oneOf) { emitGoRpcDefinition(refTypeName(propSchema.$ref, ctx.definitions), resolved, ctx); + if (goDiscriminatedUnionInfoForType(typeName, ctx)) { + return typeName; + } return isRequired ? typeName : `*${typeName}`; } return resolveGoPropertyType(resolved, parentTypeName, jsonPropName, isRequired, ctx); @@ -457,10 +617,7 @@ function resolveGoPropertyType( // anyOf [T, null/{not:{}}] → nullable T const innerType = resolveGoPropertyType(nullableInnerSchema, parentTypeName, jsonPropName, true, ctx); // Pointer-wrap if not already a pointer, slice, or map - if (innerType.startsWith("*") || innerType.startsWith("[]") || innerType.startsWith("map[")) { - return innerType; - } - return `*${innerType}`; + return goTypeWithOptionalPointer(innerType, ctx); } const nonNull = (propSchema.anyOf as JSONSchema7[]).filter((s) => s.type !== "null"); const hasNull = (propSchema.anyOf as JSONSchema7[]).some((s) => s.type === "null"); @@ -469,31 +626,15 @@ function resolveGoPropertyType( // anyOf [T, null] → nullable T const innerType = resolveGoPropertyType(nonNull[0], parentTypeName, jsonPropName, true, ctx); if (isRequired && !hasNull) return innerType; - if (innerType.startsWith("*") || innerType.startsWith("[]") || innerType.startsWith("map[")) { - return innerType; - } - return `*${innerType}`; + return goTypeWithOptionalPointer(innerType, ctx); } if (nonNull.length > 1) { - // Resolve $refs in variants before discriminator analysis - const resolvedVariants = nonNull.map((v) => { - if (v.$ref && typeof v.$ref === "string") { - return resolveRef(v.$ref, ctx.definitions) ?? v; - } - return v; - }); - // Check for discriminated union - const disc = findGoDiscriminator(resolvedVariants); - if (disc) { - const unionName = (propSchema.title as string) || nestedName; - emitGoFlatDiscriminatedUnion(unionName, disc.property, disc.mapping, ctx, propSchema.description); - return isRequired && !hasNull ? unionName : `*${unionName}`; - } - if (canFlattenGoObjectUnion(resolvedVariants, ctx)) { - const unionName = (propSchema.title as string) || nestedName; - emitGoFlattenedObjectUnion(unionName, resolvedVariants, ctx, propSchema.description); - return isRequired && !hasNull ? unionName : `*${unionName}`; + const unionName = (propSchema.title as string) || nestedName; + const plan = planGoUnion(unionName, propSchema, ctx); + if (plan) { + emitGoUnionPlan(plan, ctx); + return goUnionPlanPropertyType(plan, isRequired, hasNull); } // Non-discriminated multi-type union → any return "any"; @@ -530,8 +671,7 @@ function resolveGoPropertyType( true, ctx ); - if (inner.startsWith("*") || inner.startsWith("[]") || inner.startsWith("map[")) return inner; - return `*${inner}`; + return goTypeWithOptionalPointer(inner, ctx); } } @@ -550,14 +690,12 @@ function resolveGoPropertyType( if (type === "array") { const items = propSchema.items as JSONSchema7 | undefined; if (items) { - // Discriminated union items if (items.anyOf) { - const itemVariants = (items.anyOf as JSONSchema7[]).filter((v) => v.type !== "null"); - const disc = findGoDiscriminator(itemVariants); - if (disc) { - const itemTypeName = (items.title as string) || (nestedName + "Item"); - emitGoFlatDiscriminatedUnion(itemTypeName, disc.property, disc.mapping, ctx, items.description); - return `[]${itemTypeName}`; + const itemTypeName = (items.title as string) || (nestedName + "Item"); + const plan = planGoUnion(itemTypeName, items, ctx); + if (plan) { + emitGoUnionPlan(plan, ctx); + return `[]${goUnionPlanPropertyType(plan, true, false)}`; } } const itemType = resolveGoPropertyType(items, parentTypeName, jsonPropName + "Item", true, ctx); @@ -589,7 +727,7 @@ function resolveGoPropertyType( if (resolvedValueType?.anyOf || resolvedValueType?.oneOf) { const unionMembers = goNonNullUnionMembers(resolvedValueType) .map((member) => resolveGoUnionMember(member, ctx.definitions)); - if (!canFlattenGoObjectUnion(unionMembers, ctx) && !valueType.startsWith("*") && !valueType.startsWith("[]") && !valueType.startsWith("map[")) { + if (!canFlattenGoObjectUnion(unionMembers, ctx) && !goTypeIsNilable(valueType, ctx)) { valueType = `*${valueType}`; } } @@ -604,6 +742,117 @@ function resolveGoPropertyType( return "any"; } +interface GoStructField { + propName: string; + goName: string; + goType: string; + jsonTag: string; +} + +interface GoDiscriminatedUnionField { + kind: "single" | "slice" | "map"; + unionInfo: GoDiscriminatedUnionInfo; +} + +function goUnexportedFunctionName(prefix: string, typeName: string): string { + return prefix + typeName; +} + +function goDiscriminatedUnionInfoForType(typeName: string, ctx: GoCodegenCtx): GoDiscriminatedUnionInfo | undefined { + return ctx.discriminatedUnions.get(typeName); +} + +function goDiscriminatedUnionField(goType: string, ctx: GoCodegenCtx): GoDiscriminatedUnionField | undefined { + const single = goDiscriminatedUnionInfoForType(goType, ctx); + if (single) return { kind: "single", unionInfo: single }; + + if (goTypeIsSlice(goType)) { + const itemType = goType.slice(2); + const item = goDiscriminatedUnionInfoForType(itemType, ctx); + if (item) return { kind: "slice", unionInfo: item }; + } + + const mapMatch = /^map\[string\](.+)$/.exec(goType); + if (mapMatch) { + const value = goDiscriminatedUnionInfoForType(mapMatch[1], ctx); + if (value) return { kind: "map", unionInfo: value }; + } + + return undefined; +} + +function pushGoEncodingBlock(blockLines: string[], ctx: GoCodegenCtx): void { + if (blockLines.length === 0) return; + ctx.encoding.push(blockLines.join("\n")); +} + +function pushGoStructUnmarshalJSON(lines: string[], typeName: string, fields: GoStructField[], ctx: GoCodegenCtx): void { + const unionFields = fields + .map((field) => ({ field, unionField: goDiscriminatedUnionField(field.goType, ctx) })) + .filter((entry): entry is { field: GoStructField; unionField: GoDiscriminatedUnionField } => entry.unionField !== undefined); + if (unionFields.length === 0) return; + + const blockLines: string[] = []; + blockLines.push(`func (r *${typeName}) UnmarshalJSON(data []byte) error {`); + blockLines.push(`\ttype raw${typeName} struct {`); + for (const field of fields) { + const unionField = goDiscriminatedUnionField(field.goType, ctx); + let rawType = field.goType; + if (unionField?.kind === "single") rawType = "json.RawMessage"; + if (unionField?.kind === "slice") rawType = "[]json.RawMessage"; + if (unionField?.kind === "map") rawType = "map[string]json.RawMessage"; + blockLines.push(`\t\t${field.goName} ${rawType} \`${field.jsonTag}\``); + } + blockLines.push(`\t}`); + blockLines.push(`\tvar raw raw${typeName}`); + blockLines.push(`\tif err := json.Unmarshal(data, &raw); err != nil {`); + blockLines.push(`\t\treturn err`); + blockLines.push(`\t}`); + + for (const field of fields) { + const unionField = goDiscriminatedUnionField(field.goType, ctx); + if (!unionField) { + blockLines.push(`\tr.${field.goName} = raw.${field.goName}`); + continue; + } + + if (unionField.kind === "single") { + blockLines.push(`\tif raw.${field.goName} != nil {`); + blockLines.push(`\t\tvalue, err := ${unionField.unionInfo.unmarshalFuncName}(raw.${field.goName})`); + blockLines.push(`\t\tif err != nil {`); + blockLines.push(`\t\t\treturn err`); + blockLines.push(`\t\t}`); + blockLines.push(`\t\tr.${field.goName} = value`); + blockLines.push(`\t}`); + } else if (unionField.kind === "slice") { + blockLines.push(`\tif raw.${field.goName} != nil {`); + blockLines.push(`\t\tr.${field.goName} = make([]${unionField.unionInfo.typeName}, 0, len(raw.${field.goName}))`); + blockLines.push(`\t\tfor _, rawItem := range raw.${field.goName} {`); + blockLines.push(`\t\t\tvalue, err := ${unionField.unionInfo.unmarshalFuncName}(rawItem)`); + blockLines.push(`\t\t\tif err != nil {`); + blockLines.push(`\t\t\t\treturn err`); + blockLines.push(`\t\t\t}`); + blockLines.push(`\t\t\tr.${field.goName} = append(r.${field.goName}, value)`); + blockLines.push(`\t\t}`); + blockLines.push(`\t}`); + } else { + blockLines.push(`\tif raw.${field.goName} != nil {`); + blockLines.push(`\t\tr.${field.goName} = make(map[string]${unionField.unionInfo.typeName}, len(raw.${field.goName}))`); + blockLines.push(`\t\tfor key, rawValue := range raw.${field.goName} {`); + blockLines.push(`\t\t\tvalue, err := ${unionField.unionInfo.unmarshalFuncName}(rawValue)`); + blockLines.push(`\t\t\tif err != nil {`); + blockLines.push(`\t\t\t\treturn err`); + blockLines.push(`\t\t\t}`); + blockLines.push(`\t\t\tr.${field.goName}[key] = value`); + blockLines.push(`\t\t}`); + blockLines.push(`\t}`); + } + } + blockLines.push(`\treturn nil`); + blockLines.push(`}`); + pushGoEncodingBlock(blockLines, ctx); +} + /** * Emit a Go struct definition from an object schema. */ @@ -627,6 +876,8 @@ function emitGoStruct( } lines.push(`type ${typeName} struct {`); + const fields: GoStructField[] = []; + for (const [propName, propSchema] of sortByGoFieldName(Object.entries(schema.properties || {}))) { if (typeof propSchema !== "object") continue; const prop = propSchema as JSONSchema7; @@ -641,67 +892,531 @@ function emitGoStruct( if (isSchemaDeprecated(prop)) { pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); } - lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); + const jsonTag = `json:"${propName}${omit}"`; + lines.push(`\t${goName} ${goType} \`${jsonTag}\``); + fields.push({ propName, goName, goType, jsonTag }); } lines.push(`}`); + pushGoStructUnmarshalJSON(lines, typeName, fields, ctx); ctx.structs.push(lines.join("\n")); } -/** - * Emit a flat Go struct for a discriminated union (anyOf with const discriminator). - * Merges all variant properties into a single struct. - */ -function emitGoFlatDiscriminatedUnion( - typeName: string, - discriminatorProp: string, - mapping: Map, +function goObjectSchemaForMatch(schema: JSONSchema7, ctx: GoCodegenCtx): JSONSchema7 | undefined { + const resolved = resolveSchema(schema, ctx.definitions) ?? schema; + const objectSchema = resolveObjectSchema(resolved, ctx.definitions) ?? resolved; + if (objectSchema?.properties || objectSchema?.type === "object" || objectSchema?.additionalProperties === false) { + return objectSchema; + } + return undefined; +} + +function goSchemaNeedsJSONMatch(schema: JSONSchema7, ctx: GoCodegenCtx): boolean { + if (goObjectSchemaForMatch(schema, ctx)) return true; + return goStringEnumValues(schema, ctx) !== undefined; +} + +function pushGoJSONStringMatchLines( + lines: string[], + rawExpr: string, + values: string[], + indent: string, + varPrefix: string +): void { + const stringVar = `${varPrefix}String`; + lines.push(`${indent}var ${stringVar} string`); + lines.push(`${indent}if err := json.Unmarshal(${rawExpr}, &${stringVar}); err != nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + lines.push(`${indent}switch ${stringVar} {`); + lines.push(`${indent}case ${[...new Set(values)].sort().map((value) => JSON.stringify(value)).join(", ")}:`); + lines.push(`${indent}default:`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); +} + +function pushGoJSONObjectMatchLines( + lines: string[], + schema: JSONSchema7, + rawVar: string, ctx: GoCodegenCtx, - description?: string + indent: string, + varPrefix: string ): void { - if (ctx.generatedNames.has(typeName)) return; - ctx.generatedNames.add(typeName); + const properties = schema.properties || {}; + const propertyNames = Object.keys(properties).sort(); + const required = [...new Set(schema.required || [])].sort(); - // Collect all properties across variants, determining which are required in all - const allProps = new Map< - string, - { schema: JSONSchema7; requiredInAll: boolean } - >(); + for (const requiredProp of required) { + lines.push(`${indent}if _, ok := ${rawVar}[${JSON.stringify(requiredProp)}]; !ok {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + } - for (const [, variant] of mapping) { - const required = new Set(variant.required || []); - for (const [propName, propSchema] of Object.entries(variant.properties || {})) { - if (typeof propSchema !== "object") continue; - if (!allProps.has(propName)) { - allProps.set(propName, { - schema: propSchema as JSONSchema7, - requiredInAll: required.has(propName), - }); - } else { - const existing = allProps.get(propName)!; - if (!required.has(propName)) { - existing.requiredInAll = false; - } - } + if (schema.additionalProperties === false) { + if (propertyNames.length === 0) { + lines.push(`${indent}if len(${rawVar}) != 0 {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + } else { + lines.push(`${indent}for key := range ${rawVar} {`); + lines.push(`${indent}\tswitch key {`); + lines.push(`${indent}\tcase ${propertyNames.map((propertyName) => JSON.stringify(propertyName)).join(", ")}:`); + lines.push(`${indent}\tdefault:`); + lines.push(`${indent}\t\treturn false`); + lines.push(`${indent}\t}`); + lines.push(`${indent}}`); } } - // Properties not present in all variants must be optional - const variantCount = mapping.size; - for (const [propName, info] of allProps) { - let presentCount = 0; - for (const [, variant] of mapping) { - if (variant.properties && propName in variant.properties) { - presentCount++; + for (const [propName, propSchema] of Object.entries(properties).sort(([left], [right]) => left.localeCompare(right))) { + if (typeof propSchema !== "object") continue; + const prop = propSchema as JSONSchema7; + if (!goSchemaNeedsJSONMatch(prop, ctx)) continue; + const valueVar = `${varPrefix}${toGoFieldName(propName)}`; + lines.push(`${indent}if ${valueVar}, ok := ${rawVar}[${JSON.stringify(propName)}]; ok {`); + pushGoJSONSchemaMatchLines(lines, prop, valueVar, ctx, `${indent}\t`, valueVar); + lines.push(`${indent}}`); + } +} + +function pushGoJSONSchemaMatchLines( + lines: string[], + schema: JSONSchema7, + rawExpr: string, + ctx: GoCodegenCtx, + indent: string, + varPrefix: string +): void { + const objectSchema = goObjectSchemaForMatch(schema, ctx); + if (objectSchema) { + const objectVar = `${varPrefix}Object`; + lines.push(`${indent}var ${objectVar} map[string]json.RawMessage`); + lines.push(`${indent}if err := json.Unmarshal(${rawExpr}, &${objectVar}); err != nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + pushGoJSONObjectMatchLines(lines, objectSchema, objectVar, ctx, indent, varPrefix); + return; + } + + const stringValues = goStringEnumValues(schema, ctx); + if (stringValues) { + pushGoJSONStringMatchLines(lines, rawExpr, stringValues, indent, varPrefix); + } +} + +function goVariantMatchFuncName(variantTypeName: string): string { + return goUnexportedFunctionName("matches", variantTypeName); +} + +// Minimal checks used to distinguish variants that share the same discriminator. +// Paths and values come from the JSON schema; these two operation names are the +// only matcher primitives we currently need for const-aware tie breaking. +type GoJSONMatchTerm = + | { kind: "propertyExists"; path: string[] } + | { kind: "stringValue"; path: string[]; values: string[] }; + +interface GoVariantMatchSpec { + positiveTerms: GoJSONMatchTerm[]; + negativeExistsPaths: string[][]; +} + +interface GoJSONMatchTermGroup { + parentPath: string[]; + positiveTerms: GoJSONMatchTerm[]; + negativeProperties: string[]; +} + +function goJSONMatchPathKey(path: string[]): string { + return path.join("\0"); +} + +function goJSONMatchTermKey(term: GoJSONMatchTerm): string { + const base = `${term.kind}:${goJSONMatchPathKey(term.path)}`; + if (term.kind === "stringValue") { + return `${base}:${[...new Set(term.values)].sort().join("\0")}`; + } + return base; +} + +function dedupeGoJSONMatchTerms(terms: GoJSONMatchTerm[]): GoJSONMatchTerm[] { + const seen = new Set(); + const result: GoJSONMatchTerm[] = []; + for (const term of terms) { + const key = goJSONMatchTermKey(term); + if (seen.has(key)) continue; + seen.add(key); + result.push(term); + } + return result; +} + +function compareGoJSONPaths(left: string[], right: string[]): number { + return goJSONMatchPathKey(left).localeCompare(goJSONMatchPathKey(right)); +} + +function compareGoJSONMatchTerms(left: GoJSONMatchTerm, right: GoJSONMatchTerm): number { + const pathComparison = compareGoJSONPaths(left.path, right.path); + if (pathComparison !== 0) return pathComparison; + return left.kind.localeCompare(right.kind); +} + +function goCollectRequiredJSONMatchTerms( + schema: JSONSchema7, + ctx: GoCodegenCtx, + discriminatorProp: string, + path: string[] = [] +): GoJSONMatchTerm[] { + const objectSchema = goObjectSchemaForMatch(schema, ctx); + if (!objectSchema) return []; + + const properties = objectSchema.properties || {}; + const terms: GoJSONMatchTerm[] = []; + for (const propName of [...new Set(objectSchema.required || [])].sort()) { + if (path.length === 0 && propName === discriminatorProp) continue; + const propSchema = properties[propName]; + if (typeof propSchema !== "object") continue; + + const propPath = [...path, propName]; + const prop = propSchema as JSONSchema7; + terms.push({ kind: "propertyExists", path: propPath }); + + const stringValues = goStringEnumValues(prop, ctx); + if (stringValues) { + terms.push({ kind: "stringValue", path: propPath, values: [...new Set(stringValues)].sort() }); + } + + terms.push(...goCollectRequiredJSONMatchTerms(prop, ctx, discriminatorProp, propPath)); + } + + return dedupeGoJSONMatchTerms(terms); +} + +function removeRedundantGoJSONExistsTerms(terms: GoJSONMatchTerm[]): GoJSONMatchTerm[] { + const stringPaths = new Set(terms + .filter((term) => term.kind === "stringValue") + .map((term) => goJSONMatchPathKey(term.path))); + return terms.filter((term) => term.kind !== "propertyExists" || !stringPaths.has(goJSONMatchPathKey(term.path))); +} + +function goVariantTargetedMatchSpec( + variant: GoDiscriminatedUnionVariant, + groupVariants: GoDiscriminatedUnionVariant[], + discriminatorProp: string, + ctx: GoCodegenCtx +): GoVariantMatchSpec { + const termsByVariant = new Map(); + const termCounts = new Map(); + + for (const groupVariant of groupVariants) { + const terms = goCollectRequiredJSONMatchTerms(groupVariant.schema, ctx, discriminatorProp); + termsByVariant.set(groupVariant.typeName, terms); + for (const term of terms) { + const key = goJSONMatchTermKey(term); + termCounts.set(key, (termCounts.get(key) ?? 0) + 1); + } + } + + const variantTerms = termsByVariant.get(variant.typeName) ?? []; + const uniqueTerms = variantTerms.filter((term) => (termCounts.get(goJSONMatchTermKey(term)) ?? 0) < groupVariants.length); + const positiveTerms = removeRedundantGoJSONExistsTerms(uniqueTerms).sort(compareGoJSONMatchTerms); + + const variantPositivePathKeys = new Set(positiveTerms.map((term) => goJSONMatchPathKey(term.path))); + const peerPositivePathKeys = new Set(); + const peerPositivePaths: string[][] = []; + for (const groupVariant of groupVariants) { + if (groupVariant.typeName === variant.typeName) continue; + const groupTerms = termsByVariant.get(groupVariant.typeName) ?? []; + const peerUniqueTerms = removeRedundantGoJSONExistsTerms( + groupTerms.filter((term) => (termCounts.get(goJSONMatchTermKey(term)) ?? 0) < groupVariants.length) + ); + for (const term of peerUniqueTerms) { + const pathKey = goJSONMatchPathKey(term.path); + if (variantPositivePathKeys.has(pathKey) || peerPositivePathKeys.has(pathKey)) continue; + peerPositivePathKeys.add(pathKey); + peerPositivePaths.push(term.path); + } + } + + return { + positiveTerms, + negativeExistsPaths: peerPositivePaths.sort(compareGoJSONPaths), + }; +} + +function goJSONMatchTermParentPath(term: GoJSONMatchTerm): string[] { + return term.path.slice(0, -1); +} + +function goJSONMatchPathParentPath(path: string[]): string[] { + return path.slice(0, -1); +} + +function goJSONMatchPathProperty(path: string[]): string { + return path[path.length - 1]; +} + +function groupGoJSONMatchTerms(spec: GoVariantMatchSpec): GoJSONMatchTermGroup[] { + const groups = new Map(); + const getGroup = (parentPath: string[]): GoJSONMatchTermGroup => { + const key = goJSONMatchPathKey(parentPath); + const existing = groups.get(key); + if (existing) return existing; + const group = { parentPath, positiveTerms: [], negativeProperties: [] }; + groups.set(key, group); + return group; + }; + + for (const term of spec.positiveTerms) { + getGroup(goJSONMatchTermParentPath(term)).positiveTerms.push(term); + } + for (const path of spec.negativeExistsPaths) { + const group = getGroup(goJSONMatchPathParentPath(path)); + group.negativeProperties.push(goJSONMatchPathProperty(path)); + } + + return [...groups.values()] + .map((group) => ({ + parentPath: group.parentPath, + positiveTerms: group.positiveTerms.sort(compareGoJSONMatchTerms), + negativeProperties: [...new Set(group.negativeProperties)].sort(), + })) + .sort((left, right) => compareGoJSONPaths(left.parentPath, right.parentPath)); +} + +function goJSONRawStructFields(propNames: string[]): Map { + const fieldNames = new Map(); + const used = new Set(); + for (const propName of [...new Set(propNames)].sort()) { + const baseName = toGoFieldName(propName) || "Field"; + let fieldName = baseName; + let suffix = 2; + while (used.has(fieldName)) { + fieldName = `${baseName}${suffix++}`; + } + used.add(fieldName); + fieldNames.set(propName, fieldName); + } + return fieldNames; +} + +function pushGoJSONRawStructDeclLines( + lines: string[], + structVar: string, + propNames: string[], + indent: string +): Map { + const fieldNames = goJSONRawStructFields(propNames); + lines.push(`${indent}var ${structVar} struct {`); + for (const [propName, fieldName] of fieldNames) { + lines.push(`${indent}\t${fieldName} json.RawMessage \`json:"${propName}"\``); + } + lines.push(`${indent}}`); + return fieldNames; +} + +function pushGoJSONRawStructUnmarshalLines( + lines: string[], + rawExpr: string, + structVar: string, + propNames: string[], + indent: string +): Map { + const fieldNames = pushGoJSONRawStructDeclLines(lines, structVar, propNames, indent); + lines.push(`${indent}if err := json.Unmarshal(${rawExpr}, &${structVar}); err != nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + return fieldNames; +} + +function goJSONPathVarName(varPrefix: string, path: string[]): string { + return `${varPrefix}${path.map(toGoFieldName).join("")}`; +} + +function pushGoJSONRequiredRawPathLines( + lines: string[], + rootRawExpr: string, + path: string[], + indent: string, + varPrefix: string +): string { + let rawExpr = rootRawExpr; + for (let index = 0; index < path.length; index++) { + const structVar = goJSONPathVarName(varPrefix, path.slice(0, index)); + const fieldNames = pushGoJSONRawStructUnmarshalLines(lines, rawExpr, structVar, [path[index]], indent); + const fieldExpr = `${structVar}.${fieldNames.get(path[index])!}`; + lines.push(`${indent}if ${fieldExpr} == nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + rawExpr = fieldExpr; + } + return rawExpr; +} + +function pushGoJSONOptionalRawPathLines( + lines: string[], + rawExpr: string, + path: string[], + indent: string, + varPrefix: string, + pushInnerLines: (innerRawExpr: string, innerVarPrefix: string, innerIndent: string) => void, + pathPrefix: string[] = [], + requireObject: boolean = true +): void { + if (path.length === 0) { + pushInnerLines(rawExpr, goJSONPathVarName(varPrefix, pathPrefix), indent); + return; + } + + const [head, ...tail] = path; + const structVar = goJSONPathVarName(varPrefix, pathPrefix); + const fieldNames = pushGoJSONRawStructDeclLines(lines, structVar, [head], indent); + if (requireObject) { + lines.push(`${indent}if err := json.Unmarshal(${rawExpr}, &${structVar}); err != nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + lines.push(`${indent}if ${structVar}.${fieldNames.get(head)!} != nil {`); + } else { + lines.push(`${indent}if err := json.Unmarshal(${rawExpr}, &${structVar}); err == nil && ${structVar}.${fieldNames.get(head)!} != nil {`); + } + pushGoJSONOptionalRawPathLines( + lines, + `${structVar}.${fieldNames.get(head)!}`, + tail, + `${indent}\t`, + varPrefix, + pushInnerLines, + [...pathPrefix, head], + false + ); + lines.push(`${indent}}`); +} + +function pushGoJSONPositiveTermLines( + lines: string[], + structVar: string, + fieldNames: Map, + term: GoJSONMatchTerm, + indent: string, + varPrefix: string +): void { + const propName = goJSONMatchPathProperty(term.path); + const fieldExpr = `${structVar}.${fieldNames.get(propName)!}`; + if (term.kind === "propertyExists") { + lines.push(`${indent}if ${fieldExpr} == nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + return; + } + + lines.push(`${indent}if ${fieldExpr} == nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + pushGoJSONStringMatchLines(lines, fieldExpr, term.values, indent, varPrefix); +} + +function pushGoJSONNegativePropertyLines( + lines: string[], + structVar: string, + fieldNames: Map, + properties: string[], + indent: string, + emitFinalReturn: boolean = false +): string | undefined { + const propertyChecks = emitFinalReturn ? properties.slice(0, -1) : properties; + for (const propName of propertyChecks) { + lines.push(`${indent}if ${structVar}.${fieldNames.get(propName)!} != nil {`); + lines.push(`${indent}\treturn false`); + lines.push(`${indent}}`); + } + if (!emitFinalReturn || properties.length === 0) return undefined; + return `${structVar}.${fieldNames.get(properties[properties.length - 1])!} == nil`; +} + +function pushGoJSONTargetedMatchSpecLines( + lines: string[], + rootRawExpr: string, + spec: GoVariantMatchSpec, + indent: string +): string | undefined { + const groups = groupGoJSONMatchTerms(spec); + for (const [index, group] of groups.entries()) { + const emitFinalReturn = index === groups.length - 1; + const groupVarPrefix = `rawGroup${index}`; + const groupProperties = [ + ...group.positiveTerms.map((term) => goJSONMatchPathProperty(term.path)), + ...group.negativeProperties, + ]; + if (group.positiveTerms.length > 0) { + const rawExpr = pushGoJSONRequiredRawPathLines(lines, rootRawExpr, group.parentPath, indent, groupVarPrefix); + const structVar = goJSONPathVarName(groupVarPrefix, group.parentPath); + const fieldNames = pushGoJSONRawStructUnmarshalLines(lines, rawExpr, structVar, groupProperties, indent); + for (const term of group.positiveTerms) { + pushGoJSONPositiveTermLines(lines, structVar, fieldNames, term, indent, groupVarPrefix); } + const finalReturn = pushGoJSONNegativePropertyLines(lines, structVar, fieldNames, group.negativeProperties, indent, emitFinalReturn); + if (finalReturn) return finalReturn; + continue; } - if (presentCount < variantCount) { - info.requiredInAll = false; + + if (group.parentPath.length === 0) { + const structVar = goJSONPathVarName(groupVarPrefix, group.parentPath); + const fieldNames = pushGoJSONRawStructUnmarshalLines(lines, rootRawExpr, structVar, groupProperties, indent); + const finalReturn = pushGoJSONNegativePropertyLines(lines, structVar, fieldNames, group.negativeProperties, indent, emitFinalReturn); + if (finalReturn) return finalReturn; + continue; } + + pushGoJSONOptionalRawPathLines(lines, rootRawExpr, group.parentPath, indent, groupVarPrefix, (rawExpr, structVar, innerIndent) => { + const fieldNames = pushGoJSONRawStructDeclLines(lines, structVar, groupProperties, innerIndent); + lines.push(`${innerIndent}if err := json.Unmarshal(${rawExpr}, &${structVar}); err == nil {`); + pushGoJSONNegativePropertyLines(lines, structVar, fieldNames, group.negativeProperties, `${innerIndent}\t`); + lines.push(`${innerIndent}}`); + }); } + return undefined; +} + +function goVariantMatchFunctionLines( + variant: GoDiscriminatedUnionVariant, + groupVariants: GoDiscriminatedUnionVariant[], + discriminatorProp: string, + ctx: GoCodegenCtx +): string[] { + const lines: string[] = []; + lines.push(`func ${goVariantMatchFuncName(variant.typeName)}(data []byte) bool {`); + const spec = goVariantTargetedMatchSpec(variant, groupVariants, discriminatorProp, ctx); + if (spec.positiveTerms.length === 0 && spec.negativeExistsPaths.length === 0) { + pushGoJSONSchemaMatchLines(lines, variant.schema, "data", ctx, "\t", "raw"); + lines.push(`\treturn true`); + lines.push(`}`); + return lines; + } + + const finalReturn = pushGoJSONTargetedMatchSpecLines(lines, "data", spec, "\t"); + lines.push(`\treturn ${finalReturn ?? "true"}`); + lines.push(`}`); + return lines; +} + +/** + * Emit a Go interface for a discriminated union (anyOf with const discriminator). + */ +function emitGoFlatDiscriminatedUnion( + typeName: string, + discriminator: GoDiscriminatorInfo, + ctx: GoCodegenCtx, + description?: string +): void { + if (ctx.generatedNames.has(typeName)) return; + ctx.generatedNames.add(typeName); // Discriminator field: generate an enum from the const values + const discriminatorProp = discriminator.property; + const mapping = discriminator.mapping; + const unionVariants = [...discriminator.variants].sort((left, right) => compareGoTypeNames(left.typeName, right.typeName)); const discGoName = toGoFieldName(discriminatorProp); + const discriminatorMethodName = discGoName; const discValues = [...mapping.keys()]; const discEnumName = getOrCreateGoEnum( typeName + discGoName, @@ -710,30 +1425,262 @@ function emitGoFlatDiscriminatedUnion( `${discGoName} discriminator for ${typeName}.` ); + const unmarshalFuncName = goUnexportedFunctionName("unmarshal", typeName); + const rawDataName = `Raw${typeName}${ctx.discriminatedUnionRawVariantSuffix ?? "Data"}`; + const markerName = `${typeName.charAt(0).toLowerCase()}${typeName.slice(1)}`; + ctx.discriminatedUnions.set(typeName, { typeName, unmarshalFuncName }); + const lines: string[] = []; if (description) { pushGoCommentForContext(lines, description, ctx); } - lines.push(`type ${typeName} struct {`); + lines.push(`type ${typeName} interface {`); + lines.push(`\t${markerName}()`); + lines.push(`\t${discriminatorMethodName}() ${discEnumName}`); + lines.push(`}`); + lines.push(``); - for (const [propName, info] of sortByGoFieldName([...allProps.entries()])) { - const goName = toGoFieldName(propName); - const goType = propName === discriminatorProp - ? discEnumName - : resolveGoPropertyType(info.schema, typeName, propName, info.requiredInAll, ctx); - const omit = info.requiredInAll ? "" : ",omitempty"; - if (propName === discriminatorProp) { - lines.push(`\t// ${discGoName} discriminator`); - } else if (info.schema.description) { - pushGoCommentForContext(lines, info.schema.description, ctx, "\t"); - } - if (isSchemaDeprecated(info.schema)) { - pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); + const ambiguousGroupsByVariantTypeName = new Map(); + for (const groupVariants of mapping.values()) { + if (groupVariants.length <= 1) continue; + const sortedGroupVariants = [...groupVariants].sort((left, right) => compareGoTypeNames(left.typeName, right.typeName)); + for (const variant of groupVariants) { + ambiguousGroupsByVariantTypeName.set(variant.typeName, sortedGroupVariants); + } + } + for (const variant of unionVariants) { + const groupVariants = ambiguousGroupsByVariantTypeName.get(variant.typeName); + if (groupVariants) { + pushGoEncodingBlock(goVariantMatchFunctionLines(variant, groupVariants, discriminatorProp, ctx), ctx); } - lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); } + const unmarshalLines: string[] = []; + unmarshalLines.push(`func ${unmarshalFuncName}(data []byte) (${typeName}, error) {`); + unmarshalLines.push(`\tif string(data) == "null" {`); + unmarshalLines.push(`\t\treturn nil, nil`); + unmarshalLines.push(`\t}`); + unmarshalLines.push(`\ttype rawUnion struct {`); + unmarshalLines.push(`\t\t${discGoName} ${discEnumName} \`json:"${discriminatorProp}"\``); + unmarshalLines.push(`\t}`); + unmarshalLines.push(`\tvar raw rawUnion`); + unmarshalLines.push(`\tif err := json.Unmarshal(data, &raw); err != nil {`); + unmarshalLines.push(`\t\treturn nil, err`); + unmarshalLines.push(`\t}`); + unmarshalLines.push(``); + unmarshalLines.push(`\tswitch raw.${discGoName} {`); + for (const discriminatorValue of [...mapping.keys()].sort()) { + const constName = `${discEnumName}${goEnumConstSuffix(discriminatorValue)}`; + const mappedVariants = [...mapping.get(discriminatorValue)!].sort((left, right) => compareGoTypeNames(left.typeName, right.typeName)); + unmarshalLines.push(`\tcase ${constName}:`); + if (mappedVariants.length === 1) { + const variantTypeName = mappedVariants[0].typeName; + unmarshalLines.push(`\t\tvar d ${variantTypeName}`); + unmarshalLines.push(`\t\tif err := json.Unmarshal(data, &d); err != nil {`); + unmarshalLines.push(`\t\t\treturn nil, err`); + unmarshalLines.push(`\t\t}`); + unmarshalLines.push(`\t\treturn &d, nil`); + } else { + for (const mappedVariant of mappedVariants) { + unmarshalLines.push(`\t\tif ${goVariantMatchFuncName(mappedVariant.typeName)}(data) {`); + unmarshalLines.push(`\t\t\tvar d ${mappedVariant.typeName}`); + unmarshalLines.push(`\t\t\tif err := json.Unmarshal(data, &d); err != nil {`); + unmarshalLines.push(`\t\t\t\treturn nil, err`); + unmarshalLines.push(`\t\t\t}`); + unmarshalLines.push(`\t\t\treturn &d, nil`); + unmarshalLines.push(`\t\t}`); + } + unmarshalLines.push(`\t\treturn &${rawDataName}{Discriminator: raw.${discGoName}, Raw: data}, nil`); + } + } + unmarshalLines.push(`\tdefault:`); + unmarshalLines.push(`\t\treturn &${rawDataName}{Discriminator: raw.${discGoName}, Raw: data}, nil`); + unmarshalLines.push(`\t}`); + unmarshalLines.push(`}`); + pushGoEncodingBlock(unmarshalLines, ctx); + + lines.push(`type ${rawDataName} struct {`); + lines.push(`\tDiscriminator ${discEnumName}`); + lines.push(`\tRaw json.RawMessage`); lines.push(`}`); + lines.push(``); + lines.push(`func (${rawDataName}) ${markerName}() {}`); + lines.push(`func (r ${rawDataName}) ${discriminatorMethodName}() ${discEnumName} {`); + lines.push(`\treturn r.Discriminator`); + lines.push(`}`); + pushGoEncodingBlock([ + `func (r ${rawDataName}) MarshalJSON() ([]byte, error) {`, + `\tif r.Raw != nil {`, + `\t\treturn r.Raw, nil`, + `\t}`, + `\treturn json.Marshal(struct {`, + `\t\t${discGoName} ${discEnumName} \`json:"${discriminatorProp}"\``, + `\t}{`, + `\t\t${discGoName}: r.Discriminator,`, + `\t})`, + `}`, + ], ctx); + + for (const mappedVariant of unionVariants) { + const variant = mappedVariant.schema; + const variantTypeName = mappedVariant.typeName; + if (variant.description) { + pushGoCommentForContext(lines, variant.description, ctx); + } + ctx.generatedNames.add(variantTypeName); + lines.push(`type ${variantTypeName} struct {`); + const required = new Set(variant.required || []); + const fields: GoStructField[] = []; + for (const [propName, propSchema] of sortByGoFieldName(Object.entries(variant.properties || {}))) { + if (typeof propSchema !== "object") continue; + const prop = propSchema as JSONSchema7; + if (propName === discriminatorProp) { + if (mappedVariant.discriminatorValues.length <= 1) continue; + const goType = resolveGoPropertyType(prop, variantTypeName, propName, true, ctx); + const jsonTag = `json:"${propName},omitempty"`; + lines.push(`\tDiscriminator ${goType} \`${jsonTag}\``); + fields.push({ propName, goName: "Discriminator", goType, jsonTag }); + continue; + } + const goName = toGoFieldName(propName); + const goType = resolveGoPropertyType(prop, variantTypeName, propName, required.has(propName), ctx); + const omit = required.has(propName) ? "" : ",omitempty"; + if (prop.description) { + pushGoCommentForContext(lines, prop.description, ctx, "\t"); + } + if (isSchemaDeprecated(prop)) { + pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); + } + const jsonTag = `json:"${propName}${omit}"`; + lines.push(`\t${goName} ${goType} \`${jsonTag}\``); + fields.push({ propName, goName, goType, jsonTag }); + } + lines.push(`}`); + pushGoStructUnmarshalJSON(lines, variantTypeName, fields, ctx); + lines.push(``); + lines.push(`func (${variantTypeName}) ${markerName}() {}`); + const defaultConstName = `${discEnumName}${goEnumConstSuffix(mappedVariant.discriminatorValues[0])}`; + if (mappedVariant.discriminatorValues.length <= 1) { + lines.push(`func (${variantTypeName}) ${discriminatorMethodName}() ${discEnumName} {`); + lines.push(`\treturn ${defaultConstName}`); + } else { + lines.push(`func (r ${variantTypeName}) ${discriminatorMethodName}() ${discEnumName} {`); + lines.push(`\tif r.Discriminator == "" {`); + lines.push(`\t\treturn ${defaultConstName}`); + lines.push(`\t}`); + lines.push(`\treturn ${discEnumName}(r.Discriminator)`); + } + lines.push(`}`); + pushGoEncodingBlock([ + `func (r ${variantTypeName}) MarshalJSON() ([]byte, error) {`, + `\ttype alias ${variantTypeName}`, + `\treturn json.Marshal(struct {`, + `\t\t${discGoName} ${discEnumName} \`json:"${discriminatorProp}"\``, + `\t\talias`, + `\t}{`, + `\t\t${discGoName}: r.${discriminatorMethodName}(),`, + `\t\talias: alias(r),`, + `\t})`, + `}`, + ], ctx); + } + + ctx.structs.push(lines.join("\n")); +} + +function emitGoRequiredFieldDiscriminatedUnion( + typeName: string, + discriminator: GoRequiredFieldDiscriminatorInfo, + ctx: GoCodegenCtx, + description?: string +): void { + if (ctx.generatedNames.has(typeName)) return; + ctx.generatedNames.add(typeName); + + const unionVariants = [...discriminator.variants].sort((left, right) => compareGoTypeNames(left.typeName, right.typeName)); + const unmarshalFuncName = goUnexportedFunctionName("unmarshal", typeName); + const rawDataName = `Raw${typeName}${ctx.discriminatedUnionRawVariantSuffix ?? "Data"}`; + const markerName = `${typeName.charAt(0).toLowerCase()}${typeName.slice(1)}`; + ctx.discriminatedUnions.set(typeName, { typeName, unmarshalFuncName }); + + const lines: string[] = []; + if (description) { + pushGoCommentForContext(lines, description, ctx); + } + lines.push(`type ${typeName} interface {`); + lines.push(`\t${markerName}()`); + lines.push(`}`); + lines.push(``); + + for (const variant of unionVariants) { + pushGoEncodingBlock(goVariantMatchFunctionLines(variant, unionVariants, "", ctx), ctx); + } + + const unmarshalLines: string[] = []; + unmarshalLines.push(`func ${unmarshalFuncName}(data []byte) (${typeName}, error) {`); + unmarshalLines.push(`\tif string(data) == "null" {`); + unmarshalLines.push(`\t\treturn nil, nil`); + unmarshalLines.push(`\t}`); + for (const variant of unionVariants) { + unmarshalLines.push(`\tif ${goVariantMatchFuncName(variant.typeName)}(data) {`); + unmarshalLines.push(`\t\tvar d ${variant.typeName}`); + unmarshalLines.push(`\t\tif err := json.Unmarshal(data, &d); err != nil {`); + unmarshalLines.push(`\t\t\treturn nil, err`); + unmarshalLines.push(`\t\t}`); + unmarshalLines.push(`\t\treturn &d, nil`); + unmarshalLines.push(`\t}`); + } + unmarshalLines.push(`\treturn &${rawDataName}{Raw: data}, nil`); + unmarshalLines.push(`}`); + pushGoEncodingBlock(unmarshalLines, ctx); + + lines.push(`type ${rawDataName} struct {`); + lines.push(`\tRaw json.RawMessage`); + lines.push(`}`); + lines.push(``); + lines.push(`func (${rawDataName}) ${markerName}() {}`); + pushGoEncodingBlock([ + `func (r ${rawDataName}) MarshalJSON() ([]byte, error) {`, + `\tif r.Raw != nil {`, + `\t\treturn r.Raw, nil`, + `\t}`, + `\treturn []byte("null"), nil`, + `}`, + ], ctx); + + for (const mappedVariant of unionVariants) { + const variant = mappedVariant.schema; + const variantTypeName = mappedVariant.typeName; + if (variant.description) { + pushGoCommentForContext(lines, variant.description, ctx); + } + ctx.generatedNames.add(variantTypeName); + lines.push(`type ${variantTypeName} struct {`); + const required = new Set(variant.required || []); + const fields: GoStructField[] = []; + for (const [propName, propSchema] of sortByGoFieldName(Object.entries(variant.properties || {}))) { + if (typeof propSchema !== "object") continue; + const prop = propSchema as JSONSchema7; + const goName = toGoFieldName(propName); + const goType = resolveGoPropertyType(prop, variantTypeName, propName, required.has(propName), ctx); + const omit = required.has(propName) ? "" : ",omitempty"; + if (prop.description) { + pushGoCommentForContext(lines, prop.description, ctx, "\t"); + } + if (isSchemaDeprecated(prop)) { + pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); + } + const jsonTag = `json:"${propName}${omit}"`; + lines.push(`\t${goName} ${goType} \`${jsonTag}\``); + fields.push({ propName, goName, goType, jsonTag }); + } + lines.push(`}`); + pushGoStructUnmarshalJSON(lines, variantTypeName, fields, ctx); + lines.push(``); + lines.push(`func (${variantTypeName}) ${markerName}() {}`); + lines.push(``); + } + ctx.structs.push(lines.join("\n")); } @@ -823,6 +1770,34 @@ function goNonNullUnionMembers(schema: JSONSchema7): JSONSchema7[] { }) ?? []; } +function collectGoDiscriminatedUnionVariantDefinitionTypeNames( + definitions: Record, + ctx: GoCodegenCtx +): Set { + const definitionTypeNames = new Set(Object.keys(definitions).map((definitionName) => goDefinitionName(definitionName))); + const skipped = new Set(); + + for (const [definitionName, schema] of Object.entries(definitions)) { + const typeName = goDefinitionName(definitionName); + const effectiveSchema = resolveObjectSchema(schema, ctx.definitions) ?? resolveSchema(schema, ctx.definitions) ?? schema; + const unionMembers = goNonNullUnionMembers(effectiveSchema); + if (unionMembers.length === 0) continue; + + const discriminator = findGoDiscriminator(unionMembers, ctx, typeName); + const requiredFieldDiscriminator = discriminator ? undefined : findGoRequiredFieldDiscriminator(unionMembers, ctx, typeName); + const variants = discriminator?.variants ?? requiredFieldDiscriminator?.variants; + if (!variants) continue; + + for (const variant of variants) { + if (definitionTypeNames.has(variant.typeName)) { + skipped.add(variant.typeName); + } + } + } + + return skipped; +} + function resolveGoUnionMember(member: JSONSchema7, definitions: DefinitionCollections | undefined): JSONSchema7 { if (member.$ref) { return resolveRef(member.$ref, definitions) ?? member; @@ -932,6 +1907,8 @@ function emitGoFlattenedObjectUnion( } lines.push(`type ${typeName} struct {`); + const fields: GoStructField[] = []; + for (const [propName, info] of sortByGoFieldName([...allProps.entries()])) { const goName = toGoFieldName(propName); const mergedSchema = mergeGoFlattenedPropertySchema(typeName, propName, info.schemas, ctx); @@ -945,10 +1922,13 @@ function emitGoFlattenedObjectUnion( if (info.schemas.some((schema) => isSchemaDeprecated(schema))) { pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); } - lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); + const jsonTag = `json:"${propName}${omit}"`; + lines.push(`\t${goName} ${goType} \`${jsonTag}\``); + fields.push({ propName, goName, goType, jsonTag }); } lines.push(`}`); + pushGoStructUnmarshalJSON(lines, typeName, fields, ctx); ctx.structs.push(lines.join("\n")); } @@ -994,34 +1974,397 @@ function goPrimitiveUnionFieldName(schema: JSONSchema7): string { function goUnionFieldType(member: JSONSchema7, fieldName: string, parentTypeName: string, ctx: GoCodegenCtx): string { const memberType = resolveGoPropertyType(member, parentTypeName, fieldName, true, ctx); - if (memberType.startsWith("*") || memberType.startsWith("[]") || memberType.startsWith("map[")) { - return memberType; - } - return `*${memberType}`; + return goTypeWithOptionalPointer(memberType, ctx); } -function goUnionFieldMarshalIsSet(fieldName: string, fieldType: string): string { - if (fieldType.startsWith("*") || fieldType.startsWith("[]") || fieldType.startsWith("map[")) { +function goUnionFieldMarshalIsSet(fieldName: string, fieldType: string, ctx: GoCodegenCtx): string { + if (goTypeIsNilable(fieldType, ctx)) { return `r.${fieldName} != nil`; } return "true"; } function goUnionFieldUnmarshalType(fieldType: string): string { - if (fieldType.startsWith("*")) { + if (goTypeIsPointer(fieldType)) { return fieldType.slice(1); } return fieldType; } function goUnionFieldUnmarshalAssignment(typeName: string, fieldName: string, fieldType: string): string { - if (fieldType.startsWith("*")) { + if (goTypeIsPointer(fieldType)) { return `*r = ${typeName}{${fieldName}: &value}`; } return `*r = ${typeName}{${fieldName}: value}`; } +function goPrimitiveSchemaTypeName(schema: JSONSchema7, ctx: GoCodegenCtx): string | undefined { + const resolved = resolveSchema(schema, ctx.definitions) ?? schema; + switch (resolved.type) { + case "boolean": return "Boolean"; + case "integer": return "Integer"; + case "number": return "Number"; + case "string": return "String"; + default: return undefined; + } +} + +function goPrimitiveSchemaGoType(schema: JSONSchema7, ctx: GoCodegenCtx): string | undefined { + const resolved = resolveSchema(schema, ctx.definitions) ?? schema; + switch (resolved.type) { + case "boolean": return "bool"; + case "integer": return "int64"; + case "number": return "float64"; + case "string": return "string"; + default: return undefined; + } +} + +function goPrimitiveUnionValueName(member: JSONSchema7, ctx: GoCodegenCtx): string | undefined { + const resolved = resolveGoUnionMember(member, ctx.definitions); + if (resolved.enum || resolved.const !== undefined) return undefined; + + if (resolved.type === "array") { + const items = resolved.items && typeof resolved.items === "object" && !Array.isArray(resolved.items) + ? resolved.items as JSONSchema7 + : undefined; + if (!items) return undefined; + const itemName = goPrimitiveSchemaTypeName(items, ctx); + return itemName ? `${itemName}Array` : undefined; + } + + return goPrimitiveSchemaTypeName(resolved, ctx); +} + +function goPrimitiveUnionGoType(member: JSONSchema7, ctx: GoCodegenCtx): string | undefined { + const resolved = resolveGoUnionMember(member, ctx.definitions); + if (resolved.enum || resolved.const !== undefined) return undefined; + + if (resolved.type === "array") { + const items = resolved.items && typeof resolved.items === "object" && !Array.isArray(resolved.items) + ? resolved.items as JSONSchema7 + : undefined; + if (!items) return undefined; + const itemType = goPrimitiveSchemaGoType(items, ctx); + return itemType ? `[]${itemType}` : undefined; + } + + return goPrimitiveSchemaGoType(resolved, ctx); +} + +function goPrimitiveUnionVariantTypeName(typeName: string, valueName: string): string { + if (typeName.endsWith("FieldValue")) { + return `${typeName.slice(0, -"FieldValue".length)}${valueName}Value`; + } + if (typeName.endsWith("Value")) { + return `${typeName.slice(0, -"Value".length)}${valueName}Value`; + } + if (typeName.endsWith("Result")) { + return `${typeName.slice(0, -"Result".length)}${valueName}Result`; + } + if (typeName.endsWith("Content")) { + return `${typeName.slice(0, -"Content".length)}${valueName}Content`; + } + return `${typeName}${valueName}`; +} + +function goPrimitiveUnionVariants(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): GoPrimitiveUnionVariant[] | undefined { + const members = goNonNullUnionMembers(schema); + if (members.length === 0) return undefined; + + const variants: GoPrimitiveUnionVariant[] = []; + const seenTypeNames = new Set(); + for (const member of members) { + const valueName = goPrimitiveUnionValueName(member, ctx); + const goType = goPrimitiveUnionGoType(member, ctx); + if (!valueName || !goType) return undefined; + + const variantTypeName = goPrimitiveUnionVariantTypeName(typeName, valueName); + if (seenTypeNames.has(variantTypeName)) return undefined; + seenTypeNames.add(variantTypeName); + variants.push({ + typeName: variantTypeName, + goType, + }); + } + + return variants; +} + +function emitGoPrimitiveUnionInterface(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx, variants?: GoPrimitiveUnionVariant[]): boolean { + if (ctx.generatedNames.has(typeName)) return true; + variants ??= goPrimitiveUnionVariants(typeName, schema, ctx); + if (!variants) return false; + + ctx.generatedNames.add(typeName); + const unmarshalFuncName = goUnexportedFunctionName("unmarshal", typeName); + const markerName = `${typeName.charAt(0).toLowerCase()}${typeName.slice(1)}`; + ctx.discriminatedUnions.set(typeName, { typeName, unmarshalFuncName }); + + const lines: string[] = []; + if (schema.description) { + pushGoCommentForContext(lines, schema.description, ctx); + } + if (isSchemaDeprecated(schema)) { + pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); + } + lines.push(`type ${typeName} interface {`); + lines.push(`\t${markerName}()`); + lines.push(`}`); + + for (const variant of [...variants].sort((left, right) => compareGoTypeNames(left.typeName, right.typeName))) { + lines.push(``); + lines.push(`type ${variant.typeName} ${variant.goType}`); + lines.push(``); + lines.push(`func (${variant.typeName}) ${markerName}() {}`); + } + + const unmarshalLines: string[] = []; + unmarshalLines.push(`func ${unmarshalFuncName}(data []byte) (${typeName}, error) {`); + unmarshalLines.push(`\tif string(data) == "null" {`); + unmarshalLines.push(`\t\treturn nil, nil`); + unmarshalLines.push(`\t}`); + for (const variant of variants) { + unmarshalLines.push(`\t{`); + unmarshalLines.push(`\t\tvar value ${variant.goType}`); + unmarshalLines.push(`\t\tif err := json.Unmarshal(data, &value); err == nil {`); + unmarshalLines.push(`\t\t\treturn ${variant.typeName}(value), nil`); + unmarshalLines.push(`\t\t}`); + unmarshalLines.push(`\t}`); + } + unmarshalLines.push(`\treturn nil, errors.New("data did not match any union variant for ${typeName}")`); + unmarshalLines.push(`}`); + pushGoEncodingBlock(unmarshalLines, ctx); + + ctx.structs.push(lines.join("\n")); + return true; +} + +function goSchemaJSONKind(schema: JSONSchema7, ctx: GoCodegenCtx): string | undefined { + const resolved = resolveGoUnionMember(schema, ctx.definitions); + if (resolved.const !== undefined) { + return goSchemaJSONKind(schemaForConstValue(resolved.const), ctx); + } + + if (Array.isArray(resolved.type)) { + const nonNullTypes = resolved.type.filter((type) => type !== "null"); + if (nonNullTypes.length === 1) { + return goSchemaJSONKind({ ...resolved, type: nonNullTypes[0] } as JSONSchema7, ctx); + } + return undefined; + } + + if (goObjectUnionMemberSchema(schema, ctx)) return "object"; + + switch (resolved.type) { + case "array": return "array"; + case "boolean": return "boolean"; + case "integer": + case "number": return "number"; + case "object": return "object"; + case "string": return "string"; + default: return undefined; + } +} + +function goUntaggedUnionVariant(typeName: string, member: JSONSchema7, ctx: GoCodegenCtx): GoUntaggedUnionVariant | undefined { + const jsonKind = goSchemaJSONKind(member, ctx); + if (!jsonKind) return undefined; + + const resolved = resolveGoUnionMember(member, ctx.definitions); + if (member.$ref && typeof member.$ref === "string") { + const definitionName = refTypeName(member.$ref, ctx.definitions); + const variantTypeName = goDefinitionName(definitionName); + emitGoRpcDefinition(definitionName, resolved, ctx); + return { + typeName: variantTypeName, + goType: variantTypeName, + jsonKind, + returnExpr: goObjectUnionMemberSchema(member, ctx) ? "&value" : "value", + }; + } + + if (resolved.enum && Array.isArray(resolved.enum)) { + const enumType = getOrCreateGoEnum((resolved.title as string) || `${typeName}Enum`, resolved.enum as string[], ctx, resolved.description, isSchemaDeprecated(resolved)); + return { typeName: enumType, goType: enumType, jsonKind, returnExpr: "value" }; + } + + const primitiveValueName = goPrimitiveUnionValueName(member, ctx); + const primitiveGoType = goPrimitiveUnionGoType(member, ctx); + if (primitiveValueName && primitiveGoType) { + const variantTypeName = goPrimitiveUnionVariantTypeName(typeName, primitiveValueName); + return { + typeName: variantTypeName, + goType: primitiveGoType, + jsonKind, + typeDefinition: `type ${variantTypeName} ${primitiveGoType}`, + returnExpr: `${variantTypeName}(value)`, + }; + } + + if (jsonKind === "object" && resolved.type === "object" && resolved.additionalProperties && !resolved.properties) { + const fieldName = goUnionFieldName(resolved, ctx); + const variantTypeName = `${typeName}${fieldName}`; + const goType = resolveGoPropertyType(resolved, typeName, fieldName, true, ctx); + if (!goTypeIsMap(goType)) return undefined; + return { + typeName: variantTypeName, + goType: variantTypeName, + jsonKind, + typeDefinition: `type ${variantTypeName} ${goType}`, + returnExpr: "value", + }; + } + + if (jsonKind === "object" && (resolved.properties || resolved.additionalProperties === false)) { + const variantTypeName = (resolved.title as string) || `${typeName}Object`; + emitGoStruct(variantTypeName, resolved, ctx); + return { typeName: variantTypeName, goType: variantTypeName, jsonKind, returnExpr: "&value" }; + } + + return undefined; +} + +function goUntaggedUnionVariants(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): GoUntaggedUnionVariant[] | undefined { + const members = goNonNullUnionMembers(schema); + if (members.length === 0) return undefined; + + const variants: GoUntaggedUnionVariant[] = []; + const seenKinds = new Set(); + const seenTypeNames = new Set(); + for (const member of members) { + const variant = goUntaggedUnionVariant(typeName, member, ctx); + if (!variant) return undefined; + if (seenKinds.has(variant.jsonKind) || seenTypeNames.has(variant.typeName)) return undefined; + seenKinds.add(variant.jsonKind); + seenTypeNames.add(variant.typeName); + variants.push(variant); + } + + return variants; +} + +function emitGoUntaggedUnionInterface(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx, variants?: GoUntaggedUnionVariant[]): boolean { + if (ctx.generatedNames.has(typeName)) return true; + variants ??= goUntaggedUnionVariants(typeName, schema, ctx); + if (!variants) return false; + + ctx.generatedNames.add(typeName); + const unmarshalFuncName = goUnexportedFunctionName("unmarshal", typeName); + const markerName = `${typeName.charAt(0).toLowerCase()}${typeName.slice(1)}`; + ctx.discriminatedUnions.set(typeName, { typeName, unmarshalFuncName }); + + const lines: string[] = []; + if (schema.description) { + pushGoCommentForContext(lines, schema.description, ctx); + } + if (isSchemaDeprecated(schema)) { + pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); + } + lines.push(`type ${typeName} interface {`); + lines.push(`\t${markerName}()`); + lines.push(`}`); + + for (const variant of [...variants].sort((left, right) => compareGoTypeNames(left.typeName, right.typeName))) { + lines.push(``); + if (variant.typeDefinition) { + lines.push(variant.typeDefinition); + lines.push(``); + } + lines.push(`func (${variant.typeName}) ${markerName}() {}`); + } + + const unmarshalLines: string[] = []; + unmarshalLines.push(`func ${unmarshalFuncName}(data []byte) (${typeName}, error) {`); + unmarshalLines.push(`\tif string(data) == "null" {`); + unmarshalLines.push(`\t\treturn nil, nil`); + unmarshalLines.push(`\t}`); + for (const variant of variants) { + unmarshalLines.push(`\t{`); + unmarshalLines.push(`\t\tvar value ${variant.goType}`); + unmarshalLines.push(`\t\tif err := json.Unmarshal(data, &value); err == nil {`); + unmarshalLines.push(`\t\t\treturn ${variant.returnExpr}, nil`); + unmarshalLines.push(`\t\t}`); + unmarshalLines.push(`\t}`); + } + unmarshalLines.push(`\treturn nil, errors.New("data did not match any union variant for ${typeName}")`); + unmarshalLines.push(`}`); + pushGoEncodingBlock(unmarshalLines, ctx); + + ctx.structs.push(lines.join("\n")); + return true; +} + +function planGoUnion(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx, includeWrapper: boolean = false): GoUnionPlan | undefined { + const members = goNonNullUnionMembers(schema); + if (members.length === 0) return undefined; + + const description = (schema as JSONSchema7).description; + const discriminator = findGoDiscriminator(members, ctx, typeName); + if (discriminator) { + return { kind: "discriminated", typeName, schema, description, discriminator }; + } + + const primitiveVariants = goPrimitiveUnionVariants(typeName, schema, ctx); + if (primitiveVariants) { + return { kind: "primitive", typeName, schema, description, variants: primitiveVariants }; + } + + const requiredFieldDiscriminator = findGoRequiredFieldDiscriminator(members, ctx, typeName); + if (requiredFieldDiscriminator) { + return { kind: "requiredFieldDiscriminated", typeName, schema, description, discriminator: requiredFieldDiscriminator }; + } + + const resolvedVariants = members.map((member) => resolveGoUnionMember(member, ctx.definitions)); + if (canFlattenGoObjectUnion(resolvedVariants, ctx)) { + return { kind: "flattenedObject", typeName, schema, description, variants: resolvedVariants }; + } + + const untaggedVariants = goUntaggedUnionVariants(typeName, schema, ctx); + if (untaggedVariants) { + return { kind: "untagged", typeName, schema, description, variants: untaggedVariants }; + } + + return includeWrapper ? { kind: "wrapper", typeName, schema, description } : undefined; +} + +function emitGoUnionPlan(plan: GoUnionPlan, ctx: GoCodegenCtx): void { + switch (plan.kind) { + case "discriminated": + emitGoFlatDiscriminatedUnion(plan.typeName, plan.discriminator, ctx, plan.description); + return; + case "requiredFieldDiscriminated": + emitGoRequiredFieldDiscriminatedUnion(plan.typeName, plan.discriminator, ctx, plan.description); + return; + case "primitive": + emitGoPrimitiveUnionInterface(plan.typeName, plan.schema, ctx, plan.variants); + return; + case "flattenedObject": + emitGoFlattenedObjectUnion(plan.typeName, plan.variants, ctx, plan.description); + return; + case "untagged": + emitGoUntaggedUnionInterface(plan.typeName, plan.schema, ctx, plan.variants); + return; + case "wrapper": + emitGoUnionWrapperStruct(plan.typeName, plan.schema, ctx); + return; + } +} + +function goUnionPlanPropertyType(plan: GoUnionPlan, isRequired: boolean, hasNull: boolean): string { + if (plan.kind === "flattenedObject" || plan.kind === "wrapper") { + return isRequired && !hasNull ? plan.typeName : `*${plan.typeName}`; + } + return plan.typeName; +} + function emitGoUnionStruct(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): void { + if (ctx.generatedNames.has(typeName)) return; + const plan = planGoUnion(typeName, schema, ctx, true); + if (plan) emitGoUnionPlan(plan, ctx); +} + +function emitGoUnionWrapperStruct(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): void { if (ctx.generatedNames.has(typeName)) return; ctx.generatedNames.add(typeName); @@ -1055,32 +2398,33 @@ function emitGoUnionStruct(typeName: string, schema: JSONSchema7, ctx: GoCodegen } lines.push(`}`); - lines.push(``); - lines.push(`func (r ${typeName}) MarshalJSON() ([]byte, error) {`); + const encodingLines: string[] = []; + encodingLines.push(`func (r ${typeName}) MarshalJSON() ([]byte, error) {`); for (const field of fields) { - lines.push(`\tif ${goUnionFieldMarshalIsSet(field.name, field.type)} {`); - lines.push(`\t\treturn json.Marshal(r.${field.name})`); - lines.push(`\t}`); - } - lines.push(`\treturn []byte("null"), nil`); - lines.push(`}`); - lines.push(``); - lines.push(`func (r *${typeName}) UnmarshalJSON(data []byte) error {`); - lines.push(`\tif string(data) == "null" {`); - lines.push(`\t\t*r = ${typeName}{}`); - lines.push(`\t\treturn nil`); - lines.push(`\t}`); + encodingLines.push(`\tif ${goUnionFieldMarshalIsSet(field.name, field.type, ctx)} {`); + encodingLines.push(`\t\treturn json.Marshal(r.${field.name})`); + encodingLines.push(`\t}`); + } + encodingLines.push(`\treturn []byte("null"), nil`); + encodingLines.push(`}`); + encodingLines.push(``); + encodingLines.push(`func (r *${typeName}) UnmarshalJSON(data []byte) error {`); + encodingLines.push(`\tif string(data) == "null" {`); + encodingLines.push(`\t\t*r = ${typeName}{}`); + encodingLines.push(`\t\treturn nil`); + encodingLines.push(`\t}`); for (const field of fields) { - lines.push(`\t{`); - lines.push(`\t\tvar value ${goUnionFieldUnmarshalType(field.type)}`); - lines.push(`\t\tif err := json.Unmarshal(data, &value); err == nil {`); - lines.push(`\t\t\t${goUnionFieldUnmarshalAssignment(typeName, field.name, field.type)}`); - lines.push(`\t\t\treturn nil`); - lines.push(`\t\t}`); - lines.push(`\t}`); - } - lines.push(`\treturn errors.New("data did not match any union variant for ${typeName}")`); - lines.push(`}`); + encodingLines.push(`\t{`); + encodingLines.push(`\t\tvar value ${goUnionFieldUnmarshalType(field.type)}`); + encodingLines.push(`\t\tif err := json.Unmarshal(data, &value); err == nil {`); + encodingLines.push(`\t\t\t${goUnionFieldUnmarshalAssignment(typeName, field.name, field.type)}`); + encodingLines.push(`\t\t\treturn nil`); + encodingLines.push(`\t\t}`); + encodingLines.push(`\t}`); + } + encodingLines.push(`\treturn errors.New("data did not match any union variant for ${typeName}")`); + encodingLines.push(`}`); + pushGoEncodingBlock(encodingLines, ctx); ctx.structs.push(lines.join("\n")); } @@ -1115,15 +2459,8 @@ function emitGoRpcDefinition(definitionName: string, schema: JSONSchema7, ctx: G const unionMembers = goNonNullUnionMembers(effectiveSchema); if (unionMembers.length > 0) { - const resolvedVariants = unionMembers.map((member) => resolveGoUnionMember(member, ctx.definitions)); - const discriminator = findGoDiscriminator(resolvedVariants); - if (discriminator) { - emitGoFlatDiscriminatedUnion(typeName, discriminator.property, discriminator.mapping, ctx, (effectiveSchema as JSONSchema7).description); - } else if (canFlattenGoObjectUnion(resolvedVariants, ctx)) { - emitGoFlattenedObjectUnion(typeName, resolvedVariants, ctx, (effectiveSchema as JSONSchema7).description); - } else { - emitGoUnionStruct(typeName, effectiveSchema, ctx); - } + const plan = planGoUnion(typeName, effectiveSchema, ctx, true); + if (plan) emitGoUnionPlan(plan, ctx); return typeName; } @@ -1131,20 +2468,81 @@ function emitGoRpcDefinition(definitionName: string, schema: JSONSchema7, ctx: G return typeName; } -function generateGoRpcTypeCode(definitions: Record, definitionCollections: DefinitionCollections): string { +interface GoGeneratedTypeCode { + typeCode: string; + encodingCode: string; +} + +function stripTrailingGoWhitespace(code: string): string { + return code.replace(/[ \t]+$/gm, ""); +} + +function pushGoCodeBlocks(lines: string[], blocks: Iterable): void { + for (const block of blocks) { + lines.push(block); + lines.push(``); + } +} + +function sortedGoDeclaredTypeBlocks(blocks: string[]): string[] { + return [...blocks].sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right))); +} + +function joinGoCode(lines: string[]): string { + return lines.join("\n").replace(/\n+$/, ""); +} + +function goEncodingBlocksCode(blocks: string[] | undefined): string { + const lines: string[] = []; + pushGoCodeBlocks(lines, blocks ?? []); + return joinGoCode(lines); +} + +function goGeneratedEncodingFileCode(schemaFileName: string, packageName: string, generatedEncodingCode: string, wrapComments = false): string { + const lines: string[] = []; + lines.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); + lines.push(`// Generated from: ${schemaFileName}`); + lines.push(``); + lines.push(`package ${packageName}`); + lines.push(``); + + const imports = [`"encoding/json"`]; + if (generatedEncodingCode.includes("errors.")) { + imports.push(`"errors"`); + } + if (generatedEncodingCode.includes("time.Time")) { + imports.push(`"time"`); + } + lines.push(`import (`); + for (const imp of imports) { + lines.push(`\t${imp}`); + } + lines.push(`)`); + lines.push(``); + lines.push(generatedEncodingCode); + + const code = lines.join("\n"); + return wrapComments ? wrapGeneratedGoComments(code) : code; +} + +function generateGoRpcTypeCode(definitions: Record, definitionCollections: DefinitionCollections): GoGeneratedTypeCode { const ctx: GoCodegenCtx = { structs: [], + encoding: [], enums: [], enumsByName: new Map(), + discriminatedUnions: new Map(), generatedNames: new Set(), definitions: definitionCollections, }; + ctx.skipDefinitionTypeNames = collectGoDiscriminatedUnionVariantDefinitionTypeNames(definitions, ctx); const schemaKeysByTypeName = new Map(); const entries = Object.entries(definitions) .sort(([left], [right]) => goDefinitionName(left).localeCompare(goDefinitionName(right))); for (const [definitionName, definition] of entries) { const typeName = goDefinitionName(definitionName); + if (ctx.skipDefinitionTypeNames.has(typeName)) continue; const schemaKey = stableStringify(resolveSchema(definition, definitionCollections) ?? definition); const existingSchemaKey = schemaKeysByTypeName.get(typeName); if (existingSchemaKey && existingSchemaKey !== schemaKey) { @@ -1155,16 +2553,13 @@ function generateGoRpcTypeCode(definitions: Record, definit } const lines: string[] = []; - for (const typeCode of ctx.structs.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { - lines.push(typeCode); - lines.push(``); - } - for (const typeCode of ctx.enums.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { - lines.push(typeCode); - lines.push(``); - } + pushGoCodeBlocks(lines, sortedGoDeclaredTypeBlocks(ctx.structs)); + pushGoCodeBlocks(lines, sortedGoDeclaredTypeBlocks(ctx.enums)); - return lines.join("\n").replace(/\n+$/, ""); + return { + typeCode: joinGoCode(lines), + encodingCode: goEncodingBlocksCode(ctx.encoding), + }; } function goDeclaredTypeName(code: string): string { @@ -1174,15 +2569,18 @@ function goDeclaredTypeName(code: string): string { /** * Generate the complete Go session-events file content. */ -function generateGoSessionEventsCode(schema: JSONSchema7): string { +function generateGoSessionEventsCode(schema: JSONSchema7): GoGeneratedTypeCode { const variants = extractGoEventVariants(schema); const ctx: GoCodegenCtx = { structs: [], + encoding: [], enums: [], enumsByName: new Map(), + discriminatedUnions: new Map(), generatedNames: new Set(), definitions: collectDefinitionCollections(schema as Record), wrapComments: false, + discriminatedUnionRawVariantSuffix: "", }; const envelopeProperties = getGoSharedEventEnvelopeProperties(schema, ctx); const sessionEventStructFields = [ @@ -1197,13 +2595,6 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { `\tData SessionEventData \`json:"-"\``, ], }, - { - fieldName: "Type", - lines: [ - ...goCommentLines("The event type discriminator.", "\t", ctx.wrapComments !== false), - `\tType SessionEventType \`json:"type"\``, - ], - }, ].sort((left, right) => compareGoFieldNames(left.fieldName, right.fieldName)); const rawEventUnmarshalFields = [ ...envelopeProperties.map((property) => ({ @@ -1235,6 +2626,8 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { } lines.push(`type ${variant.dataClassName} struct {`); + const fields: GoStructField[] = []; + for (const [propName, propSchema] of sortByGoFieldName(Object.entries(variant.dataSchema.properties || {}))) { if (typeof propSchema !== "object") continue; const prop = propSchema as JSONSchema7; @@ -1249,12 +2642,24 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { if (isSchemaDeprecated(prop)) { pushGoCommentForContext(lines, `Deprecated: ${goName} is deprecated.`, ctx, "\t"); } - lines.push(`\t${goName} ${goType} \`json:"${propName}${omit}"\``); + const jsonTag = `json:"${propName}${omit}"`; + lines.push(`\t${goName} ${goType} \`${jsonTag}\``); + fields.push({ propName, goName, goType, jsonTag }); } lines.push(`}`); + pushGoStructUnmarshalJSON(lines, variant.dataClassName, fields, ctx); lines.push(``); + const constName = "SessionEventType" + variant.typeName + .split(/[._]/) + .map((w) => + goInitialisms.has(w.toLowerCase()) + ? w.toUpperCase() + : w.charAt(0).toUpperCase() + w.slice(1) + ) + .join(""); lines.push(`func (*${variant.dataClassName}) sessionEventData() {}`); + lines.push(`func (*${variant.dataClassName}) Type() SessionEventType { return ${constName} }`); dataStructs.push(lines.join("\n")); } @@ -1283,6 +2688,8 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { } eventTypeEnum.push(`)`); + const sessionEncoding: string[] = []; + // Assemble file const out: string[] = []; out.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); @@ -1293,7 +2700,6 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { // Imports — time is always needed for SessionEvent.Timestamp out.push(`import (`); - out.push(`\t"errors"`); out.push(`\t"encoding/json"`); out.push(`\t"time"`); out.push(`)`); @@ -1303,21 +2709,10 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { out.push(`// SessionEventData is the interface implemented by all per-event data types.`); out.push(`type SessionEventData interface {`); out.push(`\tsessionEventData()`); + out.push(`\tType() SessionEventType`); out.push(`}`); out.push(``); - // RawSessionEventData for unknown event types - out.push(`// RawSessionEventData holds unparsed JSON data for unrecognized event types.`); - out.push(`type RawSessionEventData struct {`); - out.push(`\tRaw json.RawMessage`); - out.push(`}`); - out.push(``); - out.push(`func (RawSessionEventData) sessionEventData() {}`); - out.push(``); - out.push(`// MarshalJSON returns the original raw JSON so round-tripping preserves the payload.`); - out.push(`func (r RawSessionEventData) MarshalJSON() ([]byte, error) { return r.Raw, nil }`); - out.push(``); - // SessionEvent struct out.push(`// SessionEvent represents a single session event with a typed data payload.`); out.push(`type SessionEvent struct {`); @@ -1327,41 +2722,13 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { out.push(`}`); out.push(``); - // UnmarshalSessionEvent - out.push(`// UnmarshalSessionEvent parses JSON bytes into a SessionEvent.`); - out.push(`func UnmarshalSessionEvent(data []byte) (SessionEvent, error) {`); - out.push(`\tvar r SessionEvent`); - out.push(`\terr := json.Unmarshal(data, &r)`); - out.push(`\treturn r, err`); - out.push(`}`); - out.push(``); - // Marshal - out.push(`// Marshal serializes the SessionEvent to JSON.`); - out.push(`func (r *SessionEvent) Marshal() ([]byte, error) {`); - out.push(`\treturn json.Marshal(r)`); - out.push(`}`); - out.push(``); + sessionEncoding.push(`// Marshal serializes the SessionEvent to JSON.`); + sessionEncoding.push(`func (r *SessionEvent) Marshal() ([]byte, error) {`); + sessionEncoding.push(`\treturn json.Marshal(r)`); + sessionEncoding.push(`}`); + sessionEncoding.push(``); - // Custom UnmarshalJSON - out.push(`func (e *SessionEvent) UnmarshalJSON(data []byte) error {`); - out.push(`\ttype rawEvent struct {`); - for (const field of rawEventUnmarshalFields) { - for (const line of field.lines) { - out.push(`\t${line}`); - } - } - out.push(`\t}`); - out.push(`\tvar raw rawEvent`); - out.push(`\tif err := json.Unmarshal(data, &raw); err != nil {`); - out.push(`\t\treturn err`); - out.push(`\t}`); - for (const property of sortedGoEventEnvelopeProperties(envelopeProperties)) { - out.push(`\te.${property.fieldName} = raw.${property.fieldName}`); - } - out.push(`\te.Type = raw.Type`); - out.push(``); - out.push(`\tswitch raw.Type {`); const eventCases = variants .map((variant) => ({ constName: "SessionEventType" + variant.typeName @@ -1375,42 +2742,92 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { dataClassName: variant.dataClassName, })) .sort((left, right) => left.constName.localeCompare(right.constName)); - for (const { constName, dataClassName } of eventCases) { - out.push(`\tcase ${constName}:`); - out.push(`\t\tvar d ${dataClassName}`); - out.push(`\t\tif err := json.Unmarshal(raw.Data, &d); err != nil {`); - out.push(`\t\t\treturn err`); - out.push(`\t\t}`); - out.push(`\t\te.Data = &d`); - } - out.push(`\tdefault:`); - out.push(`\t\te.Data = &RawSessionEventData{Raw: raw.Data}`); + + // Type method + out.push(`// Type returns the event type discriminator derived from Data.`); + out.push(`func (e SessionEvent) Type() SessionEventType {`); + out.push(`\tif e.Data == nil {`); + out.push(`\t\treturn ""`); out.push(`\t}`); - out.push(`\treturn nil`); + out.push(`\treturn e.Data.Type()`); out.push(`}`); out.push(``); + // Custom UnmarshalJSON + sessionEncoding.push(`func (e *SessionEvent) UnmarshalJSON(data []byte) error {`); + sessionEncoding.push(`\ttype rawEvent struct {`); + for (const field of rawEventUnmarshalFields) { + for (const line of field.lines) { + sessionEncoding.push(`\t${line}`); + } + } + sessionEncoding.push(`\t}`); + sessionEncoding.push(`\tvar raw rawEvent`); + sessionEncoding.push(`\tif err := json.Unmarshal(data, &raw); err != nil {`); + sessionEncoding.push(`\t\treturn err`); + sessionEncoding.push(`\t}`); + for (const property of sortedGoEventEnvelopeProperties(envelopeProperties)) { + sessionEncoding.push(`\te.${property.fieldName} = raw.${property.fieldName}`); + } + sessionEncoding.push(``); + sessionEncoding.push(`\tswitch raw.Type {`); + for (const { constName, dataClassName } of eventCases) { + sessionEncoding.push(`\tcase ${constName}:`); + sessionEncoding.push(`\t\tvar d ${dataClassName}`); + sessionEncoding.push(`\t\tif err := json.Unmarshal(raw.Data, &d); err != nil {`); + sessionEncoding.push(`\t\t\treturn err`); + sessionEncoding.push(`\t\t}`); + sessionEncoding.push(`\t\te.Data = &d`); + } + sessionEncoding.push(`\tdefault:`); + sessionEncoding.push(`\t\te.Data = &RawSessionEventData{EventType: raw.Type, Raw: raw.Data}`); + sessionEncoding.push(`\t}`); + sessionEncoding.push(`\treturn nil`); + sessionEncoding.push(`}`); + sessionEncoding.push(``); + // Custom MarshalJSON - out.push(`func (e SessionEvent) MarshalJSON() ([]byte, error) {`); - out.push(`\ttype rawEvent struct {`); + sessionEncoding.push(`func (e SessionEvent) MarshalJSON() ([]byte, error) {`); + sessionEncoding.push(`\ttype rawEvent struct {`); for (const field of rawEventMarshalFields) { for (const line of field.lines) { - out.push(`\t${line}`); + sessionEncoding.push(`\t${line}`); } } - out.push(`\t}`); - out.push(`\treturn json.Marshal(rawEvent{`); + sessionEncoding.push(`\t}`); + sessionEncoding.push(`\treturn json.Marshal(rawEvent{`); const rawEventValues = [ ...envelopeProperties.map((property) => property.fieldName), "Data", - "Type", ].sort(compareGoFieldNames); for (const fieldName of rawEventValues) { - out.push(`\t\t${fieldName}: e.${fieldName},`); + sessionEncoding.push(`\t\t${fieldName}: e.${fieldName},`); } - out.push(`\t})`); + sessionEncoding.push(`\t\tType: e.Type(),`); + sessionEncoding.push(`\t})`); + sessionEncoding.push(`}`); + sessionEncoding.push(``); + + // RawSessionEventData for unknown event types + out.push(`// RawSessionEventData holds unparsed JSON data for unrecognized event types.`); + out.push(`type RawSessionEventData struct {`); + out.push(`\tEventType SessionEventType`); + out.push(`\tRaw json.RawMessage`); out.push(`}`); out.push(``); + out.push(`func (RawSessionEventData) sessionEventData() {}`); + out.push(`func (r RawSessionEventData) Type() SessionEventType {`); + out.push(`\treturn r.EventType`); + out.push(`}`); + + sessionEncoding.push(`// MarshalJSON returns the original raw JSON so round-tripping preserves the payload.`); + sessionEncoding.push(`func (r RawSessionEventData) MarshalJSON() ([]byte, error) {`); + sessionEncoding.push(`\tif r.Raw == nil {`); + sessionEncoding.push(`\t\treturn []byte("null"), nil`); + sessionEncoding.push(`\t}`); + sessionEncoding.push(`\treturn r.Raw, nil`); + sessionEncoding.push(`}`); + sessionEncoding.push(``); // Event type enum out.push(eventTypeEnum.join("\n")); @@ -1423,16 +2840,10 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { } // Nested structs - for (const s of ctx.structs.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { - out.push(s); - out.push(``); - } + pushGoCodeBlocks(out, sortedGoDeclaredTypeBlocks(ctx.structs)); // Enums - for (const e of ctx.enums.sort((left, right) => goDeclaredTypeName(left).localeCompare(goDeclaredTypeName(right)))) { - out.push(e); - out.push(``); - } + pushGoCodeBlocks(out, sortedGoDeclaredTypeBlocks(ctx.enums)); // Type aliases for types referenced by non-generated SDK code under their short names. const TYPE_ALIASES: Record = { @@ -1463,7 +2874,14 @@ function generateGoSessionEventsCode(schema: JSONSchema7): string { out.push(`)`); out.push(``); - return out.join("\n"); + const encodingOut: string[] = [...sessionEncoding]; + if (encodingOut.length > 0) encodingOut.push(""); + pushGoCodeBlocks(encodingOut, ctx.encoding ?? []); + + return { + typeCode: joinGoCode(out), + encodingCode: joinGoCode(encodingOut), + }; } async function generateSessionEvents(schemaPath?: string): Promise { @@ -1473,12 +2891,19 @@ async function generateSessionEvents(schemaPath?: string): Promise { const schema = cloneSchemaForCodegen(JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7); const processed = postProcessSchema(schema); - const code = generateGoSessionEventsCode(processed); + const generatedSessionCode = generateGoSessionEventsCode(processed); + const generatedTypeCode = stripTrailingGoWhitespace(generatedSessionCode.typeCode); + const generatedEncodingCode = stripTrailingGoWhitespace(generatedSessionCode.encodingCode); - const outPath = await writeGeneratedFile("go/generated_session_events.go", code); + const outPath = await writeGeneratedFile("go/generated_session_events.go", generatedTypeCode); console.log(` ✓ ${outPath}`); await formatGoFile(outPath); + + const encodingOutPath = await writeGeneratedFile("go/zsession_encoding.go", goGeneratedEncodingFileCode("session-events.schema.json", "copilot", generatedEncodingCode)); + console.log(` ✓ ${encodingOutPath}`); + + await formatGoFile(encodingOutPath); } // ── RPC Types ─────────────────────────────────────────────────────────────── @@ -1559,9 +2984,10 @@ async function generateRpc(schemaPath?: string): Promise { }; rpcDefinitions = allDefinitionCollections; - let generatedTypeCode = generateGoRpcTypeCode(allDefinitions, allDefinitionCollections); // Strip trailing whitespace from generated output (gofmt requirement) - generatedTypeCode = generatedTypeCode.replace(/[ \t]+$/gm, ""); + const generatedRpcCode = generateGoRpcTypeCode(allDefinitions, allDefinitionCollections); + let generatedTypeCode = stripTrailingGoWhitespace(generatedRpcCode.typeCode); + const generatedEncodingCode = stripTrailingGoWhitespace(generatedRpcCode.encodingCode); // Extract generated type names. Some may differ from toPascalCase due explicit schema titles. const actualTypeNames = new Map(); @@ -1572,8 +2998,8 @@ async function generateRpc(schemaPath?: string): Promise { } const resolveType = (name: string): string => actualTypeNames.get(name.toLowerCase()) ?? name; - // Extract field name mappings so wrappers use the emitted Go field names. - const fieldNames = extractFieldNames(generatedTypeCode); + // Extract field metadata so wrappers use emitted Go names and nil semantics. + const fields = extractFields(generatedTypeCode); // Annotate experimental data types const experimentalTypeNames = new Set(); @@ -1659,17 +3085,17 @@ async function generateRpc(schemaPath?: string): Promise { // Emit ServerRpc if (schema.server) { const publicNode = filterNodeByVisibility(schema.server, "public"); - if (publicNode) emitRpcWrapper(lines, publicNode, false, resolveType, fieldNames, ""); + if (publicNode) emitRpcWrapper(lines, publicNode, false, resolveType, fields, ""); const internalNode = filterNodeByVisibility(schema.server, "internal"); - if (internalNode) emitRpcWrapper(lines, internalNode, false, resolveType, fieldNames, "Internal"); + if (internalNode) emitRpcWrapper(lines, internalNode, false, resolveType, fields, "Internal"); } // Emit SessionRpc if (schema.session) { const publicNode = filterNodeByVisibility(schema.session, "public"); - if (publicNode) emitRpcWrapper(lines, publicNode, true, resolveType, fieldNames, ""); + if (publicNode) emitRpcWrapper(lines, publicNode, true, resolveType, fields, ""); const internalNode = filterNodeByVisibility(schema.session, "internal"); - if (internalNode) emitRpcWrapper(lines, internalNode, true, resolveType, fieldNames, "Internal"); + if (internalNode) emitRpcWrapper(lines, internalNode, true, resolveType, fields, "Internal"); } if (schema.clientSession) { @@ -1680,6 +3106,11 @@ async function generateRpc(schemaPath?: string): Promise { console.log(` ✓ ${outPath}`); await formatGoFile(outPath); + + const encodingOutPath = await writeGeneratedFile("go/rpc/zrpc_encoding.go", goGeneratedEncodingFileCode("api.schema.json", "rpc", generatedEncodingCode, true)); + console.log(` ✓ ${encodingOutPath}`); + + await formatGoFile(encodingOutPath); } function emitApiGroup( @@ -1689,7 +3120,7 @@ function emitApiGroup( isSession: boolean, serviceName: string, resolveType: (name: string) => string, - fieldNames: Map>, + fields: Map>, groupExperimental: boolean, groupDeprecated: boolean = false ): void { @@ -1707,14 +3138,14 @@ function emitApiGroup( for (const [key, value] of methods) { if (!isRpcMethod(value)) continue; - emitMethod(lines, apiName, key, value, isSession, resolveType, fieldNames, groupExperimental, false, groupDeprecated); + emitMethod(lines, apiName, key, value, isSession, resolveType, fields, groupExperimental, false, groupDeprecated); } for (const [subGroupName, subGroupNode] of subGroups) { const subApiName = apiName.replace(/Api$/, "") + toPascalCase(subGroupName) + "Api"; const subGroupExperimental = isNodeFullyExperimental(subGroupNode as Record); const subGroupDeprecated = isNodeFullyDeprecated(subGroupNode as Record); - emitApiGroup(lines, subApiName, subGroupNode as Record, isSession, serviceName, resolveType, fieldNames, subGroupExperimental, subGroupDeprecated); + emitApiGroup(lines, subApiName, subGroupNode as Record, isSession, serviceName, resolveType, fields, subGroupExperimental, subGroupDeprecated); if (subGroupExperimental) { pushGoComment(lines, `Experimental: ${toPascalCase(subGroupName)} returns experimental APIs that may change or be removed.`); @@ -1726,7 +3157,7 @@ function emitApiGroup( } } -function emitRpcWrapper(lines: string[], node: Record, isSession: boolean, resolveType: (name: string) => string, fieldNames: Map>, classPrefix: string = ""): void { +function emitRpcWrapper(lines: string[], node: Record, isSession: boolean, resolveType: (name: string) => string, fields: Map>, classPrefix: string = ""): void { const groups = sortByPascalName(Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v))); const topLevelMethods = sortByPascalName(Object.entries(node).filter(([, v]) => isRpcMethod(v))); @@ -1751,12 +3182,12 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio const apiName = prefix + toPascalCase(groupName) + apiSuffix; const groupExperimental = isNodeFullyExperimental(groupNode as Record); const groupDeprecated = isNodeFullyDeprecated(groupNode as Record); - emitApiGroup(lines, apiName, groupNode as Record, isSession, serviceName, resolveType, fieldNames, groupExperimental, groupDeprecated); + emitApiGroup(lines, apiName, groupNode as Record, isSession, serviceName, resolveType, fields, groupExperimental, groupDeprecated); } // Compute field name lengths for gofmt-compatible column alignment const groupPascalNames = groups.map(([g]) => toPascalCase(g)); - const allFieldNames = isSession ? ["common", ...groupPascalNames] : ["common", ...groupPascalNames]; + const allFieldNames = ["common", ...groupPascalNames]; const maxFieldLen = Math.max(...allFieldNames.map((n) => n.length)); const pad = (name: string) => name.padEnd(maxFieldLen); @@ -1781,7 +3212,7 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio // Top-level methods on the wrapper use the common service fields for (const [key, value] of topLevelMethods) { if (!isRpcMethod(value)) continue; - emitMethod(lines, wrapperName, key, value, isSession, resolveType, fieldNames, false, true); + emitMethod(lines, wrapperName, key, value, isSession, resolveType, fields, false, true); } // Constructor @@ -1802,7 +3233,7 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(``); } -function emitMethod(lines: string[], receiver: string, name: string, method: RpcMethod, isSession: boolean, resolveType: (name: string) => string, fieldNames: Map>, groupExperimental = false, isWrapper = false, groupDeprecated = false): void { +function emitMethod(lines: string[], receiver: string, name: string, method: RpcMethod, isSession: boolean, resolveType: (name: string) => string, fields: Map>, groupExperimental = false, isWrapper = false, groupDeprecated = false): void { const methodName = toPascalCase(name); const resultSchema = getMethodResultSchema(method); const nullableInner = resultSchema ? getNullableInner(resultSchema) : undefined; @@ -1843,12 +3274,16 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc if (hasParams) { lines.push(`\tif params != nil {`); for (const pName of nonSessionParams) { - const goField = fieldNames.get(paramsType)?.get(pName) ?? toGoFieldName(pName); + const field = fields.get(paramsType)?.get(pName); + const goField = field?.name ?? toGoFieldName(pName); + const goType = field?.type; const isOptional = !requiredParams.has(pName); if (isOptional) { - // Optional fields are pointers - only add when non-nil and dereference + // Optional fields are usually pointers; generated union interfaces, slices, + // and maps are nilable values and should be passed through directly. lines.push(`\t\tif params.${goField} != nil {`); - lines.push(`\t\t\treq["${pName}"] = *params.${goField}`); + const valueExpr = goOptionalFieldNeedsDereference(goType) ? `*params.${goField}` : `params.${goField}`; + lines.push(`\t\t\treq["${pName}"] = ${valueExpr}`); lines.push(`\t\t}`); } else { lines.push(`\t\treq["${pName}"] = params.${goField}`); diff --git a/test/scenarios/callbacks/hooks/go/main.go b/test/scenarios/callbacks/hooks/go/main.go index ad69e55a1..4ef48b483 100644 --- a/test/scenarios/callbacks/hooks/go/main.go +++ b/test/scenarios/callbacks/hooks/go/main.go @@ -35,7 +35,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "claude-haiku-4.5", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil }, Hooks: &copilot.SessionHooks{ OnSessionStart: func(input copilot.SessionStartHookInput, inv copilot.HookInvocation) (*copilot.SessionStartHookOutput, error) { @@ -77,10 +77,10 @@ func main() { } if response != nil { -if d, ok := response.Data.(*copilot.AssistantMessageData); ok { -fmt.Println(d.Content) -} -} + if d, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(d.Content) + } + } fmt.Println("\n--- Hook execution log ---") hookLogMu.Lock() diff --git a/test/scenarios/callbacks/permissions/go/main.go b/test/scenarios/callbacks/permissions/go/main.go index fbd33ffd6..23715727b 100644 --- a/test/scenarios/callbacks/permissions/go/main.go +++ b/test/scenarios/callbacks/permissions/go/main.go @@ -30,13 +30,18 @@ func main() { Model: "claude-haiku-4.5", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { permissionLogMu.Lock() - toolName := "" - if req.ToolName != nil { - toolName = *req.ToolName + permissionName := string(req.Kind()) + switch request := req.(type) { + case *copilot.PermissionRequestCustomTool: + permissionName = request.ToolName + case *copilot.PermissionRequestHook: + permissionName = request.ToolName + case *copilot.PermissionRequestMcp: + permissionName = request.ToolName } - permissionLog = append(permissionLog, fmt.Sprintf("approved:%s", toolName)) + permissionLog = append(permissionLog, fmt.Sprintf("approved:%s", permissionName)) permissionLogMu.Unlock() - return copilot.PermissionRequestResult{Kind: "approved"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil }, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { @@ -57,10 +62,10 @@ func main() { } if response != nil { -if d, ok := response.Data.(*copilot.AssistantMessageData); ok { -fmt.Println(d.Content) -} -} + if d, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(d.Content) + } + } fmt.Println("\n--- Permission request log ---") for _, entry := range permissionLog { diff --git a/test/scenarios/callbacks/user-input/go/main.go b/test/scenarios/callbacks/user-input/go/main.go index 044c977cf..a0baf2936 100644 --- a/test/scenarios/callbacks/user-input/go/main.go +++ b/test/scenarios/callbacks/user-input/go/main.go @@ -29,7 +29,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "claude-haiku-4.5", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil }, OnUserInputRequest: func(req copilot.UserInputRequest, inv copilot.UserInputInvocation) (copilot.UserInputResponse, error) { inputLogMu.Lock() @@ -57,10 +57,10 @@ func main() { } if response != nil { -if d, ok := response.Data.(*copilot.AssistantMessageData); ok { -fmt.Println(d.Content) -} -} + if d, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(d.Content) + } + } fmt.Println("\n--- User input log ---") for _, entry := range inputLog { diff --git a/test/scenarios/prompts/attachments/README.md b/test/scenarios/prompts/attachments/README.md index 2bdb551fb..145239f08 100644 --- a/test/scenarios/prompts/attachments/README.md +++ b/test/scenarios/prompts/attachments/README.md @@ -33,14 +33,14 @@ Demonstrates sending **file attachments** alongside a prompt using the Copilot S |----------|------------------------| | TypeScript | `attachments: [{ type: "file", path: sampleFile }]` | | Python | `"attachments": [{"type": "file", "path": sample_file}]` | -| Go | `Attachments: []copilot.Attachment{{Type: "file", Path: sampleFile}}` | +| Go | `Attachments: []copilot.Attachment{&copilot.UserMessageAttachmentFile{Path: sampleFile}}` | | Rust | `Attachment::File { path, display_name: None, line_range: None }` | | Language | Blob Attachment Syntax | |----------|------------------------| | TypeScript | `attachments: [{ type: "blob", data: base64Data, mimeType: "image/png" }]` | | Python | `"attachments": [{"type": "blob", "data": base64_data, "mimeType": "image/png"}]` | -| Go | `Attachments: []copilot.Attachment{{Type: copilot.AttachmentTypeBlob, Data: &data, MIMEType: &mime}}` | +| Go | `Attachments: []copilot.Attachment{&copilot.UserMessageAttachmentBlob{Data: base64Data, MIMEType: "image/png"}}` | | Rust | `Attachment::Blob { data, mime_type, display_name: None }` | ## Sample Data diff --git a/test/scenarios/prompts/attachments/go/main.go b/test/scenarios/prompts/attachments/go/main.go index b7f4d2859..44c79cf6c 100644 --- a/test/scenarios/prompts/attachments/go/main.go +++ b/test/scenarios/prompts/attachments/go/main.go @@ -49,7 +49,7 @@ func main() { response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What languages are listed in the attached file?", Attachments: []copilot.Attachment{ - {Type: "file", Path: &sampleFile}, + copilot.UserMessageAttachmentFile{DisplayName: filepath.Base(sampleFile), Path: sampleFile}, }, }) if err != nil { @@ -57,8 +57,8 @@ func main() { } if response != nil { -if d, ok := response.Data.(*copilot.AssistantMessageData); ok { -fmt.Println(d.Content) -} -} + if d, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(d.Content) + } + } } diff --git a/test/scenarios/sessions/streaming/go/main.go b/test/scenarios/sessions/streaming/go/main.go index cd8a44801..c6df2c28b 100644 --- a/test/scenarios/sessions/streaming/go/main.go +++ b/test/scenarios/sessions/streaming/go/main.go @@ -31,7 +31,7 @@ func main() { chunkCount := 0 session.On(func(event copilot.SessionEvent) { - if event.Type == "assistant.message_delta" { + if event.Type() == "assistant.message_delta" { chunkCount++ } }) @@ -44,9 +44,9 @@ func main() { } if response != nil { -if d, ok := response.Data.(*copilot.AssistantMessageData); ok { -fmt.Println(d.Content) -} -} + if d, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(d.Content) + } + } fmt.Printf("\nStreaming chunks received: %d\n", chunkCount) } diff --git a/test/scenarios/tools/skills/go/main.go b/test/scenarios/tools/skills/go/main.go index b822377cc..7b0ef8032 100644 --- a/test/scenarios/tools/skills/go/main.go +++ b/test/scenarios/tools/skills/go/main.go @@ -29,7 +29,7 @@ func main() { Model: "claude-haiku-4.5", SkillDirectories: []string{skillsDir}, OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil }, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { @@ -50,10 +50,10 @@ func main() { } if response != nil { -if d, ok := response.Data.(*copilot.AssistantMessageData); ok { -fmt.Println(d.Content) -} -} + if d, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(d.Content) + } + } fmt.Println("\nSkill directories configured successfully") } diff --git a/test/scenarios/tools/virtual-filesystem/go/main.go b/test/scenarios/tools/virtual-filesystem/go/main.go index 1618e661a..de4b50637 100644 --- a/test/scenarios/tools/virtual-filesystem/go/main.go +++ b/test/scenarios/tools/virtual-filesystem/go/main.go @@ -89,7 +89,7 @@ func main() { AvailableTools: []string{}, Tools: []copilot.Tool{createFile, readFile, listFiles}, OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "approved"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil }, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { @@ -111,10 +111,10 @@ func main() { } if response != nil { -if d, ok := response.Data.(*copilot.AssistantMessageData); ok { -fmt.Println(d.Content) -} -} + if d, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(d.Content) + } + } // Dump the virtual filesystem to prove nothing touched disk fmt.Println("\n--- Virtual filesystem contents ---") From e0a0f5e2f9fa153e4571ad42764e608a378a69fb Mon Sep 17 00:00:00 2001 From: Quim Muntal Date: Tue, 12 May 2026 18:29:33 +0200 Subject: [PATCH 29/33] Use z-prefixed Go generated files (#1268) --- .gitattributes | 6 ++++-- go/rpc/generated_rpc_api_shape_test.go | 4 ++-- go/rpc/{generated_rpc.go => zrpc.go} | 4 ++-- go/rpc/zrpc_encoding.go | 4 ++-- go/zsession_encoding.go | 4 ++-- ...d_session_events.go => zsession_events.go} | 4 ++-- scripts/codegen/go.ts | 20 +++++++++++-------- 7 files changed, 26 insertions(+), 20 deletions(-) rename go/rpc/{generated_rpc.go => zrpc.go} (99%) rename go/{generated_session_events.go => zsession_events.go} (99%) diff --git a/.gitattributes b/.gitattributes index 689a206be..379a6bbdb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,5 +4,7 @@ nodejs/src/generated/* eol=lf linguist-generated=true dotnet/src/Generated/* eol=lf linguist-generated=true python/copilot/generated/* eol=lf linguist-generated=true -go/generated_session_events.go eol=lf linguist-generated=true -go/rpc/generated_rpc.go eol=lf linguist-generated=true \ No newline at end of file +go/zsession_events.go eol=lf linguist-generated=true +go/zsession_encoding.go eol=lf linguist-generated=true +go/rpc/zrpc.go eol=lf linguist-generated=true +go/rpc/zrpc_encoding.go eol=lf linguist-generated=true diff --git a/go/rpc/generated_rpc_api_shape_test.go b/go/rpc/generated_rpc_api_shape_test.go index 33674db18..496630c55 100644 --- a/go/rpc/generated_rpc_api_shape_test.go +++ b/go/rpc/generated_rpc_api_shape_test.go @@ -53,9 +53,9 @@ func parseGeneratedRPC(t *testing.T) (*ast.File, *token.FileSet) { t.Fatal("locate test file") } fileSet := token.NewFileSet() - file, err := parser.ParseFile(fileSet, filepath.Join(filepath.Dir(currentFile), "generated_rpc.go"), nil, 0) + file, err := parser.ParseFile(fileSet, filepath.Join(filepath.Dir(currentFile), "zrpc.go"), nil, 0) if err != nil { - t.Fatalf("parse generated_rpc.go: %v", err) + t.Fatalf("parse zrpc.go: %v", err) } return file, fileSet } diff --git a/go/rpc/generated_rpc.go b/go/rpc/zrpc.go similarity index 99% rename from go/rpc/generated_rpc.go rename to go/rpc/zrpc.go index e83d3aec3..59f5c9b57 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/zrpc.go @@ -1,5 +1,5 @@ -// AUTO-GENERATED FILE - DO NOT EDIT -// Generated from: api.schema.json +// Code generated by scripts/codegen/go.ts; DO NOT EDIT. +// Source: api.schema.json package rpc diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go index f4e21a465..b4ab8518c 100644 --- a/go/rpc/zrpc_encoding.go +++ b/go/rpc/zrpc_encoding.go @@ -1,5 +1,5 @@ -// AUTO-GENERATED FILE - DO NOT EDIT -// Generated from: api.schema.json +// Code generated by scripts/codegen/go.ts; DO NOT EDIT. +// Source: api.schema.json package rpc diff --git a/go/zsession_encoding.go b/go/zsession_encoding.go index f3c18bfaa..72fa12aed 100644 --- a/go/zsession_encoding.go +++ b/go/zsession_encoding.go @@ -1,5 +1,5 @@ -// AUTO-GENERATED FILE - DO NOT EDIT -// Generated from: session-events.schema.json +// Code generated by scripts/codegen/go.ts; DO NOT EDIT. +// Source: session-events.schema.json package copilot diff --git a/go/generated_session_events.go b/go/zsession_events.go similarity index 99% rename from go/generated_session_events.go rename to go/zsession_events.go index 316cc7df8..0c62df5c4 100644 --- a/go/generated_session_events.go +++ b/go/zsession_events.go @@ -1,5 +1,5 @@ -// AUTO-GENERATED FILE - DO NOT EDIT -// Generated from: session-events.schema.json +// Code generated by scripts/codegen/go.ts; DO NOT EDIT. +// Source: session-events.schema.json package copilot diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index ad6552f9f..4ea260852 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -2498,10 +2498,16 @@ function goEncodingBlocksCode(blocks: string[] | undefined): string { return joinGoCode(lines); } +function goDoNotEditHeader(schemaFileName: string): string[] { + return [ + `// Code generated by scripts/codegen/go.ts; DO NOT EDIT.`, + `// Source: ${schemaFileName}`, + ]; +} + function goGeneratedEncodingFileCode(schemaFileName: string, packageName: string, generatedEncodingCode: string, wrapComments = false): string { const lines: string[] = []; - lines.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); - lines.push(`// Generated from: ${schemaFileName}`); + lines.push(...goDoNotEditHeader(schemaFileName)); lines.push(``); lines.push(`package ${packageName}`); lines.push(``); @@ -2692,8 +2698,7 @@ function generateGoSessionEventsCode(schema: JSONSchema7): GoGeneratedTypeCode { // Assemble file const out: string[] = []; - out.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); - out.push(`// Generated from: session-events.schema.json`); + out.push(...goDoNotEditHeader("session-events.schema.json")); out.push(``); out.push(`package copilot`); out.push(``); @@ -2895,7 +2900,7 @@ async function generateSessionEvents(schemaPath?: string): Promise { const generatedTypeCode = stripTrailingGoWhitespace(generatedSessionCode.typeCode); const generatedEncodingCode = stripTrailingGoWhitespace(generatedSessionCode.encodingCode); - const outPath = await writeGeneratedFile("go/generated_session_events.go", generatedTypeCode); + const outPath = await writeGeneratedFile("go/zsession_events.go", generatedTypeCode); console.log(` ✓ ${outPath}`); await formatGoFile(outPath); @@ -3058,8 +3063,7 @@ async function generateRpc(schemaPath?: string): Promise { // Build method wrappers const lines: string[] = []; - lines.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); - lines.push(`// Generated from: api.schema.json`); + lines.push(...goDoNotEditHeader("api.schema.json")); lines.push(``); lines.push(`package rpc`); lines.push(``); @@ -3102,7 +3106,7 @@ async function generateRpc(schemaPath?: string): Promise { emitClientSessionApiRegistration(lines, schema.clientSession, resolveType); } - const outPath = await writeGeneratedFile("go/rpc/generated_rpc.go", wrapGeneratedGoComments(lines.join("\n"))); + const outPath = await writeGeneratedFile("go/rpc/zrpc.go", wrapGeneratedGoComments(lines.join("\n"))); console.log(` ✓ ${outPath}`); await formatGoFile(outPath); From 81b7b018faa084d2488d86a6fafe6de9c0ef058a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 12 May 2026 14:07:25 -0400 Subject: [PATCH 30/33] Support experimental schema types in codegen (#1267) * Support experimental schema types in codegen Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stabilize Rust generated imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve Rust codegen API schema argument Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Retry CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Retry CI after CodeQL 429 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Deduplicate experimental marker emission Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unused Python event experimental state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align event data experimental metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use Python event data experimental metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 7 -- dotnet/src/Types.cs | 7 ++ scripts/codegen/csharp.ts | 120 +++++++++++++++++++++------------- scripts/codegen/go.ts | 105 ++++++++++++++++++++++++----- scripts/codegen/python.ts | 74 +++++++++++++++++---- scripts/codegen/rust.ts | 74 +++++++++++++++++++-- scripts/codegen/typescript.ts | 65 +++++++++++++----- scripts/codegen/utils.ts | 5 ++ 8 files changed, 354 insertions(+), 103 deletions(-) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index e643cc8ef..29a3ab89e 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -17,13 +17,6 @@ namespace GitHub.Copilot.SDK.Rpc; -/// Diagnostic IDs for the Copilot SDK. -internal static class Diagnostics -{ - /// Indicates an experimental API that may change or be removed. - internal const string Experimental = "GHCP001"; -} - /// RPC data type for Ping operations. public sealed class PingResult { diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index bd3ba1b78..82690931a 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -42,6 +42,13 @@ internal static void WriteValue(Utf8JsonWriter writer, string value, Type typeTo } } +/// Diagnostic IDs for the Copilot SDK. +internal static class Diagnostics +{ + /// Indicates an experimental API that may change or be removed. + internal const string Experimental = "GHCP001"; +} + /// /// Represents the connection state of the Copilot client. /// diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index f82a5cd03..edfdd81b1 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -28,6 +28,7 @@ import { isNodeFullyExperimental, isNodeFullyDeprecated, isSchemaDeprecated, + isSchemaExperimental, isObjectSchema, isVoidSchema, getNullableInner, @@ -308,6 +309,17 @@ const COPYRIGHT = `/*----------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/`; +const EXPERIMENTAL_ATTRIBUTE = "[Experimental(Diagnostics.Experimental)]"; +const OBSOLETE_ATTRIBUTE = `[Obsolete("This member is deprecated and will be removed in a future version.")]`; + +function experimentalAttribute(indent = ""): string { + return `${indent}${EXPERIMENTAL_ATTRIBUTE}`; +} + +function pushExperimentalAttribute(lines: string[], indent = ""): void { + lines.push(experimentalAttribute(indent)); +} + // ══════════════════════════════════════════════════════════════════════════════ // SESSION EVENTS // ══════════════════════════════════════════════════════════════════════════════ @@ -318,6 +330,8 @@ interface EventVariant { dataClassName: string; dataSchema: JSONSchema7; dataDescription?: string; + eventExperimental: boolean; + dataExperimental: boolean; } let generatedEnums = new Map(); @@ -333,7 +347,8 @@ function getOrCreateEnum( enumOutput: string[], description?: string, explicitName?: string, - deprecated?: boolean + deprecated?: boolean, + experimental?: boolean ): string { const enumName = explicitName ?? `${parentClassName}${propName}`; const existing = generatedEnums.get(enumName); @@ -342,7 +357,8 @@ function getOrCreateEnum( const lines: string[] = []; lines.push(...xmlDocEnumComment(description, "")); - if (deprecated) lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (experimental) pushExperimentalAttribute(lines); + if (deprecated) lines.push(OBSOLETE_ATTRIBUTE); lines.push(`[JsonConverter(typeof(Converter))]`); lines.push(`[DebuggerDisplay("{Value,nq}")]`); lines.push(`public readonly struct ${enumName} : IEquatable<${enumName}>`); @@ -412,6 +428,8 @@ function extractEventVariants(schema: JSONSchema7): EventVariant[] { dataClassName: `${baseName}Data`, dataSchema, dataDescription: dataSchema?.description, + eventExperimental: isSchemaExperimental(variant), + dataExperimental: isSchemaExperimental(dataSchema), }; }); } @@ -487,13 +505,14 @@ function generateDiscriminatedUnionClass( nestedClasses: Map, enumOutput: string[], description?: string, - propertyResolver?: PropertyTypeResolver + propertyResolver?: PropertyTypeResolver, + experimental = false ): string { if (isBooleanDiscriminator(discriminatorInfo)) { - return generateFlattenedBooleanDiscriminatedClass(baseClassName, discriminatorInfo, knownTypes, nestedClasses, enumOutput, description, propertyResolver); + return generateFlattenedBooleanDiscriminatedClass(baseClassName, discriminatorInfo, knownTypes, nestedClasses, enumOutput, description, propertyResolver, experimental); } - return generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput, description, propertyResolver); + return generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput, description, propertyResolver, experimental); } function generateFlattenedBooleanDiscriminatedClass( @@ -503,7 +522,8 @@ function generateFlattenedBooleanDiscriminatedClass( nestedClasses: Map, enumOutput: string[], description?: string, - propertyResolver?: PropertyTypeResolver + propertyResolver?: PropertyTypeResolver, + experimental = false ): string { const resolver = propertyResolver ?? resolveSessionPropertyType; const renamedBase = applyTypeRename(baseClassName); @@ -532,6 +552,7 @@ function generateFlattenedBooleanDiscriminatedClass( } lines.push(...xmlDocCommentWithFallback(description, `Data type discriminated by ${escapeXml(discriminatorInfo.property)}.`, "")); + if (experimental) pushExperimentalAttribute(lines); lines.push(`public partial class ${renamedBase}`); lines.push(`{`); lines.push(` /// The boolean discriminator.`); @@ -547,7 +568,7 @@ function generateFlattenedBooleanDiscriminatedClass( lines.push(""); lines.push(...xmlDocPropertyComment(info.schema.description, propName, " ")); lines.push(...emitDataAnnotations(info.schema, " ")); - if (isSchemaDeprecated(info.schema)) lines.push(` [Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isSchemaDeprecated(info.schema)) lines.push(` ${OBSOLETE_ATTRIBUTE}`); if (isDurationProperty(info.schema)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`); if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); lines.push(` [JsonPropertyName("${propName}")]`); @@ -570,7 +591,8 @@ function generatePolymorphicClasses( nestedClasses: Map, enumOutput: string[], description?: string, - propertyResolver?: PropertyTypeResolver + propertyResolver?: PropertyTypeResolver, + experimental = false ): string { const resolver = propertyResolver ?? resolveSessionPropertyType; const lines: string[] = []; @@ -578,6 +600,7 @@ function generatePolymorphicClasses( const renamedBase = applyTypeRename(baseClassName); lines.push(...xmlDocCommentWithFallback(description, `Polymorphic base type discriminated by ${escapeXml(discriminatorProperty)}.`, "")); + if (experimental) pushExperimentalAttribute(lines); lines.push(`[JsonPolymorphic(`); lines.push(` TypeDiscriminatorPropertyName = "${discriminatorProperty}",`); lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]`); @@ -624,7 +647,8 @@ function generateDerivedClass( const required = new Set(schema.required || []); lines.push(...xmlDocCommentWithFallback(schema.description, `The ${escapeXml(discriminatorValue)} variant of .`, "")); - if (isSchemaDeprecated(schema)) lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isSchemaExperimental(schema)) pushExperimentalAttribute(lines); + if (isSchemaDeprecated(schema)) lines.push(OBSOLETE_ATTRIBUTE); lines.push(`public partial class ${className} : ${baseClassName}`); lines.push(`{`); lines.push(` /// `); @@ -643,7 +667,7 @@ function generateDerivedClass( lines.push(...xmlDocPropertyComment((propSchema as JSONSchema7).description, propName, " ")); lines.push(...emitDataAnnotations(propSchema as JSONSchema7, " ")); - if (isSchemaDeprecated(propSchema as JSONSchema7)) lines.push(` [Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isSchemaDeprecated(propSchema as JSONSchema7)) lines.push(` ${OBSOLETE_ATTRIBUTE}`); if (isDurationProperty(propSchema as JSONSchema7)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`); if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); lines.push(` [JsonPropertyName("${propName}")]`); @@ -667,7 +691,8 @@ function generateNestedClass( const required = new Set(schema.required || []); const lines: string[] = []; lines.push(...xmlDocCommentWithFallback(schema.description, `Nested data type for ${className}.`, "")); - if (isSchemaDeprecated(schema)) lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isSchemaExperimental(schema)) pushExperimentalAttribute(lines); + if (isSchemaDeprecated(schema)) lines.push(OBSOLETE_ATTRIBUTE); lines.push(`public partial class ${className}`, `{`); for (const [propName, propSchema] of Object.entries(schema.properties || {}).sort(([a], [b]) => a.localeCompare(b))) { @@ -679,7 +704,7 @@ function generateNestedClass( lines.push(...xmlDocPropertyComment(prop.description, propName, " ")); lines.push(...emitDataAnnotations(prop, " ")); - if (isSchemaDeprecated(prop)) lines.push(` [Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isSchemaDeprecated(prop)) lines.push(` ${OBSOLETE_ATTRIBUTE}`); if (isDurationProperty(prop)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`); if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); lines.push(` [JsonPropertyName("${propName}")]`); @@ -709,7 +734,7 @@ function resolveSessionPropertyType( } if (refSchema.enum && Array.isArray(refSchema.enum)) { - const enumName = getOrCreateEnum(className, "", refSchema.enum as string[], enumOutput, refSchema.description, undefined, isSchemaDeprecated(refSchema)); + const enumName = getOrCreateEnum(className, "", refSchema.enum as string[], enumOutput, refSchema.description, undefined, isSchemaDeprecated(refSchema), isSchemaExperimental(refSchema)); return isRequired ? enumName : `${enumName}?`; } @@ -743,7 +768,7 @@ function resolveSessionPropertyType( const hasNull = propSchema.anyOf.length > nonNull.length; const baseClassName = (propSchema.title as string) ?? `${parentClassName}${propName}`; const renamedBase = applyTypeRename(baseClassName); - const polymorphicCode = generateDiscriminatedUnionClass(baseClassName, discriminatorInfo, variants, knownTypes, nestedClasses, enumOutput, propSchema.description); + const polymorphicCode = generateDiscriminatedUnionClass(baseClassName, discriminatorInfo, variants, knownTypes, nestedClasses, enumOutput, propSchema.description, undefined, isSchemaExperimental(propSchema)); nestedClasses.set(renamedBase, polymorphicCode); return isRequired && !hasNull ? renamedBase : `${renamedBase}?`; } @@ -751,7 +776,7 @@ function resolveSessionPropertyType( return !isRequired ? "object?" : "object"; } if (propSchema.enum && Array.isArray(propSchema.enum)) { - const enumName = getOrCreateEnum(parentClassName, propName, propSchema.enum as string[], enumOutput, propSchema.description, propSchema.title as string | undefined, isSchemaDeprecated(propSchema)); + const enumName = getOrCreateEnum(parentClassName, propName, propSchema.enum as string[], enumOutput, propSchema.description, propSchema.title as string | undefined, isSchemaDeprecated(propSchema), isSchemaExperimental(propSchema)); return isRequired ? enumName : `${enumName}?`; } if (propSchema.type === "object" && propSchema.properties) { @@ -798,8 +823,11 @@ function generateDataClass(variant: EventVariant, knownTypes: Map.`, "")); } + if (variant.dataExperimental || isSchemaExperimental(variant.dataSchema)) { + pushExperimentalAttribute(lines); + } if (isSchemaDeprecated(variant.dataSchema)) { - lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); + lines.push(OBSOLETE_ATTRIBUTE); } lines.push(`public partial class ${variant.dataClassName}`, `{`); @@ -811,7 +839,7 @@ function generateDataClass(variant: EventVariant, knownTypes: MapRepresents the ${escapeXml(variant.typeName)} event.`); } + if (variant.eventExperimental) { + pushExperimentalAttribute(lines); + } lines.push(`public partial class ${variant.className} : SessionEvent`, `{`); lines.push(` /// `); lines.push(` [JsonIgnore]`, ` public override string Type => "${variant.typeName}";`, ""); @@ -1040,7 +1071,7 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam } if (refSchema.enum && Array.isArray(refSchema.enum)) { - const enumName = getOrCreateEnum(typeName, "", refSchema.enum as string[], rpcEnumOutput, refSchema.description, undefined, isSchemaDeprecated(refSchema)); + const enumName = getOrCreateEnum(typeName, "", refSchema.enum as string[], rpcEnumOutput, refSchema.description, undefined, isSchemaDeprecated(refSchema), isSchemaExperimental(refSchema)); return isRequired ? enumName : `${enumName}?`; } @@ -1083,7 +1114,7 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam } return result; }; - const polymorphicCode = generateDiscriminatedUnionClass(baseClassName, discriminatorInfo, variants, rpcKnownTypes, nestedMap, rpcEnumOutput, schema.description, rpcPropertyResolver); + const polymorphicCode = generateDiscriminatedUnionClass(baseClassName, discriminatorInfo, variants, rpcKnownTypes, nestedMap, rpcEnumOutput, schema.description, rpcPropertyResolver, isSchemaExperimental(schema)); classes.push(polymorphicCode); for (const nested of nestedMap.values()) classes.push(nested); } @@ -1101,6 +1132,7 @@ function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassNam schema.description, schema.title as string | undefined, isSchemaDeprecated(schema), + isSchemaExperimental(schema), ); return isRequired ? enumName : `${enumName}?`; } @@ -1163,11 +1195,11 @@ function emitRpcClass( const requiredSet = new Set(effectiveSchema.required || []); const lines: string[] = []; lines.push(...xmlDocComment(schema.description || effectiveSchema.description || `RPC data type for ${className.replace(/(Request|Result|Params)$/, "")} operations.`, "")); - if (experimentalRpcTypes.has(className)) { - lines.push(`[Experimental(Diagnostics.Experimental)]`); + if (experimentalRpcTypes.has(className) || isSchemaExperimental(schema) || isSchemaExperimental(effectiveSchema)) { + pushExperimentalAttribute(lines); } if (isSchemaDeprecated(schema) || isSchemaDeprecated(effectiveSchema)) { - lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); + lines.push(OBSOLETE_ATTRIBUTE); } lines.push(`${visibility} sealed class ${className}`, `{`); @@ -1182,7 +1214,7 @@ function emitRpcClass( lines.push(...xmlDocPropertyComment(prop.description, propName, " ")); lines.push(...emitDataAnnotations(prop, " ")); - if (isSchemaDeprecated(prop)) lines.push(` [Obsolete("This member is deprecated and will be removed in a future version.")]`); + if (isSchemaDeprecated(prop)) lines.push(` ${OBSOLETE_ATTRIBUTE}`); if (isDurationProperty(prop)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`); lines.push(` [JsonPropertyName("${propName}")]`); @@ -1214,7 +1246,7 @@ function emitRpcClass( */ function emitNonObjectResultType(typeName: string, schema: JSONSchema7, classes: string[]): string { if (schema.enum && Array.isArray(schema.enum)) { - const enumName = getOrCreateEnum("", typeName, schema.enum as string[], rpcEnumOutput, schema.description, typeName, isSchemaDeprecated(schema)); + const enumName = getOrCreateEnum("", typeName, schema.enum as string[], rpcEnumOutput, schema.description, typeName, isSchemaDeprecated(schema), isSchemaExperimental(schema)); emittedRpcEnumResultTypes.add(enumName); return enumName; } @@ -1282,10 +1314,10 @@ function emitServerApiClass(className: string, node: Record, cl const groupExperimental = isNodeFullyExperimental(node); const groupDeprecated = isNodeFullyDeprecated(node); if (groupExperimental) { - lines.push(`[Experimental(Diagnostics.Experimental)]`); + pushExperimentalAttribute(lines); } if (groupDeprecated) { - lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); + lines.push(OBSOLETE_ATTRIBUTE); } lines.push(`public sealed class ${className}`); lines.push(`{`); @@ -1371,10 +1403,10 @@ function emitServerInstanceMethod( lines.push(""); lines.push(`${indent}/// Calls "${method.rpcMethod}".`); if (method.stability === "experimental" && !groupExperimental) { - lines.push(`${indent}[Experimental(Diagnostics.Experimental)]`); + pushExperimentalAttribute(lines, indent); } if (method.deprecated && !groupDeprecated) { - lines.push(`${indent}[Obsolete("This member is deprecated and will be removed in a future version.")]`); + lines.push(`${indent}${OBSOLETE_ATTRIBUTE}`); } const sigParams: string[] = []; @@ -1477,10 +1509,10 @@ function emitSessionMethod(key: string, method: RpcMethod, lines: string[], clas lines.push("", `${indent}/// Calls "${method.rpcMethod}".`); if (method.stability === "experimental" && !groupExperimental) { - lines.push(`${indent}[Experimental(Diagnostics.Experimental)]`); + pushExperimentalAttribute(lines, indent); } if (method.deprecated && !groupDeprecated) { - lines.push(`${indent}[Obsolete("This member is deprecated and will be removed in a future version.")]`); + lines.push(`${indent}${OBSOLETE_ATTRIBUTE}`); } const sigParams: string[] = []; const bodyAssignments = [`SessionId = _sessionId`]; @@ -1509,8 +1541,8 @@ function emitSessionApiClass(className: string, node: Record, c const displayName = className.replace(/Api$/, ""); const groupExperimental = isNodeFullyExperimental(node); const groupDeprecated = isNodeFullyDeprecated(node); - const experimentalAttr = groupExperimental ? `[Experimental(Diagnostics.Experimental)]\n` : ""; - const deprecatedAttr = groupDeprecated ? `[Obsolete("This member is deprecated and will be removed in a future version.")]\n` : ""; + const experimentalAttr = groupExperimental ? `${experimentalAttribute()}\n` : ""; + const deprecatedAttr = groupDeprecated ? `${OBSOLETE_ATTRIBUTE}\n` : ""; const subGroups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); const lines = [`/// Provides session-scoped ${displayName} APIs.`, `${experimentalAttr}${deprecatedAttr}public sealed class ${className}`, `{`, ` private readonly JsonRpc _rpc;`, ` private readonly string _sessionId;`, ""]; @@ -1597,10 +1629,10 @@ function emitClientSessionApiRegistration(clientSchema: Record, const groupDeprecated = isNodeFullyDeprecated(groupNode); lines.push(`/// Handles \`${groupName}\` client session API methods.`); if (groupExperimental) { - lines.push(`[Experimental(Diagnostics.Experimental)]`); + pushExperimentalAttribute(lines); } if (groupDeprecated) { - lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`); + lines.push(OBSOLETE_ATTRIBUTE); } lines.push(`public interface ${interfaceName}`); lines.push(`{`); @@ -1611,10 +1643,10 @@ function emitClientSessionApiRegistration(clientSchema: Record, const taskType = resultTaskType(method); lines.push(` /// Handles "${method.rpcMethod}".`); if (method.stability === "experimental" && !groupExperimental) { - lines.push(` [Experimental(Diagnostics.Experimental)]`); + pushExperimentalAttribute(lines, " "); } if (method.deprecated && !groupDeprecated) { - lines.push(` [Obsolete("This member is deprecated and will be removed in a future version.")]`); + lines.push(` ${OBSOLETE_ATTRIBUTE}`); } if (hasParams) { lines.push(` ${taskType} ${clientHandlerMethodName(method.rpcMethod)}(${paramsTypeName(method)} request, CancellationToken cancellationToken = default);`); @@ -1689,6 +1721,13 @@ function generateRpcCode(schema: ApiSchema): string { rpcEnumOutput = []; generatedEnums.clear(); // Clear shared enum deduplication map rpcDefinitions = collectDefinitionCollections(schema as Record); + for (const defs of [rpcDefinitions.definitions, rpcDefinitions.$defs]) { + for (const [name, def] of Object.entries(defs ?? {})) { + if (typeof def === "object" && def !== null && isSchemaExperimental(def as JSONSchema7)) { + experimentalRpcTypes.add(typeToClassName(name)); + } + } + } const classes: string[] = []; let serverRpcParts: string[] = []; @@ -1717,13 +1756,6 @@ using System.Text.Json; using System.Text.Json.Serialization; namespace GitHub.Copilot.SDK.Rpc; - -/// Diagnostic IDs for the Copilot SDK. -internal static class Diagnostics -{ - /// Indicates an experimental API that may change or be removed. - internal const string Experimental = "GHCP001"; -} `); for (const cls of classes) if (cls) lines.push(cls, ""); diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 4ea260852..5779f2d3b 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -27,6 +27,7 @@ import { isNodeFullyExperimental, isRpcMethod, isSchemaDeprecated, + isSchemaExperimental, isVoidSchema, postProcessSchema, refTypeName, @@ -107,6 +108,30 @@ function pushGoCommentForContext(lines: string[], text: string, ctx: GoCodegenCt pushGoComment(lines, text, indent, ctx.wrapComments !== false); } +function goExperimentalTypeComment(typeName: string): string { + return `Experimental: ${typeName} is part of an experimental API and may change or be removed.`; +} + +function pushGoExperimentalTypeComment(lines: string[], typeName: string, ctx: GoCodegenCtx): void { + pushGoCommentForContext(lines, goExperimentalTypeComment(typeName), ctx); +} + +function pushGoExperimentalEventComment(lines: string[], constName: string, indent = ""): void { + pushGoComment(lines, `Experimental: ${constName} identifies an experimental event that may change or be removed.`, indent); +} + +function pushGoExperimentalApiComment(lines: string[], name: string, indent = ""): void { + pushGoComment(lines, `Experimental: ${name} contains experimental APIs that may change or be removed.`, indent); +} + +function pushGoExperimentalSubApiComment(lines: string[], name: string, indent = ""): void { + pushGoComment(lines, `Experimental: ${name} returns experimental APIs that may change or be removed.`, indent); +} + +function pushGoExperimentalMethodComment(lines: string[], methodName: string, indent = ""): void { + pushGoComment(lines, `Experimental: ${methodName} is an experimental API and may change or be removed in future versions.`, indent); +} + function goCommentLines(text: string, indent = "", wrap = true): string[] { const prefix = `${indent}//`; const lines: string[] = []; @@ -284,6 +309,8 @@ interface GoEventVariant { dataClassName: string; dataSchema: JSONSchema7; dataDescription?: string; + eventExperimental: boolean; + dataExperimental: boolean; } interface GoEventEnvelopeProperty extends SessionEventEnvelopeProperty { @@ -364,6 +391,8 @@ function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { dataClassName: `${toPascalCase(typeName)}Data`, dataSchema, dataDescription: dataSchema.description, + eventExperimental: isSchemaExperimental(variant), + dataExperimental: isSchemaExperimental(dataSchema), }; }); } @@ -496,7 +525,8 @@ function getOrCreateGoEnum( values: string[], ctx: GoCodegenCtx, description?: string, - deprecated?: boolean + deprecated?: boolean, + experimental?: boolean ): string { const existing = ctx.enumsByName.get(enumName); if (existing) return existing; @@ -505,6 +535,9 @@ function getOrCreateGoEnum( if (description) { pushGoCommentForContext(lines, description, ctx); } + if (experimental) { + pushGoExperimentalTypeComment(lines, enumName, ctx); + } if (deprecated) { pushGoCommentForContext(lines, `Deprecated: ${enumName} is deprecated and will be removed in a future version.`, ctx); } @@ -589,7 +622,7 @@ function resolveGoPropertyType( const resolved = resolveRef(propSchema.$ref, ctx.definitions); if (resolved) { if (resolved.enum) { - const enumType = getOrCreateGoEnum(typeName, resolved.enum as string[], ctx, resolved.description, isSchemaDeprecated(resolved)); + const enumType = getOrCreateGoEnum(typeName, resolved.enum as string[], ctx, resolved.description, isSchemaDeprecated(resolved), isSchemaExperimental(resolved)); return isRequired ? enumType : `*${enumType}`; } if (isNamedGoObjectSchema(resolved)) { @@ -643,7 +676,7 @@ function resolveGoPropertyType( // Handle enum if (propSchema.enum && Array.isArray(propSchema.enum)) { - const enumType = getOrCreateGoEnum((propSchema.title as string) || nestedName, propSchema.enum as string[], ctx, propSchema.description, isSchemaDeprecated(propSchema)); + const enumType = getOrCreateGoEnum((propSchema.title as string) || nestedName, propSchema.enum as string[], ctx, propSchema.description, isSchemaDeprecated(propSchema), isSchemaExperimental(propSchema)); return isRequired ? enumType : `*${enumType}`; } @@ -653,7 +686,7 @@ function resolveGoPropertyType( if (typeof propSchema.const !== "string") { return resolveGoPropertyType(schemaForConstValue(propSchema.const), parentTypeName, jsonPropName, isRequired, ctx); } - const enumType = getOrCreateGoEnum((propSchema.title as string) || nestedName, [propSchema.const], ctx, propSchema.description, isSchemaDeprecated(propSchema)); + const enumType = getOrCreateGoEnum((propSchema.title as string) || nestedName, [propSchema.const], ctx, propSchema.description, isSchemaDeprecated(propSchema), isSchemaExperimental(propSchema)); return isRequired ? enumType : `*${enumType}`; } @@ -871,6 +904,9 @@ function emitGoStruct( if (desc) { pushGoCommentForContext(lines, desc, ctx); } + if (isSchemaExperimental(schema)) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } if (isSchemaDeprecated(schema)) { pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); } @@ -1406,7 +1442,8 @@ function emitGoFlatDiscriminatedUnion( typeName: string, discriminator: GoDiscriminatorInfo, ctx: GoCodegenCtx, - description?: string + description?: string, + experimental = false ): void { if (ctx.generatedNames.has(typeName)) return; ctx.generatedNames.add(typeName); @@ -1422,7 +1459,9 @@ function emitGoFlatDiscriminatedUnion( typeName + discGoName, discValues, ctx, - `${discGoName} discriminator for ${typeName}.` + `${discGoName} discriminator for ${typeName}.`, + false, + experimental ); const unmarshalFuncName = goUnexportedFunctionName("unmarshal", typeName); @@ -1434,6 +1473,9 @@ function emitGoFlatDiscriminatedUnion( if (description) { pushGoCommentForContext(lines, description, ctx); } + if (experimental) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } lines.push(`type ${typeName} interface {`); lines.push(`\t${markerName}()`); lines.push(`\t${discriminatorMethodName}() ${discEnumName}`); @@ -1592,7 +1634,8 @@ function emitGoRequiredFieldDiscriminatedUnion( typeName: string, discriminator: GoRequiredFieldDiscriminatorInfo, ctx: GoCodegenCtx, - description?: string + description?: string, + experimental = false ): void { if (ctx.generatedNames.has(typeName)) return; ctx.generatedNames.add(typeName); @@ -1607,6 +1650,9 @@ function emitGoRequiredFieldDiscriminatedUnion( if (description) { pushGoCommentForContext(lines, description, ctx); } + if (experimental) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } lines.push(`type ${typeName} interface {`); lines.push(`\t${markerName}()`); lines.push(`}`); @@ -1870,7 +1916,8 @@ function emitGoFlattenedObjectUnion( typeName: string, variants: JSONSchema7[], ctx: GoCodegenCtx, - description?: string + description?: string, + experimental = false ): void { if (ctx.generatedNames.has(typeName)) return; ctx.generatedNames.add(typeName); @@ -1905,6 +1952,9 @@ function emitGoFlattenedObjectUnion( if (description) { pushGoCommentForContext(lines, description, ctx); } + if (experimental) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } lines.push(`type ${typeName} struct {`); const fields: GoStructField[] = []; @@ -2105,6 +2155,9 @@ function emitGoPrimitiveUnionInterface(typeName: string, schema: JSONSchema7, ct if (schema.description) { pushGoCommentForContext(lines, schema.description, ctx); } + if (isSchemaExperimental(schema)) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } if (isSchemaDeprecated(schema)) { pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); } @@ -2258,6 +2311,9 @@ function emitGoUntaggedUnionInterface(typeName: string, schema: JSONSchema7, ctx if (schema.description) { pushGoCommentForContext(lines, schema.description, ctx); } + if (isSchemaExperimental(schema)) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } if (isSchemaDeprecated(schema)) { pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); } @@ -2331,16 +2387,16 @@ function planGoUnion(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx, i function emitGoUnionPlan(plan: GoUnionPlan, ctx: GoCodegenCtx): void { switch (plan.kind) { case "discriminated": - emitGoFlatDiscriminatedUnion(plan.typeName, plan.discriminator, ctx, plan.description); + emitGoFlatDiscriminatedUnion(plan.typeName, plan.discriminator, ctx, plan.description, isSchemaExperimental(plan.schema)); return; case "requiredFieldDiscriminated": - emitGoRequiredFieldDiscriminatedUnion(plan.typeName, plan.discriminator, ctx, plan.description); + emitGoRequiredFieldDiscriminatedUnion(plan.typeName, plan.discriminator, ctx, plan.description, isSchemaExperimental(plan.schema)); return; case "primitive": emitGoPrimitiveUnionInterface(plan.typeName, plan.schema, ctx, plan.variants); return; case "flattenedObject": - emitGoFlattenedObjectUnion(plan.typeName, plan.variants, ctx, plan.description); + emitGoFlattenedObjectUnion(plan.typeName, plan.variants, ctx, plan.description, isSchemaExperimental(plan.schema)); return; case "untagged": emitGoUntaggedUnionInterface(plan.typeName, plan.schema, ctx, plan.variants); @@ -2373,6 +2429,9 @@ function emitGoUnionWrapperStruct(typeName: string, schema: JSONSchema7, ctx: Go if (schema.description) { pushGoCommentForContext(lines, schema.description, ctx); } + if (isSchemaExperimental(schema)) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } if (isSchemaDeprecated(schema)) { pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); } @@ -2436,6 +2495,9 @@ function emitGoAlias(typeName: string, schema: JSONSchema7, ctx: GoCodegenCtx): if (schema.description) { pushGoCommentForContext(lines, schema.description, ctx); } + if (isSchemaExperimental(schema)) { + pushGoExperimentalTypeComment(lines, typeName, ctx); + } if (isSchemaDeprecated(schema)) { pushGoCommentForContext(lines, `Deprecated: ${typeName} is deprecated and will be removed in a future version.`, ctx); } @@ -2448,7 +2510,7 @@ function emitGoRpcDefinition(definitionName: string, schema: JSONSchema7, ctx: G const effectiveSchema = resolveObjectSchema(schema, ctx.definitions) ?? resolveSchema(schema, ctx.definitions) ?? schema; if (isStringEnumDefinition(effectiveSchema)) { - getOrCreateGoEnum(typeName, effectiveSchema.enum, ctx, effectiveSchema.description, isSchemaDeprecated(effectiveSchema)); + getOrCreateGoEnum(typeName, effectiveSchema.enum, ctx, effectiveSchema.description, isSchemaDeprecated(effectiveSchema), isSchemaExperimental(effectiveSchema)); return typeName; } @@ -2630,6 +2692,9 @@ function generateGoSessionEventsCode(schema: JSONSchema7): GoGeneratedTypeCode { } else { pushGoCommentForContext(lines, `${variant.dataClassName} holds the payload for ${variant.typeName} events.`, ctx); } + if (variant.dataExperimental || isSchemaExperimental(variant.dataSchema)) { + pushGoExperimentalTypeComment(lines, variant.dataClassName, ctx); + } lines.push(`type ${variant.dataClassName} struct {`); const fields: GoStructField[] = []; @@ -2690,6 +2755,10 @@ function generateGoSessionEventsCode(schema: JSONSchema7): GoGeneratedTypeCode { })) .sort((left, right) => left.constName.localeCompare(right.constName)); for (const { constName, typeName } of eventTypeConsts) { + const variant = variants.find((candidate) => candidate.typeName === typeName); + if (variant?.eventExperimental) { + pushGoExperimentalEventComment(eventTypeEnum, constName, "\t"); + } eventTypeEnum.push(`\t${constName} SessionEventType = "${typeName}"`); } eventTypeEnum.push(`)`); @@ -3019,7 +3088,7 @@ async function generateRpc(schemaPath?: string): Promise { for (const typeName of experimentalTypeNames) { generatedTypeCode = generatedTypeCode.replace( new RegExp(`^(type ${typeName} struct)`, "m"), - `// Experimental: ${typeName} is part of an experimental API and may change or be removed.\n$1` + `// ${goExperimentalTypeComment(typeName)}\n$1` ); } @@ -3135,7 +3204,7 @@ function emitApiGroup( pushGoComment(lines, `Deprecated: ${apiName} contains deprecated APIs that will be removed in a future version.`); } if (groupExperimental) { - pushGoComment(lines, `Experimental: ${apiName} contains experimental APIs that may change or be removed.`); + pushGoExperimentalApiComment(lines, apiName); } lines.push(`type ${apiName} ${serviceName}`); lines.push(``); @@ -3152,7 +3221,7 @@ function emitApiGroup( emitApiGroup(lines, subApiName, subGroupNode as Record, isSession, serviceName, resolveType, fields, subGroupExperimental, subGroupDeprecated); if (subGroupExperimental) { - pushGoComment(lines, `Experimental: ${toPascalCase(subGroupName)} returns experimental APIs that may change or be removed.`); + pushGoExperimentalSubApiComment(lines, toPascalCase(subGroupName)); } lines.push(`func (s *${apiName}) ${toPascalCase(subGroupName)}() *${subApiName} {`); lines.push(`\treturn (*${subApiName})(s)`); @@ -3262,7 +3331,7 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc pushGoComment(lines, `Deprecated: ${methodName} is deprecated and will be removed in a future version.`); } if (method.stability === "experimental" && !groupExperimental) { - pushGoComment(lines, `Experimental: ${methodName} is an experimental API and may change or be removed in future versions.`); + pushGoExperimentalMethodComment(lines, methodName); } if (method.visibility === "internal") { pushGoComment(lines, `Internal: ${methodName} is part of the SDK's internal handshake/plumbing; external callers should not use it.`); @@ -3352,7 +3421,7 @@ function emitClientSessionApiRegistration(lines: string[], clientSchema: Record< pushGoComment(lines, `Deprecated: ${interfaceName} contains deprecated APIs that will be removed in a future version.`); } if (groupExperimental) { - pushGoComment(lines, `Experimental: ${interfaceName} contains experimental APIs that may change or be removed.`); + pushGoExperimentalApiComment(lines, interfaceName); } lines.push(`type ${interfaceName} interface {`); for (const method of methods) { @@ -3360,7 +3429,7 @@ function emitClientSessionApiRegistration(lines: string[], clientSchema: Record< pushGoComment(lines, `Deprecated: ${clientHandlerMethodName(method.rpcMethod)} is deprecated and will be removed in a future version.`, "\t"); } if (method.stability === "experimental" && !groupExperimental) { - pushGoComment(lines, `Experimental: ${clientHandlerMethodName(method.rpcMethod)} is an experimental API and may change or be removed in future versions.`, "\t"); + pushGoExperimentalMethodComment(lines, clientHandlerMethodName(method.rpcMethod), "\t"); } const paramsType = resolveType(goParamsTypeName(method)); const resultSchema = getMethodResultSchema(method); diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index f9327f9d8..30aff56bd 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -24,6 +24,7 @@ import { isNodeFullyExperimental, isNodeFullyDeprecated, isSchemaDeprecated, + isSchemaExperimental, postProcessSchema, stripBooleanLiterals, writeGeneratedFile, @@ -43,6 +44,20 @@ import { // ── Utilities ─────────────────────────────────────────────────────────────── +type PyExperimentalSubject = "type" | "enum" | "event"; + +function pyExperimentalComment(subject: PyExperimentalSubject, indent = ""): string { + return `${indent}# Experimental: this ${subject} is part of an experimental API and may change or be removed.`; +} + +function pushPyExperimentalComment(lines: string[], subject: PyExperimentalSubject, indent = ""): void { + lines.push(pyExperimentalComment(subject, indent)); +} + +function pushPyExperimentalApiGroupComment(lines: string[]): void { + lines.push("# Experimental: this API group is experimental and may change or be removed."); +} + /** * Modernize quicktype's Python 3.7 output to Python 3.11+ syntax: * - Optional[T] → T | None @@ -478,6 +493,8 @@ interface PyEventVariant { dataClassName: string; dataSchema: JSONSchema7; dataDescription?: string; + eventExperimental: boolean; + dataExperimental: boolean; } interface PyEventEnvelopeProperty extends SessionEventEnvelopeProperty { @@ -650,6 +667,8 @@ function extractPyEventVariants(schema: JSONSchema7): PyEventVariant[] { dataClassName: `${toPascalCase(typeName)}Data`, dataSchema, dataDescription: dataSchema.description, + eventExperimental: isSchemaExperimental(variant), + dataExperimental: isSchemaExperimental(dataSchema), }; }); } @@ -721,7 +740,8 @@ function getOrCreatePyEnum( values: string[], ctx: PyCodegenCtx, description?: string, - deprecated?: boolean + deprecated?: boolean, + experimental?: boolean ): string { const existing = ctx.enumsByName.get(enumName); if (existing) { @@ -729,6 +749,9 @@ function getOrCreatePyEnum( } const lines: string[] = []; + if (experimental) { + pushPyExperimentalComment(lines, "enum"); + } if (deprecated) { lines.push(`# Deprecated: this enum is deprecated and will be removed in a future version.`); } @@ -761,7 +784,7 @@ function resolvePyPropertyType( const resolved = resolveSchema(propSchema, ctx.definitions); if (resolved && resolved !== propSchema) { if (resolved.enum && Array.isArray(resolved.enum) && resolved.enum.every((value) => typeof value === "string")) { - const enumType = getOrCreatePyEnum(typeName, resolved.enum as string[], ctx, resolved.description, isSchemaDeprecated(resolved)); + const enumType = getOrCreatePyEnum(typeName, resolved.enum as string[], ctx, resolved.description, isSchemaDeprecated(resolved), isSchemaExperimental(resolved)); const enumResolved: PyResolvedType = { annotation: enumType, fromExpr: (expr) => `parse_enum(${enumType}, ${expr})`, @@ -820,7 +843,8 @@ function resolvePyPropertyType( discriminator.property, discriminator.mapping, ctx, - propSchema.description + propSchema.description, + isSchemaExperimental(propSchema) ); const resolved: PyResolvedType = { annotation: nestedName, @@ -840,7 +864,8 @@ function resolvePyPropertyType( propSchema.enum as string[], ctx, propSchema.description, - isSchemaDeprecated(propSchema) + isSchemaDeprecated(propSchema), + isSchemaExperimental(propSchema) ); const resolved: PyResolvedType = { annotation: enumType, @@ -963,7 +988,8 @@ function resolvePyPropertyType( discriminator.property, discriminator.mapping, ctx, - items.description + items.description, + isSchemaExperimental(items) ); const resolved: PyResolvedType = { annotation: `list[${itemTypeName}]`, @@ -1032,7 +1058,8 @@ function emitPyClass( typeName: string, schema: JSONSchema7, ctx: PyCodegenCtx, - description?: string + description?: string, + experimental = isSchemaExperimental(schema) ): void { if (ctx.generatedNames.has(typeName)) { return; @@ -1063,6 +1090,9 @@ function emitPyClass( }); const lines: string[] = []; + if (experimental) { + pushPyExperimentalComment(lines, "type"); + } if (isSchemaDeprecated(schema)) { lines.push(`# Deprecated: this type is deprecated and will be removed in a future version.`); } @@ -1131,7 +1161,8 @@ function emitPyFlatDiscriminatedUnion( discriminatorProp: string, mapping: Map, ctx: PyCodegenCtx, - description?: string + description?: string, + experimental = false ): void { if (ctx.generatedNames.has(typeName)) { return; @@ -1173,7 +1204,9 @@ function emitPyFlatDiscriminatedUnion( typeName + toPascalCase(discriminatorProp), [...mapping.keys()], ctx, - description ? `${description} discriminator` : `${typeName} discriminator` + description ? `${description} discriminator` : `${typeName} discriminator`, + false, + experimental ); const fieldEntries: Array<[string, JSONSchema7, boolean]> = [ @@ -1219,6 +1252,9 @@ function emitPyFlatDiscriminatedUnion( }); const lines: string[] = []; + if (experimental) { + pushPyExperimentalComment(lines, "type"); + } lines.push(`@dataclass`); lines.push(`class ${typeName}:`); if (description) { @@ -1279,7 +1315,13 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { }; for (const variant of variants) { - emitPyClass(variant.dataClassName, variant.dataSchema, ctx, variant.dataDescription); + emitPyClass( + variant.dataClassName, + variant.dataSchema, + ctx, + variant.dataDescription, + variant.dataExperimental + ); } const envelopeProperties = getPySharedEventEnvelopeProperties(schema, ctx); const envelopePropertiesWithoutDefaults = envelopeProperties.filter((property) => !property.hasDefault); @@ -1288,6 +1330,9 @@ export function generatePythonSessionEventsCode(schema: JSONSchema7): string { const eventTypeLines: string[] = []; eventTypeLines.push(`class SessionEventType(Enum):`); for (const variant of variants) { + if (variant.eventExperimental) { + pushPyExperimentalComment(eventTypeLines, "event", " "); + } eventTypeLines.push(` ${toEnumMemberName(variant.typeName)} = ${JSON.stringify(variant.typeName)}`); } eventTypeLines.push(` UNKNOWN = "unknown"`); @@ -1740,6 +1785,11 @@ async function generateRpc(schemaPath?: string): Promise { // Annotate experimental data types const experimentalTypeNames = new Set(); + for (const [definitionName, definition] of Object.entries(allDefinitions)) { + if (typeof definition === "object" && definition !== null && isSchemaExperimental(definition as JSONSchema7)) { + experimentalTypeNames.add(definitionName); + } + } for (const method of allMethods) { if (method.stability !== "experimental") continue; experimentalTypeNames.add(pythonResultTypeName(method)); @@ -1751,7 +1801,7 @@ async function generateRpc(schemaPath?: string): Promise { for (const typeName of experimentalTypeNames) { typesCode = typesCode.replace( new RegExp(`^(@dataclass\\n)?class ${typeName}[:(]`, "m"), - (match) => `# Experimental: this type is part of an experimental API and may change or be removed.\n${match}` + (match) => `${pyExperimentalComment("type")}\n${match}` ); } @@ -1916,7 +1966,7 @@ function emitPyApiGroup( lines.push(`# Deprecated: this API group is deprecated and will be removed in a future version.`); } if (groupExperimental) { - lines.push(`# Experimental: this API group is experimental and may change or be removed.`); + pushPyExperimentalApiGroupComment(lines); } lines.push(`class ${apiName}:`); if (isSession) { @@ -2105,7 +2155,7 @@ function emitClientSessionApiRegistration( lines.push(`# Deprecated: this API group is deprecated and will be removed in a future version.`); } if (groupExperimental) { - lines.push(`# Experimental: this API group is experimental and may change or be removed.`); + pushPyExperimentalApiGroupComment(lines); } lines.push(`class ${handlerName}(Protocol):`); for (const [methodName, value] of Object.entries(groupNode as Record)) { diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index c9ed49aca..4300c70e4 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -4,7 +4,10 @@ * Reads api.schema.json and session-events.schema.json, emits idiomatic Rust * types to rust/src/generated/. * - * Usage: npx tsx scripts/codegen/rust.ts + * Usage: + * npx tsx scripts/codegen/rust.ts + * npx tsx scripts/codegen/rust.ts + * npx tsx scripts/codegen/rust.ts */ import { execFile } from "child_process"; @@ -26,6 +29,7 @@ import { isObjectSchema, isRpcMethod, isSchemaDeprecated, + isSchemaExperimental, isVoidSchema, postProcessSchema, refTypeName, @@ -224,6 +228,7 @@ function tryEmitRustDiscriminatedUnion( lines.push(`/// ${line}`); } } + pushRustExperimentalDocs(lines, isSchemaExperimental(schema)); lines.push("#[derive(Debug, Clone, Serialize, Deserialize)]"); lines.push("#[serde(untagged)]"); lines.push(`pub enum ${enumName} {`); @@ -248,6 +253,25 @@ function makeCtx(definitions?: DefinitionCollections): RustCodegenCtx { }; } +function pushRustExperimentalDocs( + lines: string[], + experimental: boolean, + indent = "", +): void { + if (!experimental) return; + lines.push(`${indent}///`); + lines.push(`${indent}///
`); + lines.push(`${indent}///`); + lines.push( + `${indent}/// **Experimental.** This type is part of an experimental wire-protocol surface`, + ); + lines.push( + `${indent}/// and may change or be removed in future SDK or CLI releases.`, + ); + lines.push(`${indent}///`); + lines.push(`${indent}///
`); +} + // ── Type resolution ───────────────────────────────────────────────────────── /** @@ -276,6 +300,7 @@ function resolveRustType( resolved.enum as string[], ctx, resolved.description, + isSchemaExperimental(resolved), ); return wrapOption(typeName, isRequired); } @@ -377,6 +402,7 @@ function resolveRustType( propSchema.enum as string[], ctx, propSchema.description, + isSchemaExperimental(propSchema), ); return wrapOption(enumName, isRequired); } @@ -512,6 +538,7 @@ function emitRustStruct( lines.push(`/// ${line}`); } } + pushRustExperimentalDocs(lines, isSchemaExperimental(schema)); if (isSchemaDeprecated(schema)) { lines.push("#[deprecated]"); } @@ -575,6 +602,7 @@ function emitRustStringEnum( values: string[], ctx: RustCodegenCtx, description?: string, + experimental = false, ): void { if (ctx.generatedNames.has(enumName)) return; ctx.generatedNames.add(enumName); @@ -585,6 +613,7 @@ function emitRustStringEnum( lines.push(`/// ${line}`); } } + pushRustExperimentalDocs(lines, experimental); lines.push("#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]"); lines.push(`pub enum ${enumName} {`); @@ -644,6 +673,10 @@ interface EventVariant { dataSchema: JSONSchema7; /** Description of the event */ description?: string; + /** Whether the event definition is experimental. */ + eventExperimental: boolean; + /** Whether the event data definition is experimental. */ + dataExperimental: boolean; } function extractEventVariants(schema: JSONSchema7): EventVariant[] { @@ -688,6 +721,8 @@ function extractEventVariants(schema: JSONSchema7): EventVariant[] { dataClassName: `${toPascalCase(typeName)}Data`, dataSchema, description: resolvedVariant.description || dataSchema.description, + eventExperimental: isSchemaExperimental(resolvedVariant), + dataExperimental: isSchemaExperimental(dataSchema), }; }) .filter((v) => !EXCLUDED_EVENT_TYPES.has(v.typeName)); @@ -717,6 +752,11 @@ function generateSessionEventsCode(schema: JSONSchema7): string { ); typeEnumLines.push("pub enum SessionEventType {"); for (const variant of variants) { + pushRustExperimentalDocs( + typeEnumLines, + variant.eventExperimental, + " ", + ); typeEnumLines.push(` #[serde(rename = "${variant.typeName}")]`); typeEnumLines.push(` ${variant.variantName},`); } @@ -738,6 +778,11 @@ function generateSessionEventsCode(schema: JSONSchema7): string { dataEnumLines.push(`#[serde(tag = "type", content = "data")]`); dataEnumLines.push("pub enum SessionEventData {"); for (const variant of variants) { + pushRustExperimentalDocs( + dataEnumLines, + variant.dataExperimental, + " ", + ); dataEnumLines.push(` #[serde(rename = "${variant.typeName}")]`); dataEnumLines.push(` ${variant.variantName}(${variant.dataClassName}),`); } @@ -880,6 +925,7 @@ function generateApiTypesCode(apiSchema: ApiSchema): string { schema.enum as string[], ctx, schema.description, + isSchemaExperimental(schema), ); } else if (isObjectSchema(schema)) { emitRustStruct(name, schema, ctx, schema.description); @@ -1272,8 +1318,7 @@ function generateRpcCode(apiSchema: ApiSchema): string { out.push("#![allow(missing_docs)]"); out.push("#![allow(clippy::too_many_arguments)]"); out.push(""); - out.push("use super::api_types::*;"); - out.push("use super::api_types::rpc_methods;"); + out.push("use super::api_types::{rpc_methods, *};"); out.push("use crate::session::Session;"); out.push("use crate::{Client, Error};"); out.push(""); @@ -1346,11 +1391,30 @@ async function rustfmt(filePath: string): Promise { // ── Main ──────────────────────────────────────────────────────────────────── +function parseSchemaArgs(): { + sessionEventsSchemaPath?: string; + apiSchemaPath?: string; +} { + const [firstArg, secondArg] = process.argv.slice(2); + if (secondArg) { + return { + sessionEventsSchemaPath: firstArg, + apiSchemaPath: secondArg, + }; + } + + return { + apiSchemaPath: firstArg, + }; +} + async function generate(): Promise { console.log("Loading schemas..."); - const sessionEventsSchemaPath = await getSessionEventsSchemaPath(); - const apiSchemaPath = await getApiSchemaPath(process.argv[2]); + const schemaArgs = parseSchemaArgs(); + const sessionEventsSchemaPath = + schemaArgs.sessionEventsSchemaPath || (await getSessionEventsSchemaPath()); + const apiSchemaPath = await getApiSchemaPath(schemaArgs.apiSchemaPath); const sessionEventsRaw = JSON.parse( await fs.readFile(sessionEventsSchemaPath, "utf-8"), diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 5fdb829ee..0e6922e13 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -26,15 +26,49 @@ import { isNodeFullyExperimental, isNodeFullyDeprecated, isVoidSchema, + isSchemaExperimental, type ApiSchema, type DefinitionCollections, type RpcMethod, } from "./utils.js"; +const TS_EXPERIMENTAL_JSDOC = "/** @experimental */"; + +function tsExperimentalJSDoc(indent = ""): string { + return `${indent}${TS_EXPERIMENTAL_JSDOC}`; +} + function toPascalCase(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function experimentalDefinitionNames(definitions: DefinitionCollections): Set { + const names = new Set(); + for (const defs of [definitions.definitions, definitions.$defs]) { + for (const [name, def] of Object.entries(defs ?? {})) { + if (typeof def === "object" && def !== null && isSchemaExperimental(def as JSONSchema7)) { + names.add(name); + } + } + } + return names; +} + +function annotateTypeScriptTypes(code: string, typeNames: Iterable, annotation: string): string { + let annotated = code; + for (const typeName of typeNames) { + annotated = annotated.replace( + new RegExp(`(^|\\n)(export (?:interface|type|enum) ${escapeRegExp(typeName)}\\b)`, "m"), + `$1${annotation}\n$2` + ); + } + return annotated; +} + function appendUniqueExportBlocks(output: string[], compiled: string, seenBlocks: Map): void { for (const block of splitExportBlocks(compiled)) { const nameMatch = /^export\s+(?:interface|type)\s+(\w+)/m.exec(block); @@ -212,7 +246,8 @@ async function generateSessionEvents(schemaPath?: string): Promise { additionalProperties: false, }); - const outPath = await writeGeneratedFile("nodejs/src/generated/session-events.ts", ts); + const annotatedTs = annotateTypeScriptTypes(ts, experimentalDefinitionNames(definitionCollections), TS_EXPERIMENTAL_JSDOC); + const outPath = await writeGeneratedFile("nodejs/src/generated/session-events.ts", annotatedTs); console.log(` ✓ ${outPath}`); } @@ -335,7 +370,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; ); // Track which type names come from experimental methods for JSDoc annotations. - const experimentalTypes = new Set(); + const experimentalTypes = experimentalDefinitionNames(collectDefinitionCollections(combinedSchema as Record)); // Track which type names come from deprecated methods for JSDoc annotations. const deprecatedTypes = new Set(); // Types are tagged @internal directly via `visibility: "internal"` on the JSON Schema @@ -352,11 +387,12 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; for (const method of [...allMethods, ...clientSessionMethods]) { const resultSchema = getMethodResultSchema(method); if (!isVoidSchema(resultSchema) && !getNullableInner(resultSchema)) { + const resultSource = schemaSourceForNamedDefinition(method.result, resultSchema); combinedSchema.definitions![resultTypeName(method)] = withRootTitle( - schemaSourceForNamedDefinition(method.result, resultSchema), + resultSource, resultTypeName(method) ); - if (method.stability === "experimental") { + if (method.stability === "experimental" || isSchemaExperimental(resultSource)) { experimentalTypes.add(resultTypeName(method)); } if (method.deprecated && !method.result?.$ref) { @@ -379,7 +415,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; filtered, paramsTypeName(method) ); - if (method.stability === "experimental") { + if (method.stability === "experimental" || isSchemaExperimental(filtered)) { experimentalTypes.add(paramsTypeName(method)); } if (method.deprecated) { @@ -387,11 +423,12 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; } } } else { + const paramsSource = schemaSourceForNamedDefinition(method.params, resolvedParams); combinedSchema.definitions![paramsTypeName(method)] = withRootTitle( - schemaSourceForNamedDefinition(method.params, resolvedParams), + paramsSource, paramsTypeName(method) ); - if (method.stability === "experimental") { + if (method.stability === "experimental" || isSchemaExperimental(paramsSource)) { experimentalTypes.add(paramsTypeName(method)); } if (method.deprecated && !method.params?.$ref) { @@ -420,14 +457,8 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; .trim(); if (strippedTs) { - // Add @experimental JSDoc annotations for types from experimental methods - let annotatedTs = strippedTs; - for (const expType of experimentalTypes) { - annotatedTs = annotatedTs.replace( - new RegExp(`(^|\\n)(export (?:interface|type) ${expType}\\b)`, "m"), - `$1/** @experimental */\n$2` - ); - } + // Add @experimental JSDoc annotations for types from experimental methods or schemas. + let annotatedTs = annotateTypeScriptTypes(strippedTs, experimentalTypes, TS_EXPERIMENTAL_JSDOC); // Add @deprecated JSDoc annotations for types from deprecated methods for (const depType of deprecatedTypes) { annotatedTs = annotatedTs.replace( @@ -567,7 +598,7 @@ function emitGroup( lines.push(`${indent}/** @deprecated */`); } if ((value as RpcMethod).stability === "experimental" && !parentExperimental) { - lines.push(`${indent}/** @experimental */`); + lines.push(tsExperimentalJSDoc(indent)); } lines.push(`${indent}${key}: async (${sigParams.join(", ")}): Promise<${resultType}> =>`); lines.push(`${indent} connection.sendRequest("${rpcMethod}", ${bodyArg}),`); @@ -588,7 +619,7 @@ function emitGroup( lines.push(`${indent}/** @deprecated */`); } if (groupExperimental) { - lines.push(`${indent}/** @experimental */`); + lines.push(tsExperimentalJSDoc(indent)); } lines.push(`${indent}${key}: {`); lines.push(...childLines); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index a071dc6ae..85d7c1acf 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -445,6 +445,11 @@ export function isSchemaDeprecated(schema: JSONSchema7 | null | undefined): bool return typeof schema === "object" && schema !== null && (schema as Record).deprecated === true; } +/** Returns true when a JSON Schema node is marked as experimental. */ +export function isSchemaExperimental(schema: JSONSchema7 | null | undefined): boolean { + return typeof schema === "object" && schema !== null && (schema as Record).stability === "experimental"; +} + // ── $ref resolution ───────────────────────────────────────────────────────── /** Extract the generated type name from a `$ref` path (e.g. "#/definitions/Model" → "Model"). */ From 4a3f210326376d4e90ac792cd13158054f66fd77 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 12 May 2026 16:48:57 -0400 Subject: [PATCH 31/33] Normalize skill context snapshots (#1269) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/harness/replayingCapiProxy.test.ts | 39 +++++++++++++++++++ test/harness/replayingCapiProxy.ts | 10 ++++- ...and_apply_skill_from_skilldirectories.yaml | 9 ----- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/test/harness/replayingCapiProxy.test.ts b/test/harness/replayingCapiProxy.test.ts index c7abf01f2..b02a36e22 100644 --- a/test/harness/replayingCapiProxy.test.ts +++ b/test/harness/replayingCapiProxy.test.ts @@ -370,6 +370,45 @@ describe("ReplayingCapiProxy", () => { expect(result.conversations[0].messages[0].content).toBe("Say hello."); }); + test("strips skill metadata frontmatter from skill-context user messages", async () => { + const skillDir = path.join(workDir, ".test_skills", "test-skill"); + const requestBody = JSON.stringify({ + messages: [ + { + role: "user", + content: ` +Base directory for this skill: ${skillDir} + +--- +name: test-skill +description: A test skill that adds a marker to responses +--- + +# Test Skill Instructions + +Always include PINEAPPLE_COCONUT_42. +`, + }, + ], + }); + const responseBody = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "OK!" } }], + }); + + const outputPath = await createProxy([ + { url: "/chat/completions", requestBody, responseBody }, + ]); + + const result = await readYamlOutput(outputPath); + expect(result.conversations[0].messages[0].content).toBe(` +Base directory for this skill: ${workingDirPlaceholder}/.test_skills/test-skill + +# Test Skill Instructions + +Always include PINEAPPLE_COCONUT_42. +`); + }); + test("applies tool result normalizers to tool response content", async () => { const requestBody = JSON.stringify({ messages: [ diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 3007a80df..328980c3f 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -1032,7 +1032,7 @@ function transformOpenAIRequestMessage( } function normalizeUserMessage(content: string): string { - return content + return normalizeSkillContextFrontmatter(content) .replace(/.*?<\/current_datetime>/g, "") .replace(/[\s\S]*?<\/reminder>/g, "") .replace(/[\s\S]*?<\/system_reminder>/g, "") @@ -1044,6 +1044,14 @@ function normalizeUserMessage(content: string): string { .trim(); } +function normalizeSkillContextFrontmatter(content: string): string { + // Runtime versions may include or omit SKILL.md metadata in the prompt context. + return content.replace( + /(]*>\s*Base directory for this skill:[^\r\n]*(?:\r?\n)+)---\r?\n(?:(?!<\/skill-context>)[\s\S])*?\r?\n---(?:\r?\n)+/g, + "$1", + ); +} + function normalizeLargeOutputFilepaths(result: string): string { // Replaces filenames like 1774637043987-copilot-tool-output-tk7puw.txt with PLACEHOLDER-copilot-tool-output-PLACEHOLDER return result diff --git a/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml b/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml index 7d364fcbd..38b35946b 100644 --- a/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml +++ b/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml @@ -23,15 +23,6 @@ conversations: Base directory for this skill: ${workdir}/.test_skills/test-skill - --- - - name: test-skill - - description: A test skill that adds a marker to responses - - --- - - # Test Skill Instructions From a9c763e9b5dc5cb266bfd5de168f313485b73441 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 20:53:13 -0400 Subject: [PATCH 32/33] Update @github/copilot to 1.0.46 (#1270) * Update @github/copilot to 1.0.46 - Updated nodejs and test harness dependencies - Re-ran code generators - Formatted generated code * Add missing model_picker fields to Rust test fixtures The 1.0.46 update added `model_picker_category` and `model_picker_price_category` fields to the `Model` type. Update the Rust test fixtures in lib.rs and tests/e2e/client.rs to initialize these new fields so clippy passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Timothy Clem Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Generated/Rpc.cs | 141 +++++++++++++++++++++++++++++++ go/rpc/zrpc.go | 23 +++++ nodejs/package-lock.json | 56 ++++++------ nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 2 +- nodejs/src/generated/rpc.ts | 16 ++++ python/copilot/generated/rpc.py | 37 +++++++- rust/src/generated/api_types.rs | 36 ++++++++ rust/src/lib.rs | 4 + rust/tests/e2e/client.rs | 2 + test/harness/package-lock.json | 56 ++++++------ test/harness/package.json | 2 +- 12 files changed, 316 insertions(+), 61 deletions(-) diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 29a3ab89e..d51dff03e 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -169,6 +169,14 @@ public sealed class Model [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + /// Model capability category for grouping in the model picker. + [JsonPropertyName("modelPickerCategory")] + public ModelPickerCategory? ModelPickerCategory { get; set; } + + /// Relative cost tier for token-based billing users. + [JsonPropertyName("modelPickerPriceCategory")] + public ModelPickerPriceCategory? ModelPickerPriceCategory { get; set; } + /// Display name. [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; @@ -2876,6 +2884,139 @@ public sealed class SessionFsRenameRequest public string Src { get; set; } = string.Empty; } +/// Model capability category for grouping in the model picker. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ModelPickerCategory : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ModelPickerCategory(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the lightweight value. + public static ModelPickerCategory Lightweight { get; } = new("lightweight"); + + /// Gets the versatile value. + public static ModelPickerCategory Versatile { get; } = new("versatile"); + + /// Gets the powerful value. + public static ModelPickerCategory Powerful { get; } = new("powerful"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ModelPickerCategory left, ModelPickerCategory right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ModelPickerCategory left, ModelPickerCategory right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ModelPickerCategory other && Equals(other); + + /// + public bool Equals(ModelPickerCategory other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ModelPickerCategory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ModelPickerCategory value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ModelPickerCategory)); + } + } +} + + +/// Relative cost tier for token-based billing users. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ModelPickerPriceCategory : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ModelPickerPriceCategory(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Gets the low value. + public static ModelPickerPriceCategory Low { get; } = new("low"); + + /// Gets the medium value. + public static ModelPickerPriceCategory Medium { get; } = new("medium"); + + /// Gets the high value. + public static ModelPickerPriceCategory High { get; } = new("high"); + + /// Gets the very_high value. + public static ModelPickerPriceCategory VeryHigh { get; } = new("very_high"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ModelPickerPriceCategory left, ModelPickerPriceCategory right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ModelPickerPriceCategory left, ModelPickerPriceCategory right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ModelPickerPriceCategory other && Equals(other); + + /// + public bool Equals(ModelPickerPriceCategory other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ModelPickerPriceCategory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ModelPickerPriceCategory value, JsonSerializerOptions options) + { + GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ModelPickerPriceCategory)); + } + } +} + + /// Configuration source. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 59f5c9b57..81e84d3c0 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -689,6 +689,10 @@ type Model struct { DefaultReasoningEffort *string `json:"defaultReasoningEffort,omitempty"` // Model identifier (e.g., "claude-sonnet-4.5") ID string `json:"id"` + // Model capability category for grouping in the model picker + ModelPickerCategory *ModelPickerCategory `json:"modelPickerCategory,omitempty"` + // Relative cost tier for token-based billing users + ModelPickerPriceCategory *ModelPickerPriceCategory `json:"modelPickerPriceCategory,omitempty"` // Display name Name string `json:"name"` // Policy state (if applicable) @@ -2209,6 +2213,25 @@ const ( McpServerStatusPending McpServerStatus = "pending" ) +// Model capability category for grouping in the model picker +type ModelPickerCategory string + +const ( + ModelPickerCategoryLightweight ModelPickerCategory = "lightweight" + ModelPickerCategoryPowerful ModelPickerCategory = "powerful" + ModelPickerCategoryVersatile ModelPickerCategory = "versatile" +) + +// Relative cost tier for token-based billing users +type ModelPickerPriceCategory string + +const ( + ModelPickerPriceCategoryHigh ModelPickerPriceCategory = "high" + ModelPickerPriceCategoryLow ModelPickerPriceCategory = "low" + ModelPickerPriceCategoryMedium ModelPickerPriceCategory = "medium" + ModelPickerPriceCategoryVeryHigh ModelPickerPriceCategory = "very_high" +) + // Kind discriminator for PermissionDecisionApproveForLocationApproval. type PermissionDecisionApproveForLocationApprovalKind string diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index a2f495463..97dbbd3d7 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.45", + "@github/copilot": "^1.0.46", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -663,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.45.tgz", - "integrity": "sha512-2QADgQcw/d0GFqTq2+nHwX152ZRvZxW0CHONG5d1RCs6YJtdr/GdbnMYYeRH2BiBIhnfkcvF50ImCRvsS5Tnwg==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.46.tgz", + "integrity": "sha512-e3gxCj8DLGesTAZQ5+jCCbCxe3lMyjKfs5eLgER/SID8Rcb7YpgBXoUvOn3eXxLSsJEmJ3GagHaaHDkf3Zm+Ng==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.45", - "@github/copilot-darwin-x64": "1.0.45", - "@github/copilot-linux-arm64": "1.0.45", - "@github/copilot-linux-x64": "1.0.45", - "@github/copilot-win32-arm64": "1.0.45", - "@github/copilot-win32-x64": "1.0.45" + "@github/copilot-darwin-arm64": "1.0.46", + "@github/copilot-darwin-x64": "1.0.46", + "@github/copilot-linux-arm64": "1.0.46", + "@github/copilot-linux-x64": "1.0.46", + "@github/copilot-win32-arm64": "1.0.46", + "@github/copilot-win32-x64": "1.0.46" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.45.tgz", - "integrity": "sha512-gCJy1nOIWL5lpLFJTRk2Kz7bS30emkA4p4gM+PJ5/dOwNRBOyUO0/2f03/m5vYL4DNd/T47cFIN6s82gISAIYQ==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.46.tgz", + "integrity": "sha512-zbhXuRguCdDgeIZKH+rjgBM/6CDMUmhLMck8w9XFDxUY2wrP7MSWXuX8yA4/1H3ySOTZMIH1G5DQpWh+npmR2Q==", "cpu": [ "arm64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.45.tgz", - "integrity": "sha512-nLzC7C0i/WAY+4FukHuONBDNeKUAqBBab3n36aEdpqxVDP5h2Tbzg2yShqav2blR7KDJL7YMcYTVFxmwfQj+yQ==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.46.tgz", + "integrity": "sha512-kSUcV6cARhM+b/BuNSQtazbORTetRjIWpO3SqWSmH+2UoeZP5A5x+ipr7mhshq+E+pcWPeQKMGbKGY3lrCSMFw==", "cpu": [ "x64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.45.tgz", - "integrity": "sha512-MdRNZUNMrI0dpQ+DiDoZQ7AbitQp9eN7ir176Za2Kf7dkUxPwmio32yhRbBS81McU6vBw8cCzEZviwv/jc8buQ==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.46.tgz", + "integrity": "sha512-Tz3F0LuGFbOvvv0VKQJ4E5XYBsTdqTNMAwOhbkwX6TuKMX88uLJNKP5uPf6yuu1z3J3nt/5rfEd9CxVrZbnqLA==", "cpu": [ "arm64" ], @@ -728,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.45.tgz", - "integrity": "sha512-xSRUjWA+wrSSjktJSjNtiS/47Cy0PviPejj7RUmtChsPfDJB8wW2iZ6NfpdiAomtxAz5xx4AjbjT1I4b1FqnwA==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.46.tgz", + "integrity": "sha512-s9JWe/YE78I7QEeXrvDGHB5x2XnnkegUJYVE9QR2DI/qLXviHMarM3akOUhed21uVqzoiLPacXKZcTcaDO8tOg==", "cpu": [ "x64" ], @@ -744,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.45.tgz", - "integrity": "sha512-lhcTlKs7MWMzIXv21hUSpL4aFW49jqVhNrQKaB8sYk2nzvGRJvNwTcBS1Tn5ndXlPzQ9P/p9B6B5uwwmZ1vHHw==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.46.tgz", + "integrity": "sha512-auX8o8vG8A+rdSthvey1D8q3o6lNlNIfHFjoBU0Z9Fxid6Ghz2paaAn0/Uwz9Ev8W8cn/5C5kEPs3niMXSh4Jw==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.45.tgz", - "integrity": "sha512-XYZ983NQmooVr/n+pCnHIorBmf1hd3o1rMlSAodwG/VFlQaydGoOs1F1NntxWBoFAND+eM6N4PZfw8M8sRayfA==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.46.tgz", + "integrity": "sha512-iXo9TUqtSxqlBfC+SZSQMrctKJpWR19zr+8dk7hczE42gOVB0/A+NySJwCmY3UFAEY98lbLDjIC+NCbYFcpEHA==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index a9cd04a70..60002a2aa 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.45", + "@github/copilot": "^1.0.46", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 1a019f262..f2208347e 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.45", + "@github/copilot": "^1.0.46", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index ce95493cb..57aeb6f69 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -146,6 +146,20 @@ export type McpServerStatus = "connected" | "failed" | "needs-auth" | "pending" * via the `definition` "McpServerSource". */ export type McpServerSource = "user" | "workspace" | "plugin" | "builtin"; +/** + * Model capability category for grouping in the model picker + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ModelPickerCategory". + */ +export type ModelPickerCategory = "lightweight" | "versatile" | "powerful"; +/** + * Relative cost tier for token-based billing users + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ModelPickerPriceCategory". + */ +export type ModelPickerPriceCategory = "low" | "medium" | "high" | "very_high"; /** * The agent mode. Valid values: "interactive", "plan", "autopilot". * @@ -1088,6 +1102,8 @@ export interface Model { * Default reasoning effort level (only present if model supports reasoning effort) */ defaultReasoningEffort?: string; + modelPickerCategory?: ModelPickerCategory; + modelPickerPriceCategory?: ModelPickerPriceCategory; } /** * Model capabilities and limits diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 91d3f7474..c4f6fd56b 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -996,6 +996,14 @@ def to_dict(self) -> dict: result["vision"] = from_union([from_bool, from_none], self.vision) return result +class ModelPickerPriceCategory(Enum): + """Relative cost tier for token-based billing users""" + + HIGH = "high" + LOW = "low" + MEDIUM = "medium" + VERY_HIGH = "very_high" + @dataclass class ModelPolicy: """Policy state (if applicable)""" @@ -5495,6 +5503,13 @@ def to_dict(self) -> dict: result["supports"] = from_union([lambda x: to_class(ModelCapabilitiesSupports, x), from_none], self.supports) return result +class ModelPickerCategory(Enum): + """Model capability category for grouping in the model picker""" + + LIGHTWEIGHT = "lightweight" + POWERFUL = "powerful" + VERSATILE = "versatile" + @dataclass class Model: capabilities: ModelCapabilities @@ -5512,6 +5527,12 @@ class Model: default_reasoning_effort: str | None = None """Default reasoning effort level (only present if model supports reasoning effort)""" + model_picker_category: ModelPickerCategory | None = None + """Model capability category for grouping in the model picker""" + + model_picker_price_category: ModelPickerPriceCategory | None = None + """Relative cost tier for token-based billing users""" + policy: ModelPolicy | None = None """Policy state (if applicable)""" @@ -5526,9 +5547,11 @@ def from_dict(obj: Any) -> 'Model': name = from_str(obj.get("name")) billing = from_union([ModelBilling.from_dict, from_none], obj.get("billing")) default_reasoning_effort = from_union([from_str, from_none], obj.get("defaultReasoningEffort")) + model_picker_category = from_union([ModelPickerCategory, from_none], obj.get("modelPickerCategory")) + model_picker_price_category = from_union([ModelPickerPriceCategory, from_none], obj.get("modelPickerPriceCategory")) policy = from_union([ModelPolicy.from_dict, from_none], obj.get("policy")) supported_reasoning_efforts = from_union([lambda x: from_list(from_str, x), from_none], obj.get("supportedReasoningEfforts")) - return Model(capabilities, id, name, billing, default_reasoning_effort, policy, supported_reasoning_efforts) + return Model(capabilities, id, name, billing, default_reasoning_effort, model_picker_category, model_picker_price_category, policy, supported_reasoning_efforts) def to_dict(self) -> dict: result: dict = {} @@ -5539,6 +5562,10 @@ def to_dict(self) -> dict: result["billing"] = from_union([lambda x: to_class(ModelBilling, x), from_none], self.billing) if self.default_reasoning_effort is not None: result["defaultReasoningEffort"] = from_union([from_str, from_none], self.default_reasoning_effort) + if self.model_picker_category is not None: + result["modelPickerCategory"] = from_union([lambda x: to_enum(ModelPickerCategory, x), from_none], self.model_picker_category) + if self.model_picker_price_category is not None: + result["modelPickerPriceCategory"] = from_union([lambda x: to_enum(ModelPickerPriceCategory, x), from_none], self.model_picker_price_category) if self.policy is not None: result["policy"] = from_union([lambda x: to_class(ModelPolicy, x), from_none], self.policy) if self.supported_reasoning_efforts is not None: @@ -5956,6 +5983,8 @@ class RPC: model_capabilities_override_supports: ModelCapabilitiesOverrideSupports model_capabilities_supports: ModelCapabilitiesSupports model_list: ModelList + model_picker_category: ModelPickerCategory + model_picker_price_category: ModelPickerPriceCategory model_policy: ModelPolicy models_list_request: ModelsListRequest model_switch_to_request: ModelSwitchToRequest @@ -6196,6 +6225,8 @@ def from_dict(obj: Any) -> 'RPC': model_capabilities_override_supports = ModelCapabilitiesOverrideSupports.from_dict(obj.get("ModelCapabilitiesOverrideSupports")) model_capabilities_supports = ModelCapabilitiesSupports.from_dict(obj.get("ModelCapabilitiesSupports")) model_list = ModelList.from_dict(obj.get("ModelList")) + model_picker_category = ModelPickerCategory(obj.get("ModelPickerCategory")) + model_picker_price_category = ModelPickerPriceCategory(obj.get("ModelPickerPriceCategory")) model_policy = ModelPolicy.from_dict(obj.get("ModelPolicy")) models_list_request = ModelsListRequest.from_dict(obj.get("ModelsListRequest")) model_switch_to_request = ModelSwitchToRequest.from_dict(obj.get("ModelSwitchToRequest")) @@ -6342,7 +6373,7 @@ def from_dict(obj: Any) -> 'RPC': workspaces_list_files_result = WorkspacesListFilesResult.from_dict(obj.get("WorkspacesListFilesResult")) workspaces_read_file_request = WorkspacesReadFileRequest.from_dict(obj.get("WorkspacesReadFileRequest")) workspaces_read_file_result = WorkspacesReadFileResult.from_dict(obj.get("WorkspacesReadFileResult")) - return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, queued_command_handled, queued_command_not_handled, queued_command_result, remote_enable_result, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) + return RPC(account_get_quota_request, account_get_quota_result, account_quota_snapshot, agent_get_current_result, agent_info, agent_list, agent_reload_result, agent_select_request, agent_select_result, auth_info_type, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, connect_request, connect_result, current_model, discovered_mcp_server, discovered_mcp_server_source, discovered_mcp_server_type, embedded_blob_resource_contents, embedded_text_resource_contents, extension, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, filter_mapping_string, filter_mapping_value, fleet_start_request, fleet_start_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_compact_context_window, history_compact_result, history_truncate_request, history_truncate_result, instructions_get_sources_result, instructions_sources, instructions_sources_location, instructions_sources_type, log_request, log_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_oauth_login_request, mcp_oauth_login_result, mcp_server, mcp_server_config, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_local, mcp_server_config_local_type, mcp_server_list, mcp_server_source, mcp_server_status, model, model_billing, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_picker_category, model_picker_price_category, model_policy, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, name_get_result, name_set_request, permission_decision, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_request_result, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, ping_request, ping_result, plan_read_result, plan_update_request, plugin, plugin_list, queued_command_handled, queued_command_not_handled, queued_command_result, remote_enable_result, server_skill, server_skill_list, session_auth_status, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_log_level, session_mode, sessions_fork_request, sessions_fork_result, shell_exec_request, shell_exec_result, shell_kill_request, shell_kill_result, shell_kill_signal, skill, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, task_agent_info, task_agent_info_execution_mode, task_agent_info_status, task_info, task_list, tasks_cancel_request, tasks_cancel_result, task_shell_info, task_shell_info_attachment_mode, task_shell_info_execution_mode, task_shell_info_status, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, tool, tool_list, tools_list_request, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_handle_pending_elicitation_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, workspaces_create_file_request, workspaces_get_workspace_result, workspaces_list_files_result, workspaces_read_file_request, workspaces_read_file_result) def to_dict(self) -> dict: result: dict = {} @@ -6436,6 +6467,8 @@ def to_dict(self) -> dict: result["ModelCapabilitiesOverrideSupports"] = to_class(ModelCapabilitiesOverrideSupports, self.model_capabilities_override_supports) result["ModelCapabilitiesSupports"] = to_class(ModelCapabilitiesSupports, self.model_capabilities_supports) result["ModelList"] = to_class(ModelList, self.model_list) + result["ModelPickerCategory"] = to_enum(ModelPickerCategory, self.model_picker_category) + result["ModelPickerPriceCategory"] = to_enum(ModelPickerPriceCategory, self.model_picker_price_category) result["ModelPolicy"] = to_class(ModelPolicy, self.model_policy) result["ModelsListRequest"] = to_class(ModelsListRequest, self.models_list_request) result["ModelSwitchToRequest"] = to_class(ModelSwitchToRequest, self.model_switch_to_request) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 1c5ca509d..35032e8c0 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -932,6 +932,12 @@ pub struct Model { pub default_reasoning_effort: Option, /// Model identifier (e.g., "claude-sonnet-4.5") pub id: String, + /// Model capability category for grouping in the model picker + #[serde(skip_serializing_if = "Option::is_none")] + pub model_picker_category: Option, + /// Relative cost tier for token-based billing users + #[serde(skip_serializing_if = "Option::is_none")] + pub model_picker_price_category: Option, /// Display name pub name: String, /// Policy state (if applicable) @@ -3152,6 +3158,36 @@ pub enum McpServerConfigLocalType { Unknown, } +/// Model capability category for grouping in the model picker +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModelPickerCategory { + #[serde(rename = "lightweight")] + Lightweight, + #[serde(rename = "versatile")] + Versatile, + #[serde(rename = "powerful")] + Powerful, + /// Unknown variant for forward compatibility. + #[serde(other)] + Unknown, +} + +/// Relative cost tier for token-based billing users +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModelPickerPriceCategory { + #[serde(rename = "low")] + Low, + #[serde(rename = "medium")] + Medium, + #[serde(rename = "high")] + High, + #[serde(rename = "very_high")] + VeryHigh, + /// Unknown variant for forward compatibility. + #[serde(other)] + Unknown, +} + /// The agent mode. Valid values: "interactive", "plan", "autopilot". #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionMode { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index e0a724fd1..0c3117423 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2440,6 +2440,8 @@ mod tests { }, default_reasoning_effort: None, id: "byok-gpt-4".into(), + model_picker_category: None, + model_picker_price_category: None, name: "BYOK GPT-4".into(), policy: None, supported_reasoning_efforts: Vec::new(), @@ -2483,6 +2485,8 @@ mod tests { }, default_reasoning_effort: None, id: "single-flight-model".into(), + model_picker_category: None, + model_picker_price_category: None, name: "Single Flight Model".into(), policy: None, supported_reasoning_efforts: Vec::new(), diff --git a/rust/tests/e2e/client.rs b/rust/tests/e2e/client.rs index a2e431f62..7436159ed 100644 --- a/rust/tests/e2e/client.rs +++ b/rust/tests/e2e/client.rs @@ -269,6 +269,8 @@ impl ListModelsHandler for CountingModelsHandler { }, default_reasoning_effort: None, id: "custom-handler-model".to_string(), + model_picker_category: None, + model_picker_price_category: None, name: "Custom Handler Model".to_string(), policy: None, supported_reasoning_efforts: Vec::new(), diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 2b06abfa4..017220925 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.45", + "@github/copilot": "^1.0.46", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -464,27 +464,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.45.tgz", - "integrity": "sha512-2QADgQcw/d0GFqTq2+nHwX152ZRvZxW0CHONG5d1RCs6YJtdr/GdbnMYYeRH2BiBIhnfkcvF50ImCRvsS5Tnwg==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.46.tgz", + "integrity": "sha512-e3gxCj8DLGesTAZQ5+jCCbCxe3lMyjKfs5eLgER/SID8Rcb7YpgBXoUvOn3eXxLSsJEmJ3GagHaaHDkf3Zm+Ng==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.45", - "@github/copilot-darwin-x64": "1.0.45", - "@github/copilot-linux-arm64": "1.0.45", - "@github/copilot-linux-x64": "1.0.45", - "@github/copilot-win32-arm64": "1.0.45", - "@github/copilot-win32-x64": "1.0.45" + "@github/copilot-darwin-arm64": "1.0.46", + "@github/copilot-darwin-x64": "1.0.46", + "@github/copilot-linux-arm64": "1.0.46", + "@github/copilot-linux-x64": "1.0.46", + "@github/copilot-win32-arm64": "1.0.46", + "@github/copilot-win32-x64": "1.0.46" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.45.tgz", - "integrity": "sha512-gCJy1nOIWL5lpLFJTRk2Kz7bS30emkA4p4gM+PJ5/dOwNRBOyUO0/2f03/m5vYL4DNd/T47cFIN6s82gISAIYQ==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.46.tgz", + "integrity": "sha512-zbhXuRguCdDgeIZKH+rjgBM/6CDMUmhLMck8w9XFDxUY2wrP7MSWXuX8yA4/1H3ySOTZMIH1G5DQpWh+npmR2Q==", "cpu": [ "arm64" ], @@ -499,9 +499,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.45.tgz", - "integrity": "sha512-nLzC7C0i/WAY+4FukHuONBDNeKUAqBBab3n36aEdpqxVDP5h2Tbzg2yShqav2blR7KDJL7YMcYTVFxmwfQj+yQ==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.46.tgz", + "integrity": "sha512-kSUcV6cARhM+b/BuNSQtazbORTetRjIWpO3SqWSmH+2UoeZP5A5x+ipr7mhshq+E+pcWPeQKMGbKGY3lrCSMFw==", "cpu": [ "x64" ], @@ -516,9 +516,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.45.tgz", - "integrity": "sha512-MdRNZUNMrI0dpQ+DiDoZQ7AbitQp9eN7ir176Za2Kf7dkUxPwmio32yhRbBS81McU6vBw8cCzEZviwv/jc8buQ==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.46.tgz", + "integrity": "sha512-Tz3F0LuGFbOvvv0VKQJ4E5XYBsTdqTNMAwOhbkwX6TuKMX88uLJNKP5uPf6yuu1z3J3nt/5rfEd9CxVrZbnqLA==", "cpu": [ "arm64" ], @@ -533,9 +533,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.45.tgz", - "integrity": "sha512-xSRUjWA+wrSSjktJSjNtiS/47Cy0PviPejj7RUmtChsPfDJB8wW2iZ6NfpdiAomtxAz5xx4AjbjT1I4b1FqnwA==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.46.tgz", + "integrity": "sha512-s9JWe/YE78I7QEeXrvDGHB5x2XnnkegUJYVE9QR2DI/qLXviHMarM3akOUhed21uVqzoiLPacXKZcTcaDO8tOg==", "cpu": [ "x64" ], @@ -550,9 +550,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.45.tgz", - "integrity": "sha512-lhcTlKs7MWMzIXv21hUSpL4aFW49jqVhNrQKaB8sYk2nzvGRJvNwTcBS1Tn5ndXlPzQ9P/p9B6B5uwwmZ1vHHw==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.46.tgz", + "integrity": "sha512-auX8o8vG8A+rdSthvey1D8q3o6lNlNIfHFjoBU0Z9Fxid6Ghz2paaAn0/Uwz9Ev8W8cn/5C5kEPs3niMXSh4Jw==", "cpu": [ "arm64" ], @@ -567,9 +567,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.45", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.45.tgz", - "integrity": "sha512-XYZ983NQmooVr/n+pCnHIorBmf1hd3o1rMlSAodwG/VFlQaydGoOs1F1NntxWBoFAND+eM6N4PZfw8M8sRayfA==", + "version": "1.0.46", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.46.tgz", + "integrity": "sha512-iXo9TUqtSxqlBfC+SZSQMrctKJpWR19zr+8dk7hczE42gOVB0/A+NySJwCmY3UFAEY98lbLDjIC+NCbYFcpEHA==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index c40ae2aa7..3df463311 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.45", + "@github/copilot": "^1.0.46", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", From 4e3dc73c51f3875fc502f6be2025f403b960bf8c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 13 May 2026 10:32:14 +0100 Subject: [PATCH 33/33] Temporarily use beta versions for "latest" dist-tag (#1283) --- .github/workflows/publish.yml | 29 ++++++++--------------------- nodejs/scripts/calculate-version.js | 7 +++++-- nodejs/test/get-version.test.ts | 10 ++++++---- 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e79e6aaf..20df00b7b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -59,13 +59,10 @@ jobs: if [ -n "${{ github.event.inputs.version }}" ]; then VERSION="${{ github.event.inputs.version }}" # Validate version format matches dist-tag - if [ "${{ github.event.inputs.dist-tag }}" = "latest" ]; then - if [[ "$VERSION" == *-* ]]; then - echo "❌ Error: Version '$VERSION' has a prerelease suffix but dist-tag is 'latest'" >> $GITHUB_STEP_SUMMARY - echo "Use a version without suffix (e.g., '1.0.0') for latest releases" - exit 1 - fi - else + # TEMPORARY: skips validation for "latest" so prerelease versions + # can be published under that tag. To ship stable 1.0.0, revert the + # commit that introduced this temporary change. + if [ "${{ github.event.inputs.dist-tag }}" != "latest" ]; then if [[ "$VERSION" != *-* ]]; then echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable" @@ -222,21 +219,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 + # TEMPORARY: both "latest" and "prerelease" create GitHub pre-releases + # since "latest" publishes beta versions. To ship stable 1.0.0, revert + # the commit that introduced this temporary change. - name: Create GitHub Release - if: github.event.inputs.dist-tag == 'latest' - run: | - NOTES_FLAG="" - if git rev-parse "v${{ needs.version.outputs.current }}" >/dev/null 2>&1; then - NOTES_FLAG="--notes-start-tag v${{ needs.version.outputs.current }}" - fi - gh release create "v${{ needs.version.outputs.version }}" \ - --title "v${{ needs.version.outputs.version }}" \ - --generate-notes $NOTES_FLAG \ - --target ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create GitHub Pre-Release - if: github.event.inputs.dist-tag == 'prerelease' + if: github.event.inputs.dist-tag == 'latest' || github.event.inputs.dist-tag == 'prerelease' run: | NOTES_FLAG="" if git rev-parse "v${{ needs.version.outputs.current-prerelease }}" >/dev/null 2>&1; then diff --git a/nodejs/scripts/calculate-version.js b/nodejs/scripts/calculate-version.js index c90ff1a37..ac5722d43 100644 --- a/nodejs/scripts/calculate-version.js +++ b/nodejs/scripts/calculate-version.js @@ -43,10 +43,13 @@ export function calculateVersion(command, { latest, prerelease, unstable }) { } } - const increment = command === "latest" ? "patch" : "prerelease"; + // TEMPORARY: "latest" uses prerelease increments so we publish beta versions + // under the "latest" dist-tag. To ship stable 1.0.0, revert the commit that + // introduced this temporary change. + const increment = "prerelease"; const isIncrementingExistingPrerelease = semver.prerelease(higherVersion) !== null; const prereleaseIdentifier = - command === "prerelease" + command === "prerelease" || command === "latest" ? isIncrementingExistingPrerelease ? undefined : "preview" diff --git a/nodejs/test/get-version.test.ts b/nodejs/test/get-version.test.ts index 5dea84cf2..23d2486ec 100644 --- a/nodejs/test/get-version.test.ts +++ b/nodejs/test/get-version.test.ts @@ -2,13 +2,15 @@ import { describe, expect, it } from "vitest"; import { calculateVersion } from "../scripts/calculate-version.js"; describe("get-version", () => { - it("increments stable latest versions by patch", () => { - expect(calculateVersion("latest", { latest: "1.0.1" })).toBe("1.0.2"); + // TEMPORARY: these two tests reflect beta-as-latest behavior. To ship + // stable 1.0.0, revert the commit that introduced this temporary change. + it("increments latest versions as prerelease (temporary beta behavior)", () => { + expect(calculateVersion("latest", { latest: "1.0.1" })).toBe("1.0.2-preview.0"); }); - it("promotes a higher prerelease to stable for latest releases", () => { + it("continues beta prerelease for latest releases (temporary beta behavior)", () => { expect(calculateVersion("latest", { latest: "0.3.0", prerelease: "1.0.0-beta.1" })).toBe( - "1.0.0" + "1.0.0-beta.2" ); });