diff --git a/docs/auth/byok.md b/docs/auth/byok.md index 8d9650280..4099f212e 100644 --- a/docs/auth/byok.md +++ b/docs/auth/byok.md @@ -335,7 +335,7 @@ const client = new CopilotClient({ ```python from copilot import CopilotClient -from copilot.types import ModelInfo, ModelCapabilities, ModelSupports, ModelLimits +from copilot.client import ModelInfo, ModelCapabilities, ModelSupports, ModelLimits client = CopilotClient({ "on_list_models": lambda: [ diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index 47712d9cf..60cbebef1 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -65,7 +65,7 @@ const session = await client.createSession({ ```python from copilot import CopilotClient -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult client = CopilotClient() await client.start() diff --git a/docs/features/image-input.md b/docs/features/image-input.md index acec80d4a..44a9f57d9 100644 --- a/docs/features/image-input.md +++ b/docs/features/image-input.md @@ -69,7 +69,7 @@ await session.send({ ```python from copilot import CopilotClient -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult client = CopilotClient() await client.start() @@ -121,7 +121,7 @@ func main() { Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ { - Type: copilot.File, + Type: copilot.AttachmentTypeFile, Path: &path, }, }, @@ -147,7 +147,7 @@ session.Send(ctx, copilot.MessageOptions{ Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ { - Type: copilot.File, + Type: copilot.AttachmentTypeFile, Path: &path, }, }, @@ -315,7 +315,7 @@ func main() { Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ { - Type: copilot.Blob, + Type: copilot.AttachmentTypeBlob, Data: &base64ImageData, MIMEType: &mimeType, DisplayName: &displayName, @@ -333,7 +333,7 @@ session.Send(ctx, copilot.MessageOptions{ Prompt: "Describe what you see in this image", Attachments: []copilot.Attachment{ { - Type: copilot.Blob, + Type: copilot.AttachmentTypeBlob, Data: &base64ImageData, // base64-encoded string MIMEType: &mimeType, DisplayName: &displayName, diff --git a/docs/features/skills.md b/docs/features/skills.md index 466c637ff..9065697c5 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -43,7 +43,7 @@ await session.sendAndWait({ prompt: "Review this code for security issues" }); ```python from copilot import CopilotClient -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult async def main(): client = CopilotClient() diff --git a/docs/features/steering-and-queueing.md b/docs/features/steering-and-queueing.md index 7da349e1c..a3e1b6d2b 100644 --- a/docs/features/steering-and-queueing.md +++ b/docs/features/steering-and-queueing.md @@ -70,7 +70,7 @@ await session.send({ ```python from copilot import CopilotClient -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult async def main(): client = CopilotClient() @@ -229,7 +229,7 @@ await session.send({ ```python from copilot import CopilotClient -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult async def main(): client = CopilotClient() diff --git a/docs/getting-started.md b/docs/getting-started.md index 6c0aee72e..14fd8babf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -129,7 +129,8 @@ Create `main.py`: ```python import asyncio -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import PermissionHandler async def main(): client = CopilotClient() @@ -275,7 +276,8 @@ Update `main.py`: ```python import asyncio import sys -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import PermissionHandler from copilot.generated.session_events import SessionEventType async def main(): @@ -651,7 +653,8 @@ Update `main.py`: import asyncio import random import sys -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import PermissionHandler from copilot.tools import define_tool from copilot.generated.session_events import SessionEventType from pydantic import BaseModel, Field @@ -919,7 +922,8 @@ Create `weather_assistant.py`: import asyncio import random import sys -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import PermissionHandler from copilot.tools import define_tool from copilot.generated.session_events import SessionEventType from pydantic import BaseModel, Field @@ -1235,7 +1239,7 @@ const session = await client.createSession({ ### Customize the System Message -Control the AI's behavior and personality: +Control the AI's behavior and personality by appending instructions: ```typescript const session = await client.createSession({ @@ -1245,6 +1249,28 @@ const session = await client.createSession({ }); ``` +For more fine-grained control, use `mode: "customize"` to override individual sections of the system prompt while preserving the rest: + +```typescript +const session = await client.createSession({ + systemMessage: { + mode: "customize", + sections: { + tone: { action: "replace", content: "Respond in a warm, professional tone. Be thorough in explanations." }, + code_change_rules: { action: "remove" }, + guidelines: { action: "append", content: "\n* Always cite data sources" }, + }, + content: "Focus on financial analysis and reporting.", + }, +}); +``` + +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. + +See the language-specific SDK READMEs for examples in [TypeScript](../nodejs/README.md), [Python](../python/README.md), [Go](../go/README.md), and [C#](../dotnet/README.md). + --- ## Connecting to an External CLI Server @@ -1290,7 +1316,8 @@ const session = await client.createSession({ onPermissionRequest: approveAll }); Python ```python -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import PermissionHandler client = CopilotClient({ "cli_url": "localhost:4321" diff --git a/docs/hooks/error-handling.md b/docs/hooks/error-handling.md index a67906ac9..da97d5107 100644 --- a/docs/hooks/error-handling.md +++ b/docs/hooks/error-handling.md @@ -35,7 +35,7 @@ type ErrorOccurredHandler = ( ```python -from copilot.types import ErrorOccurredHookInput, HookInvocation, ErrorOccurredHookOutput +from copilot.session import ErrorOccurredHookInput, ErrorOccurredHookOutput from typing import Callable, Awaitable ErrorOccurredHandler = Callable[ diff --git a/docs/hooks/post-tool-use.md b/docs/hooks/post-tool-use.md index 029e9eb2f..fed1af727 100644 --- a/docs/hooks/post-tool-use.md +++ b/docs/hooks/post-tool-use.md @@ -35,7 +35,7 @@ type PostToolUseHandler = ( ```python -from copilot.types import PostToolUseHookInput, HookInvocation, PostToolUseHookOutput +from copilot.session import PostToolUseHookInput, PostToolUseHookOutput from typing import Callable, Awaitable PostToolUseHandler = Callable[ diff --git a/docs/hooks/pre-tool-use.md b/docs/hooks/pre-tool-use.md index e1bb97495..df23bbd55 100644 --- a/docs/hooks/pre-tool-use.md +++ b/docs/hooks/pre-tool-use.md @@ -35,7 +35,7 @@ type PreToolUseHandler = ( ```python -from copilot.types import PreToolUseHookInput, HookInvocation, PreToolUseHookOutput +from copilot.session import PreToolUseHookInput, PreToolUseHookOutput from typing import Callable, Awaitable PreToolUseHandler = Callable[ diff --git a/docs/hooks/session-lifecycle.md b/docs/hooks/session-lifecycle.md index 4efd33ccc..e86f5d7a9 100644 --- a/docs/hooks/session-lifecycle.md +++ b/docs/hooks/session-lifecycle.md @@ -39,7 +39,7 @@ type SessionStartHandler = ( ```python -from copilot.types import SessionStartHookInput, HookInvocation, SessionStartHookOutput +from copilot.session import SessionStartHookInput, SessionStartHookOutput from typing import Callable, Awaitable SessionStartHandler = Callable[ @@ -249,7 +249,7 @@ type SessionEndHandler = ( ```python -from copilot.types import SessionEndHookInput, HookInvocation +from copilot.session import SessionEndHookInput from typing import Callable, Awaitable SessionEndHandler = Callable[ diff --git a/docs/hooks/user-prompt-submitted.md b/docs/hooks/user-prompt-submitted.md index 2aca7f1ce..89831e34b 100644 --- a/docs/hooks/user-prompt-submitted.md +++ b/docs/hooks/user-prompt-submitted.md @@ -35,7 +35,7 @@ type UserPromptSubmittedHandler = ( ```python -from copilot.types import UserPromptSubmittedHookInput, HookInvocation, UserPromptSubmittedHookOutput +from copilot.session import UserPromptSubmittedHookInput, UserPromptSubmittedHookOutput from typing import Callable, Awaitable UserPromptSubmittedHandler = Callable[ diff --git a/docs/setup/azure-managed-identity.md b/docs/setup/azure-managed-identity.md index 40d87c5ba..b92b63b18 100644 --- a/docs/setup/azure-managed-identity.md +++ b/docs/setup/azure-managed-identity.md @@ -42,7 +42,8 @@ import asyncio import os from azure.identity import DefaultAzureCredential -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import ProviderConfig, SessionConfig COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" @@ -58,14 +59,15 @@ async def main(): await client.start() session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-4.1", - provider={ - "type": "openai", - "base_url": f"{foundry_url.rstrip('/')}/openai/v1/", - "bearer_token": token, # Short-lived bearer token - "wire_api": "responses", - }, + SessionConfig( + model="gpt-4.1", + provider=ProviderConfig( + type="openai", + base_url=f"{foundry_url.rstrip('/')}/openai/v1/", + bearer_token=token, # Short-lived bearer token + wire_api="responses", + ), + ) ) response = await session.send_and_wait({"prompt": "Hello from Managed Identity!"}) @@ -83,7 +85,8 @@ Bearer tokens expire (typically after ~1 hour). For servers or long-running agen ```python from azure.identity import DefaultAzureCredential -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import ProviderConfig, SessionConfig COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" @@ -97,21 +100,24 @@ class ManagedIdentityCopilotAgent: self.credential = DefaultAzureCredential() self.client = CopilotClient() - def _get_provider_config(self) -> dict: - """Build a provider config dict with a fresh bearer token.""" + def _get_session_config(self) -> SessionConfig: + """Build a SessionConfig with a fresh bearer token.""" token = self.credential.get_token(COGNITIVE_SERVICES_SCOPE).token - return { - "type": "openai", - "base_url": f"{self.foundry_url}/openai/v1/", - "bearer_token": token, - "wire_api": "responses", - } + return SessionConfig( + model=self.model, + provider=ProviderConfig( + type="openai", + base_url=f"{self.foundry_url}/openai/v1/", + bearer_token=token, + wire_api="responses", + ), + ) async def chat(self, prompt: str) -> str: """Send a prompt and return the response text.""" # Fresh token for each session - provider = self._get_provider_config() - session = await self.client.create_session(on_permission_request=PermissionHandler.approve_all, model=self.model, provider=provider) + config = self._get_session_config() + session = await self.client.create_session(config) response = await session.send_and_wait({"prompt": prompt}) await session.disconnect() diff --git a/dotnet/README.md b/dotnet/README.md index cb7dbba18..cab1cf068 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -509,6 +509,34 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +#### Customize Mode + +Use `Mode = SystemMessageMode.Customize` to selectively override individual sections of the prompt while preserving the rest: + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-5", + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + [SystemPromptSections.Tone] = new() { Action = SectionOverrideAction.Replace, Content = "Respond in a warm, professional tone. Be thorough in explanations." }, + [SystemPromptSections.CodeChangeRules] = new() { Action = SectionOverrideAction.Remove }, + [SystemPromptSections.Guidelines] = new() { Action = SectionOverrideAction.Append, Content = "\n* Always cite data sources" }, + }, + Content = "Focus on financial analysis and reporting." + } +}); +``` + +Available section IDs are defined as constants on `SystemPromptSections`: `Identity`, `Tone`, `ToolEfficiency`, `EnvironmentContext`, `CodeChangeRules`, `Guidelines`, `Safety`, `ToolInstructions`, `CustomInstructions`, `LastInstructions`. + +Each section override supports four actions: `Replace`, `Remove`, `Append`, and `Prepend`. Unknown section IDs are handled gracefully: content is appended to additional instructions, and `Remove` overrides are silently ignored. + +#### Replace Mode + For full control (removes all guardrails), use `Mode = SystemMessageMode.Replace`: ```csharp diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a9ad1fccd..99c0eff00 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -365,6 +365,44 @@ private async Task CleanupConnectionAsync(List? errors) } } + private static (SystemMessageConfig? wireConfig, Dictionary>>? callbacks) ExtractTransformCallbacks(SystemMessageConfig? systemMessage) + { + if (systemMessage?.Mode != SystemMessageMode.Customize || systemMessage.Sections == null) + { + return (systemMessage, null); + } + + var callbacks = new Dictionary>>(); + var wireSections = new Dictionary(); + + foreach (var (sectionId, sectionOverride) in systemMessage.Sections) + { + if (sectionOverride.Transform != null) + { + callbacks[sectionId] = sectionOverride.Transform; + wireSections[sectionId] = new SectionOverride { Action = SectionOverrideAction.Transform }; + } + else + { + wireSections[sectionId] = sectionOverride; + } + } + + if (callbacks.Count == 0) + { + return (systemMessage, null); + } + + var wireConfig = new SystemMessageConfig + { + Mode = systemMessage.Mode, + Content = systemMessage.Content, + Sections = wireSections + }; + + return (wireConfig, callbacks); + } + /// /// Creates a new Copilot session with the specified configuration. /// @@ -409,6 +447,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); + var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); + var sessionId = config.SessionId ?? Guid.NewGuid().ToString(); // Create and register the session before issuing the RPC so that @@ -424,6 +464,10 @@ public async Task CreateSessionAsync(SessionConfig config, Cance { session.RegisterHooks(config.Hooks); } + if (transformCallbacks != null) + { + session.RegisterTransformCallbacks(transformCallbacks); + } if (config.OnEvent != null) { session.On(config.OnEvent); @@ -440,7 +484,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.ClientName, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config.SystemMessage, + wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, @@ -519,6 +563,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null); + var (wireSystemMessage, transformCallbacks) = ExtractTransformCallbacks(config.SystemMessage); + // Create and register the session before issuing the RPC so that // events emitted by the CLI (e.g. session.start) are not dropped. var session = new CopilotSession(sessionId, connection.Rpc, _logger); @@ -532,6 +578,10 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes { session.RegisterHooks(config.Hooks); } + if (transformCallbacks != null) + { + session.RegisterTransformCallbacks(transformCallbacks); + } if (config.OnEvent != null) { session.On(config.OnEvent); @@ -548,7 +598,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Model, config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config.SystemMessage, + wireSystemMessage, config.AvailableTools, config.ExcludedTools, config.Provider, @@ -1222,6 +1272,7 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); + rpc.AddLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); rpc.StartListening(); // Transition state to Disconnected if the JSON-RPC connection drops @@ -1350,6 +1401,12 @@ public async Task OnHooksInvoke(string sessionId, string ho return new HooksInvokeResponse(output); } + public async Task OnSystemMessageTransform(string sessionId, JsonElement sections) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + return await session.HandleSystemMessageTransformAsync(sections); + } + // Protocol v2 backward-compatibility adapters public async Task OnToolCallV2(string sessionId, @@ -1685,6 +1742,7 @@ private static LogLevel MapLevel(TraceEventType eventType) [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SystemMessageConfig))] + [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] [JsonSerializable(typeof(ToolCallResponseV2))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 6fc593c12..fabe4817e 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -245,6 +245,10 @@ internal class SessionLogRequest /// When true, the message is transient and not persisted to the session event log on disk. [JsonPropertyName("ephemeral")] public bool? Ephemeral { get; set; } + + /// Optional URL the user can open in their browser for more details. + [JsonPropertyName("url")] + public string? Url { get; set; } } /// RPC data type for SessionModelGetCurrent operations. @@ -577,6 +581,347 @@ internal class SessionAgentDeselectRequest public string SessionId { get; set; } = string.Empty; } +/// RPC data type for SessionAgentReload operations. +[Experimental(Diagnostics.Experimental)] +public class SessionAgentReloadResult +{ + /// Reloaded custom agents. + [JsonPropertyName("agents")] + public List Agents { get => field ??= []; set; } +} + +/// RPC data type for SessionAgentReload operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionAgentReloadRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for Skill operations. +public class Skill +{ + /// Unique identifier for the skill. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Description of what the skill does. + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// Source location type (e.g., project, personal, plugin). + [JsonPropertyName("source")] + public string Source { get; set; } = string.Empty; + + /// Whether the skill can be invoked by the user as a slash command. + [JsonPropertyName("userInvocable")] + public bool UserInvocable { get; set; } + + /// Whether the skill is currently enabled. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// Absolute path to the skill file. + [JsonPropertyName("path")] + public string? Path { get; set; } +} + +/// RPC data type for SessionSkillsList operations. +[Experimental(Diagnostics.Experimental)] +public class SessionSkillsListResult +{ + /// Available skills. + [JsonPropertyName("skills")] + public List Skills { get => field ??= []; set; } +} + +/// RPC data type for SessionSkillsList operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionSkillsListRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for SessionSkillsEnable operations. +[Experimental(Diagnostics.Experimental)] +public class SessionSkillsEnableResult +{ +} + +/// RPC data type for SessionSkillsEnable operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionSkillsEnableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Name of the skill to enable. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +/// RPC data type for SessionSkillsDisable operations. +[Experimental(Diagnostics.Experimental)] +public class SessionSkillsDisableResult +{ +} + +/// RPC data type for SessionSkillsDisable operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionSkillsDisableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Name of the skill to disable. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +/// RPC data type for SessionSkillsReload operations. +[Experimental(Diagnostics.Experimental)] +public class SessionSkillsReloadResult +{ +} + +/// RPC data type for SessionSkillsReload operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionSkillsReloadRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for Server operations. +public class Server +{ + /// Server name (config key). + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Connection status: connected, failed, pending, disabled, or not_configured. + [JsonPropertyName("status")] + public ServerStatus Status { get; set; } + + /// Configuration source: user, workspace, plugin, or builtin. + [JsonPropertyName("source")] + public string? Source { get; set; } + + /// Error message if the server failed to connect. + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// RPC data type for SessionMcpList operations. +[Experimental(Diagnostics.Experimental)] +public class SessionMcpListResult +{ + /// Configured MCP servers. + [JsonPropertyName("servers")] + public List Servers { get => field ??= []; set; } +} + +/// RPC data type for SessionMcpList operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionMcpListRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for SessionMcpEnable operations. +[Experimental(Diagnostics.Experimental)] +public class SessionMcpEnableResult +{ +} + +/// RPC data type for SessionMcpEnable operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionMcpEnableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Name of the MCP server to enable. + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; +} + +/// RPC data type for SessionMcpDisable operations. +[Experimental(Diagnostics.Experimental)] +public class SessionMcpDisableResult +{ +} + +/// RPC data type for SessionMcpDisable operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionMcpDisableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Name of the MCP server to disable. + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; +} + +/// RPC data type for SessionMcpReload operations. +[Experimental(Diagnostics.Experimental)] +public class SessionMcpReloadResult +{ +} + +/// RPC data type for SessionMcpReload operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionMcpReloadRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for Plugin operations. +public class Plugin +{ + /// Plugin name. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Marketplace the plugin came from. + [JsonPropertyName("marketplace")] + public string Marketplace { get; set; } = string.Empty; + + /// Installed version. + [JsonPropertyName("version")] + public string? Version { get; set; } + + /// Whether the plugin is currently enabled. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } +} + +/// RPC data type for SessionPluginsList operations. +[Experimental(Diagnostics.Experimental)] +public class SessionPluginsListResult +{ + /// Installed plugins. + [JsonPropertyName("plugins")] + public List Plugins { get => field ??= []; set; } +} + +/// RPC data type for SessionPluginsList operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionPluginsListRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for Extension operations. +public class Extension +{ + /// Source-qualified ID (e.g., 'project:my-ext', 'user:auth-helper'). + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Extension name (directory name). + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/). + [JsonPropertyName("source")] + public ExtensionSource Source { get; set; } + + /// Current status: running, disabled, failed, or starting. + [JsonPropertyName("status")] + public ExtensionStatus Status { get; set; } + + /// Process ID if the extension is running. + [JsonPropertyName("pid")] + public double? Pid { get; set; } +} + +/// RPC data type for SessionExtensionsList operations. +[Experimental(Diagnostics.Experimental)] +public class SessionExtensionsListResult +{ + /// Discovered extensions and their current status. + [JsonPropertyName("extensions")] + public List Extensions { get => field ??= []; set; } +} + +/// RPC data type for SessionExtensionsList operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionExtensionsListRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// RPC data type for SessionExtensionsEnable operations. +[Experimental(Diagnostics.Experimental)] +public class SessionExtensionsEnableResult +{ +} + +/// RPC data type for SessionExtensionsEnable operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionExtensionsEnableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Source-qualified extension ID to enable. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; +} + +/// RPC data type for SessionExtensionsDisable operations. +[Experimental(Diagnostics.Experimental)] +public class SessionExtensionsDisableResult +{ +} + +/// RPC data type for SessionExtensionsDisable operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionExtensionsDisableRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Source-qualified extension ID to disable. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; +} + +/// RPC data type for SessionExtensionsReload operations. +[Experimental(Diagnostics.Experimental)] +public class SessionExtensionsReloadResult +{ +} + +/// RPC data type for SessionExtensionsReload operations. +[Experimental(Diagnostics.Experimental)] +internal class SessionExtensionsReloadRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// RPC data type for SessionCompactionCompact operations. [Experimental(Diagnostics.Experimental)] public class SessionCompactionCompactResult @@ -631,6 +976,74 @@ internal class SessionToolsHandlePendingToolCallRequest public string? Error { get; set; } } +/// RPC data type for SessionCommandsHandlePendingCommand operations. +public class SessionCommandsHandlePendingCommandResult +{ + /// Gets or sets the success value. + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +/// RPC data type for SessionCommandsHandlePendingCommand operations. +internal class SessionCommandsHandlePendingCommandRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Request ID from the command invocation event. + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + /// Error message if the command handler failed. + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// RPC data type for SessionUiElicitation operations. +public class SessionUiElicitationResult +{ + /// The user's response: accept (submitted), decline (rejected), or cancel (dismissed). + [JsonPropertyName("action")] + public SessionUiElicitationResultAction Action { get; set; } + + /// The form values submitted by the user (present when action is 'accept'). + [JsonPropertyName("content")] + public Dictionary? Content { get; set; } +} + +/// JSON Schema describing the form fields to present to the user. +public class SessionUiElicitationRequestRequestedSchema +{ + /// Schema type indicator (always 'object'). + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// Form field definitions, keyed by field name. + [JsonPropertyName("properties")] + public Dictionary Properties { get => field ??= []; set; } + + /// List of required field names. + [JsonPropertyName("required")] + public List? Required { get; set; } +} + +/// RPC data type for SessionUiElicitation operations. +internal class SessionUiElicitationRequest +{ + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Message describing what information is needed from the user. + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// JSON Schema describing the form fields to present to the user. + [JsonPropertyName("requestedSchema")] + public SessionUiElicitationRequestRequestedSchema RequestedSchema { get => field ??= new(); set; } +} + /// RPC data type for SessionPermissionsHandlePendingPermissionRequest operations. public class SessionPermissionsHandlePendingPermissionRequestResult { @@ -739,6 +1152,76 @@ public enum SessionModeGetResultMode } +/// Connection status: connected, failed, pending, disabled, or not_configured. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ServerStatus +{ + /// The connected variant. + [JsonStringEnumMemberName("connected")] + Connected, + /// The failed variant. + [JsonStringEnumMemberName("failed")] + Failed, + /// The pending variant. + [JsonStringEnumMemberName("pending")] + Pending, + /// The disabled variant. + [JsonStringEnumMemberName("disabled")] + Disabled, + /// The not_configured variant. + [JsonStringEnumMemberName("not_configured")] + NotConfigured, +} + + +/// 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, +} + + +/// 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, +} + + +/// The user's response: accept (submitted), decline (rejected), or cancel (dismissed). +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionUiElicitationResultAction +{ + /// The accept variant. + [JsonStringEnumMemberName("accept")] + Accept, + /// The decline variant. + [JsonStringEnumMemberName("decline")] + Decline, + /// The cancel variant. + [JsonStringEnumMemberName("cancel")] + Cancel, +} + + /// Signal to send (default: SIGTERM). [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionShellKillRequestSignal @@ -853,8 +1336,14 @@ internal SessionRpc(JsonRpc rpc, string sessionId) Workspace = new WorkspaceApi(rpc, sessionId); Fleet = new FleetApi(rpc, sessionId); Agent = new AgentApi(rpc, sessionId); + Skills = new SkillsApi(rpc, sessionId); + Mcp = new McpApi(rpc, sessionId); + Plugins = new PluginsApi(rpc, sessionId); + Extensions = new ExtensionsApi(rpc, sessionId); Compaction = new CompactionApi(rpc, sessionId); Tools = new ToolsApi(rpc, sessionId); + Commands = new CommandsApi(rpc, sessionId); + Ui = new UiApi(rpc, sessionId); Permissions = new PermissionsApi(rpc, sessionId); Shell = new ShellApi(rpc, sessionId); } @@ -877,12 +1366,30 @@ internal SessionRpc(JsonRpc rpc, string sessionId) /// Agent APIs. public AgentApi Agent { get; } + /// Skills APIs. + public SkillsApi Skills { get; } + + /// Mcp APIs. + public McpApi Mcp { get; } + + /// Plugins APIs. + public PluginsApi Plugins { get; } + + /// Extensions APIs. + public ExtensionsApi Extensions { get; } + /// Compaction APIs. public CompactionApi Compaction { get; } /// Tools APIs. public ToolsApi Tools { get; } + /// Commands APIs. + public CommandsApi Commands { get; } + + /// Ui APIs. + public UiApi Ui { get; } + /// Permissions APIs. public PermissionsApi Permissions { get; } @@ -890,9 +1397,9 @@ internal SessionRpc(JsonRpc rpc, string sessionId) public ShellApi Shell { get; } /// Calls "session.log". - public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default) + public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, string? url = null, CancellationToken cancellationToken = default) { - var request = new SessionLogRequest { SessionId = _sessionId, Message = message, Level = level, Ephemeral = ephemeral }; + var request = new SessionLogRequest { SessionId = _sessionId, Message = message, Level = level, Ephemeral = ephemeral, Url = url }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.log", [request], cancellationToken); } } @@ -1080,6 +1587,160 @@ public async Task DeselectAsync(CancellationToken ca var request = new SessionAgentDeselectRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.deselect", [request], cancellationToken); } + + /// Calls "session.agent.reload". + public async Task ReloadAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentReloadRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.reload", [request], cancellationToken); + } +} + +/// Provides session-scoped Skills APIs. +[Experimental(Diagnostics.Experimental)] +public class SkillsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal SkillsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.skills.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + var request = new SessionSkillsListRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.skills.list", [request], cancellationToken); + } + + /// Calls "session.skills.enable". + public async Task EnableAsync(string name, CancellationToken cancellationToken = default) + { + var request = new SessionSkillsEnableRequest { SessionId = _sessionId, Name = name }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.skills.enable", [request], cancellationToken); + } + + /// Calls "session.skills.disable". + public async Task DisableAsync(string name, CancellationToken cancellationToken = default) + { + var request = new SessionSkillsDisableRequest { SessionId = _sessionId, Name = name }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.skills.disable", [request], cancellationToken); + } + + /// Calls "session.skills.reload". + public async Task ReloadAsync(CancellationToken cancellationToken = default) + { + var request = new SessionSkillsReloadRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.skills.reload", [request], cancellationToken); + } +} + +/// Provides session-scoped Mcp APIs. +[Experimental(Diagnostics.Experimental)] +public class McpApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal McpApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.mcp.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + var request = new SessionMcpListRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.mcp.list", [request], cancellationToken); + } + + /// Calls "session.mcp.enable". + public async Task EnableAsync(string serverName, CancellationToken cancellationToken = default) + { + var request = new SessionMcpEnableRequest { SessionId = _sessionId, ServerName = serverName }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.mcp.enable", [request], cancellationToken); + } + + /// Calls "session.mcp.disable". + public async Task DisableAsync(string serverName, CancellationToken cancellationToken = default) + { + var request = new SessionMcpDisableRequest { SessionId = _sessionId, ServerName = serverName }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.mcp.disable", [request], cancellationToken); + } + + /// Calls "session.mcp.reload". + public async Task ReloadAsync(CancellationToken cancellationToken = default) + { + var request = new SessionMcpReloadRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.mcp.reload", [request], cancellationToken); + } +} + +/// Provides session-scoped Plugins APIs. +[Experimental(Diagnostics.Experimental)] +public class PluginsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal PluginsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.plugins.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + var request = new SessionPluginsListRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.plugins.list", [request], cancellationToken); + } +} + +/// Provides session-scoped Extensions APIs. +[Experimental(Diagnostics.Experimental)] +public class ExtensionsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal ExtensionsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.extensions.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + var request = new SessionExtensionsListRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.extensions.list", [request], cancellationToken); + } + + /// Calls "session.extensions.enable". + public async Task EnableAsync(string id, CancellationToken cancellationToken = default) + { + var request = new SessionExtensionsEnableRequest { SessionId = _sessionId, Id = id }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.extensions.enable", [request], cancellationToken); + } + + /// Calls "session.extensions.disable". + public async Task DisableAsync(string id, CancellationToken cancellationToken = default) + { + var request = new SessionExtensionsDisableRequest { SessionId = _sessionId, Id = id }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.extensions.disable", [request], cancellationToken); + } + + /// Calls "session.extensions.reload". + public async Task ReloadAsync(CancellationToken cancellationToken = default) + { + var request = new SessionExtensionsReloadRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.extensions.reload", [request], cancellationToken); + } } /// Provides session-scoped Compaction APIs. @@ -1123,6 +1784,46 @@ public async Task HandlePendingToolCall } } +/// Provides session-scoped Commands APIs. +public class CommandsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal CommandsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.commands.handlePendingCommand". + public async Task HandlePendingCommandAsync(string requestId, string? error = null, CancellationToken cancellationToken = default) + { + var request = new SessionCommandsHandlePendingCommandRequest { SessionId = _sessionId, RequestId = requestId, Error = error }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.commands.handlePendingCommand", [request], cancellationToken); + } +} + +/// Provides session-scoped Ui APIs. +public class UiApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal UiApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.ui.elicitation". + public async Task ElicitationAsync(string message, SessionUiElicitationRequestRequestedSchema requestedSchema, CancellationToken cancellationToken = default) + { + var request = new SessionUiElicitationRequest { SessionId = _sessionId, Message = message, RequestedSchema = requestedSchema }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.ui.elicitation", [request], cancellationToken); + } +} + /// Provides session-scoped Permissions APIs. public class PermissionsApi { @@ -1177,6 +1878,7 @@ public async Task KillAsync(string processId, SessionShe [JsonSerializable(typeof(AccountGetQuotaResult))] [JsonSerializable(typeof(AccountGetQuotaResultQuotaSnapshotsValue))] [JsonSerializable(typeof(Agent))] +[JsonSerializable(typeof(Extension))] [JsonSerializable(typeof(Model))] [JsonSerializable(typeof(ModelBilling))] [JsonSerializable(typeof(ModelCapabilities))] @@ -1186,6 +1888,8 @@ public async Task KillAsync(string processId, SessionShe [JsonSerializable(typeof(ModelsListResult))] [JsonSerializable(typeof(PingRequest))] [JsonSerializable(typeof(PingResult))] +[JsonSerializable(typeof(Plugin))] +[JsonSerializable(typeof(Server))] [JsonSerializable(typeof(SessionAgentDeselectRequest))] [JsonSerializable(typeof(SessionAgentDeselectResult))] [JsonSerializable(typeof(SessionAgentGetCurrentRequest))] @@ -1193,15 +1897,35 @@ public async Task KillAsync(string processId, SessionShe [JsonSerializable(typeof(SessionAgentGetCurrentResultAgent))] [JsonSerializable(typeof(SessionAgentListRequest))] [JsonSerializable(typeof(SessionAgentListResult))] +[JsonSerializable(typeof(SessionAgentReloadRequest))] +[JsonSerializable(typeof(SessionAgentReloadResult))] [JsonSerializable(typeof(SessionAgentSelectRequest))] [JsonSerializable(typeof(SessionAgentSelectResult))] [JsonSerializable(typeof(SessionAgentSelectResultAgent))] +[JsonSerializable(typeof(SessionCommandsHandlePendingCommandRequest))] +[JsonSerializable(typeof(SessionCommandsHandlePendingCommandResult))] [JsonSerializable(typeof(SessionCompactionCompactRequest))] [JsonSerializable(typeof(SessionCompactionCompactResult))] +[JsonSerializable(typeof(SessionExtensionsDisableRequest))] +[JsonSerializable(typeof(SessionExtensionsDisableResult))] +[JsonSerializable(typeof(SessionExtensionsEnableRequest))] +[JsonSerializable(typeof(SessionExtensionsEnableResult))] +[JsonSerializable(typeof(SessionExtensionsListRequest))] +[JsonSerializable(typeof(SessionExtensionsListResult))] +[JsonSerializable(typeof(SessionExtensionsReloadRequest))] +[JsonSerializable(typeof(SessionExtensionsReloadResult))] [JsonSerializable(typeof(SessionFleetStartRequest))] [JsonSerializable(typeof(SessionFleetStartResult))] [JsonSerializable(typeof(SessionLogRequest))] [JsonSerializable(typeof(SessionLogResult))] +[JsonSerializable(typeof(SessionMcpDisableRequest))] +[JsonSerializable(typeof(SessionMcpDisableResult))] +[JsonSerializable(typeof(SessionMcpEnableRequest))] +[JsonSerializable(typeof(SessionMcpEnableResult))] +[JsonSerializable(typeof(SessionMcpListRequest))] +[JsonSerializable(typeof(SessionMcpListResult))] +[JsonSerializable(typeof(SessionMcpReloadRequest))] +[JsonSerializable(typeof(SessionMcpReloadResult))] [JsonSerializable(typeof(SessionModeGetRequest))] [JsonSerializable(typeof(SessionModeGetResult))] [JsonSerializable(typeof(SessionModeSetRequest))] @@ -1218,18 +1942,32 @@ public async Task KillAsync(string processId, SessionShe [JsonSerializable(typeof(SessionPlanReadResult))] [JsonSerializable(typeof(SessionPlanUpdateRequest))] [JsonSerializable(typeof(SessionPlanUpdateResult))] +[JsonSerializable(typeof(SessionPluginsListRequest))] +[JsonSerializable(typeof(SessionPluginsListResult))] [JsonSerializable(typeof(SessionShellExecRequest))] [JsonSerializable(typeof(SessionShellExecResult))] [JsonSerializable(typeof(SessionShellKillRequest))] [JsonSerializable(typeof(SessionShellKillResult))] +[JsonSerializable(typeof(SessionSkillsDisableRequest))] +[JsonSerializable(typeof(SessionSkillsDisableResult))] +[JsonSerializable(typeof(SessionSkillsEnableRequest))] +[JsonSerializable(typeof(SessionSkillsEnableResult))] +[JsonSerializable(typeof(SessionSkillsListRequest))] +[JsonSerializable(typeof(SessionSkillsListResult))] +[JsonSerializable(typeof(SessionSkillsReloadRequest))] +[JsonSerializable(typeof(SessionSkillsReloadResult))] [JsonSerializable(typeof(SessionToolsHandlePendingToolCallRequest))] [JsonSerializable(typeof(SessionToolsHandlePendingToolCallResult))] +[JsonSerializable(typeof(SessionUiElicitationRequest))] +[JsonSerializable(typeof(SessionUiElicitationRequestRequestedSchema))] +[JsonSerializable(typeof(SessionUiElicitationResult))] [JsonSerializable(typeof(SessionWorkspaceCreateFileRequest))] [JsonSerializable(typeof(SessionWorkspaceCreateFileResult))] [JsonSerializable(typeof(SessionWorkspaceListFilesRequest))] [JsonSerializable(typeof(SessionWorkspaceListFilesResult))] [JsonSerializable(typeof(SessionWorkspaceReadFileRequest))] [JsonSerializable(typeof(SessionWorkspaceReadFileResult))] +[JsonSerializable(typeof(Skill))] [JsonSerializable(typeof(Tool))] [JsonSerializable(typeof(ToolsListRequest))] [JsonSerializable(typeof(ToolsListResult))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 08c6bf5e0..2821052d0 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -17,7 +17,7 @@ namespace GitHub.Copilot.SDK; [DebuggerDisplay("{DebuggerDisplay,nq}")] [JsonPolymorphic( TypeDiscriminatorPropertyName = "type", - UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] + IgnoreUnrecognizedTypeDiscriminators = true)] [JsonDerivedType(typeof(AbortEvent), "abort")] [JsonDerivedType(typeof(AssistantIntentEvent), "assistant.intent")] [JsonDerivedType(typeof(AssistantMessageEvent), "assistant.message")] @@ -29,7 +29,9 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(AssistantTurnStartEvent), "assistant.turn_start")] [JsonDerivedType(typeof(AssistantUsageEvent), "assistant.usage")] [JsonDerivedType(typeof(CommandCompletedEvent), "command.completed")] +[JsonDerivedType(typeof(CommandExecuteEvent), "command.execute")] [JsonDerivedType(typeof(CommandQueuedEvent), "command.queued")] +[JsonDerivedType(typeof(CommandsChangedEvent), "commands.changed")] [JsonDerivedType(typeof(ElicitationCompletedEvent), "elicitation.completed")] [JsonDerivedType(typeof(ElicitationRequestedEvent), "elicitation.requested")] [JsonDerivedType(typeof(ExitPlanModeCompletedEvent), "exit_plan_mode.completed")] @@ -38,6 +40,8 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(ExternalToolRequestedEvent), "external_tool.requested")] [JsonDerivedType(typeof(HookEndEvent), "hook.end")] [JsonDerivedType(typeof(HookStartEvent), "hook.start")] +[JsonDerivedType(typeof(McpOauthCompletedEvent), "mcp.oauth_completed")] +[JsonDerivedType(typeof(McpOauthRequiredEvent), "mcp.oauth_required")] [JsonDerivedType(typeof(PendingMessagesModifiedEvent), "pending_messages.modified")] [JsonDerivedType(typeof(PermissionCompletedEvent), "permission.completed")] [JsonDerivedType(typeof(PermissionRequestedEvent), "permission.requested")] @@ -46,14 +50,18 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(SessionCompactionStartEvent), "session.compaction_start")] [JsonDerivedType(typeof(SessionContextChangedEvent), "session.context_changed")] [JsonDerivedType(typeof(SessionErrorEvent), "session.error")] +[JsonDerivedType(typeof(SessionExtensionsLoadedEvent), "session.extensions_loaded")] [JsonDerivedType(typeof(SessionHandoffEvent), "session.handoff")] [JsonDerivedType(typeof(SessionIdleEvent), "session.idle")] [JsonDerivedType(typeof(SessionInfoEvent), "session.info")] +[JsonDerivedType(typeof(SessionMcpServerStatusChangedEvent), "session.mcp_server_status_changed")] +[JsonDerivedType(typeof(SessionMcpServersLoadedEvent), "session.mcp_servers_loaded")] [JsonDerivedType(typeof(SessionModeChangedEvent), "session.mode_changed")] [JsonDerivedType(typeof(SessionModelChangeEvent), "session.model_change")] [JsonDerivedType(typeof(SessionPlanChangedEvent), "session.plan_changed")] [JsonDerivedType(typeof(SessionResumeEvent), "session.resume")] [JsonDerivedType(typeof(SessionShutdownEvent), "session.shutdown")] +[JsonDerivedType(typeof(SessionSkillsLoadedEvent), "session.skills_loaded")] [JsonDerivedType(typeof(SessionSnapshotRewindEvent), "session.snapshot_rewind")] [JsonDerivedType(typeof(SessionStartEvent), "session.start")] [JsonDerivedType(typeof(SessionTaskCompleteEvent), "session.task_complete")] @@ -79,7 +87,7 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(UserInputCompletedEvent), "user_input.completed")] [JsonDerivedType(typeof(UserInputRequestedEvent), "user_input.requested")] [JsonDerivedType(typeof(UserMessageEvent), "user.message")] -public abstract partial class SessionEvent +public partial class SessionEvent { /// Unique event identifier (UUID v4), generated when the event is emitted. [JsonPropertyName("id")] @@ -102,7 +110,7 @@ public abstract partial class SessionEvent /// The event type discriminator. /// [JsonIgnore] - public abstract string Type { get; } + public virtual string Type => "unknown"; /// Deserializes a JSON string into a . public static SessionEvent FromJson(string json) => @@ -337,7 +345,7 @@ public partial class SessionUsageInfoEvent : SessionEvent public required SessionUsageInfoData Data { get; set; } } -/// Empty payload; the event signals that LLM-powered conversation compaction has begun. +/// Context window breakdown at the start of LLM-powered conversation compaction. /// Represents the session.compaction_start event. public partial class SessionCompactionStartEvent : SessionEvent { @@ -363,7 +371,7 @@ public partial class SessionCompactionCompleteEvent : SessionEvent public required SessionCompactionCompleteData Data { get; set; } } -/// Task completion notification with optional summary from the agent. +/// Task completion notification with summary from the agent. /// Represents the session.task_complete event. public partial class SessionTaskCompleteEvent : SessionEvent { @@ -376,8 +384,7 @@ public partial class SessionTaskCompleteEvent : SessionEvent public required SessionTaskCompleteData Data { get; set; } } -/// User message content with optional attachments, source information, and interaction metadata. -/// Represents the user.message event. +/// Represents the user.message event. public partial class UserMessageEvent : SessionEvent { /// @@ -779,7 +786,7 @@ public partial class UserInputCompletedEvent : SessionEvent public required UserInputCompletedData Data { get; set; } } -/// Structured form elicitation request with JSON schema definition for form fields. +/// Elicitation request; may be form-based (structured input) or URL-based (browser redirect). /// Represents the elicitation.requested event. public partial class ElicitationRequestedEvent : SessionEvent { @@ -805,6 +812,32 @@ public partial class ElicitationCompletedEvent : SessionEvent public required ElicitationCompletedData Data { get; set; } } +/// OAuth authentication request for an MCP server. +/// Represents the mcp.oauth_required event. +public partial class McpOauthRequiredEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "mcp.oauth_required"; + + /// The mcp.oauth_required event payload. + [JsonPropertyName("data")] + public required McpOauthRequiredData Data { get; set; } +} + +/// MCP OAuth request completion notification. +/// Represents the mcp.oauth_completed event. +public partial class McpOauthCompletedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "mcp.oauth_completed"; + + /// The mcp.oauth_completed event payload. + [JsonPropertyName("data")] + public required McpOauthCompletedData Data { get; set; } +} + /// External tool invocation request for client-side tool execution. /// Represents the external_tool.requested event. public partial class ExternalToolRequestedEvent : SessionEvent @@ -844,6 +877,19 @@ public partial class CommandQueuedEvent : SessionEvent public required CommandQueuedData Data { get; set; } } +/// Registered command dispatch request routed to the owning client. +/// Represents the command.execute event. +public partial class CommandExecuteEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "command.execute"; + + /// The command.execute event payload. + [JsonPropertyName("data")] + public required CommandExecuteData Data { get; set; } +} + /// Queued command completion notification signaling UI dismissal. /// Represents the command.completed event. public partial class CommandCompletedEvent : SessionEvent @@ -857,6 +903,19 @@ public partial class CommandCompletedEvent : SessionEvent public required CommandCompletedData Data { get; set; } } +/// SDK command registration change notification. +/// Represents the commands.changed event. +public partial class CommandsChangedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "commands.changed"; + + /// The commands.changed event payload. + [JsonPropertyName("data")] + public required CommandsChangedData Data { get; set; } +} + /// Plan approval request with plan content and available user actions. /// Represents the exit_plan_mode.requested event. public partial class ExitPlanModeRequestedEvent : SessionEvent @@ -907,6 +966,54 @@ public partial class SessionBackgroundTasksChangedEvent : SessionEvent public required SessionBackgroundTasksChangedData Data { get; set; } } +/// Represents the session.skills_loaded event. +public partial class SessionSkillsLoadedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.skills_loaded"; + + /// The session.skills_loaded event payload. + [JsonPropertyName("data")] + public required SessionSkillsLoadedData Data { get; set; } +} + +/// Represents the session.mcp_servers_loaded event. +public partial class SessionMcpServersLoadedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.mcp_servers_loaded"; + + /// The session.mcp_servers_loaded event payload. + [JsonPropertyName("data")] + public required SessionMcpServersLoadedData Data { get; set; } +} + +/// Represents the session.mcp_server_status_changed event. +public partial class SessionMcpServerStatusChangedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.mcp_server_status_changed"; + + /// The session.mcp_server_status_changed event payload. + [JsonPropertyName("data")] + public required SessionMcpServerStatusChangedData Data { get; set; } +} + +/// Represents the session.extensions_loaded event. +public partial class SessionExtensionsLoadedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.extensions_loaded"; + + /// The session.extensions_loaded event payload. + [JsonPropertyName("data")] + public required SessionExtensionsLoadedData Data { get; set; } +} + /// Session initialization metadata including context and configuration. public partial class SessionStartData { @@ -1008,6 +1115,11 @@ public partial class SessionErrorData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("providerCallId")] public string? ProviderCallId { get; set; } + + /// Optional URL associated with this error that the user can open in a browser. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("url")] + public string? Url { get; set; } } /// Payload indicating the agent is idle; includes any background tasks still in flight. @@ -1037,6 +1149,11 @@ public partial class SessionInfoData /// Human-readable informational message for display in the timeline. [JsonPropertyName("message")] public required string Message { get; set; } + + /// Optional URL associated with this message that the user can open in a browser. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("url")] + public string? Url { get; set; } } /// Warning message for timeline display with categorization. @@ -1049,6 +1166,11 @@ public partial class SessionWarningData /// Human-readable warning message for display in the timeline. [JsonPropertyName("message")] public required string Message { get; set; } + + /// Optional URL associated with this warning that the user can open in a browser. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("url")] + public string? Url { get; set; } } /// Model change details including previous and new model identifiers. @@ -1222,6 +1344,26 @@ public partial class SessionShutdownData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("currentModel")] public string? CurrentModel { get; set; } + + /// Total tokens in context window at shutdown. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("currentTokens")] + public double? CurrentTokens { get; set; } + + /// System message token count at shutdown. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("systemTokens")] + public double? SystemTokens { get; set; } + + /// Non-system message token count at shutdown. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("conversationTokens")] + public double? ConversationTokens { get; set; } + + /// Tool definitions token count at shutdown. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolDefinitionsTokens")] + public double? ToolDefinitionsTokens { get; set; } } /// Updated working directory and git context after the change. @@ -1276,11 +1418,45 @@ public partial class SessionUsageInfoData /// Current number of messages in the conversation. [JsonPropertyName("messagesLength")] public required double MessagesLength { get; set; } + + /// Token count from system message(s). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("systemTokens")] + public double? SystemTokens { get; set; } + + /// Token count from non-system messages (user, assistant, tool). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("conversationTokens")] + public double? ConversationTokens { get; set; } + + /// Token count from tool definitions. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolDefinitionsTokens")] + public double? ToolDefinitionsTokens { get; set; } + + /// Whether this is the first usage_info event emitted in this session. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("isInitial")] + public bool? IsInitial { get; set; } } -/// Empty payload; the event signals that LLM-powered conversation compaction has begun. +/// Context window breakdown at the start of LLM-powered conversation compaction. public partial class SessionCompactionStartData { + /// Token count from system message(s) at compaction start. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("systemTokens")] + public double? SystemTokens { get; set; } + + /// Token count from non-system messages (user, assistant, tool) at compaction start. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("conversationTokens")] + public double? ConversationTokens { get; set; } + + /// Token count from tool definitions at compaction start. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolDefinitionsTokens")] + public double? ToolDefinitionsTokens { get; set; } } /// Conversation compaction results including success status, metrics, and optional error details. @@ -1344,18 +1520,38 @@ public partial class SessionCompactionCompleteData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("requestId")] public string? RequestId { get; set; } + + /// Token count from system message(s) after compaction. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("systemTokens")] + public double? SystemTokens { get; set; } + + /// Token count from non-system messages (user, assistant, tool) after compaction. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("conversationTokens")] + public double? ConversationTokens { get; set; } + + /// Token count from tool definitions after compaction. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolDefinitionsTokens")] + public double? ToolDefinitionsTokens { get; set; } } -/// Task completion notification with optional summary from the agent. +/// Task completion notification with summary from the agent. public partial class SessionTaskCompleteData { - /// Optional summary of the completed task, provided by the agent. + /// Summary of the completed task, provided by the agent. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("summary")] public string? Summary { get; set; } + + /// Whether the tool call succeeded. False when validation failed (e.g., invalid arguments). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("success")] + public bool? Success { get; set; } } -/// User message content with optional attachments, source information, and interaction metadata. +/// Event payload for . public partial class UserMessageData { /// The user's message text as displayed in the timeline. @@ -1372,10 +1568,10 @@ public partial class UserMessageData [JsonPropertyName("attachments")] public UserMessageDataAttachmentsItem[]? Attachments { get; set; } - /// Origin of this message, used for timeline filtering and telemetry (e.g., "user", "autopilot", "skill", or "command"). + /// Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected messages that should be hidden from the user). [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("source")] - public UserMessageDataSource? Source { get; set; } + public string? Source { get; set; } /// The agent mode that was active when this message was sent. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -1953,6 +2149,11 @@ public partial class UserInputRequestedData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("allowFreeform")] public bool? AllowFreeform { get; set; } + + /// The LLM-assigned tool call ID that triggered this request; used by remote UIs to correlate responses. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolCallId")] + public string? ToolCallId { get; set; } } /// User input request completion notification signaling UI dismissal. @@ -1963,25 +2164,41 @@ public partial class UserInputCompletedData public required string RequestId { get; set; } } -/// Structured form elicitation request with JSON schema definition for form fields. +/// Elicitation request; may be form-based (structured input) or URL-based (browser redirect). public partial class ElicitationRequestedData { /// Unique identifier for this elicitation request; used to respond via session.respondToElicitation(). [JsonPropertyName("requestId")] public required string RequestId { get; set; } + /// Tool call ID from the LLM completion; used to correlate with CompletionChunk.toolCall.id for remote UIs. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("toolCallId")] + public string? ToolCallId { get; set; } + + /// The source that initiated the request (MCP server name, or absent for agent-initiated). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("elicitationSource")] + public string? ElicitationSource { get; set; } + /// Message describing what information is needed from the user. [JsonPropertyName("message")] public required string Message { get; set; } - /// Elicitation mode; currently only "form" is supported. Defaults to "form" when absent. + /// Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to "form" when absent. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("mode")] - public string? Mode { get; set; } + public ElicitationRequestedDataMode? Mode { get; set; } - /// JSON Schema describing the form fields to present to the user. + /// JSON Schema describing the form fields to present to the user (form mode only). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("requestedSchema")] - public required ElicitationRequestedDataRequestedSchema RequestedSchema { get; set; } + public ElicitationRequestedDataRequestedSchema? RequestedSchema { get; set; } + + /// URL to open in the user's browser (url mode only). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("url")] + public string? Url { get; set; } } /// Elicitation request completion notification signaling UI dismissal. @@ -1992,6 +2209,35 @@ public partial class ElicitationCompletedData public required string RequestId { get; set; } } +/// OAuth authentication request for an MCP server. +public partial class McpOauthRequiredData +{ + /// Unique identifier for this OAuth request; used to respond via session.respondToMcpOAuth(). + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + /// Display name of the MCP server that requires OAuth. + [JsonPropertyName("serverName")] + public required string ServerName { get; set; } + + /// URL of the MCP server that requires OAuth. + [JsonPropertyName("serverUrl")] + public required string ServerUrl { get; set; } + + /// Static OAuth client configuration, if the server specifies one. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("staticClientConfig")] + public McpOauthRequiredDataStaticClientConfig? StaticClientConfig { get; set; } +} + +/// MCP OAuth request completion notification. +public partial class McpOauthCompletedData +{ + /// Request ID of the resolved OAuth request. + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + /// External tool invocation request for client-side tool execution. public partial class ExternalToolRequestedData { @@ -2047,6 +2293,26 @@ public partial class CommandQueuedData public required string Command { get; set; } } +/// Registered command dispatch request routed to the owning client. +public partial class CommandExecuteData +{ + /// Unique identifier; used to respond via session.commands.handlePendingCommand(). + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + /// The full command text (e.g., /deploy production). + [JsonPropertyName("command")] + public required string Command { get; set; } + + /// Command name without leading /. + [JsonPropertyName("commandName")] + public required string CommandName { get; set; } + + /// Raw argument string after the command name. + [JsonPropertyName("args")] + public required string Args { get; set; } +} + /// Queued command completion notification signaling UI dismissal. public partial class CommandCompletedData { @@ -2055,6 +2321,14 @@ public partial class CommandCompletedData public required string RequestId { get; set; } } +/// SDK command registration change notification. +public partial class CommandsChangedData +{ + /// Current list of registered SDK commands. + [JsonPropertyName("commands")] + public required CommandsChangedDataCommandsItem[] Commands { get; set; } +} + /// Plan approval request with plan content and available user actions. public partial class ExitPlanModeRequestedData { @@ -2100,6 +2374,42 @@ public partial class SessionBackgroundTasksChangedData { } +/// Event payload for . +public partial class SessionSkillsLoadedData +{ + /// Array of resolved skill metadata. + [JsonPropertyName("skills")] + public required SessionSkillsLoadedDataSkillsItem[] Skills { get; set; } +} + +/// Event payload for . +public partial class SessionMcpServersLoadedData +{ + /// Array of MCP server status summaries. + [JsonPropertyName("servers")] + public required SessionMcpServersLoadedDataServersItem[] Servers { get; set; } +} + +/// Event payload for . +public partial class SessionMcpServerStatusChangedData +{ + /// Name of the MCP server whose status changed. + [JsonPropertyName("serverName")] + public required string ServerName { get; set; } + + /// New connection status: connected, failed, pending, disabled, or not_configured. + [JsonPropertyName("status")] + public required SessionMcpServersLoadedDataServersItemStatus Status { get; set; } +} + +/// Event payload for . +public partial class SessionExtensionsLoadedData +{ + /// Array of discovered extensions and their status. + [JsonPropertyName("extensions")] + public required SessionExtensionsLoadedDataExtensionsItem[] Extensions { get; set; } +} + /// Working directory and git context at session start. /// Nested data type for SessionStartDataContext. public partial class SessionStartDataContext @@ -2787,6 +3097,27 @@ public partial class SystemNotificationDataKindAgentCompleted : SystemNotificati public string? Prompt { get; set; } } +/// The agent_idle variant of . +public partial class SystemNotificationDataKindAgentIdle : SystemNotificationDataKind +{ + /// + [JsonIgnore] + public override string Type => "agent_idle"; + + /// Unique identifier of the background agent. + [JsonPropertyName("agentId")] + public required string AgentId { get; set; } + + /// Type of the agent (e.g., explore, task, general-purpose). + [JsonPropertyName("agentType")] + public required string AgentType { get; set; } + + /// Human-readable description of the agent task. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + /// The shell_completed variant of . public partial class SystemNotificationDataKindShellCompleted : SystemNotificationDataKind { @@ -2832,6 +3163,7 @@ public partial class SystemNotificationDataKindShellDetachedCompleted : SystemNo TypeDiscriminatorPropertyName = "type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] [JsonDerivedType(typeof(SystemNotificationDataKindAgentCompleted), "agent_completed")] +[JsonDerivedType(typeof(SystemNotificationDataKindAgentIdle), "agent_idle")] [JsonDerivedType(typeof(SystemNotificationDataKindShellCompleted), "shell_completed")] [JsonDerivedType(typeof(SystemNotificationDataKindShellDetachedCompleted), "shell_detached_completed")] public partial class SystemNotificationDataKind @@ -3130,7 +3462,7 @@ public partial class PermissionCompletedDataResult public required PermissionCompletedDataResultKind Kind { get; set; } } -/// JSON Schema describing the form fields to present to the user. +/// JSON Schema describing the form fields to present to the user (form mode only). /// Nested data type for ElicitationRequestedDataRequestedSchema. public partial class ElicitationRequestedDataRequestedSchema { @@ -3148,6 +3480,104 @@ public partial class ElicitationRequestedDataRequestedSchema public string[]? Required { get; set; } } +/// Static OAuth client configuration, if the server specifies one. +/// Nested data type for McpOauthRequiredDataStaticClientConfig. +public partial class McpOauthRequiredDataStaticClientConfig +{ + /// OAuth client ID for the server. + [JsonPropertyName("clientId")] + public required string ClientId { get; set; } + + /// Whether this is a public OAuth client. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("publicClient")] + public bool? PublicClient { get; set; } +} + +/// Nested data type for CommandsChangedDataCommandsItem. +public partial class CommandsChangedDataCommandsItem +{ + /// Gets or sets the name value. + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// Gets or sets the description value. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +/// Nested data type for SessionSkillsLoadedDataSkillsItem. +public partial class SessionSkillsLoadedDataSkillsItem +{ + /// Unique identifier for the skill. + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// Description of what the skill does. + [JsonPropertyName("description")] + public required string Description { get; set; } + + /// Source location type of the skill (e.g., project, personal, plugin). + [JsonPropertyName("source")] + public required string Source { get; set; } + + /// Whether the skill can be invoked by the user as a slash command. + [JsonPropertyName("userInvocable")] + public required bool UserInvocable { get; set; } + + /// Whether the skill is currently enabled. + [JsonPropertyName("enabled")] + public required bool Enabled { get; set; } + + /// Absolute path to the skill file, if available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("path")] + public string? Path { get; set; } +} + +/// Nested data type for SessionMcpServersLoadedDataServersItem. +public partial class SessionMcpServersLoadedDataServersItem +{ + /// Server name (config key). + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// Connection status: connected, failed, pending, disabled, or not_configured. + [JsonPropertyName("status")] + public required SessionMcpServersLoadedDataServersItemStatus Status { get; set; } + + /// Configuration source: user, workspace, plugin, or builtin. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("source")] + public string? Source { get; set; } + + /// Error message if the server failed to connect. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// Nested data type for SessionExtensionsLoadedDataExtensionsItem. +public partial class SessionExtensionsLoadedDataExtensionsItem +{ + /// Source-qualified extension ID (e.g., 'project:my-ext', 'user:auth-helper'). + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// Extension name (directory name). + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// Discovery source. + [JsonPropertyName("source")] + public required SessionExtensionsLoadedDataExtensionsItemSource Source { get; set; } + + /// Current status: running, disabled, failed, or starting. + [JsonPropertyName("status")] + public required SessionExtensionsLoadedDataExtensionsItemStatus Status { get; set; } +} + /// Hosting platform type of the repository (github or ado). [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionStartDataContextHostType @@ -3226,42 +3656,6 @@ public enum UserMessageDataAttachmentsItemGithubReferenceReferenceType Discussion, } -/// Origin of this message, used for timeline filtering and telemetry (e.g., "user", "autopilot", "skill", or "command"). -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum UserMessageDataSource -{ - /// The user variant. - [JsonStringEnumMemberName("user")] - User, - /// The autopilot variant. - [JsonStringEnumMemberName("autopilot")] - Autopilot, - /// The skill variant. - [JsonStringEnumMemberName("skill")] - Skill, - /// The system variant. - [JsonStringEnumMemberName("system")] - System, - /// The command variant. - [JsonStringEnumMemberName("command")] - Command, - /// The immediate-prompt variant. - [JsonStringEnumMemberName("immediate-prompt")] - ImmediatePrompt, - /// The jit-instruction variant. - [JsonStringEnumMemberName("jit-instruction")] - JitInstruction, - /// The snippy-blocking variant. - [JsonStringEnumMemberName("snippy-blocking")] - SnippyBlocking, - /// The thinking-exhausted-continuation variant. - [JsonStringEnumMemberName("thinking-exhausted-continuation")] - ThinkingExhaustedContinuation, - /// The other variant. - [JsonStringEnumMemberName("other")] - Other, -} - /// The agent mode that was active when this message was sent. [JsonConverter(typeof(JsonStringEnumConverter))] public enum UserMessageDataAgentMode @@ -3349,6 +3743,69 @@ public enum PermissionCompletedDataResultKind DeniedByContentExclusionPolicy, } +/// Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to "form" when absent. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ElicitationRequestedDataMode +{ + /// The form variant. + [JsonStringEnumMemberName("form")] + Form, + /// The url variant. + [JsonStringEnumMemberName("url")] + Url, +} + +/// Connection status: connected, failed, pending, disabled, or not_configured. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionMcpServersLoadedDataServersItemStatus +{ + /// The connected variant. + [JsonStringEnumMemberName("connected")] + Connected, + /// The failed variant. + [JsonStringEnumMemberName("failed")] + Failed, + /// The pending variant. + [JsonStringEnumMemberName("pending")] + Pending, + /// The disabled variant. + [JsonStringEnumMemberName("disabled")] + Disabled, + /// The not_configured variant. + [JsonStringEnumMemberName("not_configured")] + NotConfigured, +} + +/// Discovery source. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionExtensionsLoadedDataExtensionsItemSource +{ + /// The project variant. + [JsonStringEnumMemberName("project")] + Project, + /// The user variant. + [JsonStringEnumMemberName("user")] + User, +} + +/// Current status: running, disabled, failed, or starting. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionExtensionsLoadedDataExtensionsItemStatus +{ + /// The running variant. + [JsonStringEnumMemberName("running")] + Running, + /// The disabled variant. + [JsonStringEnumMemberName("disabled")] + Disabled, + /// The failed variant. + [JsonStringEnumMemberName("failed")] + Failed, + /// The starting variant. + [JsonStringEnumMemberName("starting")] + Starting, +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -3379,8 +3836,13 @@ public enum PermissionCompletedDataResultKind [JsonSerializable(typeof(AssistantUsageEvent))] [JsonSerializable(typeof(CommandCompletedData))] [JsonSerializable(typeof(CommandCompletedEvent))] +[JsonSerializable(typeof(CommandExecuteData))] +[JsonSerializable(typeof(CommandExecuteEvent))] [JsonSerializable(typeof(CommandQueuedData))] [JsonSerializable(typeof(CommandQueuedEvent))] +[JsonSerializable(typeof(CommandsChangedData))] +[JsonSerializable(typeof(CommandsChangedDataCommandsItem))] +[JsonSerializable(typeof(CommandsChangedEvent))] [JsonSerializable(typeof(ElicitationCompletedData))] [JsonSerializable(typeof(ElicitationCompletedEvent))] [JsonSerializable(typeof(ElicitationRequestedData))] @@ -3399,6 +3861,11 @@ public enum PermissionCompletedDataResultKind [JsonSerializable(typeof(HookEndEvent))] [JsonSerializable(typeof(HookStartData))] [JsonSerializable(typeof(HookStartEvent))] +[JsonSerializable(typeof(McpOauthCompletedData))] +[JsonSerializable(typeof(McpOauthCompletedEvent))] +[JsonSerializable(typeof(McpOauthRequiredData))] +[JsonSerializable(typeof(McpOauthRequiredDataStaticClientConfig))] +[JsonSerializable(typeof(McpOauthRequiredEvent))] [JsonSerializable(typeof(PendingMessagesModifiedData))] [JsonSerializable(typeof(PendingMessagesModifiedEvent))] [JsonSerializable(typeof(PermissionCompletedData))] @@ -3429,6 +3896,9 @@ public enum PermissionCompletedDataResultKind [JsonSerializable(typeof(SessionErrorData))] [JsonSerializable(typeof(SessionErrorEvent))] [JsonSerializable(typeof(SessionEvent))] +[JsonSerializable(typeof(SessionExtensionsLoadedData))] +[JsonSerializable(typeof(SessionExtensionsLoadedDataExtensionsItem))] +[JsonSerializable(typeof(SessionExtensionsLoadedEvent))] [JsonSerializable(typeof(SessionHandoffData))] [JsonSerializable(typeof(SessionHandoffDataRepository))] [JsonSerializable(typeof(SessionHandoffEvent))] @@ -3439,6 +3909,11 @@ public enum PermissionCompletedDataResultKind [JsonSerializable(typeof(SessionIdleEvent))] [JsonSerializable(typeof(SessionInfoData))] [JsonSerializable(typeof(SessionInfoEvent))] +[JsonSerializable(typeof(SessionMcpServerStatusChangedData))] +[JsonSerializable(typeof(SessionMcpServerStatusChangedEvent))] +[JsonSerializable(typeof(SessionMcpServersLoadedData))] +[JsonSerializable(typeof(SessionMcpServersLoadedDataServersItem))] +[JsonSerializable(typeof(SessionMcpServersLoadedEvent))] [JsonSerializable(typeof(SessionModeChangedData))] [JsonSerializable(typeof(SessionModeChangedEvent))] [JsonSerializable(typeof(SessionModelChangeData))] @@ -3451,6 +3926,9 @@ public enum PermissionCompletedDataResultKind [JsonSerializable(typeof(SessionShutdownData))] [JsonSerializable(typeof(SessionShutdownDataCodeChanges))] [JsonSerializable(typeof(SessionShutdownEvent))] +[JsonSerializable(typeof(SessionSkillsLoadedData))] +[JsonSerializable(typeof(SessionSkillsLoadedDataSkillsItem))] +[JsonSerializable(typeof(SessionSkillsLoadedEvent))] [JsonSerializable(typeof(SessionSnapshotRewindData))] [JsonSerializable(typeof(SessionSnapshotRewindEvent))] [JsonSerializable(typeof(SessionStartData))] @@ -3488,6 +3966,7 @@ public enum PermissionCompletedDataResultKind [JsonSerializable(typeof(SystemNotificationData))] [JsonSerializable(typeof(SystemNotificationDataKind))] [JsonSerializable(typeof(SystemNotificationDataKindAgentCompleted))] +[JsonSerializable(typeof(SystemNotificationDataKindAgentIdle))] [JsonSerializable(typeof(SystemNotificationDataKindShellCompleted))] [JsonSerializable(typeof(SystemNotificationDataKindShellDetachedCompleted))] [JsonSerializable(typeof(SystemNotificationEvent))] diff --git a/dotnet/src/SdkProtocolVersion.cs b/dotnet/src/SdkProtocolVersion.cs index f3d8f04c5..889af460b 100644 --- a/dotnet/src/SdkProtocolVersion.cs +++ b/dotnet/src/SdkProtocolVersion.cs @@ -16,8 +16,5 @@ internal static class SdkProtocolVersion /// /// Gets the SDK protocol version. /// - public static int GetVersion() - { - return Version; - } + public static int GetVersion() => Version; } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 606c0b052..675a3e0c0 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -65,6 +65,8 @@ public sealed partial class CopilotSession : IAsyncDisposable private SessionHooks? _hooks; private readonly SemaphoreSlim _hooksLock = new(1, 1); + private Dictionary>>? _transformCallbacks; + private readonly SemaphoreSlim _transformCallbacksLock = new(1, 1); private SessionRpc? _sessionRpc; private int _isDisposed; @@ -653,6 +655,72 @@ internal void RegisterHooks(SessionHooks hooks) }; } + /// + /// Registers transform callbacks for system message sections. + /// + /// The transform callbacks keyed by section identifier. + internal void RegisterTransformCallbacks(Dictionary>>? callbacks) + { + _transformCallbacksLock.Wait(); + try + { + _transformCallbacks = callbacks; + } + finally + { + _transformCallbacksLock.Release(); + } + } + + /// + /// Handles a systemMessage.transform RPC call from the Copilot CLI. + /// + /// The raw JSON element containing sections to transform. + /// A task that resolves with the transformed sections. + internal async Task HandleSystemMessageTransformAsync(JsonElement sections) + { + Dictionary>>? callbacks; + await _transformCallbacksLock.WaitAsync(); + try + { + callbacks = _transformCallbacks; + } + finally + { + _transformCallbacksLock.Release(); + } + + var parsed = JsonSerializer.Deserialize( + sections.GetRawText(), + SessionJsonContext.Default.DictionaryStringSystemMessageTransformSection) ?? new(); + + var result = new Dictionary(); + foreach (var (sectionId, data) in parsed) + { + Func>? callback = null; + callbacks?.TryGetValue(sectionId, out callback); + + if (callback != null) + { + try + { + var transformed = await callback(data.Content ?? ""); + result[sectionId] = new SystemMessageTransformSection { Content = transformed }; + } + catch + { + result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" }; + } + } + else + { + result[sectionId] = new SystemMessageTransformSection { Content = data.Content ?? "" }; + } + } + + return new SystemMessageTransformRpcResponse { Sections = result }; + } + /// /// Gets the complete list of messages and events in the session. /// @@ -749,6 +817,7 @@ public Task SetModelAsync(string model, CancellationToken cancellationToken = de /// The message to log. /// Log level (default: info). /// When true, the message is not persisted to disk. + /// Optional URL to associate with the log entry. /// Optional cancellation token. /// /// @@ -758,9 +827,9 @@ public Task SetModelAsync(string model, CancellationToken cancellationToken = de /// await session.LogAsync("Temporary status", ephemeral: true); /// /// - public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, CancellationToken cancellationToken = default) + public async Task LogAsync(string message, SessionLogRequestLevel? level = null, bool? ephemeral = null, string? url = null, CancellationToken cancellationToken = default) { - await Rpc.LogAsync(message, level, ephemeral, cancellationToken); + await Rpc.LogAsync(message, level, ephemeral, url, cancellationToken); } /// @@ -890,5 +959,8 @@ internal record SessionDestroyRequest [JsonSerializable(typeof(SessionEndHookOutput))] [JsonSerializable(typeof(ErrorOccurredHookInput))] [JsonSerializable(typeof(ErrorOccurredHookOutput))] + [JsonSerializable(typeof(SystemMessageTransformSection))] + [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] + [JsonSerializable(typeof(Dictionary))] internal partial class SessionJsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 84e7feaed..d6530f9c7 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -968,7 +968,86 @@ public enum SystemMessageMode Append, /// Replace the default system message entirely. [JsonStringEnumMemberName("replace")] - Replace + Replace, + /// Override individual sections of the system prompt. + [JsonStringEnumMemberName("customize")] + Customize +} + +/// +/// Specifies the operation to perform on a system prompt section. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SectionOverrideAction +{ + /// Replace the section content entirely. + [JsonStringEnumMemberName("replace")] + Replace, + /// Remove the section from the prompt. + [JsonStringEnumMemberName("remove")] + Remove, + /// Append content after the existing section. + [JsonStringEnumMemberName("append")] + Append, + /// Prepend content before the existing section. + [JsonStringEnumMemberName("prepend")] + Prepend, + /// Transform the section content via a callback. + [JsonStringEnumMemberName("transform")] + Transform +} + +/// +/// Override operation for a single system prompt section. +/// +public class SectionOverride +{ + /// + /// The operation to perform on this section. Ignored when Transform is set. + /// + [JsonPropertyName("action")] + public SectionOverrideAction? Action { get; set; } + + /// + /// Content for the override. Optional for all actions. Ignored for remove. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Transform callback. When set, takes precedence over Action. + /// Receives current section content, returns transformed content. + /// Not serialized — the SDK handles this locally. + /// + [JsonIgnore] + public Func>? Transform { get; set; } +} + +/// +/// Known system prompt section identifiers for the "customize" mode. +/// +public static class SystemPromptSections +{ + /// Agent identity preamble and mode statement. + public const string Identity = "identity"; + /// Response style, conciseness rules, output formatting preferences. + public const string Tone = "tone"; + /// Tool usage patterns, parallel calling, batching guidelines. + public const string ToolEfficiency = "tool_efficiency"; + /// CWD, OS, git root, directory listing, available tools. + public const string EnvironmentContext = "environment_context"; + /// Coding rules, linting/testing, ecosystem tools, style. + public const string CodeChangeRules = "code_change_rules"; + /// Tips, behavioral best practices, behavioral guidelines. + public const string Guidelines = "guidelines"; + /// Environment limitations, prohibited actions, security policies. + public const string Safety = "safety"; + /// Per-tool usage instructions. + public const string ToolInstructions = "tool_instructions"; + /// Repository and organization custom instructions. + public const string CustomInstructions = "custom_instructions"; + /// End-of-prompt instructions: parallel tool calling, persistence, task completion. + public const string LastInstructions = "last_instructions"; } /// @@ -977,13 +1056,21 @@ public enum SystemMessageMode public class SystemMessageConfig { /// - /// How the system message is applied (append or replace). + /// How the system message is applied (append, replace, or customize). /// public SystemMessageMode? Mode { get; set; } + /// - /// Content of the system message. + /// Content of the system message. Used by append and replace modes. + /// In customize mode, additional content appended after all sections. /// public string? Content { get; set; } + + /// + /// Section-level overrides for customize mode. + /// Keys are section identifiers (see ). + /// + public Dictionary? Sections { get; set; } } /// @@ -2032,6 +2119,30 @@ public class SetForegroundSessionResponse public string? Error { get; set; } } +/// +/// Content data for a single system prompt section in a transform RPC call. +/// +public class SystemMessageTransformSection +{ + /// + /// The content of the section. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +/// +/// Response to a systemMessage.transform RPC call. +/// +public class SystemMessageTransformRpcResponse +{ + /// + /// The transformed sections keyed by section identifier. + /// + [JsonPropertyName("sections")] + public Dictionary? Sections { get; set; } +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -2061,6 +2172,7 @@ public class SetForegroundSessionResponse [JsonSerializable(typeof(SessionLifecycleEvent))] [JsonSerializable(typeof(SessionLifecycleEventMetadata))] [JsonSerializable(typeof(SessionListFilter))] +[JsonSerializable(typeof(SectionOverride))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SetForegroundSessionResponse))] [JsonSerializable(typeof(SystemMessageConfig))] diff --git a/dotnet/test/ForwardCompatibilityTests.cs b/dotnet/test/ForwardCompatibilityTests.cs new file mode 100644 index 000000000..d3f5b7785 --- /dev/null +++ b/dotnet/test/ForwardCompatibilityTests.cs @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +/// +/// Tests for forward-compatible handling of unknown session event types. +/// Verifies that the SDK gracefully handles event types introduced by newer CLI versions. +/// +public class ForwardCompatibilityTests +{ + [Fact] + public void FromJson_KnownEventType_DeserializesNormally() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "user.message", + "data": { + "content": "Hello" + } + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.IsType(result); + Assert.Equal("user.message", result.Type); + } + + [Fact] + public void FromJson_UnknownEventType_ReturnsBaseSessionEvent() + { + var json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "parentId": "abcdefab-abcd-abcd-abcd-abcdefabcdef", + "type": "future.feature_from_server", + "data": { "key": "value" } + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.IsType(result); + Assert.Equal("unknown", result.Type); + } + + [Fact] + public void FromJson_UnknownEventType_PreservesBaseMetadata() + { + var json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "parentId": "abcdefab-abcd-abcd-abcd-abcdefabcdef", + "type": "future.feature_from_server", + "data": {} + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.Equal(Guid.Parse("12345678-1234-1234-1234-123456789abc"), result.Id); + Assert.Equal(DateTimeOffset.Parse("2026-06-15T10:30:00Z"), result.Timestamp); + Assert.Equal(Guid.Parse("abcdefab-abcd-abcd-abcd-abcdefabcdef"), result.ParentId); + } + + [Fact] + public void FromJson_MultipleEvents_MixedKnownAndUnknown() + { + var events = new[] + { + """{"id":"00000000-0000-0000-0000-000000000001","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Hi"}}""", + """{"id":"00000000-0000-0000-0000-000000000002","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"future.unknown_type","data":{}}""", + """{"id":"00000000-0000-0000-0000-000000000003","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Bye"}}""", + }; + + var results = events.Select(SessionEvent.FromJson).ToList(); + + Assert.Equal(3, results.Count); + Assert.IsType(results[0]); + Assert.IsType(results[1]); + Assert.IsType(results[2]); + } + + [Fact] + public void SessionEvent_Type_DefaultsToUnknown() + { + var evt = new SessionEvent(); + + Assert.Equal("unknown", evt.Type); + } +} diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 30a9135a5..5aecaccba 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -91,6 +91,37 @@ public async Task Should_Create_A_Session_With_Replaced_SystemMessage_Config() Assert.Equal(testSystemMessage, GetSystemMessage(traffic[0])); } + [Fact] + public async Task Should_Create_A_Session_With_Customized_SystemMessage_Config() + { + var customTone = "Respond in a warm, professional tone. Be thorough in explanations."; + var appendedContent = "Always mention quarterly earnings."; + var session = await CreateSessionAsync(new SessionConfig + { + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + [SystemPromptSections.Tone] = new() { Action = SectionOverrideAction.Replace, Content = customTone }, + [SystemPromptSections.CodeChangeRules] = new() { Action = SectionOverrideAction.Remove }, + }, + Content = appendedContent + } + }); + + await session.SendAsync(new MessageOptions { Prompt = "Who are you?" }); + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + + var traffic = await Ctx.GetExchangesAsync(); + Assert.NotEmpty(traffic); + var systemMessage = GetSystemMessage(traffic[0]); + Assert.Contains(customTone, systemMessage); + Assert.Contains(appendedContent, systemMessage); + Assert.DoesNotContain("", systemMessage); + } + [Fact] public async Task Should_Create_A_Session_With_AvailableTools() { diff --git a/dotnet/test/SystemMessageTransformTests.cs b/dotnet/test/SystemMessageTransformTests.cs new file mode 100644 index 000000000..cdddc5a79 --- /dev/null +++ b/dotnet/test/SystemMessageTransformTests.cs @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class SystemMessageTransformTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "system_message_transform", output) +{ + [Fact] + public async Task Should_Invoke_Transform_Callbacks_With_Section_Content() + { + var identityCallbackInvoked = false; + var toneCallbackInvoked = false; + + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + ["identity"] = new SectionOverride + { + Transform = async (content) => + { + Assert.False(string.IsNullOrEmpty(content)); + identityCallbackInvoked = true; + return content; + } + }, + ["tone"] = new SectionOverride + { + Transform = async (content) => + { + Assert.False(string.IsNullOrEmpty(content)); + toneCallbackInvoked = true; + return content; + } + } + } + } + }); + + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "test.txt"), "Hello transform!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of test.txt and tell me what it says" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.True(identityCallbackInvoked, "Expected identity transform callback to be invoked"); + Assert.True(toneCallbackInvoked, "Expected tone transform callback to be invoked"); + } + + [Fact] + public async Task Should_Apply_Transform_Modifications_To_Section_Content() + { + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + ["identity"] = new SectionOverride + { + Transform = async (content) => + { + return content + "\nAlways end your reply with TRANSFORM_MARKER"; + } + } + } + } + }); + + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "hello.txt"), "Hello!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of hello.txt" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // Verify the transform result was actually applied to the system message + var traffic = await Ctx.GetExchangesAsync(); + Assert.NotEmpty(traffic); + var systemMessage = GetSystemMessage(traffic[0]); + Assert.Contains("TRANSFORM_MARKER", systemMessage); + } + + [Fact] + public async Task Should_Work_With_Static_Overrides_And_Transforms_Together() + { + var transformCallbackInvoked = false; + + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + ["safety"] = new SectionOverride + { + Action = SectionOverrideAction.Remove + }, + ["identity"] = new SectionOverride + { + Transform = async (content) => + { + transformCallbackInvoked = true; + return content; + } + } + } + } + }); + + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "combo.txt"), "Combo test!"); + + await session.SendAsync(new MessageOptions + { + Prompt = "Read the contents of combo.txt and tell me what it says" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.True(transformCallbackInvoked, "Expected identity transform callback to be invoked"); + } +} diff --git a/go/README.md b/go/README.md index 1d0665130..f29ef9fb7 100644 --- a/go/README.md +++ b/go/README.md @@ -150,7 +150,10 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `ReasoningEffort` (string): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `ListModels()` to check which models support this option. - `SessionID` (string): Custom session ID - `Tools` ([]Tool): Custom tools exposed to the CLI -- `SystemMessage` (\*SystemMessageConfig): System message configuration +- `SystemMessage` (\*SystemMessageConfig): System message configuration. Supports three modes: + - **append** (default): Appends `Content` after the SDK-managed prompt + - **replace**: Replaces the entire prompt with `Content` + - **customize**: Selectively override individual sections via `Sections` map (keys: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`, `SectionLastInstructions`; values: `SectionOverride` with `Action` and optional `Content`) - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `Streaming` (bool): Enable streaming delta events - `InfiniteSessions` (\*InfiniteSessionConfig): Automatic context compaction configuration @@ -179,6 +182,52 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Bool(v bool) *bool` - Helper to create bool pointers for `AutoStart` option +### System Message Customization + +Control the system prompt using `SystemMessage` in session config: + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + SystemMessage: &copilot.SystemMessageConfig{ + Content: "Always check for security vulnerabilities before suggesting changes.", + }, +}) +``` + +The SDK auto-injects environment context, tool instructions, and security guardrails. The default CLI persona is preserved, and your `Content` is appended after SDK-managed sections. To change the persona or fully redefine the prompt, use `Mode: "replace"` or `Mode: "customize"`. + +#### Customize Mode + +Use `Mode: "customize"` to selectively override individual sections of the prompt while preserving the rest: + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + // Replace the tone/style section + copilot.SectionTone: {Action: "replace", Content: "Respond in a warm, professional tone. Be thorough in explanations."}, + // Remove coding-specific rules + copilot.SectionCodeChangeRules: {Action: "remove"}, + // Append to existing guidelines + copilot.SectionGuidelines: {Action: "append", Content: "\n* Always cite data sources"}, + }, + // Additional instructions appended after all sections + Content: "Focus on financial analysis and reporting.", + }, +}) +``` + +Available section constants: `SectionIdentity`, `SectionTone`, `SectionToolEfficiency`, `SectionEnvironmentContext`, `SectionCodeChangeRules`, `SectionGuidelines`, `SectionSafety`, `SectionToolInstructions`, `SectionCustomInstructions`, `SectionLastInstructions`. + +Each section override supports four actions: +- **`replace`** — Replace the section content entirely +- **`remove`** — Remove the section from the prompt +- **`append`** — Add content after the existing section +- **`prepend`** — Add content before the existing section + +Unknown section IDs are handled gracefully: content from `replace`/`append`/`prepend` overrides is appended to additional instructions, and `remove` overrides are silently ignored. + ## Image Support The SDK supports image attachments via the `Attachments` field in `MessageOptions`. You can attach images by providing their file path, or by passing base64-encoded data directly using a blob attachment: @@ -201,7 +250,7 @@ _, err = session.Send(context.Background(), copilot.MessageOptions{ Prompt: "What's in this image?", Attachments: []copilot.Attachment{ { - Type: copilot.Blob, + Type: copilot.AttachmentTypeBlob, Data: &base64ImageData, MIMEType: &mimeType, }, diff --git a/go/client.go b/go/client.go index a2431ad39..22be47ec6 100644 --- a/go/client.go +++ b/go/client.go @@ -482,6 +482,37 @@ func (c *Client) ensureConnected(ctx context.Context) error { // }, // }, // }) +// +// extractTransformCallbacks separates transform callbacks from a SystemMessageConfig, +// returning a wire-safe config and a map of callbacks (nil if none). +func extractTransformCallbacks(config *SystemMessageConfig) (*SystemMessageConfig, map[string]SectionTransformFn) { + if config == nil || config.Mode != "customize" || len(config.Sections) == 0 { + return config, nil + } + + callbacks := make(map[string]SectionTransformFn) + wireSections := make(map[string]SectionOverride) + for id, override := range config.Sections { + if override.Transform != nil { + callbacks[id] = override.Transform + wireSections[id] = SectionOverride{Action: "transform"} + } else { + wireSections[id] = override + } + } + + if len(callbacks) == 0 { + return config, nil + } + + wireConfig := &SystemMessageConfig{ + Mode: config.Mode, + Content: config.Content, + Sections: wireSections, + } + return wireConfig, callbacks +} + func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { if config == nil || config.OnPermissionRequest == nil { return nil, fmt.Errorf("an OnPermissionRequest handler is required when creating a session. For example, to allow all permissions, use &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}") @@ -497,7 +528,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.ReasoningEffort = config.ReasoningEffort req.ConfigDir = config.ConfigDir req.Tools = config.Tools - req.SystemMessage = config.SystemMessage + wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + req.SystemMessage = wireSystemMessage req.AvailableTools = config.AvailableTools req.ExcludedTools = config.ExcludedTools req.Provider = config.Provider @@ -548,6 +580,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.Hooks != nil { session.registerHooks(config.Hooks) } + if transformCallbacks != nil { + session.registerTransformCallbacks(transformCallbacks) + } if config.OnEvent != nil { session.On(config.OnEvent) } @@ -616,7 +651,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.ClientName = config.ClientName req.Model = config.Model req.ReasoningEffort = config.ReasoningEffort - req.SystemMessage = config.SystemMessage + wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider req.AvailableTools = config.AvailableTools @@ -665,6 +701,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.Hooks != nil { session.registerHooks(config.Hooks) } + if transformCallbacks != nil { + session.registerTransformCallbacks(transformCallbacks) + } if config.OnEvent != nil { session.On(config.OnEvent) } @@ -1402,6 +1441,7 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2)) c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) + c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform)) } func (c *Client) handleSessionEvent(req sessionEventRequest) { @@ -1468,6 +1508,26 @@ func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jso return result, nil } +// handleSystemMessageTransform handles a system message transform request from the CLI server. +func (c *Client) handleSystemMessageTransform(req systemMessageTransformRequest) (systemMessageTransformResponse, *jsonrpc2.Error) { + if req.SessionID == "" { + return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32602, Message: "invalid system message transform payload"} + } + + c.sessionsMux.Lock() + session, ok := c.sessions[req.SessionID] + c.sessionsMux.Unlock() + if !ok { + return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + } + + resp, err := session.handleSystemMessageTransform(req.Sections) + if err != nil { + return systemMessageTransformResponse{}, &jsonrpc2.Error{Code: -32603, Message: err.Error()} + } + return resp, nil +} + // ======================================================================== // Protocol v2 backward-compatibility adapters // ======================================================================== diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 55eea011e..fbdb1597f 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -61,15 +61,12 @@ type SessionEvent struct { // // Current context window usage statistics including token and message counts // - // Empty payload; the event signals that LLM-powered conversation compaction has begun + // Context window breakdown at the start of LLM-powered conversation compaction // // Conversation compaction results including success status, metrics, and optional error // details // - // Task completion notification with optional summary from the agent - // - // User message content with optional attachments, source information, and interaction - // metadata + // Task completion notification with summary from the agent // // Empty payload; the event signals that the pending message queue has changed // @@ -135,18 +132,27 @@ type SessionEvent struct { // // User input request completion notification signaling UI dismissal // - // Structured form elicitation request with JSON schema definition for form fields + // Elicitation request; may be form-based (structured input) or URL-based (browser + // redirect) // // Elicitation request completion notification signaling UI dismissal // + // OAuth authentication request for an MCP server + // + // MCP OAuth request completion notification + // // External tool invocation request for client-side tool execution // // External tool completion notification signaling UI dismissal // // Queued slash command dispatch request for client execution // + // Registered command dispatch request routed to the owning client + // // Queued command completion notification signaling UI dismissal // + // SDK command registration change notification + // // Plan approval request with plan content and available user actions // // Plan mode exit completion notification signaling UI dismissal @@ -198,15 +204,12 @@ type SessionEvent struct { // // # Current context window usage statistics including token and message counts // -// Empty payload; the event signals that LLM-powered conversation compaction has begun +// # Context window breakdown at the start of LLM-powered conversation compaction // // Conversation compaction results including success status, metrics, and optional error // details // -// # Task completion notification with optional summary from the agent -// -// User message content with optional attachments, source information, and interaction -// metadata +// # Task completion notification with summary from the agent // // Empty payload; the event signals that the pending message queue has changed // @@ -272,18 +275,27 @@ type SessionEvent struct { // // # User input request completion notification signaling UI dismissal // -// # Structured form elicitation request with JSON schema definition for form fields +// Elicitation request; may be form-based (structured input) or URL-based (browser +// redirect) // // # Elicitation request completion notification signaling UI dismissal // +// # OAuth authentication request for an MCP server +// +// # MCP OAuth request completion notification +// // # External tool invocation request for client-side tool execution // // # External tool completion notification signaling UI dismissal // // # Queued slash command dispatch request for client execution // +// # Registered command dispatch request routed to the owning client +// // # Queued command completion notification signaling UI dismissal // +// # SDK command registration change notification +// // # Plan approval request with plan content and available user actions // // Plan mode exit completion notification signaling UI dismissal @@ -343,6 +355,14 @@ type Data struct { Stack *string `json:"stack,omitempty"` // HTTP status code from the upstream request, if applicable StatusCode *int64 `json:"statusCode,omitempty"` + // Optional URL associated with this error that the user can open in a browser + // + // Optional URL associated with this message that the user can open in a browser + // + // Optional URL associated with this warning that the user can open in a browser + // + // URL to open in the user's browser (url mode only) + URL *string `json:"url,omitempty"` // Background tasks still running when the agent became idle BackgroundTasks *BackgroundTasks `json:"backgroundTasks,omitempty"` // The new display title for the session @@ -383,7 +403,7 @@ type Data struct { SourceType *SourceType `json:"sourceType,omitempty"` // Summary of the work done in the source session // - // Optional summary of the completed task, provided by the agent + // Summary of the completed task, provided by the agent // // Summary of the plan that was created Summary *string `json:"summary,omitempty"` @@ -409,8 +429,20 @@ type Data struct { UpToEventID *string `json:"upToEventId,omitempty"` // Aggregate code change metrics for the session CodeChanges *CodeChanges `json:"codeChanges,omitempty"` + // Non-system message token count at shutdown + // + // Token count from non-system messages (user, assistant, tool) + // + // Token count from non-system messages (user, assistant, tool) at compaction start + // + // Token count from non-system messages (user, assistant, tool) after compaction + ConversationTokens *float64 `json:"conversationTokens,omitempty"` // Model that was selected at the time of shutdown CurrentModel *string `json:"currentModel,omitempty"` + // Total tokens in context window at shutdown + // + // Current number of tokens in the context window + CurrentTokens *float64 `json:"currentTokens,omitempty"` // Error description when shutdownType is "error" ErrorReason *string `json:"errorReason,omitempty"` // Per-model usage breakdown, keyed by model identifier @@ -419,6 +451,22 @@ type Data struct { SessionStartTime *float64 `json:"sessionStartTime,omitempty"` // Whether the session ended normally ("routine") or due to a crash/fatal error ("error") ShutdownType *ShutdownType `json:"shutdownType,omitempty"` + // System message token count at shutdown + // + // Token count from system message(s) + // + // Token count from system message(s) at compaction start + // + // Token count from system message(s) after compaction + SystemTokens *float64 `json:"systemTokens,omitempty"` + // Tool definitions token count at shutdown + // + // Token count from tool definitions + // + // Token count from tool definitions at compaction start + // + // Token count from tool definitions after compaction + ToolDefinitionsTokens *float64 `json:"toolDefinitionsTokens,omitempty"` // Cumulative time spent in API calls during the session, in milliseconds TotalAPIDurationMS *float64 `json:"totalApiDurationMs,omitempty"` // Total number of premium API requests used during the session @@ -435,8 +483,8 @@ type Data struct { HeadCommit *string `json:"headCommit,omitempty"` // Hosting platform type of the repository (github or ado) HostType *HostType `json:"hostType,omitempty"` - // Current number of tokens in the context window - CurrentTokens *float64 `json:"currentTokens,omitempty"` + // Whether this is the first usage_info event emitted in this session + IsInitial *bool `json:"isInitial,omitempty"` // Current number of messages in the conversation MessagesLength *float64 `json:"messagesLength,omitempty"` // Checkpoint snapshot number created for recovery @@ -481,6 +529,11 @@ type Data struct { // Request ID of the resolved elicitation request; clients should dismiss any UI for this // request // + // Unique identifier for this OAuth request; used to respond via + // session.respondToMcpOAuth() + // + // Request ID of the resolved OAuth request + // // Unique identifier for this request; used to respond via session.respondToExternalTool() // // Request ID of the resolved external tool request; clients should dismiss any UI for this @@ -488,6 +541,8 @@ type Data struct { // // Unique identifier for this request; used to respond via session.respondToQueuedCommand() // + // Unique identifier; used to respond via session.commands.handlePendingCommand() + // // Request ID of the resolved command request; clients should dismiss any UI for this // request // @@ -498,6 +553,8 @@ type Data struct { RequestID *string `json:"requestId,omitempty"` // Whether compaction completed successfully // + // Whether the tool call succeeded. False when validation failed (e.g., invalid arguments) + // // Whether the tool execution completed successfully // // Whether the hook completed successfully @@ -530,9 +587,9 @@ type Data struct { // // CAPI interaction ID for correlating this tool execution with upstream telemetry InteractionID *string `json:"interactionId,omitempty"` - // Origin of this message, used for timeline filtering and telemetry (e.g., "user", - // "autopilot", "skill", or "command") - Source *Source `json:"source,omitempty"` + // Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected + // messages that should be hidden from the user) + Source *string `json:"source,omitempty"` // Transformed version of the message sent to the model, with XML wrapping, timestamps, and // other augmentations for prompt caching TransformedContent *string `json:"transformedContent,omitempty"` @@ -618,6 +675,12 @@ type Data struct { // // Tool call ID of the parent tool invocation that spawned this sub-agent // + // The LLM-assigned tool call ID that triggered this request; used by remote UIs to + // correlate responses + // + // Tool call ID from the LLM completion; used to correlate with CompletionChunk.toolCall.id + // for remote UIs + // // Tool call ID assigned to this external tool invocation ToolCallID *string `json:"toolCallId,omitempty"` // Name of the tool the user wants to invoke @@ -690,22 +753,49 @@ type Data struct { Choices []string `json:"choices,omitempty"` // The question or prompt to present to the user Question *string `json:"question,omitempty"` - // Elicitation mode; currently only "form" is supported. Defaults to "form" when absent. + // The source that initiated the request (MCP server name, or absent for agent-initiated) + ElicitationSource *string `json:"elicitationSource,omitempty"` + // Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to + // "form" when absent. Mode *Mode `json:"mode,omitempty"` - // JSON Schema describing the form fields to present to the user + // JSON Schema describing the form fields to present to the user (form mode only) RequestedSchema *RequestedSchema `json:"requestedSchema,omitempty"` + // Display name of the MCP server that requires OAuth + // + // Name of the MCP server whose status changed + ServerName *string `json:"serverName,omitempty"` + // URL of the MCP server that requires OAuth + ServerURL *string `json:"serverUrl,omitempty"` + // Static OAuth client configuration, if the server specifies one + StaticClientConfig *StaticClientConfig `json:"staticClientConfig,omitempty"` // W3C Trace Context traceparent header for the execute_tool span Traceparent *string `json:"traceparent,omitempty"` // W3C Trace Context tracestate header for the execute_tool span Tracestate *string `json:"tracestate,omitempty"` // The slash command text to be executed (e.g., /help, /clear) + // + // The full command text (e.g., /deploy production) Command *string `json:"command,omitempty"` + // Raw argument string after the command name + Args *string `json:"args,omitempty"` + // Command name without leading / + CommandName *string `json:"commandName,omitempty"` + // Current list of registered SDK commands + Commands []DataCommand `json:"commands,omitempty"` // Available actions the user can take (e.g., approve, edit, reject) Actions []string `json:"actions,omitempty"` // Full content of the plan file PlanContent *string `json:"planContent,omitempty"` // The recommended action for the user to take RecommendedAction *string `json:"recommendedAction,omitempty"` + // Array of resolved skill metadata + Skills []Skill `json:"skills,omitempty"` + // Array of MCP server status summaries + Servers []Server `json:"servers,omitempty"` + // New connection status: connected, failed, pending, disabled, or not_configured + Status *ServerStatus `json:"status,omitempty"` + // Array of discovered extensions and their status + Extensions []Extension `json:"extensions,omitempty"` } // A user message attachment — a file, directory, code selection, blob, or GitHub reference @@ -822,6 +912,11 @@ type CodeChanges struct { LinesRemoved float64 `json:"linesRemoved"` } +type DataCommand struct { + Description *string `json:"description,omitempty"` + Name string `json:"name"` +} + // Token usage breakdown for the compaction LLM call type CompactionTokensUsed struct { // Cached input tokens reused in the compaction LLM call @@ -885,6 +980,17 @@ type ErrorClass struct { Stack *string `json:"stack,omitempty"` } +type Extension 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 Source `json:"source"` + // Current status: running, disabled, failed, or starting + Status ExtensionStatus `json:"status"` +} + // Structured metadata identifying what triggered this notification type KindClass struct { // Unique identifier of the background agent @@ -898,8 +1004,8 @@ type KindClass struct { // The full prompt given to the background agent Prompt *string `json:"prompt,omitempty"` // Whether the agent completed successfully or failed - Status *Status `json:"status,omitempty"` - Type KindType `json:"type"` + Status *KindStatus `json:"status,omitempty"` + Type KindType `json:"type"` // Exit code of the shell command, if available ExitCode *float64 `json:"exitCode,omitempty"` // Unique identifier of the shell session @@ -964,7 +1070,7 @@ type PermissionRequest struct { // Whether the UI can offer session-wide approval for this command pattern CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` // Parsed command identifiers found in the command text - Commands []CommandElement `json:"commands,omitempty"` + Commands []PermissionRequestCommand `json:"commands,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 >>) @@ -1027,7 +1133,7 @@ type PermissionRequest struct { ToolArgs interface{} `json:"toolArgs"` } -type CommandElement struct { +type PermissionRequestCommand struct { // Command identifier (e.g., executable name) Identifier string `json:"identifier"` // Whether this command is read-only (no side effects) @@ -1068,7 +1174,7 @@ type RepositoryClass struct { Owner string `json:"owner"` } -// JSON Schema describing the form fields to present to the user +// JSON Schema describing the form fields to present to the user (form mode only) type RequestedSchema struct { // Form field definitions, keyed by field name Properties map[string]interface{} `json:"properties"` @@ -1172,6 +1278,40 @@ type ResourceClass struct { Blob *string `json:"blob,omitempty"` } +type Server 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, pending, disabled, or not_configured + Status ServerStatus `json:"status"` +} + +type Skill 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"` +} + +// Static OAuth client configuration, if the server specifies one +type StaticClientConfig struct { + // OAuth client ID for the server + ClientID string `json:"clientId"` + // Whether this is a public OAuth client + PublicClient *bool `json:"publicClient,omitempty"` +} + // A tool invocation request from the assistant type ToolRequest struct { // Arguments to pass to the tool, format depends on the tool @@ -1193,59 +1333,81 @@ type ToolRequest struct { type AgentMode string const ( - AgentModeAutopilot AgentMode = "autopilot" - AgentModeShell AgentMode = "shell" - Interactive AgentMode = "interactive" - Plan AgentMode = "plan" + AgentModeShell AgentMode = "shell" + AgentModeAutopilot AgentMode = "autopilot" + AgentModeInteractive AgentMode = "interactive" + AgentModePlan AgentMode = "plan" ) // Type of GitHub reference type ReferenceType string const ( - Discussion ReferenceType = "discussion" - Issue ReferenceType = "issue" - PR ReferenceType = "pr" + ReferenceTypeDiscussion ReferenceType = "discussion" + ReferenceTypeIssue ReferenceType = "issue" + ReferenceTypePr ReferenceType = "pr" ) type AttachmentType string const ( - Blob AttachmentType = "blob" - Directory AttachmentType = "directory" - File AttachmentType = "file" - GithubReference AttachmentType = "github_reference" - Selection AttachmentType = "selection" + AttachmentTypeBlob AttachmentType = "blob" + AttachmentTypeDirectory AttachmentType = "directory" + AttachmentTypeFile AttachmentType = "file" + AttachmentTypeGithubReference AttachmentType = "github_reference" + AttachmentTypeSelection AttachmentType = "selection" ) // Hosting platform type of the repository (github or ado) type HostType string const ( - ADO HostType = "ado" - Github HostType = "github" + HostTypeAdo HostType = "ado" + HostTypeGithub HostType = "github" +) + +// Discovery source +type Source string + +const ( + SourceProject Source = "project" + SourceUser Source = "user" +) + +// Current status: running, disabled, failed, or starting +type ExtensionStatus string + +const ( + ExtensionStatusDisabled ExtensionStatus = "disabled" + ExtensionStatusFailed ExtensionStatus = "failed" + ExtensionStatusRunning ExtensionStatus = "running" + ExtensionStatusStarting ExtensionStatus = "starting" ) // Whether the agent completed successfully or failed -type Status string +type KindStatus string const ( - Completed Status = "completed" - Failed Status = "failed" + KindStatusCompleted KindStatus = "completed" + KindStatusFailed KindStatus = "failed" ) type KindType string const ( - AgentCompleted KindType = "agent_completed" - ShellCompleted KindType = "shell_completed" - ShellDetachedCompleted KindType = "shell_detached_completed" + KindTypeAgentCompleted KindType = "agent_completed" + KindTypeAgentIdle KindType = "agent_idle" + KindTypeShellCompleted KindType = "shell_completed" + KindTypeShellDetachedCompleted KindType = "shell_detached_completed" ) +// Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to +// "form" when absent. type Mode string const ( - Form Mode = "form" + ModeForm Mode = "form" + ModeURL Mode = "url" ) // The type of operation performed on the plan file @@ -1254,99 +1416,95 @@ const ( type Operation string const ( - Create Operation = "create" - Delete Operation = "delete" - Update Operation = "update" + OperationCreate Operation = "create" + OperationDelete Operation = "delete" + OperationUpdate Operation = "update" ) type PermissionRequestKind string const ( - CustomTool PermissionRequestKind = "custom-tool" - Hook PermissionRequestKind = "hook" - KindShell PermissionRequestKind = "shell" - MCP PermissionRequestKind = "mcp" - Memory PermissionRequestKind = "memory" - Read PermissionRequestKind = "read" - URL PermissionRequestKind = "url" - Write PermissionRequestKind = "write" + PermissionRequestKindCustomTool PermissionRequestKind = "custom-tool" + PermissionRequestKindHook PermissionRequestKind = "hook" + PermissionRequestKindShell PermissionRequestKind = "shell" + PermissionRequestKindURL PermissionRequestKind = "url" + PermissionRequestKindMcp PermissionRequestKind = "mcp" + PermissionRequestKindMemory PermissionRequestKind = "memory" + PermissionRequestKindRead PermissionRequestKind = "read" + PermissionRequestKindWrite PermissionRequestKind = "write" ) type RequestedSchemaType string const ( - Object RequestedSchemaType = "object" + RequestedSchemaTypeObject RequestedSchemaType = "object" ) // Theme variant this icon is intended for type Theme string const ( - Dark Theme = "dark" - Light Theme = "light" + ThemeDark Theme = "dark" + ThemeLight Theme = "light" ) type ContentType string const ( - Audio ContentType = "audio" - Image ContentType = "image" - Resource ContentType = "resource" - ResourceLink ContentType = "resource_link" - Terminal ContentType = "terminal" - Text ContentType = "text" + ContentTypeAudio ContentType = "audio" + ContentTypeImage ContentType = "image" + ContentTypeResource ContentType = "resource" + ContentTypeResourceLink ContentType = "resource_link" + ContentTypeTerminal ContentType = "terminal" + ContentTypeText ContentType = "text" ) // The outcome of the permission request type ResultKind string const ( - Approved ResultKind = "approved" - DeniedByContentExclusionPolicy ResultKind = "denied-by-content-exclusion-policy" - DeniedByRules ResultKind = "denied-by-rules" - DeniedInteractivelyByUser ResultKind = "denied-interactively-by-user" - DeniedNoApprovalRuleAndCouldNotRequestFromUser ResultKind = "denied-no-approval-rule-and-could-not-request-from-user" + ResultKindApproved ResultKind = "approved" + ResultKindDeniedByContentExclusionPolicy ResultKind = "denied-by-content-exclusion-policy" + ResultKindDeniedByRules ResultKind = "denied-by-rules" + ResultKindDeniedInteractivelyByUser ResultKind = "denied-interactively-by-user" + ResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser ResultKind = "denied-no-approval-rule-and-could-not-request-from-user" ) // Message role: "system" for system prompts, "developer" for developer-injected instructions type Role string const ( - Developer Role = "developer" - RoleSystem Role = "system" + RoleDeveloper Role = "developer" + RoleSystem Role = "system" ) -// Whether the session ended normally ("routine") or due to a crash/fatal error ("error") -type ShutdownType string +// Connection status: connected, failed, pending, disabled, or not_configured +// +// New connection status: connected, failed, pending, disabled, or not_configured +type ServerStatus string const ( - Error ShutdownType = "error" - Routine ShutdownType = "routine" + ServerStatusConnected ServerStatus = "connected" + ServerStatusDisabled ServerStatus = "disabled" + ServerStatusNotConfigured ServerStatus = "not_configured" + ServerStatusPending ServerStatus = "pending" + ServerStatusFailed ServerStatus = "failed" ) -// Origin of this message, used for timeline filtering and telemetry (e.g., "user", -// "autopilot", "skill", or "command") -type Source string +// Whether the session ended normally ("routine") or due to a crash/fatal error ("error") +type ShutdownType string const ( - Command Source = "command" - ImmediatePrompt Source = "immediate-prompt" - JITInstruction Source = "jit-instruction" - Other Source = "other" - Skill Source = "skill" - SnippyBlocking Source = "snippy-blocking" - SourceAutopilot Source = "autopilot" - SourceSystem Source = "system" - ThinkingExhaustedContinuation Source = "thinking-exhausted-continuation" - User Source = "user" + ShutdownTypeError ShutdownType = "error" + ShutdownTypeRoutine ShutdownType = "routine" ) // Origin type of the session being handed off type SourceType string const ( - Local SourceType = "local" - Remote SourceType = "remote" + SourceTypeLocal SourceType = "local" + SourceTypeRemote SourceType = "remote" ) // Tool call type: "function" for standard tool calls, "custom" for grammar-based tool @@ -1354,74 +1512,82 @@ const ( type ToolRequestType string const ( - Custom ToolRequestType = "custom" - Function ToolRequestType = "function" + ToolRequestTypeCustom ToolRequestType = "custom" + ToolRequestTypeFunction ToolRequestType = "function" ) type SessionEventType string const ( - Abort SessionEventType = "abort" - AssistantIntent SessionEventType = "assistant.intent" - AssistantMessage SessionEventType = "assistant.message" - AssistantMessageDelta SessionEventType = "assistant.message_delta" - AssistantReasoning SessionEventType = "assistant.reasoning" - AssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" - AssistantStreamingDelta SessionEventType = "assistant.streaming_delta" - AssistantTurnEnd SessionEventType = "assistant.turn_end" - AssistantTurnStart SessionEventType = "assistant.turn_start" - AssistantUsage SessionEventType = "assistant.usage" - CommandCompleted SessionEventType = "command.completed" - CommandQueued SessionEventType = "command.queued" - ElicitationCompleted SessionEventType = "elicitation.completed" - ElicitationRequested SessionEventType = "elicitation.requested" - ExitPlanModeCompleted SessionEventType = "exit_plan_mode.completed" - ExitPlanModeRequested SessionEventType = "exit_plan_mode.requested" - ExternalToolCompleted SessionEventType = "external_tool.completed" - ExternalToolRequested SessionEventType = "external_tool.requested" - HookEnd SessionEventType = "hook.end" - HookStart SessionEventType = "hook.start" - PendingMessagesModified SessionEventType = "pending_messages.modified" - PermissionCompleted SessionEventType = "permission.completed" - PermissionRequested SessionEventType = "permission.requested" - SessionBackgroundTasksChanged SessionEventType = "session.background_tasks_changed" - SessionCompactionComplete SessionEventType = "session.compaction_complete" - SessionCompactionStart SessionEventType = "session.compaction_start" - SessionContextChanged SessionEventType = "session.context_changed" - SessionError SessionEventType = "session.error" - SessionHandoff SessionEventType = "session.handoff" - SessionIdle SessionEventType = "session.idle" - SessionInfo SessionEventType = "session.info" - SessionModeChanged SessionEventType = "session.mode_changed" - SessionModelChange SessionEventType = "session.model_change" - SessionPlanChanged SessionEventType = "session.plan_changed" - SessionResume SessionEventType = "session.resume" - SessionShutdown SessionEventType = "session.shutdown" - SessionSnapshotRewind SessionEventType = "session.snapshot_rewind" - SessionStart SessionEventType = "session.start" - SessionTaskComplete SessionEventType = "session.task_complete" - SessionTitleChanged SessionEventType = "session.title_changed" - SessionToolsUpdated SessionEventType = "session.tools_updated" - SessionTruncation SessionEventType = "session.truncation" - SessionUsageInfo SessionEventType = "session.usage_info" - SessionWarning SessionEventType = "session.warning" - SessionWorkspaceFileChanged SessionEventType = "session.workspace_file_changed" - SkillInvoked SessionEventType = "skill.invoked" - SubagentCompleted SessionEventType = "subagent.completed" - SubagentDeselected SessionEventType = "subagent.deselected" - SubagentFailed SessionEventType = "subagent.failed" - SubagentSelected SessionEventType = "subagent.selected" - SubagentStarted SessionEventType = "subagent.started" - SystemMessage SessionEventType = "system.message" - SystemNotification SessionEventType = "system.notification" - ToolExecutionComplete SessionEventType = "tool.execution_complete" - ToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" - ToolExecutionProgress SessionEventType = "tool.execution_progress" - ToolExecutionStart SessionEventType = "tool.execution_start" - ToolUserRequested SessionEventType = "tool.user_requested" - UserInputCompleted SessionEventType = "user_input.completed" - UserInputRequested SessionEventType = "user_input.requested" - UserMessage SessionEventType = "user.message" + SessionEventTypeAbort SessionEventType = "abort" + SessionEventTypeAssistantIntent SessionEventType = "assistant.intent" + SessionEventTypeAssistantMessage SessionEventType = "assistant.message" + SessionEventTypeAssistantMessageDelta SessionEventType = "assistant.message_delta" + SessionEventTypeAssistantReasoning SessionEventType = "assistant.reasoning" + SessionEventTypeAssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" + SessionEventTypeAssistantStreamingDelta SessionEventType = "assistant.streaming_delta" + SessionEventTypeAssistantTurnEnd SessionEventType = "assistant.turn_end" + SessionEventTypeAssistantTurnStart SessionEventType = "assistant.turn_start" + SessionEventTypeAssistantUsage SessionEventType = "assistant.usage" + 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" + SessionEventTypePendingMessagesModified SessionEventType = "pending_messages.modified" + SessionEventTypePermissionCompleted SessionEventType = "permission.completed" + SessionEventTypePermissionRequested SessionEventType = "permission.requested" + SessionEventTypeSessionBackgroundTasksChanged SessionEventType = "session.background_tasks_changed" + SessionEventTypeSessionCompactionComplete SessionEventType = "session.compaction_complete" + SessionEventTypeSessionCompactionStart SessionEventType = "session.compaction_start" + SessionEventTypeSessionContextChanged SessionEventType = "session.context_changed" + SessionEventTypeSessionError SessionEventType = "session.error" + SessionEventTypeSessionExtensionsLoaded SessionEventType = "session.extensions_loaded" + SessionEventTypeSessionHandoff SessionEventType = "session.handoff" + SessionEventTypeSessionIdle SessionEventType = "session.idle" + SessionEventTypeSessionInfo SessionEventType = "session.info" + SessionEventTypeSessionMcpServerStatusChanged SessionEventType = "session.mcp_server_status_changed" + SessionEventTypeSessionMcpServersLoaded SessionEventType = "session.mcp_servers_loaded" + SessionEventTypeSessionModeChanged SessionEventType = "session.mode_changed" + SessionEventTypeSessionModelChange SessionEventType = "session.model_change" + SessionEventTypeSessionPlanChanged SessionEventType = "session.plan_changed" + SessionEventTypeSessionResume SessionEventType = "session.resume" + 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" + SessionEventTypeSubagentCompleted SessionEventType = "subagent.completed" + SessionEventTypeSubagentDeselected SessionEventType = "subagent.deselected" + SessionEventTypeSubagentFailed SessionEventType = "subagent.failed" + SessionEventTypeSubagentSelected SessionEventType = "subagent.selected" + SessionEventTypeSubagentStarted SessionEventType = "subagent.started" + SessionEventTypeSystemMessage SessionEventType = "system.message" + SessionEventTypeSystemNotification SessionEventType = "system.notification" + 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" + SessionEventTypeUserInputRequested SessionEventType = "user_input.requested" + SessionEventTypeUserMessage SessionEventType = "user.message" ) type ContextUnion struct { diff --git a/go/internal/e2e/agent_and_compact_rpc_test.go b/go/internal/e2e/agent_and_compact_rpc_test.go index 338f4da67..cbd52a326 100644 --- a/go/internal/e2e/agent_and_compact_rpc_test.go +++ b/go/internal/e2e/agent_and_compact_rpc_test.go @@ -215,7 +215,7 @@ func TestAgentSelectionRpc(t *testing.T) { } }) - t.Run("should return empty list when no custom agents configured", func(t *testing.T) { + t.Run("should return no custom agents when none configured", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ CLIPath: cliPath, UseStdio: copilot.Bool(true), @@ -238,8 +238,13 @@ func TestAgentSelectionRpc(t *testing.T) { t.Fatalf("Failed to list agents: %v", err) } - if len(result.Agents) != 0 { - t.Errorf("Expected empty agent list, got %d agents", len(result.Agents)) + // The CLI may return built-in/default agents even when no custom agents + // are configured, so just verify none of the known custom agent names appear. + customNames := map[string]bool{"test-agent": true, "another-agent": true} + for _, agent := range result.Agents { + if customNames[agent.Name] { + t.Errorf("Expected no custom agents, but found %q", agent.Name) + } } if err := client.Stop(); err != nil { diff --git a/go/internal/e2e/compaction_test.go b/go/internal/e2e/compaction_test.go index aee80704d..888ab2aa9 100644 --- a/go/internal/e2e/compaction_test.go +++ b/go/internal/e2e/compaction_test.go @@ -36,10 +36,10 @@ func TestCompaction(t *testing.T) { var compactionCompleteEvents []copilot.SessionEvent session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionCompactionStart { + if event.Type == copilot.SessionEventTypeSessionCompactionStart { compactionStartEvents = append(compactionStartEvents, event) } - if event.Type == copilot.SessionCompactionComplete { + if event.Type == copilot.SessionEventTypeSessionCompactionComplete { compactionCompleteEvents = append(compactionCompleteEvents, event) } }) @@ -105,7 +105,7 @@ func TestCompaction(t *testing.T) { var compactionEvents []copilot.SessionEvent session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionCompactionStart || event.Type == copilot.SessionCompactionComplete { + if event.Type == copilot.SessionEventTypeSessionCompactionStart || event.Type == copilot.SessionEventTypeSessionCompactionComplete { compactionEvents = append(compactionEvents, event) } }) diff --git a/go/internal/e2e/multi_client_test.go b/go/internal/e2e/multi_client_test.go index 9571ab58e..3c7dc34c3 100644 --- a/go/internal/e2e/multi_client_test.go +++ b/go/internal/e2e/multi_client_test.go @@ -79,13 +79,13 @@ func TestMultiClient(t *testing.T) { client2Completed := make(chan struct{}, 1) session1.On(func(event copilot.SessionEvent) { - if event.Type == copilot.ExternalToolRequested { + if event.Type == copilot.SessionEventTypeExternalToolRequested { select { case client1Requested <- struct{}{}: default: } } - if event.Type == copilot.ExternalToolCompleted { + if event.Type == copilot.SessionEventTypeExternalToolCompleted { select { case client1Completed <- struct{}{}: default: @@ -93,13 +93,13 @@ func TestMultiClient(t *testing.T) { } }) session2.On(func(event copilot.SessionEvent) { - if event.Type == copilot.ExternalToolRequested { + if event.Type == copilot.SessionEventTypeExternalToolRequested { select { case client2Requested <- struct{}{}: default: } } - if event.Type == copilot.ExternalToolCompleted { + if event.Type == copilot.SessionEventTypeExternalToolCompleted { select { case client2Completed <- struct{}{}: default: @@ -120,7 +120,7 @@ func TestMultiClient(t *testing.T) { } // Wait for all broadcast events to arrive on both clients - timeout := time.After(10 * time.Second) + timeout := time.After(30 * time.Second) for _, ch := range []chan struct{}{client1Requested, client2Requested, client1Completed, client2Completed} { select { case <-ch: @@ -197,10 +197,10 @@ func TestMultiClient(t *testing.T) { // Both clients should have seen permission.requested events mu1.Lock() - c1PermRequested := filterEventsByType(client1Events, copilot.PermissionRequested) + c1PermRequested := filterEventsByType(client1Events, copilot.SessionEventTypePermissionRequested) mu1.Unlock() mu2.Lock() - c2PermRequested := filterEventsByType(client2Events, copilot.PermissionRequested) + c2PermRequested := filterEventsByType(client2Events, copilot.SessionEventTypePermissionRequested) mu2.Unlock() if len(c1PermRequested) == 0 { @@ -212,10 +212,10 @@ func TestMultiClient(t *testing.T) { // Both clients should have seen permission.completed events with approved result mu1.Lock() - c1PermCompleted := filterEventsByType(client1Events, copilot.PermissionCompleted) + c1PermCompleted := filterEventsByType(client1Events, copilot.SessionEventTypePermissionCompleted) mu1.Unlock() mu2.Lock() - c2PermCompleted := filterEventsByType(client2Events, copilot.PermissionCompleted) + c2PermCompleted := filterEventsByType(client2Events, copilot.SessionEventTypePermissionCompleted) mu2.Unlock() if len(c1PermCompleted) == 0 { @@ -293,10 +293,10 @@ func TestMultiClient(t *testing.T) { // Both clients should have seen permission.requested events mu1.Lock() - c1PermRequested := filterEventsByType(client1Events, copilot.PermissionRequested) + c1PermRequested := filterEventsByType(client1Events, copilot.SessionEventTypePermissionRequested) mu1.Unlock() mu2.Lock() - c2PermRequested := filterEventsByType(client2Events, copilot.PermissionRequested) + c2PermRequested := filterEventsByType(client2Events, copilot.SessionEventTypePermissionRequested) mu2.Unlock() if len(c1PermRequested) == 0 { @@ -308,10 +308,10 @@ func TestMultiClient(t *testing.T) { // Both clients should see the denial in the completed event mu1.Lock() - c1PermCompleted := filterEventsByType(client1Events, copilot.PermissionCompleted) + c1PermCompleted := filterEventsByType(client1Events, copilot.SessionEventTypePermissionCompleted) mu1.Unlock() mu2.Lock() - c2PermCompleted := filterEventsByType(client2Events, copilot.PermissionCompleted) + c2PermCompleted := filterEventsByType(client2Events, copilot.SessionEventTypePermissionCompleted) mu2.Unlock() if len(c1PermCompleted) == 0 { diff --git a/go/internal/e2e/permissions_test.go b/go/internal/e2e/permissions_test.go index 328e7e788..98f620043 100644 --- a/go/internal/e2e/permissions_test.go +++ b/go/internal/e2e/permissions_test.go @@ -173,7 +173,7 @@ func TestPermissions(t *testing.T) { permissionDenied := false session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.ToolExecutionComplete && + if event.Type == copilot.SessionEventTypeToolExecutionComplete && event.Data.Success != nil && !*event.Data.Success && event.Data.Error != nil && event.Data.Error.ErrorClass != nil && strings.Contains(event.Data.Error.ErrorClass.Message, "Permission denied") { @@ -223,7 +223,7 @@ func TestPermissions(t *testing.T) { permissionDenied := false session2.On(func(event copilot.SessionEvent) { - if event.Type == copilot.ToolExecutionComplete && + if event.Type == copilot.SessionEventTypeToolExecutionComplete && event.Data.Success != nil && !*event.Data.Success && event.Data.Error != nil && event.Data.Error.ErrorClass != nil && strings.Contains(event.Data.Error.ErrorClass.Message, "Permission denied") { diff --git a/go/internal/e2e/rpc_test.go b/go/internal/e2e/rpc_test.go index ebcbe1130..3d69b97ad 100644 --- a/go/internal/e2e/rpc_test.go +++ b/go/internal/e2e/rpc_test.go @@ -219,16 +219,16 @@ func TestSessionRpc(t *testing.T) { if err != nil { t.Fatalf("Failed to get mode: %v", err) } - if initial.Mode != rpc.Interactive { + if initial.Mode != rpc.ModeInteractive { t.Errorf("Expected initial mode 'interactive', got %q", initial.Mode) } // Switch to plan mode - planResult, err := session.RPC.Mode.Set(t.Context(), &rpc.SessionModeSetParams{Mode: rpc.Plan}) + planResult, err := session.RPC.Mode.Set(t.Context(), &rpc.SessionModeSetParams{Mode: rpc.ModePlan}) if err != nil { t.Fatalf("Failed to set mode to plan: %v", err) } - if planResult.Mode != rpc.Plan { + if planResult.Mode != rpc.ModePlan { t.Errorf("Expected mode 'plan', got %q", planResult.Mode) } @@ -237,16 +237,16 @@ func TestSessionRpc(t *testing.T) { if err != nil { t.Fatalf("Failed to get mode after plan: %v", err) } - if afterPlan.Mode != rpc.Plan { + if afterPlan.Mode != rpc.ModePlan { t.Errorf("Expected mode 'plan' after set, got %q", afterPlan.Mode) } // Switch back to interactive - interactiveResult, err := session.RPC.Mode.Set(t.Context(), &rpc.SessionModeSetParams{Mode: rpc.Interactive}) + interactiveResult, err := session.RPC.Mode.Set(t.Context(), &rpc.SessionModeSetParams{Mode: rpc.ModeInteractive}) if err != nil { t.Fatalf("Failed to set mode to interactive: %v", err) } - if interactiveResult.Mode != rpc.Interactive { + if interactiveResult.Mode != rpc.ModeInteractive { t.Errorf("Expected mode 'interactive', got %q", interactiveResult.Mode) } }) diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 052ae1580..7f1817da9 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -184,6 +184,51 @@ func TestSession(t *testing.T) { } }) + t.Run("should create a session with customized systemMessage config", func(t *testing.T) { + ctx.ConfigureForTest(t) + + customTone := "Respond in a warm, professional tone. Be thorough in explanations." + appendedContent := "Always mention quarterly earnings." + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + copilot.SectionTone: {Action: "replace", Content: customTone}, + copilot.SectionCodeChangeRules: {Action: "remove"}, + }, + Content: appendedContent, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Who are you?"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Validate the system message sent to the model + traffic, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("Failed to get exchanges: %v", err) + } + if len(traffic) == 0 { + t.Fatal("Expected at least one exchange") + } + systemMessage := getSystemMessage(traffic[0]) + if !strings.Contains(systemMessage, customTone) { + t.Errorf("Expected system message to contain custom tone, got %q", systemMessage) + } + if !strings.Contains(systemMessage, appendedContent) { + t.Errorf("Expected system message to contain appended content, got %q", systemMessage) + } + if strings.Contains(systemMessage, "") { + t.Error("Expected system message to NOT contain code_change_instructions (it was removed)") + } + }) + t.Run("should create a session with availableTools", func(t *testing.T) { ctx.ConfigureForTest(t) @@ -506,7 +551,7 @@ func TestSession(t *testing.T) { toolStartCh := make(chan *copilot.SessionEvent, 1) toolStartErrCh := make(chan error, 1) go func() { - evt, err := testharness.GetNextEventOfType(session, copilot.ToolExecutionStart, 60*time.Second) + evt, err := testharness.GetNextEventOfType(session, copilot.SessionEventTypeToolExecutionStart, 60*time.Second) if err != nil { toolStartErrCh <- err } else { @@ -517,7 +562,7 @@ func TestSession(t *testing.T) { sessionIdleCh := make(chan *copilot.SessionEvent, 1) sessionIdleErrCh := make(chan error, 1) go func() { - evt, err := testharness.GetNextEventOfType(session, copilot.SessionIdle, 60*time.Second) + evt, err := testharness.GetNextEventOfType(session, copilot.SessionEventTypeSessionIdle, 60*time.Second) if err != nil { sessionIdleErrCh <- err } else { @@ -565,7 +610,7 @@ func TestSession(t *testing.T) { // Verify messages contain an abort event hasAbortEvent := false for _, msg := range messages { - if msg.Type == copilot.Abort { + if msg.Type == copilot.SessionEventTypeAbort { hasAbortEvent = true break } @@ -913,7 +958,7 @@ func TestSetModelWithReasoningEffort(t *testing.T) { modelChanged := make(chan copilot.SessionEvent, 1) session.On(func(event copilot.SessionEvent) { - if event.Type == copilot.SessionModelChange { + if event.Type == copilot.SessionEventTypeSessionModelChange { select { case modelChanged <- event: default: @@ -964,7 +1009,7 @@ func TestSessionBlobAttachment(t *testing.T) { Prompt: "Describe this image", Attachments: []copilot.Attachment{ { - Type: copilot.Blob, + Type: copilot.AttachmentTypeBlob, Data: &data, MIMEType: &mimeType, DisplayName: &displayName, @@ -1028,7 +1073,7 @@ func TestSessionLog(t *testing.T) { t.Fatalf("Log failed: %v", err) } - evt := waitForEvent(t, &mu, &events, copilot.SessionInfo, "Info message", 5*time.Second) + evt := waitForEvent(t, &mu, &events, copilot.SessionEventTypeSessionInfo, "Info message", 5*time.Second) if evt.Data.InfoType == nil || *evt.Data.InfoType != "notification" { t.Errorf("Expected infoType 'notification', got %v", evt.Data.InfoType) } @@ -1038,11 +1083,11 @@ func TestSessionLog(t *testing.T) { }) t.Run("should log warning message", func(t *testing.T) { - if err := session.Log(t.Context(), "Warning message", &copilot.LogOptions{Level: rpc.Warning}); err != nil { + if err := session.Log(t.Context(), "Warning message", &copilot.LogOptions{Level: rpc.LevelWarning}); err != nil { t.Fatalf("Log failed: %v", err) } - evt := waitForEvent(t, &mu, &events, copilot.SessionWarning, "Warning message", 5*time.Second) + evt := waitForEvent(t, &mu, &events, copilot.SessionEventTypeSessionWarning, "Warning message", 5*time.Second) if evt.Data.WarningType == nil || *evt.Data.WarningType != "notification" { t.Errorf("Expected warningType 'notification', got %v", evt.Data.WarningType) } @@ -1052,11 +1097,11 @@ func TestSessionLog(t *testing.T) { }) t.Run("should log error message", func(t *testing.T) { - if err := session.Log(t.Context(), "Error message", &copilot.LogOptions{Level: rpc.Error}); err != nil { + if err := session.Log(t.Context(), "Error message", &copilot.LogOptions{Level: rpc.LevelError}); err != nil { t.Fatalf("Log failed: %v", err) } - evt := waitForEvent(t, &mu, &events, copilot.SessionError, "Error message", 5*time.Second) + evt := waitForEvent(t, &mu, &events, copilot.SessionEventTypeSessionError, "Error message", 5*time.Second) if evt.Data.ErrorType == nil || *evt.Data.ErrorType != "notification" { t.Errorf("Expected errorType 'notification', got %v", evt.Data.ErrorType) } @@ -1070,7 +1115,7 @@ func TestSessionLog(t *testing.T) { t.Fatalf("Log failed: %v", err) } - evt := waitForEvent(t, &mu, &events, copilot.SessionInfo, "Ephemeral message", 5*time.Second) + evt := waitForEvent(t, &mu, &events, copilot.SessionEventTypeSessionInfo, "Ephemeral message", 5*time.Second) if evt.Data.InfoType == nil || *evt.Data.InfoType != "notification" { t.Errorf("Expected infoType 'notification', got %v", evt.Data.InfoType) } diff --git a/go/internal/e2e/system_message_transform_test.go b/go/internal/e2e/system_message_transform_test.go new file mode 100644 index 000000000..2d62b01cf --- /dev/null +++ b/go/internal/e2e/system_message_transform_test.go @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package e2e + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestSystemMessageTransform(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should_invoke_transform_callbacks_with_section_content", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var identityContent string + var toneContent string + var mu sync.Mutex + identityCalled := false + toneCalled := false + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + "identity": { + Transform: func(currentContent string) (string, error) { + mu.Lock() + identityCalled = true + identityContent = currentContent + mu.Unlock() + return currentContent, nil + }, + }, + "tone": { + Transform: func(currentContent string) (string, error) { + mu.Lock() + toneCalled = true + toneContent = currentContent + mu.Unlock() + return currentContent, nil + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + testFile := filepath.Join(ctx.WorkDir, "test.txt") + err = os.WriteFile(testFile, []byte("Hello transform!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of test.txt and tell me what it says", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if !identityCalled { + t.Error("Expected identity transform callback to be invoked") + } + if !toneCalled { + t.Error("Expected tone transform callback to be invoked") + } + if identityContent == "" { + t.Error("Expected identity transform to receive non-empty content") + } + if toneContent == "" { + t.Error("Expected tone transform to receive non-empty content") + } + }) + + t.Run("should_apply_transform_modifications_to_section_content", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + "identity": { + Transform: func(currentContent string) (string, error) { + return currentContent + "\nAlways end your reply with TRANSFORM_MARKER", nil + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + testFile := filepath.Join(ctx.WorkDir, "hello.txt") + err = os.WriteFile(testFile, []byte("Hello!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + assistantMessage, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of hello.txt", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Verify the transform result was actually applied to the system message + traffic, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("Failed to get exchanges: %v", err) + } + if len(traffic) == 0 { + t.Fatal("Expected at least one exchange") + } + systemMessage := getSystemMessage(traffic[0]) + if !strings.Contains(systemMessage, "TRANSFORM_MARKER") { + t.Errorf("Expected system message to contain TRANSFORM_MARKER, got %q", systemMessage) + } + + _ = assistantMessage + }) + + t.Run("should_work_with_static_overrides_and_transforms_together", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var mu sync.Mutex + transformCalled := false + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Sections: map[string]copilot.SectionOverride{ + "safety": { + Action: copilot.SectionActionRemove, + }, + "identity": { + Transform: func(currentContent string) (string, error) { + mu.Lock() + transformCalled = true + mu.Unlock() + return currentContent, nil + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + testFile := filepath.Join(ctx.WorkDir, "combo.txt") + err = os.WriteFile(testFile, []byte("Combo test!"), 0644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Read the contents of combo.txt and tell me what it says", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + + if !transformCalled { + t.Error("Expected identity transform callback to be invoked") + } + }) +} diff --git a/go/internal/e2e/testharness/helper.go b/go/internal/e2e/testharness/helper.go index 05947c806..3b521f330 100644 --- a/go/internal/e2e/testharness/helper.go +++ b/go/internal/e2e/testharness/helper.go @@ -67,7 +67,7 @@ func GetNextEventOfType(session *copilot.Session, eventType copilot.SessionEvent case result <- &event: default: } - case copilot.SessionError: + case copilot.SessionEventTypeSessionError: msg := "session error" if event.Data.Message != nil { msg = *event.Data.Message diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index 401f38305..b9ba408b5 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -223,10 +223,10 @@ type SessionFleetStartParams struct { // Experimental: SessionAgentListResult is part of an experimental API and may change or be removed. type SessionAgentListResult struct { // Available custom agents - Agents []AgentElement `json:"agents"` + Agents []SessionAgentListResultAgent `json:"agents"` } -type AgentElement struct { +type SessionAgentListResultAgent struct { // Description of the agent's purpose Description string `json:"description"` // Human-readable display name @@ -276,6 +276,161 @@ type SessionAgentSelectParams struct { type SessionAgentDeselectResult struct { } +// Experimental: SessionAgentReloadResult is part of an experimental API and may change or be removed. +type SessionAgentReloadResult struct { + // Reloaded custom agents + Agents []SessionAgentReloadResultAgent `json:"agents"` +} + +type SessionAgentReloadResultAgent struct { + // Description of the agent's purpose + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier of the custom agent + Name string `json:"name"` +} + +// Experimental: SessionSkillsListResult is part of an experimental API and may change or be removed. +type SessionSkillsListResult struct { + // Available skills + Skills []Skill `json:"skills"` +} + +type Skill 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 + Path *string `json:"path,omitempty"` + // Source location type (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"` +} + +// Experimental: SessionSkillsEnableResult is part of an experimental API and may change or be removed. +type SessionSkillsEnableResult struct { +} + +// Experimental: SessionSkillsEnableParams is part of an experimental API and may change or be removed. +type SessionSkillsEnableParams struct { + // Name of the skill to enable + Name string `json:"name"` +} + +// Experimental: SessionSkillsDisableResult is part of an experimental API and may change or be removed. +type SessionSkillsDisableResult struct { +} + +// Experimental: SessionSkillsDisableParams is part of an experimental API and may change or be removed. +type SessionSkillsDisableParams struct { + // Name of the skill to disable + Name string `json:"name"` +} + +// Experimental: SessionSkillsReloadResult is part of an experimental API and may change or be removed. +type SessionSkillsReloadResult struct { +} + +type SessionMCPListResult struct { + // Configured MCP servers + Servers []Server `json:"servers"` +} + +type Server 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, pending, disabled, or not_configured + Status ServerStatus `json:"status"` +} + +type SessionMCPEnableResult struct { +} + +type SessionMCPEnableParams struct { + // Name of the MCP server to enable + ServerName string `json:"serverName"` +} + +type SessionMCPDisableResult struct { +} + +type SessionMCPDisableParams struct { + // Name of the MCP server to disable + ServerName string `json:"serverName"` +} + +type SessionMCPReloadResult struct { +} + +// Experimental: SessionPluginsListResult is part of an experimental API and may change or be removed. +type SessionPluginsListResult struct { + // Installed plugins + Plugins []Plugin `json:"plugins"` +} + +type Plugin struct { + // Whether the plugin is currently enabled + Enabled bool `json:"enabled"` + // Marketplace the plugin came from + Marketplace string `json:"marketplace"` + // Plugin name + Name string `json:"name"` + // Installed version + Version *string `json:"version,omitempty"` +} + +// Experimental: SessionExtensionsListResult is part of an experimental API and may change or be removed. +type SessionExtensionsListResult struct { + // Discovered extensions and their current status + Extensions []Extension `json:"extensions"` +} + +type Extension struct { + // Source-qualified ID (e.g., 'project:my-ext', 'user:auth-helper') + ID string `json:"id"` + // Extension name (directory name) + Name string `json:"name"` + // Process ID if the extension is running + PID *int64 `json:"pid,omitempty"` + // Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/) + Source Source `json:"source"` + // Current status: running, disabled, failed, or starting + Status ExtensionStatus `json:"status"` +} + +// Experimental: SessionExtensionsEnableResult is part of an experimental API and may change or be removed. +type SessionExtensionsEnableResult struct { +} + +// Experimental: SessionExtensionsEnableParams is part of an experimental API and may change or be removed. +type SessionExtensionsEnableParams struct { + // Source-qualified extension ID to enable + ID string `json:"id"` +} + +// Experimental: SessionExtensionsDisableResult is part of an experimental API and may change or be removed. +type SessionExtensionsDisableResult struct { +} + +// Experimental: SessionExtensionsDisableParams is part of an experimental API and may change or be removed. +type SessionExtensionsDisableParams struct { + // Source-qualified extension ID to disable + ID string `json:"id"` +} + +// Experimental: SessionExtensionsReloadResult is part of an experimental API and may change or be removed. +type SessionExtensionsReloadResult struct { +} + // Experimental: SessionCompactionCompactResult is part of an experimental API and may change or be removed. type SessionCompactionCompactResult struct { // Number of messages removed during compaction @@ -304,6 +459,75 @@ type ResultResult struct { ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` } +type SessionCommandsHandlePendingCommandResult struct { + Success bool `json:"success"` +} + +type SessionCommandsHandlePendingCommandParams struct { + // Error message if the command handler failed + Error *string `json:"error,omitempty"` + // Request ID from the command invocation event + RequestID string `json:"requestId"` +} + +type SessionUIElicitationResult struct { + // The user's response: accept (submitted), decline (rejected), or cancel (dismissed) + Action Action `json:"action"` + // The form values submitted by the user (present when action is 'accept') + Content map[string]*Content `json:"content,omitempty"` +} + +type SessionUIElicitationParams struct { + // Message describing what information is needed from the user + Message string `json:"message"` + // JSON Schema describing the form fields to present to the user + RequestedSchema RequestedSchema `json:"requestedSchema"` +} + +// JSON Schema describing the form fields to present to the user +type RequestedSchema struct { + // Form field definitions, keyed by field name + Properties map[string]Property `json:"properties"` + // List of required field names + Required []string `json:"required,omitempty"` + // Schema type indicator (always 'object') + Type RequestedSchemaType `json:"type"` +} + +type Property struct { + Default *Content `json:"default"` + Description *string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + EnumNames []string `json:"enumNames,omitempty"` + Title *string `json:"title,omitempty"` + Type PropertyType `json:"type"` + OneOf []OneOf `json:"oneOf,omitempty"` + Items *Items `json:"items,omitempty"` + MaxItems *float64 `json:"maxItems,omitempty"` + MinItems *float64 `json:"minItems,omitempty"` + Format *Format `json:"format,omitempty"` + MaxLength *float64 `json:"maxLength,omitempty"` + MinLength *float64 `json:"minLength,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` +} + +type Items struct { + Enum []string `json:"enum,omitempty"` + Type *ItemsType `json:"type,omitempty"` + AnyOf []AnyOf `json:"anyOf,omitempty"` +} + +type AnyOf struct { + Const string `json:"const"` + Title string `json:"title"` +} + +type OneOf struct { + Const string `json:"const"` + Title string `json:"title"` +} + type SessionPermissionsHandlePendingPermissionRequestResult struct { // Whether the permission request was handled successfully Success bool `json:"success"` @@ -335,6 +559,8 @@ type SessionLogParams struct { Level *Level `json:"level,omitempty"` // Human-readable message Message string `json:"message"` + // Optional URL the user can open in their browser for more details + URL *string `json:"url,omitempty"` } type SessionShellExecResult struct { @@ -371,19 +597,88 @@ type SessionShellKillParams struct { type Mode string const ( - Autopilot Mode = "autopilot" - Interactive Mode = "interactive" - Plan Mode = "plan" + ModeAutopilot Mode = "autopilot" + ModeInteractive Mode = "interactive" + ModePlan Mode = "plan" +) + +// Connection status: connected, failed, pending, disabled, or not_configured +type ServerStatus string + +const ( + ServerStatusConnected ServerStatus = "connected" + ServerStatusNotConfigured ServerStatus = "not_configured" + ServerStatusPending ServerStatus = "pending" + ServerStatusDisabled ServerStatus = "disabled" + ServerStatusFailed ServerStatus = "failed" +) + +// Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/) +type Source string + +const ( + SourceProject Source = "project" + SourceUser Source = "user" +) + +// Current status: running, disabled, failed, or starting +type ExtensionStatus string + +const ( + ExtensionStatusDisabled ExtensionStatus = "disabled" + ExtensionStatusFailed ExtensionStatus = "failed" + ExtensionStatusRunning ExtensionStatus = "running" + ExtensionStatusStarting ExtensionStatus = "starting" +) + +// The user's response: accept (submitted), decline (rejected), or cancel (dismissed) +type Action string + +const ( + ActionAccept Action = "accept" + ActionCancel Action = "cancel" + ActionDecline Action = "decline" +) + +type Format string + +const ( + FormatDate Format = "date" + FormatDateTime Format = "date-time" + FormatEmail Format = "email" + FormatUri Format = "uri" +) + +type ItemsType string + +const ( + ItemsTypeString ItemsType = "string" +) + +type PropertyType string + +const ( + PropertyTypeArray PropertyType = "array" + PropertyTypeBoolean PropertyType = "boolean" + PropertyTypeString PropertyType = "string" + PropertyTypeInteger PropertyType = "integer" + PropertyTypeNumber PropertyType = "number" +) + +type RequestedSchemaType string + +const ( + RequestedSchemaTypeObject RequestedSchemaType = "object" ) type Kind string const ( - Approved Kind = "approved" - DeniedByContentExclusionPolicy Kind = "denied-by-content-exclusion-policy" - DeniedByRules Kind = "denied-by-rules" - DeniedInteractivelyByUser Kind = "denied-interactively-by-user" - DeniedNoApprovalRuleAndCouldNotRequestFromUser Kind = "denied-no-approval-rule-and-could-not-request-from-user" + KindApproved Kind = "approved" + KindDeniedByContentExclusionPolicy Kind = "denied-by-content-exclusion-policy" + KindDeniedByRules Kind = "denied-by-rules" + KindDeniedInteractivelyByUser Kind = "denied-interactively-by-user" + KindDeniedNoApprovalRuleAndCouldNotRequestFromUser Kind = "denied-no-approval-rule-and-could-not-request-from-user" ) // Log severity level. Determines how the message is displayed in the timeline. Defaults to @@ -391,18 +686,18 @@ const ( type Level string const ( - Error Level = "error" - Info Level = "info" - Warning Level = "warning" + LevelError Level = "error" + LevelInfo Level = "info" + LevelWarning Level = "warning" ) // Signal to send (default: SIGTERM) type Signal string const ( - Sigint Signal = "SIGINT" - Sigkill Signal = "SIGKILL" - Sigterm Signal = "SIGTERM" + SignalSIGINT Signal = "SIGINT" + SignalSIGKILL Signal = "SIGKILL" + SignalSIGTERM Signal = "SIGTERM" ) type ResultUnion struct { @@ -410,6 +705,13 @@ type ResultUnion struct { String *string } +type Content struct { + Bool *bool + Double *float64 + String *string + StringArray []string +} + type ServerModelsRpcApi struct { client *jsonrpc2.Client } @@ -740,6 +1042,230 @@ func (a *AgentRpcApi) Deselect(ctx context.Context) (*SessionAgentDeselectResult return &result, nil } +func (a *AgentRpcApi) Reload(ctx context.Context) (*SessionAgentReloadResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.reload", req) + if err != nil { + return nil, err + } + var result SessionAgentReloadResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Experimental: SkillsRpcApi contains experimental APIs that may change or be removed. +type SkillsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *SkillsRpcApi) List(ctx context.Context) (*SessionSkillsListResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.skills.list", req) + if err != nil { + return nil, err + } + var result SessionSkillsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *SkillsRpcApi) Enable(ctx context.Context, params *SessionSkillsEnableParams) (*SessionSkillsEnableResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["name"] = params.Name + } + raw, err := a.client.Request("session.skills.enable", req) + if err != nil { + return nil, err + } + var result SessionSkillsEnableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *SkillsRpcApi) Disable(ctx context.Context, params *SessionSkillsDisableParams) (*SessionSkillsDisableResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["name"] = params.Name + } + raw, err := a.client.Request("session.skills.disable", req) + if err != nil { + return nil, err + } + var result SessionSkillsDisableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *SkillsRpcApi) Reload(ctx context.Context) (*SessionSkillsReloadResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.skills.reload", req) + if err != nil { + return nil, err + } + var result SessionSkillsReloadResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Experimental: McpRpcApi contains experimental APIs that may change or be removed. +type McpRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *McpRpcApi) List(ctx context.Context) (*SessionMCPListResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.mcp.list", req) + if err != nil { + return nil, err + } + var result SessionMCPListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *McpRpcApi) Enable(ctx context.Context, params *SessionMCPEnableParams) (*SessionMCPEnableResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["serverName"] = params.ServerName + } + raw, err := a.client.Request("session.mcp.enable", req) + if err != nil { + return nil, err + } + var result SessionMCPEnableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *McpRpcApi) Disable(ctx context.Context, params *SessionMCPDisableParams) (*SessionMCPDisableResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["serverName"] = params.ServerName + } + raw, err := a.client.Request("session.mcp.disable", req) + if err != nil { + return nil, err + } + var result SessionMCPDisableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *McpRpcApi) Reload(ctx context.Context) (*SessionMCPReloadResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.mcp.reload", req) + if err != nil { + return nil, err + } + var result SessionMCPReloadResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Experimental: PluginsRpcApi contains experimental APIs that may change or be removed. +type PluginsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *PluginsRpcApi) List(ctx context.Context) (*SessionPluginsListResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.plugins.list", req) + if err != nil { + return nil, err + } + var result SessionPluginsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Experimental: ExtensionsRpcApi contains experimental APIs that may change or be removed. +type ExtensionsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *ExtensionsRpcApi) List(ctx context.Context) (*SessionExtensionsListResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.extensions.list", req) + if err != nil { + return nil, err + } + var result SessionExtensionsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *ExtensionsRpcApi) Enable(ctx context.Context, params *SessionExtensionsEnableParams) (*SessionExtensionsEnableResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["id"] = params.ID + } + raw, err := a.client.Request("session.extensions.enable", req) + if err != nil { + return nil, err + } + var result SessionExtensionsEnableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *ExtensionsRpcApi) Disable(ctx context.Context, params *SessionExtensionsDisableParams) (*SessionExtensionsDisableResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["id"] = params.ID + } + raw, err := a.client.Request("session.extensions.disable", req) + if err != nil { + return nil, err + } + var result SessionExtensionsDisableResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *ExtensionsRpcApi) Reload(ctx context.Context) (*SessionExtensionsReloadResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.extensions.reload", req) + if err != nil { + return nil, err + } + var result SessionExtensionsReloadResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Experimental: CompactionRpcApi contains experimental APIs that may change or be removed. type CompactionRpcApi struct { client *jsonrpc2.Client @@ -786,6 +1312,52 @@ func (a *ToolsRpcApi) HandlePendingToolCall(ctx context.Context, params *Session return &result, nil } +type CommandsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *CommandsRpcApi) HandlePendingCommand(ctx context.Context, params *SessionCommandsHandlePendingCommandParams) (*SessionCommandsHandlePendingCommandResult, error) { + req := map[string]interface{}{"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) + if err != nil { + return nil, err + } + var result SessionCommandsHandlePendingCommandResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type UiRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *UiRpcApi) Elicitation(ctx context.Context, params *SessionUIElicitationParams) (*SessionUIElicitationResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["message"] = params.Message + req["requestedSchema"] = params.RequestedSchema + } + raw, err := a.client.Request("session.ui.elicitation", req) + if err != nil { + return nil, err + } + var result SessionUIElicitationResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + type PermissionsRpcApi struct { client *jsonrpc2.Client sessionID string @@ -864,8 +1436,14 @@ type SessionRpc struct { Workspace *WorkspaceRpcApi Fleet *FleetRpcApi Agent *AgentRpcApi + Skills *SkillsRpcApi + Mcp *McpRpcApi + Plugins *PluginsRpcApi + Extensions *ExtensionsRpcApi Compaction *CompactionRpcApi Tools *ToolsRpcApi + Commands *CommandsRpcApi + Ui *UiRpcApi Permissions *PermissionsRpcApi Shell *ShellRpcApi } @@ -880,6 +1458,9 @@ func (a *SessionRpc) Log(ctx context.Context, params *SessionLogParams) (*Sessio if params.Ephemeral != nil { req["ephemeral"] = *params.Ephemeral } + if params.URL != nil { + req["url"] = *params.URL + } } raw, err := a.client.Request("session.log", req) if err != nil { @@ -900,8 +1481,14 @@ func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, Agent: &AgentRpcApi{client: client, sessionID: sessionID}, + Skills: &SkillsRpcApi{client: client, sessionID: sessionID}, + Mcp: &McpRpcApi{client: client, sessionID: sessionID}, + Plugins: &PluginsRpcApi{client: client, sessionID: sessionID}, + Extensions: &ExtensionsRpcApi{client: client, sessionID: sessionID}, Compaction: &CompactionRpcApi{client: client, sessionID: sessionID}, Tools: &ToolsRpcApi{client: client, sessionID: sessionID}, + Commands: &CommandsRpcApi{client: client, sessionID: sessionID}, + Ui: &UiRpcApi{client: client, sessionID: sessionID}, Permissions: &PermissionsRpcApi{client: client, sessionID: sessionID}, Shell: &ShellRpcApi{client: client, sessionID: sessionID}, } diff --git a/go/samples/chat.go b/go/samples/chat.go index f984f758a..4d5e98d7d 100644 --- a/go/samples/chat.go +++ b/go/samples/chat.go @@ -35,11 +35,11 @@ func main() { session.On(func(event copilot.SessionEvent) { var output string switch event.Type { - case copilot.AssistantReasoning: + case copilot.SessionEventTypeAssistantReasoning: if event.Data.Content != nil { output = fmt.Sprintf("[reasoning: %s]", *event.Data.Content) } - case copilot.ToolExecutionStart: + case copilot.SessionEventTypeToolExecutionStart: if event.Data.ToolName != nil { output = fmt.Sprintf("[tool: %s]", *event.Data.ToolName) } diff --git a/go/session.go b/go/session.go index d2a5785be..3a94a818e 100644 --- a/go/session.go +++ b/go/session.go @@ -50,20 +50,22 @@ type sessionHandler struct { // }) type Session struct { // SessionID is the unique identifier for this session. - SessionID string - workspacePath string - client *jsonrpc2.Client - 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 + SessionID string + workspacePath string + client *jsonrpc2.Client + 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 // eventCh serializes user event handler dispatch. dispatchEvent enqueues; // a single goroutine (processEvents) dequeues and invokes handlers in FIFO order. @@ -182,17 +184,17 @@ func (s *Session) SendAndWait(ctx context.Context, options MessageOptions) (*Ses unsubscribe := s.On(func(event SessionEvent) { switch event.Type { - case AssistantMessage: + case SessionEventTypeAssistantMessage: mu.Lock() eventCopy := event lastAssistantMessage = &eventCopy mu.Unlock() - case SessionIdle: + case SessionEventTypeSessionIdle: select { case idleCh <- struct{}{}: default: } - case SessionError: + case SessionEventTypeSessionError: errMsg := "session error" if event.Data.Message != nil { errMsg = *event.Data.Message @@ -446,6 +448,56 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( } } +// registerTransformCallbacks registers transform callbacks for this session. +// +// Transform callbacks are invoked when the CLI requests system message section +// transforms. This method is internal and typically called when creating a session. +func (s *Session) registerTransformCallbacks(callbacks map[string]SectionTransformFn) { + s.transformMu.Lock() + defer s.transformMu.Unlock() + s.transformCallbacks = callbacks +} + +type systemMessageTransformSection struct { + Content string `json:"content"` +} + +type systemMessageTransformRequest struct { + SessionID string `json:"sessionId"` + Sections map[string]systemMessageTransformSection `json:"sections"` +} + +type systemMessageTransformResponse struct { + Sections map[string]systemMessageTransformSection `json:"sections"` +} + +// handleSystemMessageTransform handles a system message transform request from the Copilot CLI. +// This is an internal method called by the SDK when the CLI requests section transforms. +func (s *Session) handleSystemMessageTransform(sections map[string]systemMessageTransformSection) (systemMessageTransformResponse, error) { + s.transformMu.Lock() + callbacks := s.transformCallbacks + s.transformMu.Unlock() + + result := make(map[string]systemMessageTransformSection) + for sectionID, data := range sections { + var callback SectionTransformFn + if callbacks != nil { + callback = callbacks[sectionID] + } + if callback != nil { + transformed, err := callback(data.Content) + if err != nil { + result[sectionID] = systemMessageTransformSection{Content: data.Content} + } else { + result[sectionID] = systemMessageTransformSection{Content: transformed} + } + } else { + result[sectionID] = systemMessageTransformSection{Content: data.Content} + } + } + return systemMessageTransformResponse{Sections: result}, nil +} + // dispatchEvent enqueues an event for delivery to user handlers and fires // broadcast handlers concurrently. // @@ -501,7 +553,7 @@ func (s *Session) processEvents() { // cause RPC deadlocks. func (s *Session) handleBroadcastEvent(event SessionEvent) { switch event.Type { - case ExternalToolRequested: + case SessionEventTypeExternalToolRequested: requestID := event.Data.RequestID toolName := event.Data.ToolName if requestID == nil || toolName == nil { @@ -524,7 +576,7 @@ func (s *Session) handleBroadcastEvent(event SessionEvent) { } s.executeToolAndRespond(*requestID, *toolName, toolCallID, event.Data.Arguments, handler, tp, ts) - case PermissionRequested: + case SessionEventTypePermissionRequested: requestID := event.Data.RequestID if requestID == nil || event.Data.PermissionRequest == nil { return @@ -585,7 +637,7 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ RequestID: requestID, Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ - Kind: rpc.DeniedNoApprovalRuleAndCouldNotRequestFromUser, + Kind: rpc.KindDeniedNoApprovalRuleAndCouldNotRequestFromUser, }, }) } @@ -600,7 +652,7 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ RequestID: requestID, Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ - Kind: rpc.DeniedNoApprovalRuleAndCouldNotRequestFromUser, + Kind: rpc.KindDeniedNoApprovalRuleAndCouldNotRequestFromUser, }, }) return @@ -770,8 +822,8 @@ func (s *Session) SetModel(ctx context.Context, model string, opts ...SetModelOp // LogOptions configures optional parameters for [Session.Log]. type LogOptions struct { - // Level sets the log severity. Valid values are [rpc.Info] (default), - // [rpc.Warning], and [rpc.Error]. + // Level sets the log severity. Valid values are [rpc.LevelInfo] (default), + // [rpc.LevelWarning], and [rpc.LevelError]. Level rpc.Level // Ephemeral marks the message as transient so it is not persisted // to the session event log on disk. When nil the server decides the @@ -791,7 +843,7 @@ type LogOptions struct { // session.Log(ctx, "Processing started") // // // Warning with options -// session.Log(ctx, "Rate limit approaching", &copilot.LogOptions{Level: rpc.Warning}) +// session.Log(ctx, "Rate limit approaching", &copilot.LogOptions{Level: rpc.LevelWarning}) // // // Ephemeral message (not persisted) // session.Log(ctx, "Working...", &copilot.LogOptions{Ephemeral: copilot.Bool(true)}) diff --git a/go/types.go b/go/types.go index fd9968e3e..502d61c1c 100644 --- a/go/types.go +++ b/go/types.go @@ -111,6 +111,51 @@ func Float64(v float64) *float64 { return &v } +// Known system prompt section identifiers for the "customize" mode. +const ( + SectionIdentity = "identity" + SectionTone = "tone" + SectionToolEfficiency = "tool_efficiency" + SectionEnvironmentContext = "environment_context" + SectionCodeChangeRules = "code_change_rules" + SectionGuidelines = "guidelines" + SectionSafety = "safety" + SectionToolInstructions = "tool_instructions" + SectionCustomInstructions = "custom_instructions" + SectionLastInstructions = "last_instructions" +) + +// SectionOverrideAction represents the action to perform on a system prompt section. +type SectionOverrideAction string + +const ( + // SectionActionReplace replaces section content entirely. + SectionActionReplace SectionOverrideAction = "replace" + // SectionActionRemove removes the section. + SectionActionRemove SectionOverrideAction = "remove" + // SectionActionAppend appends to existing section content. + SectionActionAppend SectionOverrideAction = "append" + // SectionActionPrepend prepends to existing section content. + SectionActionPrepend SectionOverrideAction = "prepend" +) + +// SectionTransformFn is a callback that receives the current content of a system prompt section +// and returns the transformed content. Used with the "transform" action to read-then-write +// modify sections at runtime. +type SectionTransformFn func(currentContent string) (string, error) + +// SectionOverride defines an override operation for a single system prompt section. +type SectionOverride struct { + // Action is the operation to perform: "replace", "remove", "append", "prepend", or "transform". + Action SectionOverrideAction `json:"action,omitempty"` + // Content for the override. Optional for all actions. Ignored for "remove". + Content string `json:"content,omitempty"` + // Transform is a callback invoked when Action is "transform". + // The runtime calls this with the current section content and uses the returned string. + // Excluded from JSON serialization; the SDK registers it as an RPC callback internally. + Transform SectionTransformFn `json:"-"` +} + // SystemMessageAppendConfig is append mode: use CLI foundation with optional appended content. type SystemMessageAppendConfig struct { // Mode is optional, defaults to "append" @@ -129,11 +174,15 @@ type SystemMessageReplaceConfig struct { } // SystemMessageConfig represents system message configuration for session creation. -// Use SystemMessageAppendConfig for default behavior, SystemMessageReplaceConfig for full control. -// In Go, use one struct or the other based on your needs. +// - Append mode (default): SDK foundation + optional custom content +// - Replace mode: Full control, caller provides entire system message +// - Customize mode: Section-level overrides with graceful fallback +// +// In Go, use one struct and set fields appropriate for the desired mode. type SystemMessageConfig struct { - Mode string `json:"mode,omitempty"` - Content string `json:"content,omitempty"` + Mode string `json:"mode,omitempty"` + Content string `json:"content,omitempty"` + Sections map[string]SectionOverride `json:"sections,omitempty"` } // PermissionRequestResultKind represents the kind of a permission request result. diff --git a/nodejs/README.md b/nodejs/README.md index e9d23c529..cc5d62416 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -473,7 +473,45 @@ const session = await client.createSession({ }); ``` -The SDK auto-injects environment context, tool instructions, and security guardrails. The default CLI persona is preserved, and your `content` is appended after SDK-managed sections. To change the persona or fully redefine the prompt, use `mode: "replace"`. +The SDK auto-injects environment context, tool instructions, and security guardrails. The default CLI persona is preserved, and your `content` is appended after SDK-managed sections. To change the persona or fully redefine the prompt, use `mode: "replace"` or `mode: "customize"`. + +#### Customize Mode + +Use `mode: "customize"` to selectively override individual sections of the prompt while preserving the rest: + +```typescript +import { SYSTEM_PROMPT_SECTIONS } from "@github/copilot-sdk"; +import type { SectionOverride, SystemPromptSection } from "@github/copilot-sdk"; + +const session = await client.createSession({ + model: "gpt-5", + systemMessage: { + mode: "customize", + sections: { + // Replace the tone/style section + tone: { action: "replace", content: "Respond in a warm, professional tone. Be thorough in explanations." }, + // Remove coding-specific rules + code_change_rules: { action: "remove" }, + // Append to existing guidelines + guidelines: { action: "append", content: "\n* Always cite data sources" }, + }, + // Additional instructions appended after all sections + content: "Focus on financial analysis and reporting.", + }, +}); +``` + +Available section IDs: `identity`, `tone`, `tool_efficiency`, `environment_context`, `code_change_rules`, `guidelines`, `safety`, `tool_instructions`, `custom_instructions`, `last_instructions`. Use the `SYSTEM_PROMPT_SECTIONS` constant for descriptions of each section. + +Each section override supports four actions: +- **`replace`** — Replace the section content entirely +- **`remove`** — Remove the section from the prompt +- **`append`** — Add content after the existing section +- **`prepend`** — Add content before the existing section + +Unknown section IDs are handled gracefully: content from `replace`/`append`/`prepend` overrides is appended to additional instructions, and `remove` overrides are silently ignored. + +#### Replace Mode For full control (removes all guardrails), use `mode: "replace"`: diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 0952122f0..fd56aa84b 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.4", + "@github/copilot": "^1.0.10-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4.tgz", - "integrity": "sha512-IpPg+zYplLu4F4lmatEDdR/1Y/jJ9cGWt89m3K3H4YSfYrZ5Go4UlM28llulYCG7sVdQeIGauQN1/KiBI/Rocg==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.10-0.tgz", + "integrity": "sha512-LmVe3yVDamZc4cbZeyprZ6WjTME9Z4UcB5YWnEagtXJ19KP5PBKbBZVG7pZnQHL2/IHZ/dqcZW3IHMgYDoqDvg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.4", - "@github/copilot-darwin-x64": "1.0.4", - "@github/copilot-linux-arm64": "1.0.4", - "@github/copilot-linux-x64": "1.0.4", - "@github/copilot-win32-arm64": "1.0.4", - "@github/copilot-win32-x64": "1.0.4" + "@github/copilot-darwin-arm64": "1.0.10-0", + "@github/copilot-darwin-x64": "1.0.10-0", + "@github/copilot-linux-arm64": "1.0.10-0", + "@github/copilot-linux-x64": "1.0.10-0", + "@github/copilot-win32-arm64": "1.0.10-0", + "@github/copilot-win32-x64": "1.0.10-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-/YGGhv6cp0ItolsF0HsLq2KmesA4atn0IEYApBs770fzJ8OP2pkOEzrxo3gWU3wc7fHF2uDB1RrJEZ7QSFLdEQ==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.10-0.tgz", + "integrity": "sha512-u5CbflcTpvc4E48E0jrqbN3Y5hWzValMs21RR6L+GDjQpPI2pvDeUWAJZ03Y7qQ2Uk3KZ+hOIJWJvje9VHxrDQ==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4.tgz", - "integrity": "sha512-gwn2QjZbc1SqPVSAtDMesU1NopyHZT8Qsn37xPfznpV9s94KVyX4TTiDZaUwfnI0wr8kVHBL46RPLNz6I8kR9A==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.10-0.tgz", + "integrity": "sha512-4y5OXhAfWX+il9slhrq7v8ONzq+Hpw46ktnz7l1fAZKdmn+dzmFVCvr6pJPr5Az78cAKBuN+Gt4eeSNaxuKCmA==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4.tgz", - "integrity": "sha512-92vzHKxN55BpI76sP/5fXIXfat1gzAhsq4bNLqLENGfZyMP/25OiVihCZuQHnvxzXaHBITFGUvtxfdll2kbcng==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.10-0.tgz", + "integrity": "sha512-j+Z/ZahEIT5SCblUqOJ2+2glWeIIUPKXXFS5bbu5kFZ9Xyag37FBvTjyxDeB02dpSKKDD4xbMVjcijFbtyr1PA==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4.tgz", - "integrity": "sha512-wQvpwf4/VMTnSmWyYzq07Xg18Vxg7aZ5NVkkXqlLTuXRASW0kvCCb5USEtXHHzR7E6rJztkhCjFRE1bZW8jAGw==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.10-0.tgz", + "integrity": "sha512-S8IfuiMZWwnFW1v0vOGHalPIXq/75kL/RpZCYd1sleQA/yztCNNjxH9tNpXsdZnhYrAgU/3hqseWq5hbz8xjxA==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4.tgz", - "integrity": "sha512-zOvD/5GVxDf0ZdlTkK+m55Vs55xuHNmACX50ZO2N23ZGG2dmkdS4mkruL59XB5ISgrOfeqvnqrwTFHbmPZtLfw==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.10-0.tgz", + "integrity": "sha512-6HJErp91fLrwIkoXegLK8SXjHzLgbl9GF+QdOtUGqZ915UUfXcchef0tQjN8u35yNLEW82VnAmft/PJ9Ok2UhQ==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4.tgz", - "integrity": "sha512-yQenHMdkV0b77mF6aLM60TuwtNZ592TluptVDF+80Sj2zPfCpLyvrRh2FCIHRtuwTy4BfxETh2hCFHef8E6IOw==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.10-0.tgz", + "integrity": "sha512-AQwZYHoarRACbmPUPmH7gPOEomTAtDusCn65ancI3BoWGj9fzAgZEZ5JSaR3N/VUoXWoEbSe+PcH380ZYwsPag==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 6b0d30f2c..7d1822a9c 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.4", + "@github/copilot": "^1.0.10-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 4f93a271c..77daced15 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.4", + "@github/copilot": "^1.0.10-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 46d932242..9b8af3dd1 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -36,6 +36,7 @@ import type { GetStatusResponse, ModelInfo, ResumeSessionConfig, + SectionTransformFn, SessionConfig, SessionContext, SessionEvent, @@ -44,6 +45,7 @@ import type { SessionLifecycleHandler, SessionListFilter, SessionMetadata, + SystemMessageCustomizeConfig, TelemetryConfig, Tool, ToolCallRequestPayload, @@ -82,6 +84,45 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | return parameters; } +/** + * Extract transform callbacks from a system message config and prepare the wire payload. + * Function-valued actions are replaced with `{ action: "transform" }` for serialization, + * and the original callbacks are returned in a separate map. + */ +function extractTransformCallbacks(systemMessage: SessionConfig["systemMessage"]): { + wirePayload: SessionConfig["systemMessage"]; + transformCallbacks: Map | undefined; +} { + if (!systemMessage || systemMessage.mode !== "customize" || !systemMessage.sections) { + return { wirePayload: systemMessage, transformCallbacks: undefined }; + } + + const transformCallbacks = new Map(); + const wireSections: Record = {}; + + for (const [sectionId, override] of Object.entries(systemMessage.sections)) { + if (!override) continue; + + if (typeof override.action === "function") { + transformCallbacks.set(sectionId, override.action); + wireSections[sectionId] = { action: "transform" }; + } else { + wireSections[sectionId] = { action: override.action, content: override.content }; + } + } + + if (transformCallbacks.size === 0) { + return { wirePayload: systemMessage, transformCallbacks: undefined }; + } + + const wirePayload: SystemMessageCustomizeConfig = { + ...systemMessage, + sections: wireSections as SystemMessageCustomizeConfig["sections"], + }; + + return { wirePayload, transformCallbacks }; +} + function getNodeExecPath(): string { if (process.versions.bun) { return "node"; @@ -605,6 +646,15 @@ export class CopilotClient { if (config.hooks) { session.registerHooks(config.hooks); } + + // Extract transform callbacks from system message config before serialization. + const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( + config.systemMessage + ); + if (transformCallbacks) { + session.registerTransformCallbacks(transformCallbacks); + } + if (config.onEvent) { session.on(config.onEvent); } @@ -624,7 +674,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), - systemMessage: config.systemMessage, + systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, provider: config.provider, @@ -711,6 +761,15 @@ export class CopilotClient { if (config.hooks) { session.registerHooks(config.hooks); } + + // Extract transform callbacks from system message config before serialization. + const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( + config.systemMessage + ); + if (transformCallbacks) { + session.registerTransformCallbacks(transformCallbacks); + } + if (config.onEvent) { session.on(config.onEvent); } @@ -723,7 +782,7 @@ export class CopilotClient { clientName: config.clientName, model: config.model, reasoningEffort: config.reasoningEffort, - systemMessage: config.systemMessage, + systemMessage: wireSystemMessage, availableTools: config.availableTools, excludedTools: config.excludedTools, tools: config.tools?.map((tool) => ({ @@ -1477,6 +1536,15 @@ export class CopilotClient { }): Promise<{ output?: unknown }> => await this.handleHooksInvoke(params) ); + this.connection.onRequest( + "systemMessage.transform", + async (params: { + sessionId: string; + sections: Record; + }): Promise<{ sections: Record }> => + await this.handleSystemMessageTransform(params) + ); + this.connection.onClose(() => { this.state = "disconnected"; }); @@ -1588,6 +1656,27 @@ export class CopilotClient { return { output }; } + private async handleSystemMessageTransform(params: { + sessionId: string; + sections: Record; + }): Promise<{ sections: Record }> { + if ( + !params || + typeof params.sessionId !== "string" || + !params.sections || + typeof params.sections !== "object" + ) { + throw new Error("Invalid systemMessage.transform payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + return await session._handleSystemMessageTransform(params.sections); + } + // ======================================================================== // Protocol v2 backward-compatibility adapters // ======================================================================== diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 16907fdba..dadb9e79d 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -462,6 +462,302 @@ export interface SessionAgentDeselectParams { sessionId: string; } +/** @experimental */ +export interface SessionAgentReloadResult { + /** + * Reloaded custom agents + */ + agents: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + }[]; +} + +/** @experimental */ +export interface SessionAgentReloadParams { + /** + * Target session identifier + */ + sessionId: string; +} + +/** @experimental */ +export interface SessionSkillsListResult { + /** + * Available skills + */ + skills: { + /** + * Unique identifier for the skill + */ + name: string; + /** + * Description of what the skill does + */ + description: string; + /** + * Source location type (e.g., project, personal, plugin) + */ + source: string; + /** + * Whether the skill can be invoked by the user as a slash command + */ + userInvocable: boolean; + /** + * Whether the skill is currently enabled + */ + enabled: boolean; + /** + * Absolute path to the skill file + */ + path?: string; + }[]; +} + +/** @experimental */ +export interface SessionSkillsListParams { + /** + * Target session identifier + */ + sessionId: string; +} + +/** @experimental */ +export interface SessionSkillsEnableResult {} + +/** @experimental */ +export interface SessionSkillsEnableParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Name of the skill to enable + */ + name: string; +} + +/** @experimental */ +export interface SessionSkillsDisableResult {} + +/** @experimental */ +export interface SessionSkillsDisableParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Name of the skill to disable + */ + name: string; +} + +/** @experimental */ +export interface SessionSkillsReloadResult {} + +/** @experimental */ +export interface SessionSkillsReloadParams { + /** + * Target session identifier + */ + sessionId: string; +} + +/** @experimental */ +export interface SessionMcpListResult { + /** + * Configured MCP servers + */ + servers: { + /** + * Server name (config key) + */ + name: string; + /** + * Connection status: connected, failed, pending, disabled, or not_configured + */ + status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + /** + * Configuration source: user, workspace, plugin, or builtin + */ + source?: string; + /** + * Error message if the server failed to connect + */ + error?: string; + }[]; +} + +/** @experimental */ +export interface SessionMcpListParams { + /** + * Target session identifier + */ + sessionId: string; +} + +/** @experimental */ +export interface SessionMcpEnableResult {} + +/** @experimental */ +export interface SessionMcpEnableParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Name of the MCP server to enable + */ + serverName: string; +} + +/** @experimental */ +export interface SessionMcpDisableResult {} + +/** @experimental */ +export interface SessionMcpDisableParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Name of the MCP server to disable + */ + serverName: string; +} + +/** @experimental */ +export interface SessionMcpReloadResult {} + +/** @experimental */ +export interface SessionMcpReloadParams { + /** + * Target session identifier + */ + sessionId: string; +} + +/** @experimental */ +export interface SessionPluginsListResult { + /** + * Installed plugins + */ + plugins: { + /** + * Plugin name + */ + name: string; + /** + * Marketplace the plugin came from + */ + marketplace: string; + /** + * Installed version + */ + version?: string; + /** + * Whether the plugin is currently enabled + */ + enabled: boolean; + }[]; +} + +/** @experimental */ +export interface SessionPluginsListParams { + /** + * Target session identifier + */ + sessionId: string; +} + +/** @experimental */ +export interface SessionExtensionsListResult { + /** + * Discovered extensions and their current status + */ + extensions: { + /** + * Source-qualified ID (e.g., 'project:my-ext', 'user:auth-helper') + */ + id: string; + /** + * Extension name (directory name) + */ + name: string; + /** + * Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/) + */ + source: "project" | "user"; + /** + * Current status: running, disabled, failed, or starting + */ + status: "running" | "disabled" | "failed" | "starting"; + /** + * Process ID if the extension is running + */ + pid?: number; + }[]; +} + +/** @experimental */ +export interface SessionExtensionsListParams { + /** + * Target session identifier + */ + sessionId: string; +} + +/** @experimental */ +export interface SessionExtensionsEnableResult {} + +/** @experimental */ +export interface SessionExtensionsEnableParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Source-qualified extension ID to enable + */ + id: string; +} + +/** @experimental */ +export interface SessionExtensionsDisableResult {} + +/** @experimental */ +export interface SessionExtensionsDisableParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Source-qualified extension ID to disable + */ + id: string; +} + +/** @experimental */ +export interface SessionExtensionsReloadResult {} + +/** @experimental */ +export interface SessionExtensionsReloadParams { + /** + * Target session identifier + */ + sessionId: string; +} + /** @experimental */ export interface SessionCompactionCompactResult { /** @@ -512,6 +808,135 @@ export interface SessionToolsHandlePendingToolCallParams { error?: string; } +export interface SessionCommandsHandlePendingCommandResult { + success: boolean; +} + +export interface SessionCommandsHandlePendingCommandParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Request ID from the command invocation event + */ + requestId: string; + /** + * Error message if the command handler failed + */ + error?: string; +} + +export interface SessionUiElicitationResult { + /** + * The user's response: accept (submitted), decline (rejected), or cancel (dismissed) + */ + action: "accept" | "decline" | "cancel"; + /** + * The form values submitted by the user (present when action is 'accept') + */ + content?: { + [k: string]: string | number | boolean | string[]; + }; +} + +export interface SessionUiElicitationParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Message describing what information is needed from the user + */ + message: string; + /** + * JSON Schema describing the form fields to present to the user + */ + requestedSchema: { + /** + * Schema type indicator (always 'object') + */ + type: "object"; + /** + * Form field definitions, keyed by field name + */ + properties: { + [k: string]: + | { + type: "string"; + title?: string; + description?: string; + enum: string[]; + enumNames?: string[]; + default?: string; + } + | { + type: "string"; + title?: string; + description?: string; + oneOf: { + const: string; + title: string; + }[]; + default?: string; + } + | { + type: "array"; + title?: string; + description?: string; + minItems?: number; + maxItems?: number; + items: { + type: "string"; + enum: string[]; + }; + default?: string[]; + } + | { + type: "array"; + title?: string; + description?: string; + minItems?: number; + maxItems?: number; + items: { + anyOf: { + const: string; + title: string; + }[]; + }; + default?: string[]; + } + | { + type: "boolean"; + title?: string; + description?: string; + default?: boolean; + } + | { + type: "string"; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: "email" | "uri" | "date" | "date-time"; + default?: string; + } + | { + type: "number" | "integer"; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; + }; + }; + /** + * List of required field names + */ + required?: string[]; + }; +} + export interface SessionPermissionsHandlePendingPermissionRequestResult { /** * Whether the permission request was handled successfully @@ -571,6 +996,10 @@ export interface SessionLogParams { * When true, the message is transient and not persisted to the session event log on disk */ ephemeral?: boolean; + /** + * Optional URL the user can open in their browser for more details + */ + url?: string; } export interface SessionShellExecResult { @@ -687,6 +1116,46 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin connection.sendRequest("session.agent.select", { sessionId, ...params }), deselect: async (): Promise => connection.sendRequest("session.agent.deselect", { sessionId }), + reload: async (): Promise => + connection.sendRequest("session.agent.reload", { sessionId }), + }, + /** @experimental */ + skills: { + list: async (): Promise => + connection.sendRequest("session.skills.list", { sessionId }), + enable: async (params: Omit): Promise => + connection.sendRequest("session.skills.enable", { sessionId, ...params }), + disable: async (params: Omit): Promise => + connection.sendRequest("session.skills.disable", { sessionId, ...params }), + reload: async (): Promise => + connection.sendRequest("session.skills.reload", { sessionId }), + }, + /** @experimental */ + mcp: { + list: async (): Promise => + connection.sendRequest("session.mcp.list", { sessionId }), + enable: async (params: Omit): Promise => + connection.sendRequest("session.mcp.enable", { sessionId, ...params }), + disable: async (params: Omit): Promise => + connection.sendRequest("session.mcp.disable", { sessionId, ...params }), + reload: async (): Promise => + connection.sendRequest("session.mcp.reload", { sessionId }), + }, + /** @experimental */ + plugins: { + list: async (): Promise => + connection.sendRequest("session.plugins.list", { sessionId }), + }, + /** @experimental */ + extensions: { + list: async (): Promise => + connection.sendRequest("session.extensions.list", { sessionId }), + enable: async (params: Omit): Promise => + connection.sendRequest("session.extensions.enable", { sessionId, ...params }), + disable: async (params: Omit): Promise => + connection.sendRequest("session.extensions.disable", { sessionId, ...params }), + reload: async (): Promise => + connection.sendRequest("session.extensions.reload", { sessionId }), }, /** @experimental */ compaction: { @@ -697,6 +1166,14 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin handlePendingToolCall: async (params: Omit): Promise => connection.sendRequest("session.tools.handlePendingToolCall", { sessionId, ...params }), }, + commands: { + handlePendingCommand: async (params: Omit): Promise => + connection.sendRequest("session.commands.handlePendingCommand", { sessionId, ...params }), + }, + ui: { + elicitation: async (params: Omit): Promise => + connection.sendRequest("session.ui.elicitation", { sessionId, ...params }), + }, permissions: { handlePendingPermissionRequest: async (params: Omit): Promise => connection.sendRequest("session.permissions.handlePendingPermissionRequest", { sessionId, ...params }), diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index e9d48bc57..9ad6d3c02 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -212,6 +212,10 @@ export type SessionEvent = * GitHub request tracing ID (x-github-request-id header) for correlating with server-side logs */ providerCallId?: string; + /** + * Optional URL associated with this error that the user can open in a browser + */ + url?: string; }; } | { @@ -325,6 +329,10 @@ export type SessionEvent = * Human-readable informational message for display in the timeline */ message: string; + /** + * Optional URL associated with this message that the user can open in a browser + */ + url?: string; }; } | { @@ -357,6 +365,10 @@ export type SessionEvent = * Human-readable warning message for display in the timeline */ message: string; + /** + * Optional URL associated with this warning that the user can open in a browser + */ + url?: string; }; } | { @@ -741,6 +753,22 @@ export type SessionEvent = * Model that was selected at the time of shutdown */ currentModel?: string; + /** + * Total tokens in context window at shutdown + */ + currentTokens?: number; + /** + * System message token count at shutdown + */ + systemTokens?: number; + /** + * Non-system message token count at shutdown + */ + conversationTokens?: number; + /** + * Tool definitions token count at shutdown + */ + toolDefinitionsTokens?: number; }; } | { @@ -826,6 +854,22 @@ export type SessionEvent = * Current number of messages in the conversation */ messagesLength: number; + /** + * Token count from system message(s) + */ + systemTokens?: number; + /** + * Token count from non-system messages (user, assistant, tool) + */ + conversationTokens?: number; + /** + * Token count from tool definitions + */ + toolDefinitionsTokens?: number; + /** + * Whether this is the first usage_info event emitted in this session + */ + isInitial?: boolean; }; } | { @@ -847,9 +891,22 @@ export type SessionEvent = ephemeral?: boolean; type: "session.compaction_start"; /** - * Empty payload; the event signals that LLM-powered conversation compaction has begun + * Context window breakdown at the start of LLM-powered conversation compaction */ - data: {}; + data: { + /** + * Token count from system message(s) at compaction start + */ + systemTokens?: number; + /** + * Token count from non-system messages (user, assistant, tool) at compaction start + */ + conversationTokens?: number; + /** + * Token count from tool definitions at compaction start + */ + toolDefinitionsTokens?: number; + }; } | { /** @@ -934,6 +991,18 @@ export type SessionEvent = * GitHub request tracing ID (x-github-request-id header) for the compaction LLM call */ requestId?: string; + /** + * Token count from system message(s) after compaction + */ + systemTokens?: number; + /** + * Token count from non-system messages (user, assistant, tool) after compaction + */ + conversationTokens?: number; + /** + * Token count from tool definitions after compaction + */ + toolDefinitionsTokens?: number; }; } | { @@ -955,13 +1024,17 @@ export type SessionEvent = ephemeral?: boolean; type: "session.task_complete"; /** - * Task completion notification with optional summary from the agent + * Task completion notification with summary from the agent */ data: { /** - * Optional summary of the completed task, provided by the agent + * Summary of the completed task, provided by the agent */ summary?: string; + /** + * Whether the tool call succeeded. False when validation failed (e.g., invalid arguments) + */ + success?: boolean; }; } | { @@ -982,9 +1055,6 @@ export type SessionEvent = */ ephemeral?: boolean; type: "user.message"; - /** - * User message content with optional attachments, source information, and interaction metadata - */ data: { /** * The user's message text as displayed in the timeline @@ -1134,19 +1204,9 @@ export type SessionEvent = } )[]; /** - * Origin of this message, used for timeline filtering and telemetry (e.g., "user", "autopilot", "skill", or "command") + * Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected messages that should be hidden from the user) */ - source?: - | "user" - | "autopilot" - | "skill" - | "system" - | "command" - | "immediate-prompt" - | "jit-instruction" - | "snippy-blocking" - | "thinking-exhausted-continuation" - | "other"; + source?: string; /** * The agent mode that was active when this message was sent */ @@ -2434,6 +2494,21 @@ export type SessionEvent = */ prompt?: string; } + | { + type: "agent_idle"; + /** + * Unique identifier of the background agent + */ + agentId: string; + /** + * Type of the agent (e.g., explore, task, general-purpose) + */ + agentType: string; + /** + * Human-readable description of the agent task + */ + description?: string; + } | { type: "shell_completed"; /** @@ -2785,6 +2860,10 @@ export type SessionEvent = * Whether the user can provide a free-form text response in addition to predefined choices */ allowFreeform?: boolean; + /** + * The LLM-assigned tool call ID that triggered this request; used by remote UIs to correlate responses + */ + toolCallId?: string; }; } | { @@ -2828,25 +2907,33 @@ export type SessionEvent = ephemeral: true; type: "elicitation.requested"; /** - * Structured form elicitation request with JSON schema definition for form fields + * Elicitation request; may be form-based (structured input) or URL-based (browser redirect) */ data: { /** * Unique identifier for this elicitation request; used to respond via session.respondToElicitation() */ requestId: string; + /** + * Tool call ID from the LLM completion; used to correlate with CompletionChunk.toolCall.id for remote UIs + */ + toolCallId?: string; + /** + * The source that initiated the request (MCP server name, or absent for agent-initiated) + */ + elicitationSource?: string; /** * Message describing what information is needed from the user */ message: string; /** - * Elicitation mode; currently only "form" is supported. Defaults to "form" when absent. + * Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to "form" when absent. */ - mode?: "form"; + mode?: "form" | "url"; /** - * JSON Schema describing the form fields to present to the user + * JSON Schema describing the form fields to present to the user (form mode only) */ - requestedSchema: { + requestedSchema?: { /** * Schema type indicator (always 'object') */ @@ -2862,6 +2949,10 @@ export type SessionEvent = */ required?: string[]; }; + /** + * URL to open in the user's browser (url mode only) + */ + url?: string; [k: string]: unknown; }; } @@ -2890,6 +2981,77 @@ export type SessionEvent = requestId: string; }; } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "mcp.oauth_required"; + /** + * OAuth authentication request for an MCP server + */ + data: { + /** + * Unique identifier for this OAuth request; used to respond via session.respondToMcpOAuth() + */ + requestId: string; + /** + * Display name of the MCP server that requires OAuth + */ + serverName: string; + /** + * URL of the MCP server that requires OAuth + */ + serverUrl: string; + /** + * Static OAuth client configuration, if the server specifies one + */ + staticClientConfig?: { + /** + * OAuth client ID for the server + */ + clientId: string; + /** + * Whether this is a public OAuth client + */ + publicClient?: boolean; + }; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "mcp.oauth_completed"; + /** + * MCP OAuth request completion notification + */ + data: { + /** + * Request ID of the resolved OAuth request + */ + requestId: string; + }; + } | { /** * Unique event identifier (UUID v4), generated when the event is emitted @@ -2995,6 +3157,43 @@ export type SessionEvent = command: string; }; } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "command.execute"; + /** + * Registered command dispatch request routed to the owning client + */ + data: { + /** + * Unique identifier; used to respond via session.commands.handlePendingCommand() + */ + requestId: string; + /** + * The full command text (e.g., /deploy production) + */ + command: string; + /** + * Command name without leading / + */ + commandName: string; + /** + * Raw argument string after the command name + */ + args: string; + }; + } | { /** * Unique event identifier (UUID v4), generated when the event is emitted @@ -3020,6 +3219,34 @@ export type SessionEvent = requestId: string; }; } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "commands.changed"; + /** + * SDK command registration change notification + */ + data: { + /** + * Current list of registered SDK commands + */ + commands: { + name: string; + description?: string; + }[]; + }; + } | { /** * Unique event identifier (UUID v4), generated when the event is emitted @@ -3121,4 +3348,155 @@ export type SessionEvent = ephemeral: true; type: "session.background_tasks_changed"; data: {}; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "session.skills_loaded"; + data: { + /** + * Array of resolved skill metadata + */ + skills: { + /** + * Unique identifier for the skill + */ + name: string; + /** + * Description of what the skill does + */ + description: string; + /** + * Source location type of the skill (e.g., project, personal, plugin) + */ + source: string; + /** + * Whether the skill can be invoked by the user as a slash command + */ + userInvocable: boolean; + /** + * Whether the skill is currently enabled + */ + enabled: boolean; + /** + * Absolute path to the skill file, if available + */ + path?: string; + }[]; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "session.mcp_servers_loaded"; + data: { + /** + * Array of MCP server status summaries + */ + servers: { + /** + * Server name (config key) + */ + name: string; + /** + * Connection status: connected, failed, pending, disabled, or not_configured + */ + status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + /** + * Configuration source: user, workspace, plugin, or builtin + */ + source?: string; + /** + * Error message if the server failed to connect + */ + error?: string; + }[]; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "session.mcp_server_status_changed"; + data: { + /** + * Name of the MCP server whose status changed + */ + serverName: string; + /** + * New connection status: connected, failed, pending, disabled, or not_configured + */ + status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "session.extensions_loaded"; + data: { + /** + * Array of discovered extensions and their status + */ + extensions: { + /** + * Source-qualified extension ID (e.g., 'project:my-ext', 'user:auth-helper') + */ + id: string; + /** + * Extension name (directory name) + */ + name: string; + /** + * Discovery source + */ + source: "project" | "user"; + /** + * Current status: running, disabled, failed, or starting + */ + status: "running" | "disabled" | "failed" | "starting"; + }[]; + }; }; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 214b80050..f3788e168 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,7 +10,7 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool, approveAll } from "./types.js"; +export { defineTool, approveAll, SYSTEM_PROMPT_SECTIONS } from "./types.js"; export type { ConnectionState, CopilotClientOptions, @@ -31,6 +31,9 @@ export type { PermissionRequest, PermissionRequestResult, ResumeSessionConfig, + SectionOverride, + SectionOverrideAction, + SectionTransformFn, SessionConfig, SessionEvent, SessionEventHandler, @@ -44,7 +47,9 @@ export type { SessionMetadata, SystemMessageAppendConfig, SystemMessageConfig, + SystemMessageCustomizeConfig, SystemMessageReplaceConfig, + SystemPromptSection, TelemetryConfig, TraceContext, TraceContextProvider, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 674526764..122f4ece8 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -17,6 +17,7 @@ import type { PermissionRequest, PermissionRequestResult, ReasoningEffort, + SectionTransformFn, SessionEvent, SessionEventHandler, SessionEventPayload, @@ -70,6 +71,7 @@ export class CopilotSession { private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private hooks?: SessionHooks; + private transformCallbacks?: Map; private _rpc: ReturnType | null = null; private traceContextProvider?: TraceContextProvider; @@ -517,6 +519,48 @@ export class CopilotSession { this.hooks = hooks; } + /** + * Registers transform callbacks for system message sections. + * + * @param callbacks - Map of section ID to transform callback, or undefined to clear + * @internal This method is typically called internally when creating a session. + */ + registerTransformCallbacks(callbacks?: Map): void { + this.transformCallbacks = callbacks; + } + + /** + * Handles a systemMessage.transform request from the runtime. + * Dispatches each section to its registered transform callback. + * + * @param sections - Map of section IDs to their current rendered content + * @returns A promise that resolves with the transformed sections + * @internal This method is for internal use by the SDK. + */ + async _handleSystemMessageTransform( + sections: Record + ): Promise<{ sections: Record }> { + const result: Record = {}; + + for (const [sectionId, { content }] of Object.entries(sections)) { + const callback = this.transformCallbacks?.get(sectionId); + if (callback) { + try { + const transformed = await callback(content); + result[sectionId] = { content: transformed }; + } catch (_error) { + // Callback failed — return original content + result[sectionId] = { content }; + } + } else { + // No callback for this section — pass through unchanged + result[sectionId] = { content }; + } + } + + return { sections: result }; + } + /** * Handles a permission request in the v2 protocol format (synchronous RPC). * Used as a back-compat adapter when connected to a v2 server. diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9052bde52..992dbdb9d 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -272,6 +272,79 @@ export interface ToolCallResponsePayload { result: ToolResult; } +/** + * Known system prompt section identifiers for the "customize" mode. + * Each section corresponds to a distinct part of the system prompt. + */ +export type SystemPromptSection = + | "identity" + | "tone" + | "tool_efficiency" + | "environment_context" + | "code_change_rules" + | "guidelines" + | "safety" + | "tool_instructions" + | "custom_instructions" + | "last_instructions"; + +/** Section metadata for documentation and tooling. */ +export const SYSTEM_PROMPT_SECTIONS: Record = { + identity: { description: "Agent identity preamble and mode statement" }, + tone: { description: "Response style, conciseness rules, output formatting preferences" }, + tool_efficiency: { description: "Tool usage patterns, parallel calling, batching guidelines" }, + environment_context: { description: "CWD, OS, git root, directory listing, available tools" }, + code_change_rules: { description: "Coding rules, linting/testing, ecosystem tools, style" }, + guidelines: { description: "Tips, behavioral best practices, behavioral guidelines" }, + safety: { description: "Environment limitations, prohibited actions, security policies" }, + tool_instructions: { description: "Per-tool usage instructions" }, + custom_instructions: { description: "Repository and organization custom instructions" }, + last_instructions: { + description: + "End-of-prompt instructions: parallel tool calling, persistence, task completion", + }, +}; + +/** + * Transform callback for a single section: receives current content, returns new content. + */ +export type SectionTransformFn = (currentContent: string) => string | Promise; + +/** + * Override action: a string literal for static overrides, or a callback for transforms. + * + * - `"replace"`: Replace section content entirely + * - `"remove"`: Remove the section + * - `"append"`: Append to existing section content + * - `"prepend"`: Prepend to existing section content + * - `function`: Transform callback — receives current section content, returns new content + */ +export type SectionOverrideAction = + | "replace" + | "remove" + | "append" + | "prepend" + | SectionTransformFn; + +/** + * Override operation for a single system prompt section. + */ +export interface SectionOverride { + /** + * The operation to perform on this section. + * Can be a string action or a transform callback function. + */ + action: SectionOverrideAction; + + /** + * Content for the override. Optional for all actions. + * - For replace, omitting content replaces with an empty string. + * - For append/prepend, content is added before/after the existing section. + * - Ignored for the remove action. + */ + content?: string; +} + /** * Append mode: Use CLI foundation with optional appended content (default). */ @@ -298,12 +371,37 @@ export interface SystemMessageReplaceConfig { content: string; } +/** + * Customize mode: Override individual sections of the system prompt. + * Keeps the SDK-managed prompt structure while allowing targeted modifications. + */ +export interface SystemMessageCustomizeConfig { + mode: "customize"; + + /** + * Override specific sections of the system prompt by section ID. + * Unknown section IDs gracefully fall back: content-bearing overrides are appended + * to additional instructions, and "remove" on unknown sections is a silent no-op. + */ + sections?: Partial>; + + /** + * Additional content appended after all sections. + * Equivalent to append mode's content field — provided for convenience. + */ + content?: string; +} + /** * System message configuration for session creation. * - Append mode (default): SDK foundation + optional custom content * - Replace mode: Full control, caller provides entire system message + * - Customize mode: Section-level overrides with graceful fallback */ -export type SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig; +export type SystemMessageConfig = + | SystemMessageAppendConfig + | SystemMessageReplaceConfig + | SystemMessageCustomizeConfig; /** * Permission request types from the server diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 1eb8a175d..dbcbed8bb 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -96,6 +96,33 @@ describe("Sessions", async () => { expect(systemMessage).toEqual(testSystemMessage); // Exact match }); + it("should create a session with customized systemMessage config", async () => { + const customTone = "Respond in a warm, professional tone. Be thorough in explanations."; + const appendedContent = "Always mention quarterly earnings."; + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + tone: { action: "replace", content: customTone }, + code_change_rules: { action: "remove" }, + }, + content: appendedContent, + }, + }); + + const assistantMessage = await session.sendAndWait({ prompt: "Who are you?" }); + expect(assistantMessage?.data.content).toBeDefined(); + + // Validate the system message sent to the model + const traffic = await openAiEndpoint.getExchanges(); + const systemMessage = getSystemMessage(traffic[0]); + expect(systemMessage).toContain(customTone); + expect(systemMessage).toContain(appendedContent); + // The code_change_rules section should have been removed + expect(systemMessage).not.toContain(""); + }); + it("should create a session with availableTools", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, diff --git a/nodejs/test/e2e/system_message_transform.test.ts b/nodejs/test/e2e/system_message_transform.test.ts new file mode 100644 index 000000000..ef37c39e9 --- /dev/null +++ b/nodejs/test/e2e/system_message_transform.test.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { writeFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("System message transform", async () => { + const { copilotClient: client, openAiEndpoint, workDir } = await createSdkTestContext(); + + it("should invoke transform callbacks with section content", async () => { + const transformedSections: Record = {}; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + identity: { + action: (content: string) => { + transformedSections["identity"] = content; + // Pass through unchanged + return content; + }, + }, + tone: { + action: (content: string) => { + transformedSections["tone"] = content; + return content; + }, + }, + }, + }, + }); + + await writeFile(join(workDir, "test.txt"), "Hello transform!"); + + await session.sendAndWait({ + prompt: "Read the contents of test.txt and tell me what it says", + }); + + // Transform callbacks should have been invoked with real section content + expect(Object.keys(transformedSections).length).toBe(2); + expect(transformedSections["identity"]).toBeDefined(); + expect(transformedSections["identity"]!.length).toBeGreaterThan(0); + expect(transformedSections["tone"]).toBeDefined(); + expect(transformedSections["tone"]!.length).toBeGreaterThan(0); + + await session.disconnect(); + }); + + it("should apply transform modifications to section content", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + identity: { + action: (content: string) => { + return content + "\nTRANSFORM_MARKER"; + }, + }, + }, + }, + }); + + await writeFile(join(workDir, "hello.txt"), "Hello!"); + + await session.sendAndWait({ + prompt: "Read the contents of hello.txt", + }); + + // Verify the transform result was actually applied to the system message + const traffic = await openAiEndpoint.getExchanges(); + const systemMessage = getSystemMessage(traffic[0]); + expect(systemMessage).toContain("TRANSFORM_MARKER"); + + await session.disconnect(); + }); + + it("should work with static overrides and transforms together", async () => { + const transformedSections: Record = {}; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + systemMessage: { + mode: "customize", + sections: { + // Static override + safety: { action: "remove" }, + // Transform + identity: { + action: (content: string) => { + transformedSections["identity"] = content; + return content; + }, + }, + }, + }, + }); + + await writeFile(join(workDir, "combo.txt"), "Combo test!"); + + await session.sendAndWait({ + prompt: "Read the contents of combo.txt and tell me what it says", + }); + + // Transform should have been invoked + expect(transformedSections["identity"]).toBeDefined(); + expect(transformedSections["identity"]!.length).toBeGreaterThan(0); + + await session.disconnect(); + }); +}); + +function getSystemMessage(exchange: ParsedHttpExchange): string | undefined { + const systemMessage = exchange.request.messages.find((m) => m.role === "system") as + | { role: "system"; content: string } + | undefined; + return systemMessage?.content; +} diff --git a/python/README.md b/python/README.md index 2394c351a..57bb78cab 100644 --- a/python/README.md +++ b/python/README.md @@ -33,7 +33,10 @@ async def main(): await client.start() # Create a session (on_permission_request is required) - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5") + session = await client.create_session({ + "model": "gpt-5", + "on_permission_request": PermissionHandler.approve_all, + }) # Wait for response using session.idle event done = asyncio.Event() @@ -60,7 +63,10 @@ asyncio.run(main()) Sessions also support the `async with` context manager pattern for automatic cleanup: ```python -async with await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5") as session: +async with await client.create_session({ + "model": "gpt-5", + "on_permission_request": PermissionHandler.approve_all, +}) as session: await session.send("What is 2+2?") # session is automatically disconnected when leaving the block ``` @@ -85,7 +91,7 @@ from copilot import CopilotClient, SubprocessConfig client = CopilotClient() # uses bundled CLI, stdio transport await client.start() -session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5") +session = await client.create_session({"model": "gpt-5"}) def on_event(event): print(f"Event: {event['type']}") @@ -134,59 +140,19 @@ CopilotClient( - `url` (str): Server URL (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). -**`create_session` Parameters:** - -All parameters are keyword-only: +**SessionConfig Options (for `create_session`):** -- `on_permission_request` (callable): **Required.** Handler called before each tool execution to approve or deny it. Use `PermissionHandler.approve_all` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. -- `model` (str): Model to use ("gpt-5", "claude-sonnet-4.5", etc.). -- `session_id` (str): Custom session ID for resuming or identifying sessions. -- `client_name` (str): Client name to identify the application using the SDK. Included in the User-Agent header for API requests. +- `model` (str): Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** - `reasoning_effort` (str): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `list_models()` to check which models support this option. -- `tools` (list): Custom tools exposed to the CLI. -- `system_message` (dict): System message configuration. -- `available_tools` (list[str]): List of tool names to allow. Takes precedence over `excluded_tools`. -- `excluded_tools` (list[str]): List of tool names to disable. Ignored if `available_tools` is set. -- `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. -- `hooks` (dict): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. -- `working_directory` (str): Working directory for the session. Tool operations will be relative to this directory. +- `session_id` (str): Custom session ID +- `tools` (list): Custom tools exposed to the CLI +- `system_message` (dict): System message configuration +- `streaming` (bool): Enable streaming delta events - `provider` (dict): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. -- `streaming` (bool): Enable streaming delta events. -- `mcp_servers` (dict): MCP server configurations for the session. -- `custom_agents` (list): Custom agent configurations for the session. -- `config_dir` (str): Override the default configuration directory location. -- `skill_directories` (list[str]): Directories to load skills from. -- `disabled_skills` (list[str]): List of skill names to disable. -- `infinite_sessions` (dict): Automatic context compaction configuration. - -**`resume_session` Parameters:** - -- `session_id` (str): **Required.** The ID of the session to resume. - -The parameters below are keyword-only: - +- `infinite_sessions` (dict): Automatic context compaction configuration - `on_permission_request` (callable): **Required.** Handler called before each tool execution to approve or deny it. Use `PermissionHandler.approve_all` to allow everything, or provide a custom function for fine-grained control. See [Permission Handling](#permission-handling) section. -- `model` (str): Model to use (can change the model when resuming). -- `client_name` (str): Client name to identify the application using the SDK. -- `reasoning_effort` (str): Reasoning effort level ("low", "medium", "high", "xhigh"). -- `tools` (list): Custom tools exposed to the CLI. -- `system_message` (dict): System message configuration. -- `available_tools` (list[str]): List of tool names to allow. Takes precedence over `excluded_tools`. -- `excluded_tools` (list[str]): List of tool names to disable. Ignored if `available_tools` is set. -- `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). -- `hooks` (dict): Hook handlers for session lifecycle events. -- `working_directory` (str): Working directory for the session. -- `provider` (dict): Custom API provider configuration (BYOK). -- `streaming` (bool): Enable streaming delta events. -- `mcp_servers` (dict): MCP server configurations for the session. -- `custom_agents` (list): Custom agent configurations for the session. -- `agent` (str): Name of the custom agent to activate when the session starts. -- `config_dir` (str): Override the default configuration directory location. -- `skill_directories` (list[str]): Directories to load skills from. -- `disabled_skills` (list[str]): List of skill names to disable. -- `infinite_sessions` (dict): Automatic context compaction configuration. -- `disable_resume` (bool): Skip emitting the session.resume event (default: False). -- `on_event` (callable): Event handler registered before the session.resume RPC. +- `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. +- `hooks` (dict): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. **Session Lifecycle Methods:** @@ -223,7 +189,7 @@ Define tools with automatic JSON schema generation using the `@define_tool` deco ```python from pydantic import BaseModel, Field -from copilot import CopilotClient, define_tool, PermissionHandler +from copilot import CopilotClient, define_tool class LookupIssueParams(BaseModel): id: str = Field(description="Issue identifier") @@ -233,11 +199,10 @@ async def lookup_issue(params: LookupIssueParams) -> str: issue = await fetch_issue(params.id) return issue.summary -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-5", - tools=[lookup_issue], -) +session = await client.create_session({ + "model": "gpt-5", + "tools": [lookup_issue], +}) ``` > **Note:** When using `from __future__ import annotations`, define Pydantic models at module level (not inside functions). @@ -247,7 +212,8 @@ session = await client.create_session( For users who prefer manual schema definition: ```python -from copilot import CopilotClient, Tool, PermissionHandler +from copilot import CopilotClient +from copilot.tools import Tool async def lookup_issue(invocation): issue_id = invocation["arguments"]["id"] @@ -258,10 +224,9 @@ async def lookup_issue(invocation): "sessionLog": f"Fetched issue {issue_id}", } -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-5", - tools=[ +session = await client.create_session({ + "model": "gpt-5", + "tools": [ Tool( name="lookup_issue", description="Fetch issue details from our tracker", @@ -275,7 +240,7 @@ session = await client.create_session( handler=lookup_issue, ) ], -) +}) ``` The SDK automatically handles `tool.call`, executes your handler (sync or async), and responds with the final result when the tool completes. @@ -345,17 +310,16 @@ Enable streaming to receive assistant response chunks as they're generated: ```python import asyncio -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient async def main(): client = CopilotClient() await client.start() - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-5", - streaming=True, - ) + session = await client.create_session({ + "model": "gpt-5", + "streaming": True + }) # Use asyncio.Event to wait for completion done = asyncio.Event() @@ -406,29 +370,27 @@ By default, sessions use **infinite sessions** which automatically manage contex ```python # Default: infinite sessions enabled with default thresholds -session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5") +session = await client.create_session({"model": "gpt-5"}) # Access the workspace path for checkpoints and files print(session.workspace_path) # => ~/.copilot/session-state/{session_id}/ # Custom thresholds -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-5", - infinite_sessions={ +session = await client.create_session({ + "model": "gpt-5", + "infinite_sessions": { "enabled": True, "background_compaction_threshold": 0.80, # Start compacting at 80% context usage "buffer_exhaustion_threshold": 0.95, # Block at 95% until compaction completes }, -) +}) # Disable infinite sessions -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-5", - infinite_sessions={"enabled": False}, -) +session = await client.create_session({ + "model": "gpt-5", + "infinite_sessions": {"enabled": False}, +}) ``` When enabled, sessions emit compaction events: @@ -452,15 +414,14 @@ The SDK supports custom OpenAI-compatible API providers (BYOK - Bring Your Own K **Example with Ollama:** ```python -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="deepseek-coder-v2:16b", # Model to use with the custom provider - provider={ +session = await client.create_session({ + "model": "deepseek-coder-v2:16b", # Required when using custom provider + "provider": { "type": "openai", "base_url": "http://localhost:11434/v1", # Ollama endpoint # api_key not required for Ollama }, -) +}) await session.send("Hello!") ``` @@ -470,15 +431,14 @@ await session.send("Hello!") ```python import os -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-4", - provider={ +session = await client.create_session({ + "model": "gpt-4", + "provider": { "type": "openai", "base_url": "https://my-api.example.com/v1", "api_key": os.environ["MY_API_KEY"], }, -) +}) ``` **Example with Azure OpenAI:** @@ -486,10 +446,9 @@ session = await client.create_session( ```python import os -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-4", - provider={ +session = await client.create_session({ + "model": "gpt-4", + "provider": { "type": "azure", # Must be "azure" for Azure endpoints, NOT "openai" "base_url": "https://my-resource.openai.azure.com", # Just the host, no path "api_key": os.environ["AZURE_OPENAI_KEY"], @@ -497,10 +456,11 @@ session = await client.create_session( "api_version": "2024-10-21", }, }, -) +}) ``` > **Important notes:** +> - When using a custom provider, the `model` parameter is **required**. The SDK will throw an error if no model is specified. > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `type: "azure"`, not `type: "openai"`. > - The `base_url` 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 automatically. @@ -635,11 +595,10 @@ async def handle_user_input(request, invocation): "wasFreeform": True, # Whether the answer was freeform (not from choices) } -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-5", - on_user_input_request=handle_user_input, -) +session = await client.create_session({ + "model": "gpt-5", + "on_user_input_request": handle_user_input, +}) ``` ## Session Hooks @@ -683,10 +642,9 @@ async def on_error_occurred(input, invocation): "errorHandling": "retry", # "retry", "skip", or "abort" } -session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="gpt-5", - hooks={ +session = await client.create_session({ + "model": "gpt-5", + "hooks": { "on_pre_tool_use": on_pre_tool_use, "on_post_tool_use": on_post_tool_use, "on_user_prompt_submitted": on_user_prompt_submitted, @@ -694,7 +652,7 @@ session = await client.create_session( "on_session_end": on_session_end, "on_error_occurred": on_error_occurred, }, -) +}) ``` **Available hooks:** diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index e1fdf9253..92764c0e8 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -4,84 +4,16 @@ JSON-RPC based SDK for programmatic control of GitHub Copilot CLI """ -from .client import CopilotClient +from .client import CopilotClient, ExternalServerConfig, SubprocessConfig from .session import CopilotSession from .tools import define_tool -from .types import ( - Attachment, - AzureProviderOptions, - BlobAttachment, - ConnectionState, - CustomAgentConfig, - DirectoryAttachment, - ExternalServerConfig, - FileAttachment, - GetAuthStatusResponse, - GetStatusResponse, - MCPLocalServerConfig, - MCPRemoteServerConfig, - MCPServerConfig, - ModelBilling, - ModelCapabilities, - ModelInfo, - ModelPolicy, - PermissionHandler, - PermissionRequest, - PermissionRequestResult, - PingResponse, - ProviderConfig, - SelectionAttachment, - SessionContext, - SessionEvent, - SessionListFilter, - SessionMetadata, - StopError, - SubprocessConfig, - TelemetryConfig, - Tool, - ToolHandler, - ToolInvocation, - ToolResult, -) __version__ = "0.1.0" __all__ = [ - "Attachment", - "AzureProviderOptions", - "BlobAttachment", "CopilotClient", "CopilotSession", - "ConnectionState", - "CustomAgentConfig", - "DirectoryAttachment", "ExternalServerConfig", - "FileAttachment", - "GetAuthStatusResponse", - "GetStatusResponse", - "MCPLocalServerConfig", - "MCPRemoteServerConfig", - "MCPServerConfig", - "ModelBilling", - "ModelCapabilities", - "ModelInfo", - "ModelPolicy", - "PermissionHandler", - "PermissionRequest", - "PermissionRequestResult", - "PingResponse", - "ProviderConfig", - "SelectionAttachment", - "SessionContext", - "SessionEvent", - "SessionListFilter", - "SessionMetadata", - "StopError", "SubprocessConfig", - "TelemetryConfig", - "Tool", - "ToolHandler", - "ToolInvocation", - "ToolResult", "define_tool", ] diff --git a/python/copilot/client.py b/python/copilot/client.py index 28050088e..4a5632e5e 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -5,15 +5,15 @@ to the Copilot CLI server and provides session management capabilities. Example: - >>> from copilot import CopilotClient, PermissionHandler + >>> from copilot import CopilotClient >>> >>> async with CopilotClient() as client: - ... session = await client.create_session( - ... on_permission_request=PermissionHandler.approve_all - ... ) + ... session = await client.create_session() ... await session.send("Hello!") """ +from __future__ import annotations + import asyncio import inspect import os @@ -24,43 +24,619 @@ import threading import uuid from collections.abc import Awaitable, Callable +from dataclasses import KW_ONLY, dataclass, field from pathlib import Path -from typing import Any, cast, overload +from typing import Any, Literal, TypedDict, cast, overload from ._jsonrpc import JsonRpcClient, ProcessExitedError from ._sdk_protocol_version import get_sdk_protocol_version from ._telemetry import get_trace_context, trace_context from .generated.rpc import ServerRpc from .generated.session_events import PermissionRequest, session_event_from_dict -from .session import CopilotSession -from .types import ( - ConnectionState, +from .session import ( + CopilotSession, CustomAgentConfig, - ExternalServerConfig, - GetAuthStatusResponse, - GetStatusResponse, - InfiniteSessionConfig, - MCPServerConfig, - ModelInfo, - PingResponse, ProviderConfig, - ReasoningEffort, - SessionEvent, - SessionHooks, - SessionLifecycleEvent, - SessionLifecycleEventType, - SessionLifecycleHandler, - SessionListFilter, - SessionMetadata, - StopError, - SubprocessConfig, - SystemMessageConfig, - Tool, - ToolInvocation, - ToolResult, - UserInputHandler, - _PermissionHandlerFn, + ResumeSessionConfig, + SessionConfig, ) +from .tools import ToolInvocation, ToolResult + +# ============================================================================ +# Connection Types +# ============================================================================ + +ConnectionState = Literal["disconnected", "connecting", "connected", "error"] + +LogLevel = Literal["none", "error", "warning", "info", "debug", "all"] + + +class TelemetryConfig(TypedDict, total=False): + """Configuration for OpenTelemetry integration with the Copilot CLI.""" + + otlp_endpoint: str + """OTLP HTTP endpoint URL for trace/metric export. Sets OTEL_EXPORTER_OTLP_ENDPOINT.""" + file_path: str + """File path for JSON-lines trace output. Sets COPILOT_OTEL_FILE_EXPORTER_PATH.""" + exporter_type: str + """Exporter backend type: "otlp-http" or "file". Sets COPILOT_OTEL_EXPORTER_TYPE.""" + source_name: str + """Instrumentation scope name. Sets COPILOT_OTEL_SOURCE_NAME.""" + capture_content: bool + """Whether to capture message content. Sets OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.""" # noqa: E501 + + +@dataclass +class SubprocessConfig: + """Config for spawning a local Copilot CLI subprocess. + + Example: + >>> config = SubprocessConfig(github_token="ghp_...") + >>> client = CopilotClient(config) + + >>> # Custom CLI path with TCP transport + >>> config = SubprocessConfig( + ... cli_path="/usr/local/bin/copilot", + ... use_stdio=False, + ... log_level="debug", + ... ) + """ + + cli_path: str | None = None + """Path to the Copilot CLI executable. ``None`` uses the bundled binary.""" + + cli_args: list[str] = field(default_factory=list) + """Extra arguments passed to the CLI executable (inserted before SDK-managed args).""" + + _: KW_ONLY + + cwd: str | None = None + """Working directory for the CLI process. ``None`` uses the current directory.""" + + use_stdio: bool = True + """Use stdio transport (``True``, default) or TCP (``False``).""" + + port: int = 0 + """TCP port for the CLI server (only when ``use_stdio=False``). 0 means random.""" + + log_level: LogLevel = "info" + """Log level for the CLI process.""" + + env: dict[str, str] | None = None + """Environment variables for the CLI process. ``None`` inherits the current env.""" + + github_token: str | None = None + """GitHub token for authentication. Takes priority over other auth methods.""" + + use_logged_in_user: bool | None = None + """Use the logged-in user for authentication. + + ``None`` (default) resolves to ``True`` unless ``github_token`` is set. + """ + + telemetry: TelemetryConfig | None = None + """OpenTelemetry configuration. Providing this enables telemetry — no separate flag needed.""" + + +@dataclass +class ExternalServerConfig: + """Config for connecting to an existing Copilot CLI server over TCP. + + Example: + >>> config = ExternalServerConfig(url="localhost:3000") + >>> client = CopilotClient(config) + """ + + url: str + """Server URL. Supports ``"host:port"``, ``"http://host:port"``, or just ``"port"``.""" + + +# ============================================================================ +# Response Types +# ============================================================================ + + +@dataclass +class PingResponse: + """Response from ping""" + + message: str # Echo message with "pong: " prefix + timestamp: int # Server timestamp in milliseconds + protocolVersion: int # Protocol version for SDK compatibility + + @staticmethod + def from_dict(obj: Any) -> PingResponse: + assert isinstance(obj, dict) + message = obj.get("message") + timestamp = obj.get("timestamp") + protocolVersion = obj.get("protocolVersion") + if message is None or timestamp is None or protocolVersion is None: + raise ValueError( + f"Missing required fields in PingResponse: message={message}, " + f"timestamp={timestamp}, protocolVersion={protocolVersion}" + ) + return PingResponse(str(message), int(timestamp), int(protocolVersion)) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = self.message + result["timestamp"] = self.timestamp + result["protocolVersion"] = self.protocolVersion + return result + + +@dataclass +class StopError(Exception): + """Error that occurred during client stop cleanup.""" + + message: str # Error message describing what failed during cleanup + + def __post_init__(self) -> None: + Exception.__init__(self, self.message) + + @staticmethod + def from_dict(obj: Any) -> StopError: + assert isinstance(obj, dict) + message = obj.get("message") + if message is None: + raise ValueError("Missing required field 'message' in StopError") + return StopError(str(message)) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = self.message + return result + + +@dataclass +class GetStatusResponse: + """Response from status.get""" + + version: str # Package version (e.g., "1.0.0") + protocolVersion: int # Protocol version for SDK compatibility + + @staticmethod + def from_dict(obj: Any) -> GetStatusResponse: + assert isinstance(obj, dict) + version = obj.get("version") + protocolVersion = obj.get("protocolVersion") + if version is None or protocolVersion is None: + raise ValueError( + f"Missing required fields in GetStatusResponse: version={version}, " + f"protocolVersion={protocolVersion}" + ) + return GetStatusResponse(str(version), int(protocolVersion)) + + def to_dict(self) -> dict: + result: dict = {} + result["version"] = self.version + result["protocolVersion"] = self.protocolVersion + return result + + +@dataclass +class GetAuthStatusResponse: + """Response from auth.getStatus""" + + isAuthenticated: bool # Whether the user is authenticated + authType: str | None = None # Authentication type + host: str | None = None # GitHub host URL + login: str | None = None # User login name + statusMessage: str | None = None # Human-readable status message + + @staticmethod + def from_dict(obj: Any) -> GetAuthStatusResponse: + assert isinstance(obj, dict) + isAuthenticated = obj.get("isAuthenticated") + if isAuthenticated is None: + raise ValueError("Missing required field 'isAuthenticated' in GetAuthStatusResponse") + authType = obj.get("authType") + host = obj.get("host") + login = obj.get("login") + statusMessage = obj.get("statusMessage") + return GetAuthStatusResponse( + isAuthenticated=bool(isAuthenticated), + authType=authType, + host=host, + login=login, + statusMessage=statusMessage, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["isAuthenticated"] = self.isAuthenticated + if self.authType is not None: + result["authType"] = self.authType + if self.host is not None: + result["host"] = self.host + if self.login is not None: + result["login"] = self.login + if self.statusMessage is not None: + result["statusMessage"] = self.statusMessage + return result + + +# ============================================================================ +# Model Types +# ============================================================================ + + +@dataclass +class ModelVisionLimits: + """Vision-specific limits""" + + supported_media_types: list[str] | None = None + max_prompt_images: int | None = None + max_prompt_image_size: int | None = None + + @staticmethod + def from_dict(obj: Any) -> ModelVisionLimits: + assert isinstance(obj, dict) + supported_media_types = obj.get("supported_media_types") + max_prompt_images = obj.get("max_prompt_images") + max_prompt_image_size = obj.get("max_prompt_image_size") + return ModelVisionLimits( + supported_media_types=supported_media_types, + max_prompt_images=max_prompt_images, + max_prompt_image_size=max_prompt_image_size, + ) + + def to_dict(self) -> dict: + result: dict = {} + if self.supported_media_types is not None: + result["supported_media_types"] = self.supported_media_types + if self.max_prompt_images is not None: + result["max_prompt_images"] = self.max_prompt_images + if self.max_prompt_image_size is not None: + result["max_prompt_image_size"] = self.max_prompt_image_size + return result + + +@dataclass +class ModelLimits: + """Model limits""" + + max_prompt_tokens: int | None = None + max_context_window_tokens: int | None = None + vision: ModelVisionLimits | None = None + + @staticmethod + def from_dict(obj: Any) -> ModelLimits: + assert isinstance(obj, dict) + max_prompt_tokens = obj.get("max_prompt_tokens") + max_context_window_tokens = obj.get("max_context_window_tokens") + vision_dict = obj.get("vision") + vision = ModelVisionLimits.from_dict(vision_dict) if vision_dict else None + return ModelLimits( + max_prompt_tokens=max_prompt_tokens, + max_context_window_tokens=max_context_window_tokens, + vision=vision, + ) + + def to_dict(self) -> dict: + result: dict = {} + if self.max_prompt_tokens is not None: + result["max_prompt_tokens"] = self.max_prompt_tokens + if self.max_context_window_tokens is not None: + result["max_context_window_tokens"] = self.max_context_window_tokens + if self.vision is not None: + result["vision"] = self.vision.to_dict() + return result + + +@dataclass +class ModelSupports: + """Model support flags""" + + vision: bool + reasoning_effort: bool = False # Whether this model supports reasoning effort + + @staticmethod + def from_dict(obj: Any) -> ModelSupports: + assert isinstance(obj, dict) + vision = obj.get("vision") + if vision is None: + raise ValueError("Missing required field 'vision' in ModelSupports") + reasoning_effort = obj.get("reasoningEffort", False) + return ModelSupports(vision=bool(vision), reasoning_effort=bool(reasoning_effort)) + + def to_dict(self) -> dict: + result: dict = {} + result["vision"] = self.vision + result["reasoningEffort"] = self.reasoning_effort + return result + + +@dataclass +class ModelCapabilities: + """Model capabilities and limits""" + + supports: ModelSupports + limits: ModelLimits + + @staticmethod + def from_dict(obj: Any) -> ModelCapabilities: + assert isinstance(obj, dict) + supports_dict = obj.get("supports") + limits_dict = obj.get("limits") + if supports_dict is None or limits_dict is None: + raise ValueError( + f"Missing required fields in ModelCapabilities: supports={supports_dict}, " + f"limits={limits_dict}" + ) + supports = ModelSupports.from_dict(supports_dict) + limits = ModelLimits.from_dict(limits_dict) + return ModelCapabilities(supports=supports, limits=limits) + + def to_dict(self) -> dict: + result: dict = {} + result["supports"] = self.supports.to_dict() + result["limits"] = self.limits.to_dict() + return result + + +@dataclass +class ModelPolicy: + """Model policy state""" + + state: str # "enabled", "disabled", or "unconfigured" + terms: str + + @staticmethod + def from_dict(obj: Any) -> ModelPolicy: + assert isinstance(obj, dict) + state = obj.get("state") + terms = obj.get("terms") + if state is None or terms is None: + raise ValueError( + f"Missing required fields in ModelPolicy: state={state}, terms={terms}" + ) + return ModelPolicy(state=str(state), terms=str(terms)) + + def to_dict(self) -> dict: + result: dict = {} + result["state"] = self.state + result["terms"] = self.terms + return result + + +@dataclass +class ModelBilling: + """Model billing information""" + + multiplier: float + + @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(multiplier=float(multiplier)) + + def to_dict(self) -> dict: + result: dict = {} + result["multiplier"] = self.multiplier + return result + + +@dataclass +class ModelInfo: + """Information about an available model""" + + id: str # Model identifier (e.g., "claude-sonnet-4.5") + name: str # Display name + capabilities: ModelCapabilities # Model capabilities and limits + policy: ModelPolicy | None = None # Policy state + billing: ModelBilling | None = None # Billing information + # Supported reasoning effort levels (only present if model supports reasoning effort) + supported_reasoning_efforts: list[str] | None = None + # Default reasoning effort level (only present if model supports reasoning effort) + default_reasoning_effort: str | None = None + + @staticmethod + def from_dict(obj: Any) -> ModelInfo: + assert isinstance(obj, dict) + id = obj.get("id") + name = obj.get("name") + capabilities_dict = obj.get("capabilities") + if id is None or name is None or capabilities_dict is None: + raise ValueError( + f"Missing required fields in ModelInfo: id={id}, name={name}, " + f"capabilities={capabilities_dict}" + ) + capabilities = ModelCapabilities.from_dict(capabilities_dict) + policy_dict = obj.get("policy") + policy = ModelPolicy.from_dict(policy_dict) if policy_dict else None + billing_dict = obj.get("billing") + billing = ModelBilling.from_dict(billing_dict) if billing_dict else None + supported_reasoning_efforts = obj.get("supportedReasoningEfforts") + default_reasoning_effort = obj.get("defaultReasoningEffort") + return ModelInfo( + id=str(id), + name=str(name), + capabilities=capabilities, + policy=policy, + billing=billing, + supported_reasoning_efforts=supported_reasoning_efforts, + default_reasoning_effort=default_reasoning_effort, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = self.id + result["name"] = self.name + result["capabilities"] = self.capabilities.to_dict() + if self.policy is not None: + result["policy"] = self.policy.to_dict() + if self.billing is not None: + result["billing"] = self.billing.to_dict() + if self.supported_reasoning_efforts is not None: + result["supportedReasoningEfforts"] = self.supported_reasoning_efforts + if self.default_reasoning_effort is not None: + result["defaultReasoningEffort"] = self.default_reasoning_effort + return result + + +# ============================================================================ +# Session Metadata Types +# ============================================================================ + + +@dataclass +class SessionContext: + """Working directory context for a session""" + + cwd: str # Working directory where the session was created + gitRoot: str | None = None # Git repository root (if in a git repo) + repository: str | None = None # GitHub repository in "owner/repo" format + branch: str | None = None # Current git branch + + @staticmethod + def from_dict(obj: Any) -> SessionContext: + assert isinstance(obj, dict) + cwd = obj.get("cwd") + if cwd is None: + raise ValueError("Missing required field 'cwd' in SessionContext") + return SessionContext( + cwd=str(cwd), + gitRoot=obj.get("gitRoot"), + repository=obj.get("repository"), + branch=obj.get("branch"), + ) + + def to_dict(self) -> dict: + result: dict = {"cwd": self.cwd} + if self.gitRoot is not None: + result["gitRoot"] = self.gitRoot + if self.repository is not None: + result["repository"] = self.repository + if self.branch is not None: + result["branch"] = self.branch + return result + + +@dataclass +class SessionListFilter: + """Filter options for listing sessions""" + + cwd: str | None = None # Filter by exact cwd match + gitRoot: str | None = None # Filter by git root + repository: str | None = None # Filter by repository (owner/repo format) + branch: str | None = None # Filter by branch + + def to_dict(self) -> dict: + result: dict = {} + if self.cwd is not None: + result["cwd"] = self.cwd + if self.gitRoot is not None: + result["gitRoot"] = self.gitRoot + if self.repository is not None: + result["repository"] = self.repository + if self.branch is not None: + result["branch"] = self.branch + return result + + +@dataclass +class SessionMetadata: + """Metadata about a session""" + + sessionId: str # Session identifier + startTime: str # ISO 8601 timestamp when session was created + modifiedTime: str # ISO 8601 timestamp when session was last modified + isRemote: bool # Whether the session is remote + summary: str | None = None # Optional summary of the session + context: SessionContext | None = None # Working directory context + + @staticmethod + def from_dict(obj: Any) -> SessionMetadata: + assert isinstance(obj, dict) + sessionId = obj.get("sessionId") + startTime = obj.get("startTime") + modifiedTime = obj.get("modifiedTime") + isRemote = obj.get("isRemote") + if sessionId is None or startTime is None or modifiedTime is None or isRemote is None: + raise ValueError( + f"Missing required fields in SessionMetadata: sessionId={sessionId}, " + f"startTime={startTime}, modifiedTime={modifiedTime}, isRemote={isRemote}" + ) + summary = obj.get("summary") + context_dict = obj.get("context") + context = SessionContext.from_dict(context_dict) if context_dict else None + return SessionMetadata( + sessionId=str(sessionId), + startTime=str(startTime), + modifiedTime=str(modifiedTime), + isRemote=bool(isRemote), + summary=summary, + context=context, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["sessionId"] = self.sessionId + result["startTime"] = self.startTime + result["modifiedTime"] = self.modifiedTime + result["isRemote"] = self.isRemote + if self.summary is not None: + result["summary"] = self.summary + if self.context is not None: + result["context"] = self.context.to_dict() + return result + + +# ============================================================================ +# Session Lifecycle Types (for TUI+server mode) +# ============================================================================ + +SessionLifecycleEventType = Literal[ + "session.created", + "session.deleted", + "session.updated", + "session.foreground", + "session.background", +] + + +@dataclass +class SessionLifecycleEventMetadata: + """Metadata for session lifecycle events.""" + + startTime: str + modifiedTime: str + summary: str | None = None + + @staticmethod + def from_dict(data: dict) -> SessionLifecycleEventMetadata: + return SessionLifecycleEventMetadata( + startTime=data.get("startTime", ""), + modifiedTime=data.get("modifiedTime", ""), + summary=data.get("summary"), + ) + + +@dataclass +class SessionLifecycleEvent: + """Session lifecycle event notification.""" + + type: SessionLifecycleEventType + sessionId: str + metadata: SessionLifecycleEventMetadata | None = None + + @staticmethod + def from_dict(data: dict) -> SessionLifecycleEvent: + metadata = None + if "metadata" in data and data["metadata"]: + metadata = SessionLifecycleEventMetadata.from_dict(data["metadata"]) + return SessionLifecycleEvent( + type=data.get("type", "session.updated"), + sessionId=data.get("sessionId", ""), + metadata=metadata, + ) + + +SessionLifecycleHandler = Callable[[SessionLifecycleEvent], None] HandlerUnsubcribe = Callable[[], None] @@ -110,10 +686,10 @@ class CopilotClient: >>> await client.start() >>> >>> # Create a session and send a message - >>> session = await client.create_session( - ... PermissionHandler.approve_all, - ... "gpt-4", - ... ) + >>> session = await client.create_session({ + ... "on_permission_request": PermissionHandler.approve_all, + ... "model": "gpt-4", + ... }) >>> session.on(lambda event: print(event.type)) >>> await session.send("Hello!") >>> @@ -152,12 +728,10 @@ def __init__( >>> client = CopilotClient(ExternalServerConfig(url="localhost:3000")) >>> >>> # Custom CLI path with specific log level - >>> client = CopilotClient( - ... SubprocessConfig( - ... cli_path="/usr/local/bin/copilot", - ... log_level="debug", - ... ) - ... ) + >>> client = CopilotClient(SubprocessConfig( + ... cli_path="/usr/local/bin/copilot", + ... log_level="debug", + ... )) """ if config is None: config = SubprocessConfig() @@ -435,32 +1009,7 @@ async def force_stop(self) -> None: if not self._is_external_server: self._actual_port = None - async def create_session( - self, - *, - on_permission_request: _PermissionHandlerFn, - model: str | None = None, - session_id: str | None = None, - client_name: str | None = None, - reasoning_effort: ReasoningEffort | None = None, - tools: list[Tool] | None = None, - system_message: SystemMessageConfig | None = None, - available_tools: list[str] | None = None, - excluded_tools: list[str] | None = None, - on_user_input_request: UserInputHandler | None = None, - hooks: SessionHooks | None = None, - working_directory: str | None = None, - provider: ProviderConfig | None = None, - streaming: bool | None = None, - mcp_servers: dict[str, MCPServerConfig] | None = None, - custom_agents: list[CustomAgentConfig] | None = None, - agent: str | None = None, - config_dir: str | None = None, - skill_directories: list[str] | None = None, - disabled_skills: list[str] | None = None, - infinite_sessions: InfiniteSessionConfig | None = None, - on_event: Callable[[SessionEvent], None] | None = None, - ) -> CopilotSession: + async def create_session(self, config: SessionConfig) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -469,29 +1018,8 @@ async def create_session( automatically start the connection. Args: - on_permission_request: Handler for permission requests from the server. - model: Model to use for this session. - session_id: Custom session ID. - client_name: Client name to identify the application using the SDK. - reasoning_effort: Reasoning effort level ("low", "medium", "high", "xhigh"). - tools: Custom tools exposed to the CLI. - system_message: System message configuration. - available_tools: List of tool names to allow (takes precedence over excluded_tools). - excluded_tools: List of tool names to disable (ignored if available_tools is set). - on_user_input_request: Handler for user input requests (enables ask_user tool). - hooks: Hook handlers for intercepting session lifecycle events. - working_directory: Working directory for the session. - provider: Custom provider configuration (BYOK - Bring Your Own Key). - streaming: Enable streaming of assistant message and reasoning chunks. - mcp_servers: MCP server configurations for the session. - custom_agents: Custom agent configurations for the session. - agent: Name of the custom agent to activate when the session starts. - config_dir: Override the default configuration directory location. - skill_directories: Directories to load skills from. - disabled_skills: List of skill names to disable. - infinite_sessions: Infinite session configuration for persistent workspaces. - on_event: Event handler registered before the session.create RPC, ensuring - early events (e.g. session.start) are not missed. + config: Optional configuration for the session, including model selection, + custom tools, system messages, and more. Returns: A :class:`CopilotSession` instance for the new session. @@ -500,30 +1028,34 @@ async def create_session( RuntimeError: If the client is not connected and auto_start is disabled. Example: - >>> session = await client.create_session( - ... on_permission_request=PermissionHandler.approve_all, - ... ) + >>> # Basic session + >>> config = {"on_permission_request": PermissionHandler.approve_all} + >>> session = await client.create_session(config) >>> >>> # Session with model and streaming - >>> session = await client.create_session( - ... on_permission_request=PermissionHandler.approve_all, - ... model="gpt-4", - ... streaming=True, - ... ) + >>> session = await client.create_session({ + ... "on_permission_request": PermissionHandler.approve_all, + ... "model": "gpt-4", + ... "streaming": True + ... }) """ - if not on_permission_request or not callable(on_permission_request): - raise ValueError( - "A valid on_permission_request handler is required. " - "Use PermissionHandler.approve_all or provide a custom handler." - ) - if not self._client: if self._auto_start: await self.start() else: raise RuntimeError("Client not connected. Call start() first.") + cfg = config + + if not cfg.get("on_permission_request"): + raise ValueError( + "An on_permission_request handler is required when creating a session. " + "For example, to allow all permissions, use " + '{"on_permission_request": PermissionHandler.approve_all}.' + ) + tool_defs = [] + tools = cfg.get("tools") if tools: for tool in tools: definition: dict[str, Any] = { @@ -539,61 +1071,92 @@ async def create_session( tool_defs.append(definition) payload: dict[str, Any] = {} - if model: - payload["model"] = model - if client_name: - payload["clientName"] = client_name - if reasoning_effort: - payload["reasoningEffort"] = reasoning_effort + if cfg.get("model"): + payload["model"] = cfg["model"] + if cfg.get("client_name"): + payload["clientName"] = cfg["client_name"] + if cfg.get("reasoning_effort"): + payload["reasoningEffort"] = cfg["reasoning_effort"] if tool_defs: payload["tools"] = tool_defs + # Add system message configuration if provided + system_message = cfg.get("system_message") if system_message: payload["systemMessage"] = system_message + # Add tool filtering options + available_tools = cfg.get("available_tools") if available_tools is not None: payload["availableTools"] = available_tools + excluded_tools = cfg.get("excluded_tools") if excluded_tools is not None: payload["excludedTools"] = excluded_tools + # Always enable permission request callback (deny by default if no handler provided) + on_permission_request = cfg.get("on_permission_request") payload["requestPermission"] = True + # Enable user input request callback if handler provided + on_user_input_request = cfg.get("on_user_input_request") if on_user_input_request: payload["requestUserInput"] = True + # Enable hooks callback if any hook handler provided + hooks = cfg.get("hooks") if hooks and any(hooks.values()): payload["hooks"] = True + # Add working directory if provided + working_directory = cfg.get("working_directory") if working_directory: payload["workingDirectory"] = working_directory + # Add streaming option if provided + streaming = cfg.get("streaming") if streaming is not None: payload["streaming"] = streaming + # Add provider configuration if provided + provider = cfg.get("provider") if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) + # Add MCP servers configuration if provided + mcp_servers = cfg.get("mcp_servers") if mcp_servers: payload["mcpServers"] = mcp_servers payload["envValueMode"] = "direct" + # Add custom agents configuration if provided + custom_agents = cfg.get("custom_agents") if custom_agents: payload["customAgents"] = [ - self._convert_custom_agent_to_wire_format(ca) for ca in custom_agents + self._convert_custom_agent_to_wire_format(agent) for agent in custom_agents ] + # Add agent selection if provided + agent = cfg.get("agent") if agent: payload["agent"] = agent + # Add config directory override if provided + config_dir = cfg.get("config_dir") if config_dir: payload["configDir"] = config_dir + # Add skill directories configuration if provided + skill_directories = cfg.get("skill_directories") if skill_directories: payload["skillDirectories"] = skill_directories + # Add disabled skills configuration if provided + disabled_skills = cfg.get("disabled_skills") if disabled_skills: payload["disabledSkills"] = disabled_skills + # Add infinite sessions configuration if provided + infinite_sessions = cfg.get("infinite_sessions") if infinite_sessions: wire_config: dict[str, Any] = {} if "enabled" in infinite_sessions: @@ -611,7 +1174,7 @@ async def create_session( if not self._client: raise RuntimeError("Client not connected") - session_id = session_id or str(uuid.uuid4()) + session_id = cfg.get("session_id") or str(uuid.uuid4()) payload["sessionId"] = session_id # Propagate W3C Trace Context to CLI if OpenTelemetry is active @@ -627,6 +1190,7 @@ async def create_session( session._register_user_input_handler(on_user_input_request) if hooks: session._register_hooks(hooks) + on_event = cfg.get("on_event") if on_event: session.on(on_event) with self._sessions_lock: @@ -642,33 +1206,7 @@ async def create_session( return session - async def resume_session( - self, - session_id: str, - *, - on_permission_request: _PermissionHandlerFn, - model: str | None = None, - client_name: str | None = None, - reasoning_effort: ReasoningEffort | None = None, - tools: list[Tool] | None = None, - system_message: SystemMessageConfig | None = None, - available_tools: list[str] | None = None, - excluded_tools: list[str] | None = None, - on_user_input_request: UserInputHandler | None = None, - hooks: SessionHooks | None = None, - working_directory: str | None = None, - provider: ProviderConfig | None = None, - streaming: bool | None = None, - mcp_servers: dict[str, MCPServerConfig] | None = None, - custom_agents: list[CustomAgentConfig] | None = None, - agent: str | None = None, - config_dir: str | None = None, - skill_directories: list[str] | None = None, - disabled_skills: list[str] | None = None, - infinite_sessions: InfiniteSessionConfig | None = None, - disable_resume: bool = False, - on_event: Callable[[SessionEvent], None] | None = None, - ) -> CopilotSession: + async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -678,30 +1216,7 @@ async def resume_session( Args: session_id: The ID of the session to resume. - on_permission_request: Handler for permission requests from the server. - model: Model to use for this session. Can change the model when resuming. - client_name: Client name to identify the application using the SDK. - reasoning_effort: Reasoning effort level ("low", "medium", "high", "xhigh"). - tools: Custom tools exposed to the CLI. - system_message: System message configuration. - available_tools: List of tool names to allow (takes precedence over excluded_tools). - excluded_tools: List of tool names to disable (ignored if available_tools is set). - on_user_input_request: Handler for user input requests (enables ask_user tool). - hooks: Hook handlers for intercepting session lifecycle events. - working_directory: Working directory for the session. - provider: Custom provider configuration (BYOK - Bring Your Own Key). - streaming: Enable streaming of assistant message and reasoning chunks. - mcp_servers: MCP server configurations for the session. - custom_agents: Custom agent configurations for the session. - agent: Name of the custom agent to activate when the session starts. - config_dir: Override the default configuration directory location. - skill_directories: Directories to load skills from. - disabled_skills: List of skill names to disable. - infinite_sessions: Infinite session configuration for persistent workspaces. - disable_resume: When True, skips emitting the session.resume event. - Useful for reconnecting without triggering resume-related side effects. - on_event: Event handler registered before the session.resume RPC, ensuring - early events (e.g. session.start) are not missed. + config: Optional configuration for the resumed session. Returns: A :class:`CopilotSession` instance for the resumed session. @@ -710,32 +1225,33 @@ async def resume_session( RuntimeError: If the session does not exist or the client is not connected. Example: - >>> session = await client.resume_session( - ... "session-123", - ... on_permission_request=PermissionHandler.approve_all, - ... ) + >>> # Resume a previous session + >>> config = {"on_permission_request": PermissionHandler.approve_all} + >>> session = await client.resume_session("session-123", config) >>> - >>> # Resume with model and streaming - >>> session = await client.resume_session( - ... "session-123", - ... on_permission_request=PermissionHandler.approve_all, - ... model="gpt-4", - ... streaming=True, - ... ) + >>> # Resume with new tools + >>> session = await client.resume_session("session-123", { + ... "on_permission_request": PermissionHandler.approve_all, + ... "tools": [my_new_tool] + ... }) """ - if not on_permission_request or not callable(on_permission_request): - raise ValueError( - "A valid on_permission_request handler is required. " - "Use PermissionHandler.approve_all or provide a custom handler." - ) - if not self._client: if self._auto_start: await self.start() else: raise RuntimeError("Client not connected. Call start() first.") + cfg = config + + if not cfg.get("on_permission_request"): + raise ValueError( + "An on_permission_request handler is required when resuming a session. " + "For example, to allow all permissions, use " + '{"on_permission_request": PermissionHandler.approve_all}.' + ) + tool_defs = [] + tools = cfg.get("tools") if tools: for tool in tools: definition: dict[str, Any] = { @@ -751,64 +1267,104 @@ async def resume_session( tool_defs.append(definition) payload: dict[str, Any] = {"sessionId": session_id} - if model: - payload["model"] = model + + # Add client name if provided + client_name = cfg.get("client_name") if client_name: payload["clientName"] = client_name - if reasoning_effort: - payload["reasoningEffort"] = reasoning_effort + + # Add model if provided + model = cfg.get("model") + if model: + payload["model"] = model + + if cfg.get("reasoning_effort"): + payload["reasoningEffort"] = cfg["reasoning_effort"] if tool_defs: payload["tools"] = tool_defs + # Add system message configuration if provided + system_message = cfg.get("system_message") if system_message: payload["systemMessage"] = system_message + # Add available/excluded tools if provided + available_tools = cfg.get("available_tools") if available_tools is not None: payload["availableTools"] = available_tools + + excluded_tools = cfg.get("excluded_tools") if excluded_tools is not None: payload["excludedTools"] = excluded_tools + provider = cfg.get("provider") + if provider: + payload["provider"] = self._convert_provider_to_wire_format(provider) + + # Add streaming option if provided + streaming = cfg.get("streaming") + if streaming is not None: + payload["streaming"] = streaming + + # Always enable permission request callback (deny by default if no handler provided) + on_permission_request = cfg.get("on_permission_request") payload["requestPermission"] = True + # Enable user input request callback if handler provided + on_user_input_request = cfg.get("on_user_input_request") if on_user_input_request: payload["requestUserInput"] = True + # Enable hooks callback if any hook handler provided + hooks = cfg.get("hooks") if hooks and any(hooks.values()): payload["hooks"] = True + # Add working directory if provided + working_directory = cfg.get("working_directory") if working_directory: payload["workingDirectory"] = working_directory - if streaming is not None: - payload["streaming"] = streaming + # Add config directory if provided + config_dir = cfg.get("config_dir") + if config_dir: + payload["configDir"] = config_dir - if provider: - payload["provider"] = self._convert_provider_to_wire_format(provider) + # Add disable resume flag if provided + disable_resume = cfg.get("disable_resume") + if disable_resume: + payload["disableResume"] = True + # Add MCP servers configuration if provided + mcp_servers = cfg.get("mcp_servers") if mcp_servers: payload["mcpServers"] = mcp_servers payload["envValueMode"] = "direct" + # Add custom agents configuration if provided + custom_agents = cfg.get("custom_agents") if custom_agents: payload["customAgents"] = [ - self._convert_custom_agent_to_wire_format(ca) for ca in custom_agents + self._convert_custom_agent_to_wire_format(agent) for agent in custom_agents ] + # Add agent selection if provided + agent = cfg.get("agent") if agent: payload["agent"] = agent - if config_dir: - payload["configDir"] = config_dir - - if disable_resume: - payload["disableResume"] = True - + # Add skill directories configuration if provided + skill_directories = cfg.get("skill_directories") if skill_directories: payload["skillDirectories"] = skill_directories + # Add disabled skills configuration if provided + disabled_skills = cfg.get("disabled_skills") if disabled_skills: payload["disabledSkills"] = disabled_skills + # Add infinite sessions configuration if provided + infinite_sessions = cfg.get("infinite_sessions") if infinite_sessions: wire_config: dict[str, Any] = {} if "enabled" in infinite_sessions: @@ -833,12 +1389,13 @@ async def resume_session( # Create and register the session before issuing the RPC so that # events emitted by the CLI (e.g. session.start) are not dropped. session = CopilotSession(session_id, self._client, None) - session._register_tools(tools) + session._register_tools(cfg.get("tools")) session._register_permission_handler(on_permission_request) if on_user_input_request: session._register_user_input_handler(on_user_input_request) if hooks: session._register_hooks(hooks) + on_event = cfg.get("on_event") if on_event: session.on(on_event) with self._sessions_lock: @@ -868,7 +1425,7 @@ def get_state(self) -> ConnectionState: """ return self._state - async def ping(self, message: str | None = None) -> "PingResponse": + async def ping(self, message: str | None = None) -> PingResponse: """ Send a ping request to the server to verify connectivity. @@ -891,7 +1448,7 @@ async def ping(self, message: str | None = None) -> "PingResponse": result = await self._client.request("ping", {"message": message}) return PingResponse.from_dict(result) - async def get_status(self) -> "GetStatusResponse": + async def get_status(self) -> GetStatusResponse: """ Get CLI status including version and protocol information. @@ -911,7 +1468,7 @@ async def get_status(self) -> "GetStatusResponse": result = await self._client.request("status.get", {}) return GetStatusResponse.from_dict(result) - async def get_auth_status(self) -> "GetAuthStatusResponse": + async def get_auth_status(self) -> GetAuthStatusResponse: """ Get current authentication status. @@ -932,7 +1489,7 @@ async def get_auth_status(self) -> "GetAuthStatusResponse": result = await self._client.request("auth.getStatus", {}) return GetAuthStatusResponse.from_dict(result) - async def list_models(self) -> list["ModelInfo"]: + async def list_models(self) -> list[ModelInfo]: """ List available models with their metadata. @@ -982,9 +1539,7 @@ async def list_models(self) -> list["ModelInfo"]: return list(models) # Return a copy to prevent cache mutation - async def list_sessions( - self, filter: "SessionListFilter | None" = None - ) -> list["SessionMetadata"]: + async def list_sessions(self, filter: SessionListFilter | None = None) -> list[SessionMetadata]: """ List all available sessions known to the server. @@ -1005,7 +1560,7 @@ async def list_sessions( >>> for session in sessions: ... print(f"Session: {session.sessionId}") >>> # Filter sessions by repository - >>> from copilot import SessionListFilter + >>> from copilot.client import SessionListFilter >>> filtered = await client.list_sessions(SessionListFilter(repository="owner/repo")) """ if not self._client: @@ -1068,7 +1623,8 @@ async def get_last_session_id(self) -> str | None: Example: >>> last_id = await client.get_last_session_id() >>> if last_id: - ... session = await client.resume_session(last_id, PermissionHandler.approve_all) + ... config = {"on_permission_request": PermissionHandler.approve_all} + ... session = await client.resume_session(last_id, config) """ if not self._client: raise RuntimeError("Client not connected") diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index da6748d79..14ae307d7 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -74,6 +74,11 @@ def to_enum(c: type[EnumT], x: Any) -> EnumT: return x.value +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + @dataclass class PingResult: message: str @@ -762,7 +767,7 @@ def to_dict(self) -> dict: @dataclass -class AgentElement: +class SessionAgentListResultAgent: description: str """Description of the agent's purpose""" @@ -773,12 +778,12 @@ class AgentElement: """Unique identifier of the custom agent""" @staticmethod - def from_dict(obj: Any) -> 'AgentElement': + def from_dict(obj: Any) -> 'SessionAgentListResultAgent': assert isinstance(obj, dict) description = from_str(obj.get("description")) display_name = from_str(obj.get("displayName")) name = from_str(obj.get("name")) - return AgentElement(description, display_name, name) + return SessionAgentListResultAgent(description, display_name, name) def to_dict(self) -> dict: result: dict = {} @@ -791,18 +796,18 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionAgentListResult: - agents: list[AgentElement] + agents: list[SessionAgentListResultAgent] """Available custom agents""" @staticmethod def from_dict(obj: Any) -> 'SessionAgentListResult': assert isinstance(obj, dict) - agents = from_list(AgentElement.from_dict, obj.get("agents")) + agents = from_list(SessionAgentListResultAgent.from_dict, obj.get("agents")) return SessionAgentListResult(agents) def to_dict(self) -> dict: result: dict = {} - result["agents"] = from_list(lambda x: to_class(AgentElement, x), self.agents) + result["agents"] = from_list(lambda x: to_class(SessionAgentListResultAgent, x), self.agents) return result @@ -929,334 +934,1129 @@ def to_dict(self) -> dict: return result -# Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class SessionCompactionCompactResult: - messages_removed: float - """Number of messages removed during compaction""" +class SessionAgentReloadResultAgent: + description: str + """Description of the agent's purpose""" - success: bool - """Whether compaction completed successfully""" + display_name: str + """Human-readable display name""" - tokens_removed: float - """Number of tokens freed by compaction""" + name: str + """Unique identifier of the custom agent""" @staticmethod - def from_dict(obj: Any) -> 'SessionCompactionCompactResult': + def from_dict(obj: Any) -> 'SessionAgentReloadResultAgent': assert isinstance(obj, dict) - messages_removed = from_float(obj.get("messagesRemoved")) - success = from_bool(obj.get("success")) - tokens_removed = from_float(obj.get("tokensRemoved")) - return SessionCompactionCompactResult(messages_removed, success, tokens_removed) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return SessionAgentReloadResultAgent(description, display_name, name) def to_dict(self) -> dict: result: dict = {} - result["messagesRemoved"] = to_float(self.messages_removed) - result["success"] = from_bool(self.success) - result["tokensRemoved"] = to_float(self.tokens_removed) + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class SessionToolsHandlePendingToolCallResult: - success: bool - """Whether the tool call result was handled successfully""" +class SessionAgentReloadResult: + agents: list[SessionAgentReloadResultAgent] + """Reloaded custom agents""" @staticmethod - def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallResult': + def from_dict(obj: Any) -> 'SessionAgentReloadResult': assert isinstance(obj, dict) - success = from_bool(obj.get("success")) - return SessionToolsHandlePendingToolCallResult(success) + agents = from_list(SessionAgentReloadResultAgent.from_dict, obj.get("agents")) + return SessionAgentReloadResult(agents) def to_dict(self) -> dict: result: dict = {} - result["success"] = from_bool(self.success) + result["agents"] = from_list(lambda x: to_class(SessionAgentReloadResultAgent, x), self.agents) return result @dataclass -class ResultResult: - text_result_for_llm: str - error: str | None = None - result_type: str | None = None - tool_telemetry: dict[str, Any] | None = None +class Skill: + description: str + """Description of what the skill does""" + + enabled: bool + """Whether the skill is currently enabled""" + + name: str + """Unique identifier for the skill""" + + source: str + """Source location type (e.g., project, personal, plugin)""" + + user_invocable: bool + """Whether the skill can be invoked by the user as a slash command""" + + path: str | None = None + """Absolute path to the skill file""" @staticmethod - def from_dict(obj: Any) -> 'ResultResult': + def from_dict(obj: Any) -> 'Skill': assert isinstance(obj, dict) - text_result_for_llm = from_str(obj.get("textResultForLlm")) - error = from_union([from_str, from_none], obj.get("error")) - result_type = from_union([from_str, from_none], obj.get("resultType")) - tool_telemetry = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("toolTelemetry")) - return ResultResult(text_result_for_llm, error, result_type, tool_telemetry) + description = from_str(obj.get("description")) + enabled = from_bool(obj.get("enabled")) + name = from_str(obj.get("name")) + source = from_str(obj.get("source")) + user_invocable = from_bool(obj.get("userInvocable")) + path = from_union([from_str, from_none], obj.get("path")) + return Skill(description, enabled, name, source, user_invocable, path) def to_dict(self) -> dict: result: dict = {} - result["textResultForLlm"] = from_str(self.text_result_for_llm) - if self.error is not None: - result["error"] = from_union([from_str, from_none], self.error) - if self.result_type is not None: - result["resultType"] = from_union([from_str, from_none], self.result_type) - if self.tool_telemetry is not None: - result["toolTelemetry"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.tool_telemetry) + result["description"] = from_str(self.description) + result["enabled"] = from_bool(self.enabled) + result["name"] = from_str(self.name) + result["source"] = from_str(self.source) + result["userInvocable"] = from_bool(self.user_invocable) + if self.path is not None: + result["path"] = from_union([from_str, from_none], self.path) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class SessionToolsHandlePendingToolCallParams: - request_id: str - error: str | None = None - result: ResultResult | str | None = None +class SessionSkillsListResult: + skills: list[Skill] + """Available skills""" @staticmethod - def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallParams': + def from_dict(obj: Any) -> 'SessionSkillsListResult': assert isinstance(obj, dict) - request_id = from_str(obj.get("requestId")) - error = from_union([from_str, from_none], obj.get("error")) - result = from_union([ResultResult.from_dict, from_str, from_none], obj.get("result")) - return SessionToolsHandlePendingToolCallParams(request_id, error, result) + skills = from_list(Skill.from_dict, obj.get("skills")) + return SessionSkillsListResult(skills) def to_dict(self) -> dict: result: dict = {} - result["requestId"] = from_str(self.request_id) - if self.error is not None: - result["error"] = from_union([from_str, from_none], self.error) - if self.result is not None: - result["result"] = from_union([lambda x: to_class(ResultResult, x), from_str, from_none], self.result) + result["skills"] = from_list(lambda x: to_class(Skill, x), self.skills) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class SessionPermissionsHandlePendingPermissionRequestResult: - success: bool - """Whether the permission request was handled successfully""" - +class SessionSkillsEnableResult: @staticmethod - def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestResult': + def from_dict(obj: Any) -> 'SessionSkillsEnableResult': assert isinstance(obj, dict) - success = from_bool(obj.get("success")) - return SessionPermissionsHandlePendingPermissionRequestResult(success) + return SessionSkillsEnableResult() def to_dict(self) -> dict: result: dict = {} - result["success"] = from_bool(self.success) return result -class Kind(Enum): - APPROVED = "approved" - DENIED_BY_CONTENT_EXCLUSION_POLICY = "denied-by-content-exclusion-policy" - DENIED_BY_RULES = "denied-by-rules" - DENIED_INTERACTIVELY_BY_USER = "denied-interactively-by-user" - DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER = "denied-no-approval-rule-and-could-not-request-from-user" +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionSkillsEnableParams: + name: str + """Name of the skill to enable""" + @staticmethod + def from_dict(obj: Any) -> 'SessionSkillsEnableParams': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + return SessionSkillsEnableParams(name) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + return result -@dataclass -class SessionPermissionsHandlePendingPermissionRequestParamsResult: - kind: Kind - rules: list[Any] | None = None - feedback: str | None = None - message: str | None = None - path: str | None = None +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionSkillsDisableResult: @staticmethod - def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParamsResult': + def from_dict(obj: Any) -> 'SessionSkillsDisableResult': assert isinstance(obj, dict) - kind = Kind(obj.get("kind")) - rules = from_union([lambda x: from_list(lambda x: x, x), from_none], obj.get("rules")) - feedback = from_union([from_str, from_none], obj.get("feedback")) - message = from_union([from_str, from_none], obj.get("message")) - path = from_union([from_str, from_none], obj.get("path")) - return SessionPermissionsHandlePendingPermissionRequestParamsResult(kind, rules, feedback, message, path) + return SessionSkillsDisableResult() def to_dict(self) -> dict: result: dict = {} - result["kind"] = to_enum(Kind, self.kind) - if self.rules is not None: - result["rules"] = from_union([lambda x: from_list(lambda x: x, x), from_none], self.rules) - if self.feedback is not None: - result["feedback"] = from_union([from_str, from_none], self.feedback) - if self.message is not None: - result["message"] = from_union([from_str, from_none], self.message) - if self.path is not None: - result["path"] = from_union([from_str, from_none], self.path) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class SessionPermissionsHandlePendingPermissionRequestParams: - request_id: str - result: SessionPermissionsHandlePendingPermissionRequestParamsResult +class SessionSkillsDisableParams: + name: str + """Name of the skill to disable""" @staticmethod - def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParams': + def from_dict(obj: Any) -> 'SessionSkillsDisableParams': assert isinstance(obj, dict) - request_id = from_str(obj.get("requestId")) - result = SessionPermissionsHandlePendingPermissionRequestParamsResult.from_dict(obj.get("result")) - return SessionPermissionsHandlePendingPermissionRequestParams(request_id, result) + name = from_str(obj.get("name")) + return SessionSkillsDisableParams(name) def to_dict(self) -> dict: result: dict = {} - result["requestId"] = from_str(self.request_id) - result["result"] = to_class(SessionPermissionsHandlePendingPermissionRequestParamsResult, self.result) + result["name"] = from_str(self.name) return result +# Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class SessionLogResult: - event_id: UUID - """The unique identifier of the emitted session event""" - +class SessionSkillsReloadResult: @staticmethod - def from_dict(obj: Any) -> 'SessionLogResult': + def from_dict(obj: Any) -> 'SessionSkillsReloadResult': assert isinstance(obj, dict) - event_id = UUID(obj.get("eventId")) - return SessionLogResult(event_id) + return SessionSkillsReloadResult() def to_dict(self) -> dict: result: dict = {} - result["eventId"] = str(self.event_id) return result -class Level(Enum): - """Log severity level. Determines how the message is displayed in the timeline. Defaults to - "info". - """ - ERROR = "error" - INFO = "info" - WARNING = "warning" +class ServerStatus(Enum): + """Connection status: connected, failed, pending, disabled, or not_configured""" + + CONNECTED = "connected" + DISABLED = "disabled" + FAILED = "failed" + NOT_CONFIGURED = "not_configured" + PENDING = "pending" @dataclass -class SessionLogParams: - message: str - """Human-readable message""" +class Server: + name: str + """Server name (config key)""" - ephemeral: bool | None = None - """When true, the message is transient and not persisted to the session event log on disk""" + status: ServerStatus + """Connection status: connected, failed, pending, disabled, or not_configured""" - level: Level | None = None - """Log severity level. Determines how the message is displayed in the timeline. Defaults to - "info". - """ + error: str | None = None + """Error message if the server failed to connect""" + + source: str | None = None + """Configuration source: user, workspace, plugin, or builtin""" @staticmethod - def from_dict(obj: Any) -> 'SessionLogParams': + def from_dict(obj: Any) -> 'Server': assert isinstance(obj, dict) - message = from_str(obj.get("message")) - ephemeral = from_union([from_bool, from_none], obj.get("ephemeral")) - level = from_union([Level, from_none], obj.get("level")) - return SessionLogParams(message, ephemeral, level) + name = from_str(obj.get("name")) + status = ServerStatus(obj.get("status")) + error = from_union([from_str, from_none], obj.get("error")) + source = from_union([from_str, from_none], obj.get("source")) + return Server(name, status, error, source) def to_dict(self) -> dict: result: dict = {} - result["message"] = from_str(self.message) - if self.ephemeral is not None: - result["ephemeral"] = from_union([from_bool, from_none], self.ephemeral) - if self.level is not None: - result["level"] = from_union([lambda x: to_enum(Level, x), from_none], self.level) + result["name"] = from_str(self.name) + result["status"] = to_enum(ServerStatus, self.status) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.source is not None: + result["source"] = from_union([from_str, from_none], self.source) return result @dataclass -class SessionShellExecResult: - process_id: str - """Unique identifier for tracking streamed output""" +class SessionMCPListResult: + servers: list[Server] + """Configured MCP servers""" @staticmethod - def from_dict(obj: Any) -> 'SessionShellExecResult': + def from_dict(obj: Any) -> 'SessionMCPListResult': assert isinstance(obj, dict) - process_id = from_str(obj.get("processId")) - return SessionShellExecResult(process_id) + servers = from_list(Server.from_dict, obj.get("servers")) + return SessionMCPListResult(servers) def to_dict(self) -> dict: result: dict = {} - result["processId"] = from_str(self.process_id) + result["servers"] = from_list(lambda x: to_class(Server, x), self.servers) return result @dataclass -class SessionShellExecParams: - command: str - """Shell command to execute""" - - cwd: str | None = None - """Working directory (defaults to session working directory)""" - - timeout: float | None = None - """Timeout in milliseconds (default: 30000)""" - +class SessionMCPEnableResult: @staticmethod - def from_dict(obj: Any) -> 'SessionShellExecParams': + def from_dict(obj: Any) -> 'SessionMCPEnableResult': assert isinstance(obj, dict) - command = from_str(obj.get("command")) - cwd = from_union([from_str, from_none], obj.get("cwd")) - timeout = from_union([from_float, from_none], obj.get("timeout")) - return SessionShellExecParams(command, cwd, timeout) + return SessionMCPEnableResult() def to_dict(self) -> dict: result: dict = {} - result["command"] = from_str(self.command) - if self.cwd is not None: - result["cwd"] = from_union([from_str, from_none], self.cwd) - if self.timeout is not None: - result["timeout"] = from_union([to_float, from_none], self.timeout) return result @dataclass -class SessionShellKillResult: - killed: bool - """Whether the signal was sent successfully""" +class SessionMCPEnableParams: + server_name: str + """Name of the MCP server to enable""" @staticmethod - def from_dict(obj: Any) -> 'SessionShellKillResult': + def from_dict(obj: Any) -> 'SessionMCPEnableParams': assert isinstance(obj, dict) - killed = from_bool(obj.get("killed")) - return SessionShellKillResult(killed) + server_name = from_str(obj.get("serverName")) + return SessionMCPEnableParams(server_name) def to_dict(self) -> dict: result: dict = {} - result["killed"] = from_bool(self.killed) + result["serverName"] = from_str(self.server_name) return result -class Signal(Enum): - """Signal to send (default: SIGTERM)""" +@dataclass +class SessionMCPDisableResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionMCPDisableResult': + assert isinstance(obj, dict) + return SessionMCPDisableResult() - SIGINT = "SIGINT" - SIGKILL = "SIGKILL" - SIGTERM = "SIGTERM" + def to_dict(self) -> dict: + result: dict = {} + return result @dataclass -class SessionShellKillParams: - process_id: str - """Process identifier returned by shell.exec""" - - signal: Signal | None = None - """Signal to send (default: SIGTERM)""" +class SessionMCPDisableParams: + server_name: str + """Name of the MCP server to disable""" @staticmethod - def from_dict(obj: Any) -> 'SessionShellKillParams': + def from_dict(obj: Any) -> 'SessionMCPDisableParams': assert isinstance(obj, dict) - process_id = from_str(obj.get("processId")) - signal = from_union([Signal, from_none], obj.get("signal")) - return SessionShellKillParams(process_id, signal) + server_name = from_str(obj.get("serverName")) + return SessionMCPDisableParams(server_name) def to_dict(self) -> dict: result: dict = {} - result["processId"] = from_str(self.process_id) - if self.signal is not None: - result["signal"] = from_union([lambda x: to_enum(Signal, x), from_none], self.signal) + result["serverName"] = from_str(self.server_name) return result -def ping_result_from_dict(s: Any) -> PingResult: - return PingResult.from_dict(s) - - +@dataclass +class SessionMCPReloadResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionMCPReloadResult': + assert isinstance(obj, dict) + return SessionMCPReloadResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +@dataclass +class Plugin: + enabled: bool + """Whether the plugin is currently enabled""" + + marketplace: str + """Marketplace the plugin came from""" + + name: str + """Plugin name""" + + version: str | None = None + """Installed version""" + + @staticmethod + def from_dict(obj: Any) -> 'Plugin': + assert isinstance(obj, dict) + enabled = from_bool(obj.get("enabled")) + marketplace = from_str(obj.get("marketplace")) + name = from_str(obj.get("name")) + version = from_union([from_str, from_none], obj.get("version")) + return Plugin(enabled, marketplace, name, version) + + def to_dict(self) -> dict: + result: dict = {} + result["enabled"] = from_bool(self.enabled) + result["marketplace"] = from_str(self.marketplace) + result["name"] = from_str(self.name) + if self.version is not None: + 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 SessionPluginsListResult: + plugins: list[Plugin] + """Installed plugins""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionPluginsListResult': + assert isinstance(obj, dict) + plugins = from_list(Plugin.from_dict, obj.get("plugins")) + return SessionPluginsListResult(plugins) + + def to_dict(self) -> dict: + result: dict = {} + result["plugins"] = from_list(lambda x: to_class(Plugin, x), self.plugins) + return result + + +class Source(Enum): + """Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/)""" + + PROJECT = "project" + USER = "user" + + +class ExtensionStatus(Enum): + """Current status: running, disabled, failed, or starting""" + + DISABLED = "disabled" + FAILED = "failed" + RUNNING = "running" + STARTING = "starting" + + +@dataclass +class Extension: + id: str + """Source-qualified ID (e.g., 'project:my-ext', 'user:auth-helper')""" + + name: str + """Extension name (directory name)""" + + source: Source + """Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/)""" + + status: ExtensionStatus + """Current status: running, disabled, failed, or starting""" + + pid: int | None = None + """Process ID if the extension is running""" + + @staticmethod + def from_dict(obj: Any) -> 'Extension': + assert isinstance(obj, dict) + id = from_str(obj.get("id")) + name = from_str(obj.get("name")) + source = Source(obj.get("source")) + status = ExtensionStatus(obj.get("status")) + pid = from_union([from_int, from_none], obj.get("pid")) + return Extension(id, name, source, status, pid) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = from_str(self.id) + result["name"] = from_str(self.name) + result["source"] = to_enum(Source, self.source) + result["status"] = to_enum(ExtensionStatus, self.status) + if self.pid is not None: + result["pid"] = from_union([from_int, from_none], self.pid) + return result + + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionExtensionsListResult: + extensions: list[Extension] + """Discovered extensions and their current status""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionExtensionsListResult': + assert isinstance(obj, dict) + extensions = from_list(Extension.from_dict, obj.get("extensions")) + return SessionExtensionsListResult(extensions) + + def to_dict(self) -> dict: + result: dict = {} + result["extensions"] = from_list(lambda x: to_class(Extension, x), self.extensions) + return result + + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionExtensionsEnableResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionExtensionsEnableResult': + assert isinstance(obj, dict) + return SessionExtensionsEnableResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionExtensionsEnableParams: + id: str + """Source-qualified extension ID to enable""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionExtensionsEnableParams': + assert isinstance(obj, dict) + id = from_str(obj.get("id")) + return SessionExtensionsEnableParams(id) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = from_str(self.id) + return result + + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionExtensionsDisableResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionExtensionsDisableResult': + assert isinstance(obj, dict) + return SessionExtensionsDisableResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionExtensionsDisableParams: + id: str + """Source-qualified extension ID to disable""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionExtensionsDisableParams': + assert isinstance(obj, dict) + id = from_str(obj.get("id")) + return SessionExtensionsDisableParams(id) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = from_str(self.id) + return result + + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionExtensionsReloadResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionExtensionsReloadResult': + assert isinstance(obj, dict) + return SessionExtensionsReloadResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionCompactionCompactResult: + messages_removed: float + """Number of messages removed during compaction""" + + success: bool + """Whether compaction completed successfully""" + + tokens_removed: float + """Number of tokens freed by compaction""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionCompactionCompactResult': + assert isinstance(obj, dict) + messages_removed = from_float(obj.get("messagesRemoved")) + success = from_bool(obj.get("success")) + tokens_removed = from_float(obj.get("tokensRemoved")) + return SessionCompactionCompactResult(messages_removed, success, tokens_removed) + + def to_dict(self) -> dict: + result: dict = {} + result["messagesRemoved"] = to_float(self.messages_removed) + result["success"] = from_bool(self.success) + result["tokensRemoved"] = to_float(self.tokens_removed) + return result + + +@dataclass +class SessionToolsHandlePendingToolCallResult: + success: bool + """Whether the tool call result was handled successfully""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionToolsHandlePendingToolCallResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +@dataclass +class ResultResult: + text_result_for_llm: str + error: str | None = None + result_type: str | None = None + tool_telemetry: dict[str, Any] | None = None + + @staticmethod + def from_dict(obj: Any) -> 'ResultResult': + assert isinstance(obj, dict) + text_result_for_llm = from_str(obj.get("textResultForLlm")) + error = from_union([from_str, from_none], obj.get("error")) + result_type = from_union([from_str, from_none], obj.get("resultType")) + tool_telemetry = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("toolTelemetry")) + return ResultResult(text_result_for_llm, error, result_type, tool_telemetry) + + def to_dict(self) -> dict: + result: dict = {} + result["textResultForLlm"] = from_str(self.text_result_for_llm) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.result_type is not None: + result["resultType"] = from_union([from_str, from_none], self.result_type) + if self.tool_telemetry is not None: + result["toolTelemetry"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.tool_telemetry) + return result + + +@dataclass +class SessionToolsHandlePendingToolCallParams: + request_id: str + error: str | None = None + result: ResultResult | str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallParams': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + error = from_union([from_str, from_none], obj.get("error")) + result = from_union([ResultResult.from_dict, from_str, from_none], obj.get("result")) + return SessionToolsHandlePendingToolCallParams(request_id, error, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.result is not None: + result["result"] = from_union([lambda x: to_class(ResultResult, x), from_str, from_none], self.result) + return result + + +@dataclass +class SessionCommandsHandlePendingCommandResult: + success: bool + + @staticmethod + def from_dict(obj: Any) -> 'SessionCommandsHandlePendingCommandResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionCommandsHandlePendingCommandResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +@dataclass +class SessionCommandsHandlePendingCommandParams: + request_id: str + """Request ID from the command invocation event""" + + error: str | None = None + """Error message if the command handler failed""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionCommandsHandlePendingCommandParams': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + error = from_union([from_str, from_none], obj.get("error")) + return SessionCommandsHandlePendingCommandParams(request_id, error) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + return result + + +class Action(Enum): + """The user's response: accept (submitted), decline (rejected), or cancel (dismissed)""" + + ACCEPT = "accept" + CANCEL = "cancel" + DECLINE = "decline" + + +@dataclass +class SessionUIElicitationResult: + action: Action + """The user's response: accept (submitted), decline (rejected), or cancel (dismissed)""" + + content: dict[str, float | bool | list[str] | str] | None = None + """The form values submitted by the user (present when action is 'accept')""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionUIElicitationResult': + assert isinstance(obj, dict) + action = Action(obj.get("action")) + content = from_union([lambda x: from_dict(lambda x: from_union([from_float, from_bool, lambda x: from_list(from_str, x), from_str], x), x), from_none], obj.get("content")) + return SessionUIElicitationResult(action, content) + + def to_dict(self) -> dict: + result: dict = {} + result["action"] = to_enum(Action, self.action) + if self.content is not None: + result["content"] = from_union([lambda x: from_dict(lambda x: from_union([to_float, from_bool, lambda x: from_list(from_str, x), from_str], x), x), from_none], self.content) + return result + + +class Format(Enum): + DATE = "date" + DATE_TIME = "date-time" + EMAIL = "email" + URI = "uri" + + +@dataclass +class AnyOf: + const: str + title: str + + @staticmethod + def from_dict(obj: Any) -> 'AnyOf': + assert isinstance(obj, dict) + const = from_str(obj.get("const")) + title = from_str(obj.get("title")) + return AnyOf(const, title) + + def to_dict(self) -> dict: + result: dict = {} + result["const"] = from_str(self.const) + result["title"] = from_str(self.title) + return result + + +class ItemsType(Enum): + STRING = "string" + + +@dataclass +class Items: + enum: list[str] | None = None + type: ItemsType | None = None + any_of: list[AnyOf] | None = None + + @staticmethod + def from_dict(obj: Any) -> 'Items': + assert isinstance(obj, dict) + enum = from_union([lambda x: from_list(from_str, x), from_none], obj.get("enum")) + type = from_union([ItemsType, from_none], obj.get("type")) + any_of = from_union([lambda x: from_list(AnyOf.from_dict, x), from_none], obj.get("anyOf")) + return Items(enum, type, any_of) + + def to_dict(self) -> dict: + result: dict = {} + if self.enum is not None: + result["enum"] = from_union([lambda x: from_list(from_str, x), from_none], self.enum) + if self.type is not None: + result["type"] = from_union([lambda x: to_enum(ItemsType, x), from_none], self.type) + if self.any_of is not None: + result["anyOf"] = from_union([lambda x: from_list(lambda x: to_class(AnyOf, x), x), from_none], self.any_of) + return result + + +@dataclass +class OneOf: + const: str + title: str + + @staticmethod + def from_dict(obj: Any) -> 'OneOf': + assert isinstance(obj, dict) + const = from_str(obj.get("const")) + title = from_str(obj.get("title")) + return OneOf(const, title) + + def to_dict(self) -> dict: + result: dict = {} + result["const"] = from_str(self.const) + result["title"] = from_str(self.title) + return result + + +class PropertyType(Enum): + ARRAY = "array" + BOOLEAN = "boolean" + INTEGER = "integer" + NUMBER = "number" + STRING = "string" + + +@dataclass +class Property: + type: PropertyType + default: float | bool | list[str] | str | None = None + description: str | None = None + enum: list[str] | None = None + enum_names: list[str] | None = None + title: str | None = None + one_of: list[OneOf] | None = None + items: Items | None = None + max_items: float | None = None + min_items: float | None = None + format: Format | None = None + max_length: float | None = None + min_length: float | None = None + maximum: float | None = None + minimum: float | None = None + + @staticmethod + def from_dict(obj: Any) -> 'Property': + assert isinstance(obj, dict) + type = PropertyType(obj.get("type")) + default = from_union([from_float, from_bool, lambda x: from_list(from_str, x), from_str, from_none], obj.get("default")) + description = from_union([from_str, from_none], obj.get("description")) + enum = from_union([lambda x: from_list(from_str, x), from_none], obj.get("enum")) + enum_names = from_union([lambda x: from_list(from_str, x), from_none], obj.get("enumNames")) + title = from_union([from_str, from_none], obj.get("title")) + one_of = from_union([lambda x: from_list(OneOf.from_dict, x), from_none], obj.get("oneOf")) + items = from_union([Items.from_dict, from_none], obj.get("items")) + max_items = from_union([from_float, from_none], obj.get("maxItems")) + min_items = from_union([from_float, from_none], obj.get("minItems")) + format = from_union([Format, from_none], obj.get("format")) + max_length = from_union([from_float, from_none], obj.get("maxLength")) + min_length = from_union([from_float, from_none], obj.get("minLength")) + maximum = from_union([from_float, from_none], obj.get("maximum")) + minimum = from_union([from_float, from_none], obj.get("minimum")) + return Property(type, default, description, enum, enum_names, title, one_of, items, max_items, min_items, format, max_length, min_length, maximum, minimum) + + def to_dict(self) -> dict: + result: dict = {} + result["type"] = to_enum(PropertyType, self.type) + if self.default is not None: + result["default"] = from_union([to_float, from_bool, lambda x: from_list(from_str, x), from_str, from_none], self.default) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.enum is not None: + result["enum"] = from_union([lambda x: from_list(from_str, x), from_none], self.enum) + if self.enum_names is not None: + result["enumNames"] = from_union([lambda x: from_list(from_str, x), from_none], self.enum_names) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) + if self.one_of is not None: + result["oneOf"] = from_union([lambda x: from_list(lambda x: to_class(OneOf, x), x), from_none], self.one_of) + if self.items is not None: + result["items"] = from_union([lambda x: to_class(Items, x), from_none], self.items) + if self.max_items is not None: + result["maxItems"] = from_union([to_float, from_none], self.max_items) + if self.min_items is not None: + result["minItems"] = from_union([to_float, from_none], self.min_items) + if self.format is not None: + result["format"] = from_union([lambda x: to_enum(Format, x), from_none], self.format) + if self.max_length is not None: + result["maxLength"] = from_union([to_float, from_none], self.max_length) + if self.min_length is not None: + result["minLength"] = from_union([to_float, from_none], self.min_length) + if self.maximum is not None: + result["maximum"] = from_union([to_float, from_none], self.maximum) + if self.minimum is not None: + result["minimum"] = from_union([to_float, from_none], self.minimum) + return result + + +class RequestedSchemaType(Enum): + OBJECT = "object" + + +@dataclass +class RequestedSchema: + """JSON Schema describing the form fields to present to the user""" + + properties: dict[str, Property] + """Form field definitions, keyed by field name""" + + type: RequestedSchemaType + """Schema type indicator (always 'object')""" + + required: list[str] | None = None + """List of required field names""" + + @staticmethod + def from_dict(obj: Any) -> 'RequestedSchema': + assert isinstance(obj, dict) + properties = from_dict(Property.from_dict, obj.get("properties")) + type = RequestedSchemaType(obj.get("type")) + required = from_union([lambda x: from_list(from_str, x), from_none], obj.get("required")) + return RequestedSchema(properties, type, required) + + def to_dict(self) -> dict: + result: dict = {} + result["properties"] = from_dict(lambda x: to_class(Property, x), self.properties) + result["type"] = to_enum(RequestedSchemaType, self.type) + if self.required is not None: + result["required"] = from_union([lambda x: from_list(from_str, x), from_none], self.required) + return result + + +@dataclass +class SessionUIElicitationParams: + message: str + """Message describing what information is needed from the user""" + + requested_schema: RequestedSchema + """JSON Schema describing the form fields to present to the user""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionUIElicitationParams': + assert isinstance(obj, dict) + message = from_str(obj.get("message")) + requested_schema = RequestedSchema.from_dict(obj.get("requestedSchema")) + return SessionUIElicitationParams(message, requested_schema) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = from_str(self.message) + result["requestedSchema"] = to_class(RequestedSchema, self.requested_schema) + return result + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestResult: + success: bool + """Whether the permission request was handled successfully""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionPermissionsHandlePendingPermissionRequestResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +class Kind(Enum): + APPROVED = "approved" + DENIED_BY_CONTENT_EXCLUSION_POLICY = "denied-by-content-exclusion-policy" + DENIED_BY_RULES = "denied-by-rules" + DENIED_INTERACTIVELY_BY_USER = "denied-interactively-by-user" + DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER = "denied-no-approval-rule-and-could-not-request-from-user" + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestParamsResult: + kind: Kind + rules: list[Any] | None = None + feedback: str | None = None + message: str | None = None + path: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParamsResult': + assert isinstance(obj, dict) + kind = Kind(obj.get("kind")) + rules = from_union([lambda x: from_list(lambda x: x, x), from_none], obj.get("rules")) + feedback = from_union([from_str, from_none], obj.get("feedback")) + message = from_union([from_str, from_none], obj.get("message")) + path = from_union([from_str, from_none], obj.get("path")) + return SessionPermissionsHandlePendingPermissionRequestParamsResult(kind, rules, feedback, message, path) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = to_enum(Kind, self.kind) + if self.rules is not None: + result["rules"] = from_union([lambda x: from_list(lambda x: x, x), from_none], self.rules) + if self.feedback is not None: + result["feedback"] = from_union([from_str, from_none], self.feedback) + if self.message is not None: + result["message"] = from_union([from_str, from_none], self.message) + if self.path is not None: + result["path"] = from_union([from_str, from_none], self.path) + return result + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestParams: + request_id: str + result: SessionPermissionsHandlePendingPermissionRequestParamsResult + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParams': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + result = SessionPermissionsHandlePendingPermissionRequestParamsResult.from_dict(obj.get("result")) + return SessionPermissionsHandlePendingPermissionRequestParams(request_id, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + result["result"] = to_class(SessionPermissionsHandlePendingPermissionRequestParamsResult, self.result) + return result + + +@dataclass +class SessionLogResult: + event_id: UUID + """The unique identifier of the emitted session event""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionLogResult': + assert isinstance(obj, dict) + event_id = UUID(obj.get("eventId")) + return SessionLogResult(event_id) + + def to_dict(self) -> dict: + result: dict = {} + result["eventId"] = str(self.event_id) + return result + + +class Level(Enum): + """Log severity level. Determines how the message is displayed in the timeline. Defaults to + "info". + """ + ERROR = "error" + INFO = "info" + WARNING = "warning" + + +@dataclass +class SessionLogParams: + message: str + """Human-readable message""" + + ephemeral: bool | None = None + """When true, the message is transient and not persisted to the session event log on disk""" + + level: Level | None = None + """Log severity level. Determines how the message is displayed in the timeline. Defaults to + "info". + """ + url: str | None = None + """Optional URL the user can open in their browser for more details""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionLogParams': + assert isinstance(obj, dict) + message = from_str(obj.get("message")) + ephemeral = from_union([from_bool, from_none], obj.get("ephemeral")) + level = from_union([Level, from_none], obj.get("level")) + url = from_union([from_str, from_none], obj.get("url")) + return SessionLogParams(message, ephemeral, level, url) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = from_str(self.message) + if self.ephemeral is not None: + result["ephemeral"] = from_union([from_bool, from_none], self.ephemeral) + if self.level is not None: + result["level"] = from_union([lambda x: to_enum(Level, x), from_none], self.level) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class SessionShellExecResult: + process_id: str + """Unique identifier for tracking streamed output""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionShellExecResult': + assert isinstance(obj, dict) + process_id = from_str(obj.get("processId")) + return SessionShellExecResult(process_id) + + def to_dict(self) -> dict: + result: dict = {} + result["processId"] = from_str(self.process_id) + return result + + +@dataclass +class SessionShellExecParams: + command: str + """Shell command to execute""" + + cwd: str | None = None + """Working directory (defaults to session working directory)""" + + timeout: float | None = None + """Timeout in milliseconds (default: 30000)""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionShellExecParams': + assert isinstance(obj, dict) + command = from_str(obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + timeout = from_union([from_float, from_none], obj.get("timeout")) + return SessionShellExecParams(command, cwd, timeout) + + def to_dict(self) -> dict: + result: dict = {} + result["command"] = from_str(self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.timeout is not None: + result["timeout"] = from_union([to_float, from_none], self.timeout) + return result + + +@dataclass +class SessionShellKillResult: + killed: bool + """Whether the signal was sent successfully""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionShellKillResult': + assert isinstance(obj, dict) + killed = from_bool(obj.get("killed")) + return SessionShellKillResult(killed) + + def to_dict(self) -> dict: + result: dict = {} + result["killed"] = from_bool(self.killed) + return result + + +class Signal(Enum): + """Signal to send (default: SIGTERM)""" + + SIGINT = "SIGINT" + SIGKILL = "SIGKILL" + SIGTERM = "SIGTERM" + + +@dataclass +class SessionShellKillParams: + process_id: str + """Process identifier returned by shell.exec""" + + signal: Signal | None = None + """Signal to send (default: SIGTERM)""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionShellKillParams': + assert isinstance(obj, dict) + process_id = from_str(obj.get("processId")) + signal = from_union([Signal, from_none], obj.get("signal")) + return SessionShellKillParams(process_id, signal) + + def to_dict(self) -> dict: + result: dict = {} + result["processId"] = from_str(self.process_id) + if self.signal is not None: + result["signal"] = from_union([lambda x: to_enum(Signal, x), from_none], self.signal) + return result + + +def ping_result_from_dict(s: Any) -> PingResult: + return PingResult.from_dict(s) + + def ping_result_to_dict(x: PingResult) -> Any: return to_class(PingResult, x) @@ -1477,6 +2277,166 @@ def session_agent_deselect_result_to_dict(x: SessionAgentDeselectResult) -> Any: return to_class(SessionAgentDeselectResult, x) +def session_agent_reload_result_from_dict(s: Any) -> SessionAgentReloadResult: + return SessionAgentReloadResult.from_dict(s) + + +def session_agent_reload_result_to_dict(x: SessionAgentReloadResult) -> Any: + return to_class(SessionAgentReloadResult, x) + + +def session_skills_list_result_from_dict(s: Any) -> SessionSkillsListResult: + return SessionSkillsListResult.from_dict(s) + + +def session_skills_list_result_to_dict(x: SessionSkillsListResult) -> Any: + return to_class(SessionSkillsListResult, x) + + +def session_skills_enable_result_from_dict(s: Any) -> SessionSkillsEnableResult: + return SessionSkillsEnableResult.from_dict(s) + + +def session_skills_enable_result_to_dict(x: SessionSkillsEnableResult) -> Any: + return to_class(SessionSkillsEnableResult, x) + + +def session_skills_enable_params_from_dict(s: Any) -> SessionSkillsEnableParams: + return SessionSkillsEnableParams.from_dict(s) + + +def session_skills_enable_params_to_dict(x: SessionSkillsEnableParams) -> Any: + return to_class(SessionSkillsEnableParams, x) + + +def session_skills_disable_result_from_dict(s: Any) -> SessionSkillsDisableResult: + return SessionSkillsDisableResult.from_dict(s) + + +def session_skills_disable_result_to_dict(x: SessionSkillsDisableResult) -> Any: + return to_class(SessionSkillsDisableResult, x) + + +def session_skills_disable_params_from_dict(s: Any) -> SessionSkillsDisableParams: + return SessionSkillsDisableParams.from_dict(s) + + +def session_skills_disable_params_to_dict(x: SessionSkillsDisableParams) -> Any: + return to_class(SessionSkillsDisableParams, x) + + +def session_skills_reload_result_from_dict(s: Any) -> SessionSkillsReloadResult: + return SessionSkillsReloadResult.from_dict(s) + + +def session_skills_reload_result_to_dict(x: SessionSkillsReloadResult) -> Any: + return to_class(SessionSkillsReloadResult, x) + + +def session_mcp_list_result_from_dict(s: Any) -> SessionMCPListResult: + return SessionMCPListResult.from_dict(s) + + +def session_mcp_list_result_to_dict(x: SessionMCPListResult) -> Any: + return to_class(SessionMCPListResult, x) + + +def session_mcp_enable_result_from_dict(s: Any) -> SessionMCPEnableResult: + return SessionMCPEnableResult.from_dict(s) + + +def session_mcp_enable_result_to_dict(x: SessionMCPEnableResult) -> Any: + return to_class(SessionMCPEnableResult, x) + + +def session_mcp_enable_params_from_dict(s: Any) -> SessionMCPEnableParams: + return SessionMCPEnableParams.from_dict(s) + + +def session_mcp_enable_params_to_dict(x: SessionMCPEnableParams) -> Any: + return to_class(SessionMCPEnableParams, x) + + +def session_mcp_disable_result_from_dict(s: Any) -> SessionMCPDisableResult: + return SessionMCPDisableResult.from_dict(s) + + +def session_mcp_disable_result_to_dict(x: SessionMCPDisableResult) -> Any: + return to_class(SessionMCPDisableResult, x) + + +def session_mcp_disable_params_from_dict(s: Any) -> SessionMCPDisableParams: + return SessionMCPDisableParams.from_dict(s) + + +def session_mcp_disable_params_to_dict(x: SessionMCPDisableParams) -> Any: + return to_class(SessionMCPDisableParams, x) + + +def session_mcp_reload_result_from_dict(s: Any) -> SessionMCPReloadResult: + return SessionMCPReloadResult.from_dict(s) + + +def session_mcp_reload_result_to_dict(x: SessionMCPReloadResult) -> Any: + return to_class(SessionMCPReloadResult, x) + + +def session_plugins_list_result_from_dict(s: Any) -> SessionPluginsListResult: + return SessionPluginsListResult.from_dict(s) + + +def session_plugins_list_result_to_dict(x: SessionPluginsListResult) -> Any: + return to_class(SessionPluginsListResult, x) + + +def session_extensions_list_result_from_dict(s: Any) -> SessionExtensionsListResult: + return SessionExtensionsListResult.from_dict(s) + + +def session_extensions_list_result_to_dict(x: SessionExtensionsListResult) -> Any: + return to_class(SessionExtensionsListResult, x) + + +def session_extensions_enable_result_from_dict(s: Any) -> SessionExtensionsEnableResult: + return SessionExtensionsEnableResult.from_dict(s) + + +def session_extensions_enable_result_to_dict(x: SessionExtensionsEnableResult) -> Any: + return to_class(SessionExtensionsEnableResult, x) + + +def session_extensions_enable_params_from_dict(s: Any) -> SessionExtensionsEnableParams: + return SessionExtensionsEnableParams.from_dict(s) + + +def session_extensions_enable_params_to_dict(x: SessionExtensionsEnableParams) -> Any: + return to_class(SessionExtensionsEnableParams, x) + + +def session_extensions_disable_result_from_dict(s: Any) -> SessionExtensionsDisableResult: + return SessionExtensionsDisableResult.from_dict(s) + + +def session_extensions_disable_result_to_dict(x: SessionExtensionsDisableResult) -> Any: + return to_class(SessionExtensionsDisableResult, x) + + +def session_extensions_disable_params_from_dict(s: Any) -> SessionExtensionsDisableParams: + return SessionExtensionsDisableParams.from_dict(s) + + +def session_extensions_disable_params_to_dict(x: SessionExtensionsDisableParams) -> Any: + return to_class(SessionExtensionsDisableParams, x) + + +def session_extensions_reload_result_from_dict(s: Any) -> SessionExtensionsReloadResult: + return SessionExtensionsReloadResult.from_dict(s) + + +def session_extensions_reload_result_to_dict(x: SessionExtensionsReloadResult) -> Any: + return to_class(SessionExtensionsReloadResult, x) + + def session_compaction_compact_result_from_dict(s: Any) -> SessionCompactionCompactResult: return SessionCompactionCompactResult.from_dict(s) @@ -1501,6 +2461,38 @@ def session_tools_handle_pending_tool_call_params_to_dict(x: SessionToolsHandleP return to_class(SessionToolsHandlePendingToolCallParams, x) +def session_commands_handle_pending_command_result_from_dict(s: Any) -> SessionCommandsHandlePendingCommandResult: + return SessionCommandsHandlePendingCommandResult.from_dict(s) + + +def session_commands_handle_pending_command_result_to_dict(x: SessionCommandsHandlePendingCommandResult) -> Any: + return to_class(SessionCommandsHandlePendingCommandResult, x) + + +def session_commands_handle_pending_command_params_from_dict(s: Any) -> SessionCommandsHandlePendingCommandParams: + return SessionCommandsHandlePendingCommandParams.from_dict(s) + + +def session_commands_handle_pending_command_params_to_dict(x: SessionCommandsHandlePendingCommandParams) -> Any: + return to_class(SessionCommandsHandlePendingCommandParams, x) + + +def session_ui_elicitation_result_from_dict(s: Any) -> SessionUIElicitationResult: + return SessionUIElicitationResult.from_dict(s) + + +def session_ui_elicitation_result_to_dict(x: SessionUIElicitationResult) -> Any: + return to_class(SessionUIElicitationResult, x) + + +def session_ui_elicitation_params_from_dict(s: Any) -> SessionUIElicitationParams: + return SessionUIElicitationParams.from_dict(s) + + +def session_ui_elicitation_params_to_dict(x: SessionUIElicitationParams) -> Any: + return to_class(SessionUIElicitationParams, x) + + def session_permissions_handle_pending_permission_request_result_from_dict(s: Any) -> SessionPermissionsHandlePendingPermissionRequestResult: return SessionPermissionsHandlePendingPermissionRequestResult.from_dict(s) @@ -1706,6 +2698,88 @@ async def select(self, params: SessionAgentSelectParams, *, timeout: float | Non async def deselect(self, *, timeout: float | None = None) -> SessionAgentDeselectResult: return SessionAgentDeselectResult.from_dict(await self._client.request("session.agent.deselect", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + async def reload(self, *, timeout: float | None = None) -> SessionAgentReloadResult: + return SessionAgentReloadResult.from_dict(await self._client.request("session.agent.reload", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + +# Experimental: this API group is experimental and may change or be removed. +class SkillsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self, *, timeout: float | None = None) -> SessionSkillsListResult: + return SessionSkillsListResult.from_dict(await self._client.request("session.skills.list", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + async def enable(self, params: SessionSkillsEnableParams, *, timeout: float | None = None) -> SessionSkillsEnableResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionSkillsEnableResult.from_dict(await self._client.request("session.skills.enable", params_dict, **_timeout_kwargs(timeout))) + + async def disable(self, params: SessionSkillsDisableParams, *, timeout: float | None = None) -> SessionSkillsDisableResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionSkillsDisableResult.from_dict(await self._client.request("session.skills.disable", params_dict, **_timeout_kwargs(timeout))) + + async def reload(self, *, timeout: float | None = None) -> SessionSkillsReloadResult: + return SessionSkillsReloadResult.from_dict(await self._client.request("session.skills.reload", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + +# Experimental: this API group is experimental and may change or be removed. +class McpApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self, *, timeout: float | None = None) -> SessionMCPListResult: + return SessionMCPListResult.from_dict(await self._client.request("session.mcp.list", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + async def enable(self, params: SessionMCPEnableParams, *, timeout: float | None = None) -> SessionMCPEnableResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionMCPEnableResult.from_dict(await self._client.request("session.mcp.enable", params_dict, **_timeout_kwargs(timeout))) + + async def disable(self, params: SessionMCPDisableParams, *, timeout: float | None = None) -> SessionMCPDisableResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionMCPDisableResult.from_dict(await self._client.request("session.mcp.disable", params_dict, **_timeout_kwargs(timeout))) + + async def reload(self, *, timeout: float | None = None) -> SessionMCPReloadResult: + return SessionMCPReloadResult.from_dict(await self._client.request("session.mcp.reload", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + +# Experimental: this API group is experimental and may change or be removed. +class PluginsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self, *, timeout: float | None = None) -> SessionPluginsListResult: + return SessionPluginsListResult.from_dict(await self._client.request("session.plugins.list", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + +# Experimental: this API group is experimental and may change or be removed. +class ExtensionsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self, *, timeout: float | None = None) -> SessionExtensionsListResult: + return SessionExtensionsListResult.from_dict(await self._client.request("session.extensions.list", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + async def enable(self, params: SessionExtensionsEnableParams, *, timeout: float | None = None) -> SessionExtensionsEnableResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionExtensionsEnableResult.from_dict(await self._client.request("session.extensions.enable", params_dict, **_timeout_kwargs(timeout))) + + async def disable(self, params: SessionExtensionsDisableParams, *, timeout: float | None = None) -> SessionExtensionsDisableResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionExtensionsDisableResult.from_dict(await self._client.request("session.extensions.disable", params_dict, **_timeout_kwargs(timeout))) + + async def reload(self, *, timeout: float | None = None) -> SessionExtensionsReloadResult: + return SessionExtensionsReloadResult.from_dict(await self._client.request("session.extensions.reload", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + # Experimental: this API group is experimental and may change or be removed. class CompactionApi: @@ -1728,6 +2802,28 @@ async def handle_pending_tool_call(self, params: SessionToolsHandlePendingToolCa return SessionToolsHandlePendingToolCallResult.from_dict(await self._client.request("session.tools.handlePendingToolCall", params_dict, **_timeout_kwargs(timeout))) +class CommandsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def handle_pending_command(self, params: SessionCommandsHandlePendingCommandParams, *, timeout: float | None = None) -> SessionCommandsHandlePendingCommandResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionCommandsHandlePendingCommandResult.from_dict(await self._client.request("session.commands.handlePendingCommand", params_dict, **_timeout_kwargs(timeout))) + + +class UiApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def elicitation(self, params: SessionUIElicitationParams, *, timeout: float | None = None) -> SessionUIElicitationResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionUIElicitationResult.from_dict(await self._client.request("session.ui.elicitation", params_dict, **_timeout_kwargs(timeout))) + + class PermissionsApi: def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client @@ -1766,8 +2862,14 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.workspace = WorkspaceApi(client, session_id) self.fleet = FleetApi(client, session_id) self.agent = AgentApi(client, session_id) + self.skills = SkillsApi(client, session_id) + self.mcp = McpApi(client, session_id) + self.plugins = PluginsApi(client, session_id) + self.extensions = ExtensionsApi(client, session_id) self.compaction = CompactionApi(client, session_id) self.tools = ToolsApi(client, session_id) + self.commands = CommandsApi(client, session_id) + self.ui = UiApi(client, session_id) self.permissions = PermissionsApi(client, session_id) self.shell = ShellApi(client, session_id) diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 3fc313399..f3970b815 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -419,6 +419,26 @@ def to_dict(self) -> dict: return result +@dataclass +class DataCommand: + name: str + description: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'DataCommand': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + description = from_union([from_str, from_none], obj.get("description")) + return DataCommand(name, description) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + return result + + @dataclass class CompactionTokensUsed: """Token usage breakdown for the compaction LLM call""" @@ -605,7 +625,55 @@ def to_dict(self) -> dict: return result -class Status(Enum): +class Source(Enum): + """Discovery source""" + + PROJECT = "project" + USER = "user" + + +class ExtensionStatus(Enum): + """Current status: running, disabled, failed, or starting""" + + DISABLED = "disabled" + FAILED = "failed" + RUNNING = "running" + STARTING = "starting" + + +@dataclass +class Extension: + id: str + """Source-qualified extension ID (e.g., 'project:my-ext', 'user:auth-helper')""" + + name: str + """Extension name (directory name)""" + + source: Source + """Discovery source""" + + status: ExtensionStatus + """Current status: running, disabled, failed, or starting""" + + @staticmethod + def from_dict(obj: Any) -> 'Extension': + assert isinstance(obj, dict) + id = from_str(obj.get("id")) + name = from_str(obj.get("name")) + source = Source(obj.get("source")) + status = ExtensionStatus(obj.get("status")) + return Extension(id, name, source, status) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = from_str(self.id) + result["name"] = from_str(self.name) + result["source"] = to_enum(Source, self.source) + result["status"] = to_enum(ExtensionStatus, self.status) + return result + + +class KindStatus(Enum): """Whether the agent completed successfully or failed""" COMPLETED = "completed" @@ -614,6 +682,7 @@ class Status(Enum): class KindType(Enum): AGENT_COMPLETED = "agent_completed" + AGENT_IDLE = "agent_idle" SHELL_COMPLETED = "shell_completed" SHELL_DETACHED_COMPLETED = "shell_detached_completed" @@ -637,7 +706,7 @@ class KindClass: prompt: str | None = None """The full prompt given to the background agent""" - status: Status | None = None + status: KindStatus | None = None """Whether the agent completed successfully or failed""" exit_code: float | None = None @@ -657,7 +726,7 @@ def from_dict(obj: Any) -> 'KindClass': agent_type = from_union([from_str, from_none], obj.get("agentType")) description = from_union([from_str, from_none], obj.get("description")) prompt = from_union([from_str, from_none], obj.get("prompt")) - status = from_union([Status, from_none], obj.get("status")) + status = from_union([KindStatus, from_none], obj.get("status")) exit_code = from_union([from_float, from_none], obj.get("exitCode")) shell_id = from_union([from_str, from_none], obj.get("shellId")) return KindClass(type, agent_id, agent_type, description, prompt, status, exit_code, shell_id) @@ -674,7 +743,7 @@ def to_dict(self) -> dict: if self.prompt is not None: result["prompt"] = from_union([from_str, from_none], self.prompt) if self.status is not None: - result["status"] = from_union([lambda x: to_enum(Status, x), from_none], self.status) + result["status"] = from_union([lambda x: to_enum(KindStatus, x), from_none], self.status) if self.exit_code is not None: result["exitCode"] = from_union([to_float, from_none], self.exit_code) if self.shell_id is not None: @@ -709,7 +778,11 @@ def to_dict(self) -> dict: class Mode(Enum): + """Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to + "form" when absent. + """ FORM = "form" + URL = "url" @dataclass @@ -803,7 +876,7 @@ class Operation(Enum): @dataclass -class Command: +class PermissionRequestCommand: identifier: str """Command identifier (e.g., executable name)""" @@ -811,11 +884,11 @@ class Command: """Whether this command is read-only (no side effects)""" @staticmethod - def from_dict(obj: Any) -> 'Command': + def from_dict(obj: Any) -> 'PermissionRequestCommand': assert isinstance(obj, dict) identifier = from_str(obj.get("identifier")) read_only = from_bool(obj.get("readOnly")) - return Command(identifier, read_only) + return PermissionRequestCommand(identifier, read_only) def to_dict(self) -> dict: result: dict = {} @@ -878,7 +951,7 @@ class PermissionRequest: can_offer_session_approval: bool | None = None """Whether the UI can offer session-wide approval for this command pattern""" - commands: list[Command] | None = None + commands: list[PermissionRequestCommand] | None = None """Parsed command identifiers found in the command text""" full_command_text: str | None = None @@ -967,7 +1040,7 @@ def from_dict(obj: Any) -> 'PermissionRequest': assert isinstance(obj, dict) kind = PermissionRequestKind(obj.get("kind")) can_offer_session_approval = from_union([from_bool, from_none], obj.get("canOfferSessionApproval")) - commands = from_union([lambda x: from_list(Command.from_dict, x), from_none], obj.get("commands")) + commands = from_union([lambda x: from_list(PermissionRequestCommand.from_dict, x), from_none], obj.get("commands")) full_command_text = from_union([from_str, from_none], obj.get("fullCommandText")) has_write_file_redirection = from_union([from_bool, from_none], obj.get("hasWriteFileRedirection")) intention = from_union([from_str, from_none], obj.get("intention")) @@ -999,7 +1072,7 @@ def to_dict(self) -> dict: if self.can_offer_session_approval is not None: result["canOfferSessionApproval"] = from_union([from_bool, from_none], self.can_offer_session_approval) if self.commands is not None: - result["commands"] = from_union([lambda x: from_list(lambda x: to_class(Command, x), x), from_none], self.commands) + result["commands"] = from_union([lambda x: from_list(lambda x: to_class(PermissionRequestCommand, x), x), from_none], self.commands) if self.full_command_text is not None: result["fullCommandText"] = from_union([from_str, from_none], self.full_command_text) if self.has_write_file_redirection is not None: @@ -1138,7 +1211,7 @@ class RequestedSchemaType(Enum): @dataclass class RequestedSchema: - """JSON Schema describing the form fields to present to the user""" + """JSON Schema describing the form fields to present to the user (form mode only)""" properties: dict[str, Any] """Form field definitions, keyed by field name""" @@ -1430,6 +1503,52 @@ class Role(Enum): SYSTEM = "system" +class ServerStatus(Enum): + """Connection status: connected, failed, pending, disabled, or not_configured + + New connection status: connected, failed, pending, disabled, or not_configured + """ + CONNECTED = "connected" + DISABLED = "disabled" + FAILED = "failed" + NOT_CONFIGURED = "not_configured" + PENDING = "pending" + + +@dataclass +class Server: + name: str + """Server name (config key)""" + + status: ServerStatus + """Connection status: connected, failed, pending, disabled, or not_configured""" + + error: str | None = None + """Error message if the server failed to connect""" + + source: str | None = None + """Configuration source: user, workspace, plugin, or builtin""" + + @staticmethod + def from_dict(obj: Any) -> 'Server': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + status = ServerStatus(obj.get("status")) + error = from_union([from_str, from_none], obj.get("error")) + source = from_union([from_str, from_none], obj.get("source")) + return Server(name, status, error, source) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["status"] = to_enum(ServerStatus, self.status) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.source is not None: + result["source"] = from_union([from_str, from_none], self.source) + return result + + class ShutdownType(Enum): """Whether the session ended normally ("routine") or due to a crash/fatal error ("error")""" @@ -1437,20 +1556,47 @@ class ShutdownType(Enum): ROUTINE = "routine" -class Source(Enum): - """Origin of this message, used for timeline filtering and telemetry (e.g., "user", - "autopilot", "skill", or "command") - """ - AUTOPILOT = "autopilot" - COMMAND = "command" - IMMEDIATE_PROMPT = "immediate-prompt" - JIT_INSTRUCTION = "jit-instruction" - OTHER = "other" - SKILL = "skill" - SNIPPY_BLOCKING = "snippy-blocking" - SYSTEM = "system" - THINKING_EXHAUSTED_CONTINUATION = "thinking-exhausted-continuation" - USER = "user" +@dataclass +class Skill: + description: str + """Description of what the skill does""" + + enabled: bool + """Whether the skill is currently enabled""" + + name: str + """Unique identifier for the skill""" + + source: str + """Source location type of the skill (e.g., project, personal, plugin)""" + + user_invocable: bool + """Whether the skill can be invoked by the user as a slash command""" + + path: str | None = None + """Absolute path to the skill file, if available""" + + @staticmethod + def from_dict(obj: Any) -> 'Skill': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + enabled = from_bool(obj.get("enabled")) + name = from_str(obj.get("name")) + source = from_str(obj.get("source")) + user_invocable = from_bool(obj.get("userInvocable")) + path = from_union([from_str, from_none], obj.get("path")) + return Skill(description, enabled, name, source, user_invocable, path) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["enabled"] = from_bool(self.enabled) + result["name"] = from_str(self.name) + result["source"] = from_str(self.source) + result["userInvocable"] = from_bool(self.user_invocable) + if self.path is not None: + result["path"] = from_union([from_str, from_none], self.path) + return result class SourceType(Enum): @@ -1460,6 +1606,31 @@ class SourceType(Enum): REMOTE = "remote" +@dataclass +class StaticClientConfig: + """Static OAuth client configuration, if the server specifies one""" + + client_id: str + """OAuth client ID for the server""" + + public_client: bool | None = None + """Whether this is a public OAuth client""" + + @staticmethod + def from_dict(obj: Any) -> 'StaticClientConfig': + assert isinstance(obj, dict) + client_id = from_str(obj.get("clientId")) + public_client = from_union([from_bool, from_none], obj.get("publicClient")) + return StaticClientConfig(client_id, public_client) + + def to_dict(self) -> dict: + result: dict = {} + result["clientId"] = from_str(self.client_id) + if self.public_client is not None: + result["publicClient"] = from_union([from_bool, from_none], self.public_client) + return result + + class ToolRequestType(Enum): """Tool call type: "function" for standard tool calls, "custom" for grammar-based tool calls. Defaults to "function" when absent. @@ -1555,15 +1726,12 @@ class Data: Current context window usage statistics including token and message counts - Empty payload; the event signals that LLM-powered conversation compaction has begun + Context window breakdown at the start of LLM-powered conversation compaction Conversation compaction results including success status, metrics, and optional error details - Task completion notification with optional summary from the agent - - User message content with optional attachments, source information, and interaction - metadata + Task completion notification with summary from the agent Empty payload; the event signals that the pending message queue has changed @@ -1629,18 +1797,27 @@ class Data: User input request completion notification signaling UI dismissal - Structured form elicitation request with JSON schema definition for form fields + Elicitation request; may be form-based (structured input) or URL-based (browser + redirect) Elicitation request completion notification signaling UI dismissal + OAuth authentication request for an MCP server + + MCP OAuth request completion notification + External tool invocation request for client-side tool execution External tool completion notification signaling UI dismissal Queued slash command dispatch request for client execution + Registered command dispatch request routed to the owning client + Queued command completion notification signaling UI dismissal + SDK command registration change notification + Plan approval request with plan content and available user actions Plan mode exit completion notification signaling UI dismissal @@ -1716,6 +1893,15 @@ class Data: status_code: int | None = None """HTTP status code from the upstream request, if applicable""" + url: str | None = None + """Optional URL associated with this error that the user can open in a browser + + Optional URL associated with this message that the user can open in a browser + + Optional URL associated with this warning that the user can open in a browser + + URL to open in the user's browser (url mode only) + """ background_tasks: BackgroundTasks | None = None """Background tasks still running when the agent became idle""" @@ -1772,7 +1958,7 @@ class Data: summary: str | None = None """Summary of the work done in the source session - Optional summary of the completed task, provided by the agent + Summary of the completed task, provided by the agent Summary of the plan that was created """ @@ -1809,9 +1995,23 @@ class Data: code_changes: CodeChanges | None = None """Aggregate code change metrics for the session""" + conversation_tokens: float | None = None + """Non-system message token count at shutdown + + Token count from non-system messages (user, assistant, tool) + + Token count from non-system messages (user, assistant, tool) at compaction start + + Token count from non-system messages (user, assistant, tool) after compaction + """ current_model: str | None = None """Model that was selected at the time of shutdown""" + current_tokens: float | None = None + """Total tokens in context window at shutdown + + Current number of tokens in the context window + """ error_reason: str | None = None """Error description when shutdownType is "error\"""" @@ -1824,6 +2024,24 @@ class Data: shutdown_type: ShutdownType | None = None """Whether the session ended normally ("routine") or due to a crash/fatal error ("error")""" + system_tokens: float | None = None + """System message token count at shutdown + + Token count from system message(s) + + Token count from system message(s) at compaction start + + Token count from system message(s) after compaction + """ + tool_definitions_tokens: float | None = None + """Tool definitions token count at shutdown + + Token count from tool definitions + + Token count from tool definitions at compaction start + + Token count from tool definitions after compaction + """ total_api_duration_ms: float | None = None """Cumulative time spent in API calls during the session, in milliseconds""" @@ -1848,8 +2066,8 @@ class Data: host_type: HostType | None = None """Hosting platform type of the repository (github or ado)""" - current_tokens: float | None = None - """Current number of tokens in the context window""" + is_initial: bool | None = None + """Whether this is the first usage_info event emitted in this session""" messages_length: float | None = None """Current number of messages in the conversation""" @@ -1905,6 +2123,11 @@ class Data: Request ID of the resolved elicitation request; clients should dismiss any UI for this request + Unique identifier for this OAuth request; used to respond via + session.respondToMcpOAuth() + + Request ID of the resolved OAuth request + Unique identifier for this request; used to respond via session.respondToExternalTool() Request ID of the resolved external tool request; clients should dismiss any UI for this @@ -1912,6 +2135,8 @@ class Data: Unique identifier for this request; used to respond via session.respondToQueuedCommand() + Unique identifier; used to respond via session.commands.handlePendingCommand() + Request ID of the resolved command request; clients should dismiss any UI for this request @@ -1923,6 +2148,8 @@ class Data: success: bool | None = None """Whether compaction completed successfully + Whether the tool call succeeded. False when validation failed (e.g., invalid arguments) + Whether the tool execution completed successfully Whether the hook completed successfully @@ -1961,9 +2188,9 @@ class Data: CAPI interaction ID for correlating this tool execution with upstream telemetry """ - source: Source | None = None - """Origin of this message, used for timeline filtering and telemetry (e.g., "user", - "autopilot", "skill", or "command") + source: str | None = None + """Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected + messages that should be hidden from the user) """ transformed_content: str | None = None """Transformed version of the message sent to the model, with XML wrapping, timestamps, and @@ -2077,6 +2304,12 @@ class Data: Tool call ID of the parent tool invocation that spawned this sub-agent + The LLM-assigned tool call ID that triggered this request; used by remote UIs to + correlate responses + + Tool call ID from the LLM completion; used to correlate with CompletionChunk.toolCall.id + for remote UIs + Tool call ID assigned to this external tool invocation """ tool_name: str | None = None @@ -2176,11 +2409,26 @@ class Data: question: str | None = None """The question or prompt to present to the user""" - mode: Mode | None = None - """Elicitation mode; currently only "form" is supported. Defaults to "form" when absent.""" + elicitation_source: str | None = None + """The source that initiated the request (MCP server name, or absent for agent-initiated)""" + mode: Mode | None = None + """Elicitation mode; "form" for structured input, "url" for browser-based. Defaults to + "form" when absent. + """ requested_schema: RequestedSchema | None = None - """JSON Schema describing the form fields to present to the user""" + """JSON Schema describing the form fields to present to the user (form mode only)""" + + server_name: str | None = None + """Display name of the MCP server that requires OAuth + + Name of the MCP server whose status changed + """ + server_url: str | None = None + """URL of the MCP server that requires OAuth""" + + static_client_config: StaticClientConfig | None = None + """Static OAuth client configuration, if the server specifies one""" traceparent: str | None = None """W3C Trace Context traceparent header for the execute_tool span""" @@ -2189,7 +2437,18 @@ class Data: """W3C Trace Context tracestate header for the execute_tool span""" command: str | None = None - """The slash command text to be executed (e.g., /help, /clear)""" + """The slash command text to be executed (e.g., /help, /clear) + + The full command text (e.g., /deploy production) + """ + args: str | None = None + """Raw argument string after the command name""" + + command_name: str | None = None + """Command name without leading /""" + + commands: list[DataCommand] | None = None + """Current list of registered SDK commands""" actions: list[str] | None = None """Available actions the user can take (e.g., approve, edit, reject)""" @@ -2200,6 +2459,18 @@ class Data: recommended_action: str | None = None """The recommended action for the user to take""" + skills: list[Skill] | None = None + """Array of resolved skill metadata""" + + servers: list[Server] | None = None + """Array of MCP server status summaries""" + + status: ServerStatus | None = None + """New connection status: connected, failed, pending, disabled, or not_configured""" + + extensions: list[Extension] | None = None + """Array of discovered extensions and their status""" + @staticmethod def from_dict(obj: Any) -> 'Data': assert isinstance(obj, dict) @@ -2219,6 +2490,7 @@ def from_dict(obj: Any) -> 'Data': provider_call_id = from_union([from_str, from_none], obj.get("providerCallId")) stack = from_union([from_str, from_none], obj.get("stack")) status_code = from_union([from_int, from_none], obj.get("statusCode")) + url = from_union([from_str, from_none], obj.get("url")) background_tasks = from_union([BackgroundTasks.from_dict, from_none], obj.get("backgroundTasks")) title = from_union([from_str, from_none], obj.get("title")) info_type = from_union([from_str, from_none], obj.get("infoType")) @@ -2246,11 +2518,15 @@ def from_dict(obj: Any) -> 'Data': events_removed = from_union([from_float, from_none], obj.get("eventsRemoved")) up_to_event_id = from_union([from_str, from_none], obj.get("upToEventId")) code_changes = from_union([CodeChanges.from_dict, from_none], obj.get("codeChanges")) + conversation_tokens = from_union([from_float, from_none], obj.get("conversationTokens")) current_model = from_union([from_str, from_none], obj.get("currentModel")) + current_tokens = from_union([from_float, from_none], obj.get("currentTokens")) error_reason = from_union([from_str, from_none], obj.get("errorReason")) model_metrics = from_union([lambda x: from_dict(ModelMetric.from_dict, x), from_none], obj.get("modelMetrics")) session_start_time = from_union([from_float, from_none], obj.get("sessionStartTime")) shutdown_type = from_union([ShutdownType, from_none], obj.get("shutdownType")) + system_tokens = from_union([from_float, from_none], obj.get("systemTokens")) + tool_definitions_tokens = from_union([from_float, from_none], obj.get("toolDefinitionsTokens")) total_api_duration_ms = from_union([from_float, from_none], obj.get("totalApiDurationMs")) total_premium_requests = from_union([from_float, from_none], obj.get("totalPremiumRequests")) base_commit = from_union([from_str, from_none], obj.get("baseCommit")) @@ -2259,7 +2535,7 @@ def from_dict(obj: Any) -> 'Data': git_root = from_union([from_str, from_none], obj.get("gitRoot")) head_commit = from_union([from_str, from_none], obj.get("headCommit")) host_type = from_union([HostType, from_none], obj.get("hostType")) - current_tokens = from_union([from_float, from_none], obj.get("currentTokens")) + is_initial = from_union([from_bool, from_none], obj.get("isInitial")) messages_length = from_union([from_float, from_none], obj.get("messagesLength")) checkpoint_number = from_union([from_float, from_none], obj.get("checkpointNumber")) checkpoint_path = from_union([from_str, from_none], obj.get("checkpointPath")) @@ -2277,7 +2553,7 @@ def from_dict(obj: Any) -> 'Data': attachments = from_union([lambda x: from_list(Attachment.from_dict, x), from_none], obj.get("attachments")) content = from_union([from_str, from_none], obj.get("content")) interaction_id = from_union([from_str, from_none], obj.get("interactionId")) - source = from_union([Source, from_none], obj.get("source")) + source = from_union([from_str, from_none], obj.get("source")) transformed_content = from_union([from_str, from_none], obj.get("transformedContent")) turn_id = from_union([from_str, from_none], obj.get("turnId")) intent = from_union([from_str, from_none], obj.get("intent")) @@ -2332,15 +2608,26 @@ def from_dict(obj: Any) -> 'Data': allow_freeform = from_union([from_bool, from_none], obj.get("allowFreeform")) choices = from_union([lambda x: from_list(from_str, x), from_none], obj.get("choices")) question = from_union([from_str, from_none], obj.get("question")) + elicitation_source = from_union([from_str, from_none], obj.get("elicitationSource")) mode = from_union([Mode, from_none], obj.get("mode")) requested_schema = from_union([RequestedSchema.from_dict, from_none], obj.get("requestedSchema")) + server_name = from_union([from_str, from_none], obj.get("serverName")) + server_url = from_union([from_str, from_none], obj.get("serverUrl")) + static_client_config = from_union([StaticClientConfig.from_dict, from_none], obj.get("staticClientConfig")) traceparent = from_union([from_str, from_none], obj.get("traceparent")) tracestate = from_union([from_str, from_none], obj.get("tracestate")) command = from_union([from_str, from_none], obj.get("command")) + args = from_union([from_str, from_none], obj.get("args")) + command_name = from_union([from_str, from_none], obj.get("commandName")) + commands = from_union([lambda x: from_list(DataCommand.from_dict, x), from_none], obj.get("commands")) actions = from_union([lambda x: from_list(from_str, x), from_none], obj.get("actions")) plan_content = from_union([from_str, from_none], obj.get("planContent")) recommended_action = from_union([from_str, from_none], obj.get("recommendedAction")) - return Data(already_in_use, context, copilot_version, producer, reasoning_effort, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, background_tasks, title, info_type, warning_type, new_model, previous_model, previous_reasoning_effort, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, base_commit, branch, cwd, git_root, head_commit, host_type, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, model, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role, kind, permission_request, allow_freeform, choices, question, mode, requested_schema, traceparent, tracestate, command, actions, plan_content, recommended_action) + skills = from_union([lambda x: from_list(Skill.from_dict, x), from_none], obj.get("skills")) + servers = from_union([lambda x: from_list(Server.from_dict, x), from_none], obj.get("servers")) + status = from_union([ServerStatus, from_none], obj.get("status")) + extensions = from_union([lambda x: from_list(Extension.from_dict, x), from_none], obj.get("extensions")) + return Data(already_in_use, context, copilot_version, producer, reasoning_effort, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, url, background_tasks, title, info_type, warning_type, new_model, previous_model, previous_reasoning_effort, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, conversation_tokens, current_model, current_tokens, error_reason, model_metrics, session_start_time, shutdown_type, system_tokens, tool_definitions_tokens, total_api_duration_ms, total_premium_requests, base_commit, branch, cwd, git_root, head_commit, host_type, is_initial, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, model, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role, kind, permission_request, allow_freeform, choices, question, elicitation_source, mode, requested_schema, server_name, server_url, static_client_config, traceparent, tracestate, command, args, command_name, commands, actions, plan_content, recommended_action, skills, servers, status, extensions) def to_dict(self) -> dict: result: dict = {} @@ -2376,6 +2663,8 @@ def to_dict(self) -> dict: result["stack"] = from_union([from_str, from_none], self.stack) if self.status_code is not None: result["statusCode"] = from_union([from_int, from_none], self.status_code) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) if self.background_tasks is not None: result["backgroundTasks"] = from_union([lambda x: to_class(BackgroundTasks, x), from_none], self.background_tasks) if self.title is not None: @@ -2430,8 +2719,12 @@ def to_dict(self) -> dict: result["upToEventId"] = from_union([from_str, from_none], self.up_to_event_id) if self.code_changes is not None: result["codeChanges"] = from_union([lambda x: to_class(CodeChanges, x), from_none], self.code_changes) + if self.conversation_tokens is not None: + result["conversationTokens"] = from_union([to_float, from_none], self.conversation_tokens) if self.current_model is not None: result["currentModel"] = from_union([from_str, from_none], self.current_model) + if self.current_tokens is not None: + result["currentTokens"] = from_union([to_float, from_none], self.current_tokens) if self.error_reason is not None: result["errorReason"] = from_union([from_str, from_none], self.error_reason) if self.model_metrics is not None: @@ -2440,6 +2733,10 @@ def to_dict(self) -> dict: result["sessionStartTime"] = from_union([to_float, from_none], self.session_start_time) if self.shutdown_type is not None: result["shutdownType"] = from_union([lambda x: to_enum(ShutdownType, x), from_none], self.shutdown_type) + if self.system_tokens is not None: + result["systemTokens"] = from_union([to_float, from_none], self.system_tokens) + if self.tool_definitions_tokens is not None: + result["toolDefinitionsTokens"] = from_union([to_float, from_none], self.tool_definitions_tokens) if self.total_api_duration_ms is not None: result["totalApiDurationMs"] = from_union([to_float, from_none], self.total_api_duration_ms) if self.total_premium_requests is not None: @@ -2456,8 +2753,8 @@ def to_dict(self) -> dict: result["headCommit"] = from_union([from_str, from_none], self.head_commit) if self.host_type is not None: result["hostType"] = from_union([lambda x: to_enum(HostType, x), from_none], self.host_type) - if self.current_tokens is not None: - result["currentTokens"] = from_union([to_float, from_none], self.current_tokens) + if self.is_initial is not None: + result["isInitial"] = from_union([from_bool, from_none], self.is_initial) if self.messages_length is not None: result["messagesLength"] = from_union([to_float, from_none], self.messages_length) if self.checkpoint_number is not None: @@ -2493,7 +2790,7 @@ def to_dict(self) -> dict: if self.interaction_id is not None: result["interactionId"] = from_union([from_str, from_none], self.interaction_id) if self.source is not None: - result["source"] = from_union([lambda x: to_enum(Source, x), from_none], self.source) + result["source"] = from_union([from_str, from_none], self.source) if self.transformed_content is not None: result["transformedContent"] = from_union([from_str, from_none], self.transformed_content) if self.turn_id is not None: @@ -2602,22 +2899,44 @@ def to_dict(self) -> dict: result["choices"] = from_union([lambda x: from_list(from_str, x), from_none], self.choices) if self.question is not None: result["question"] = from_union([from_str, from_none], self.question) + if self.elicitation_source is not None: + result["elicitationSource"] = from_union([from_str, from_none], self.elicitation_source) if self.mode is not None: result["mode"] = from_union([lambda x: to_enum(Mode, x), from_none], self.mode) if self.requested_schema is not None: result["requestedSchema"] = from_union([lambda x: to_class(RequestedSchema, x), from_none], self.requested_schema) + if self.server_name is not None: + result["serverName"] = from_union([from_str, from_none], self.server_name) + if self.server_url is not None: + result["serverUrl"] = from_union([from_str, from_none], self.server_url) + if self.static_client_config is not None: + result["staticClientConfig"] = from_union([lambda x: to_class(StaticClientConfig, x), from_none], self.static_client_config) if self.traceparent is not None: result["traceparent"] = from_union([from_str, from_none], self.traceparent) if self.tracestate is not None: result["tracestate"] = from_union([from_str, from_none], self.tracestate) if self.command is not None: result["command"] = from_union([from_str, from_none], self.command) + if self.args is not None: + result["args"] = from_union([from_str, from_none], self.args) + if self.command_name is not None: + result["commandName"] = from_union([from_str, from_none], self.command_name) + if self.commands is not None: + result["commands"] = from_union([lambda x: from_list(lambda x: to_class(DataCommand, x), x), from_none], self.commands) if self.actions is not None: result["actions"] = from_union([lambda x: from_list(from_str, x), from_none], self.actions) if self.plan_content is not None: result["planContent"] = from_union([from_str, from_none], self.plan_content) if self.recommended_action is not None: result["recommendedAction"] = from_union([from_str, from_none], self.recommended_action) + if self.skills is not None: + result["skills"] = from_union([lambda x: from_list(lambda x: to_class(Skill, x), x), from_none], self.skills) + if self.servers is not None: + result["servers"] = from_union([lambda x: from_list(lambda x: to_class(Server, x), x), from_none], self.servers) + if self.status is not None: + result["status"] = from_union([lambda x: to_enum(ServerStatus, x), from_none], self.status) + if self.extensions is not None: + result["extensions"] = from_union([lambda x: from_list(lambda x: to_class(Extension, x), x), from_none], self.extensions) return result @@ -2632,7 +2951,9 @@ class SessionEventType(Enum): ASSISTANT_TURN_END = "assistant.turn_end" ASSISTANT_TURN_START = "assistant.turn_start" ASSISTANT_USAGE = "assistant.usage" + COMMANDS_CHANGED = "commands.changed" COMMAND_COMPLETED = "command.completed" + COMMAND_EXECUTE = "command.execute" COMMAND_QUEUED = "command.queued" ELICITATION_COMPLETED = "elicitation.completed" ELICITATION_REQUESTED = "elicitation.requested" @@ -2642,6 +2963,8 @@ class SessionEventType(Enum): EXTERNAL_TOOL_REQUESTED = "external_tool.requested" HOOK_END = "hook.end" HOOK_START = "hook.start" + MCP_OAUTH_COMPLETED = "mcp.oauth_completed" + MCP_OAUTH_REQUIRED = "mcp.oauth_required" PENDING_MESSAGES_MODIFIED = "pending_messages.modified" PERMISSION_COMPLETED = "permission.completed" PERMISSION_REQUESTED = "permission.requested" @@ -2650,14 +2973,18 @@ class SessionEventType(Enum): SESSION_COMPACTION_START = "session.compaction_start" SESSION_CONTEXT_CHANGED = "session.context_changed" SESSION_ERROR = "session.error" + SESSION_EXTENSIONS_LOADED = "session.extensions_loaded" SESSION_HANDOFF = "session.handoff" SESSION_IDLE = "session.idle" SESSION_INFO = "session.info" + SESSION_MCP_SERVERS_LOADED = "session.mcp_servers_loaded" + SESSION_MCP_SERVER_STATUS_CHANGED = "session.mcp_server_status_changed" SESSION_MODEL_CHANGE = "session.model_change" SESSION_MODE_CHANGED = "session.mode_changed" SESSION_PLAN_CHANGED = "session.plan_changed" SESSION_RESUME = "session.resume" SESSION_SHUTDOWN = "session.shutdown" + SESSION_SKILLS_LOADED = "session.skills_loaded" SESSION_SNAPSHOT_REWIND = "session.snapshot_rewind" SESSION_START = "session.start" SESSION_TASK_COMPLETE = "session.task_complete" @@ -2731,15 +3058,12 @@ class SessionEvent: Current context window usage statistics including token and message counts - Empty payload; the event signals that LLM-powered conversation compaction has begun + Context window breakdown at the start of LLM-powered conversation compaction Conversation compaction results including success status, metrics, and optional error details - Task completion notification with optional summary from the agent - - User message content with optional attachments, source information, and interaction - metadata + Task completion notification with summary from the agent Empty payload; the event signals that the pending message queue has changed @@ -2805,18 +3129,27 @@ class SessionEvent: User input request completion notification signaling UI dismissal - Structured form elicitation request with JSON schema definition for form fields + Elicitation request; may be form-based (structured input) or URL-based (browser + redirect) Elicitation request completion notification signaling UI dismissal + OAuth authentication request for an MCP server + + MCP OAuth request completion notification + External tool invocation request for client-side tool execution External tool completion notification signaling UI dismissal Queued slash command dispatch request for client execution + Registered command dispatch request routed to the owning client + Queued command completion notification signaling UI dismissal + SDK command registration change notification + Plan approval request with plan content and available user actions Plan mode exit completion notification signaling UI dismissal diff --git a/python/copilot/session.py b/python/copilot/session.py index 7a8b9f05d..936c07d6c 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -2,14 +2,18 @@ Copilot Session - represents a single conversation session with the Copilot CLI. This module provides the CopilotSession class for managing individual -conversation sessions with the Copilot CLI. +conversation sessions with the Copilot CLI, along with all session-related +configuration and handler types. """ +from __future__ import annotations + import asyncio import inspect import threading -from collections.abc import Callable -from typing import Any, Literal, cast +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Literal, NotRequired, TypedDict, cast from ._jsonrpc import JsonRpcError, ProcessExitedError from ._telemetry import get_trace_context, trace_context @@ -24,24 +28,532 @@ SessionRpc, SessionToolsHandlePendingToolCallParams, ) -from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict -from .types import ( - Attachment, +from .generated.session_events import ( PermissionRequest, - PermissionRequestResult, - SessionHooks, - Tool, - ToolHandler, - ToolInvocation, - ToolResult, - UserInputHandler, - UserInputRequest, - UserInputResponse, - _PermissionHandlerFn, -) -from .types import ( - SessionEvent as SessionEventTypeAlias, + SessionEvent, + SessionEventType, + session_event_from_dict, ) +from .tools import Tool, ToolHandler, ToolInvocation, ToolResult + +# Re-export SessionEvent under an alias used internally +SessionEventTypeAlias = SessionEvent + +# ============================================================================ +# Reasoning Effort +# ============================================================================ + +ReasoningEffort = Literal["low", "medium", "high", "xhigh"] + +# ============================================================================ +# Attachment Types +# ============================================================================ + + +class SelectionRange(TypedDict): + line: int + character: int + + +class Selection(TypedDict): + start: SelectionRange + end: SelectionRange + + +class FileAttachment(TypedDict): + """File attachment.""" + + type: Literal["file"] + path: str + displayName: NotRequired[str] + + +class DirectoryAttachment(TypedDict): + """Directory attachment.""" + + type: Literal["directory"] + path: str + displayName: NotRequired[str] + + +class SelectionAttachment(TypedDict): + """Selection attachment with text from a file.""" + + type: Literal["selection"] + filePath: str + displayName: str + selection: NotRequired[Selection] + text: NotRequired[str] + + +class BlobAttachment(TypedDict): + """Inline base64-encoded content attachment (e.g. images).""" + + type: Literal["blob"] + data: str + """Base64-encoded content""" + mimeType: str + """MIME type of the inline data""" + displayName: NotRequired[str] + + +Attachment = FileAttachment | DirectoryAttachment | SelectionAttachment | BlobAttachment + +# ============================================================================ +# System Message Configuration +# ============================================================================ + + +class SystemMessageAppendConfig(TypedDict, total=False): + """ + Append mode: Use CLI foundation with optional appended content. + """ + + mode: NotRequired[Literal["append"]] + content: NotRequired[str] + + +class SystemMessageReplaceConfig(TypedDict): + """ + Replace mode: Use caller-provided system message entirely. + Removes all SDK guardrails including security restrictions. + """ + + mode: Literal["replace"] + content: str + + +SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig + +# ============================================================================ +# Permission Types +# ============================================================================ + +PermissionRequestResultKind = Literal[ + "approved", + "denied-by-rules", + "denied-by-content-exclusion-policy", + "denied-no-approval-rule-and-could-not-request-from-user", + "denied-interactively-by-user", + "no-result", +] + + +@dataclass +class PermissionRequestResult: + """Result of a permission request.""" + + kind: PermissionRequestResultKind = "denied-no-approval-rule-and-could-not-request-from-user" + rules: list[Any] | None = None + feedback: str | None = None + message: str | None = None + path: str | None = None + + +_PermissionHandlerFn = Callable[ + [PermissionRequest, dict[str, str]], + PermissionRequestResult | Awaitable[PermissionRequestResult], +] + + +class PermissionHandler: + @staticmethod + def approve_all( + request: PermissionRequest, invocation: dict[str, str] + ) -> PermissionRequestResult: + return PermissionRequestResult(kind="approved") + + +# ============================================================================ +# User Input Request Types +# ============================================================================ + + +class UserInputRequest(TypedDict, total=False): + """Request for user input from the agent (enables ask_user tool)""" + + question: str + choices: list[str] + allowFreeform: bool + + +class UserInputResponse(TypedDict): + """Response to a user input request""" + + answer: str + wasFreeform: bool + + +UserInputHandler = Callable[ + [UserInputRequest, dict[str, str]], + UserInputResponse | Awaitable[UserInputResponse], +] + +# ============================================================================ +# Hook Types +# ============================================================================ + + +class BaseHookInput(TypedDict): + """Base interface for all hook inputs""" + + timestamp: int + cwd: str + + +class PreToolUseHookInput(TypedDict): + """Input for pre-tool-use hook""" + + timestamp: int + cwd: str + toolName: str + toolArgs: Any + + +class PreToolUseHookOutput(TypedDict, total=False): + """Output for pre-tool-use hook""" + + permissionDecision: Literal["allow", "deny", "ask"] + permissionDecisionReason: str + modifiedArgs: Any + additionalContext: str + suppressOutput: bool + + +PreToolUseHandler = Callable[ + [PreToolUseHookInput, dict[str, str]], + PreToolUseHookOutput | None | Awaitable[PreToolUseHookOutput | None], +] + + +class PostToolUseHookInput(TypedDict): + """Input for post-tool-use hook""" + + timestamp: int + cwd: str + toolName: str + toolArgs: Any + toolResult: Any + + +class PostToolUseHookOutput(TypedDict, total=False): + """Output for post-tool-use hook""" + + modifiedResult: Any + additionalContext: str + suppressOutput: bool + + +PostToolUseHandler = Callable[ + [PostToolUseHookInput, dict[str, str]], + PostToolUseHookOutput | None | Awaitable[PostToolUseHookOutput | None], +] + + +class UserPromptSubmittedHookInput(TypedDict): + """Input for user-prompt-submitted hook""" + + timestamp: int + cwd: str + prompt: str + + +class UserPromptSubmittedHookOutput(TypedDict, total=False): + """Output for user-prompt-submitted hook""" + + modifiedPrompt: str + additionalContext: str + suppressOutput: bool + + +UserPromptSubmittedHandler = Callable[ + [UserPromptSubmittedHookInput, dict[str, str]], + UserPromptSubmittedHookOutput | None | Awaitable[UserPromptSubmittedHookOutput | None], +] + + +class SessionStartHookInput(TypedDict): + """Input for session-start hook""" + + timestamp: int + cwd: str + source: Literal["startup", "resume", "new"] + initialPrompt: NotRequired[str] + + +class SessionStartHookOutput(TypedDict, total=False): + """Output for session-start hook""" + + additionalContext: str + modifiedConfig: dict[str, Any] + + +SessionStartHandler = Callable[ + [SessionStartHookInput, dict[str, str]], + SessionStartHookOutput | None | Awaitable[SessionStartHookOutput | None], +] + + +class SessionEndHookInput(TypedDict): + """Input for session-end hook""" + + timestamp: int + cwd: str + reason: Literal["complete", "error", "abort", "timeout", "user_exit"] + finalMessage: NotRequired[str] + error: NotRequired[str] + + +class SessionEndHookOutput(TypedDict, total=False): + """Output for session-end hook""" + + suppressOutput: bool + cleanupActions: list[str] + sessionSummary: str + + +SessionEndHandler = Callable[ + [SessionEndHookInput, dict[str, str]], + SessionEndHookOutput | None | Awaitable[SessionEndHookOutput | None], +] + + +class ErrorOccurredHookInput(TypedDict): + """Input for error-occurred hook""" + + timestamp: int + cwd: str + error: str + errorContext: Literal["model_call", "tool_execution", "system", "user_input"] + recoverable: bool + + +class ErrorOccurredHookOutput(TypedDict, total=False): + """Output for error-occurred hook""" + + suppressOutput: bool + errorHandling: Literal["retry", "skip", "abort"] + retryCount: int + userNotification: str + + +ErrorOccurredHandler = Callable[ + [ErrorOccurredHookInput, dict[str, str]], + ErrorOccurredHookOutput | None | Awaitable[ErrorOccurredHookOutput | None], +] + + +class SessionHooks(TypedDict, total=False): + """Configuration for session hooks""" + + on_pre_tool_use: PreToolUseHandler + on_post_tool_use: PostToolUseHandler + on_user_prompt_submitted: UserPromptSubmittedHandler + on_session_start: SessionStartHandler + on_session_end: SessionEndHandler + on_error_occurred: ErrorOccurredHandler + + +# ============================================================================ +# MCP Server Configuration Types +# ============================================================================ + + +class MCPLocalServerConfig(TypedDict, total=False): + """Configuration for a local/stdio MCP server.""" + + tools: list[str] # List of tools to include. [] means none. "*" means all. + type: NotRequired[Literal["local", "stdio"]] # Server type + timeout: NotRequired[int] # Timeout in milliseconds + command: str # Command to run + args: list[str] # Command arguments + env: NotRequired[dict[str, str]] # Environment variables + cwd: NotRequired[str] # Working directory + + +class MCPRemoteServerConfig(TypedDict, total=False): + """Configuration for a remote MCP server (HTTP or SSE).""" + + tools: list[str] # List of tools to include. [] means none. "*" means all. + type: Literal["http", "sse"] # Server type + timeout: NotRequired[int] # Timeout in milliseconds + url: str # URL of the remote server + headers: NotRequired[dict[str, str]] # HTTP headers + + +MCPServerConfig = MCPLocalServerConfig | MCPRemoteServerConfig + +# ============================================================================ +# Custom Agent Configuration Types +# ============================================================================ + + +class CustomAgentConfig(TypedDict, total=False): + """Configuration for a custom agent.""" + + name: str # Unique name of the custom agent + display_name: NotRequired[str] # Display name for UI purposes + description: NotRequired[str] # Description of what the agent does + # List of tool names the agent can use + tools: NotRequired[list[str] | None] + prompt: str # The prompt content for the agent + # MCP servers specific to agent + mcp_servers: NotRequired[dict[str, MCPServerConfig]] + infer: NotRequired[bool] # Whether agent is available for model inference + + +class InfiniteSessionConfig(TypedDict, total=False): + """ + Configuration for infinite sessions with automatic context compaction + and workspace persistence. + + When enabled, sessions automatically manage context window limits through + background compaction and persist state to a workspace directory. + """ + + # Whether infinite sessions are enabled (default: True) + enabled: bool + # Context utilization threshold (0.0-1.0) at which background compaction starts. + # Compaction runs asynchronously, allowing the session to continue processing. + # Default: 0.80 + background_compaction_threshold: float + # Context utilization threshold (0.0-1.0) at which the session blocks until + # compaction completes. This prevents context overflow when compaction hasn't + # finished in time. Default: 0.95 + buffer_exhaustion_threshold: float + + +# ============================================================================ +# Session Configuration +# ============================================================================ + + +class AzureProviderOptions(TypedDict, total=False): + """Azure-specific provider configuration""" + + api_version: str # Azure API version. Defaults to "2024-10-21". + + +class ProviderConfig(TypedDict, total=False): + """Configuration for a custom API provider""" + + type: Literal["openai", "azure", "anthropic"] + wire_api: Literal["completions", "responses"] + base_url: str + api_key: str + # Bearer token for authentication. Sets the Authorization header directly. + # Use this for services requiring bearer token auth instead of API key. + # Takes precedence over api_key when both are set. + bearer_token: str + azure: AzureProviderOptions # Azure-specific options + + +class SessionConfig(TypedDict, total=False): + """Configuration for creating a session""" + + session_id: str # Optional custom session ID + # Client name to identify the application using the SDK. + # Included in the User-Agent header for API requests. + client_name: str + model: str # Model to use for this session. Use client.list_models() to see available models. + # Reasoning effort level for models that support it. + # Only valid for models where capabilities.supports.reasoning_effort is True. + reasoning_effort: ReasoningEffort + tools: list[Tool] + system_message: SystemMessageConfig # System message configuration + # List of tool names to allow (takes precedence over excluded_tools) + available_tools: list[str] + # List of tool names to disable (ignored if available_tools is set) + excluded_tools: list[str] + # Handler for permission requests from the server + on_permission_request: _PermissionHandlerFn + # Handler for user input requests from the agent (enables ask_user tool) + on_user_input_request: UserInputHandler + # Hook handlers for intercepting session lifecycle events + hooks: SessionHooks + # Working directory for the session. Tool operations will be relative to this directory. + working_directory: str + # Custom provider configuration (BYOK - Bring Your Own Key) + provider: ProviderConfig + # 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 + streaming: bool + # MCP server configurations for the session + mcp_servers: dict[str, MCPServerConfig] + # Custom agent configurations for the session + custom_agents: list[CustomAgentConfig] + # Name of the custom agent to activate when the session starts. + # Must match the name of one of the agents in custom_agents. + agent: str + # Override the default configuration directory location. + # When specified, the session will use this directory for storing config and state. + config_dir: str + # Directories to load skills from + skill_directories: list[str] + # List of skill names to disable + disabled_skills: list[str] + # Infinite session configuration for persistent workspaces and automatic compaction. + # When enabled (default), sessions automatically manage context limits and persist state. + # Set to {"enabled": False} to disable. + infinite_sessions: InfiniteSessionConfig + # Optional event handler that is registered on the session before the + # session.create RPC is issued, ensuring early events (e.g. session.start) + # are delivered. Equivalent to calling session.on(handler) immediately + # after creation, but executes earlier in the lifecycle so no events are missed. + on_event: Callable[[SessionEvent], None] + + +class ResumeSessionConfig(TypedDict, total=False): + """Configuration for resuming a session""" + + # Client name to identify the application using the SDK. + # Included in the User-Agent header for API requests. + client_name: str + # Model to use for this session. Can change the model when resuming. + model: str + tools: list[Tool] + system_message: SystemMessageConfig # System message configuration + # List of tool names to allow (takes precedence over excluded_tools) + available_tools: list[str] + # List of tool names to disable (ignored if available_tools is set) + excluded_tools: list[str] + provider: ProviderConfig + # Reasoning effort level for models that support it. + reasoning_effort: ReasoningEffort + on_permission_request: _PermissionHandlerFn + # Handler for user input requestsfrom the agent (enables ask_user tool) + on_user_input_request: UserInputHandler + # Hook handlers for intercepting session lifecycle events + hooks: SessionHooks + # Working directory for the session. Tool operations will be relative to this directory. + working_directory: str + # Override the default configuration directory location. + config_dir: str + # Enable streaming of assistant message chunks + streaming: bool + # MCP server configurations for the session + mcp_servers: dict[str, MCPServerConfig] + # Custom agent configurations for the session + custom_agents: list[CustomAgentConfig] + # Name of the custom agent to activate when the session starts. + # Must match the name of one of the agents in custom_agents. + agent: str + # Directories to load skills from + skill_directories: list[str] + # List of skill names to disable + disabled_skills: list[str] + # Infinite session configuration for persistent workspaces and automatic compaction. + infinite_sessions: InfiniteSessionConfig + # When True, skips emitting the session.resume event. + # Useful for reconnecting to a session without triggering resume-related side effects. + disable_resume: bool + # Optional event handler registered before the session.resume RPC is issued, + # ensuring early events are delivered. See SessionConfig.on_event. + on_event: Callable[[SessionEvent], None] + + +SessionEventHandler = Callable[[SessionEvent], None] class CopilotSession: @@ -245,7 +757,9 @@ def on(self, handler: Callable[[SessionEvent], None]) -> Callable[[], None]: ... print(f"Assistant: {event.data.content}") ... elif event.type == "session.error": ... print(f"Error: {event.data.message}") + ... >>> unsubscribe = session.on(handle_event) + ... >>> # Later, to stop receiving events: >>> unsubscribe() """ @@ -706,7 +1220,7 @@ async def destroy(self) -> None: ) await self.disconnect() - async def __aenter__(self) -> "CopilotSession": + async def __aenter__(self) -> CopilotSession: """Enable use as an async context manager.""" return self @@ -728,7 +1242,9 @@ async def abort(self) -> None: >>> import asyncio >>> >>> # Start a long-running request - >>> task = asyncio.create_task(session.send("Write a very long story...")) + >>> task = asyncio.create_task( + ... session.send("Write a very long story...") + ... ) >>> >>> # Abort after 5 seconds >>> await asyncio.sleep(5) diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 58e58d97e..f559cfefe 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -9,12 +9,59 @@ import inspect import json -from collections.abc import Callable -from typing import Any, TypeVar, get_type_hints, overload +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Literal, TypeVar, get_type_hints, overload from pydantic import BaseModel -from .types import Tool, ToolInvocation, ToolResult +ToolResultType = Literal["success", "failure", "rejected", "denied"] + + +@dataclass +class ToolBinaryResult: + """Binary content returned by a tool.""" + + data: str = "" + mime_type: str = "" + type: str = "" + description: str = "" + + +@dataclass +class ToolResult: + """Result of a tool invocation.""" + + text_result_for_llm: str = "" + result_type: ToolResultType = "success" + error: str | None = None + binary_results_for_llm: list[ToolBinaryResult] | None = None + session_log: str | None = None + tool_telemetry: dict[str, Any] | None = None + + +@dataclass +class ToolInvocation: + """Context passed to a tool handler when invoked.""" + + session_id: str = "" + tool_call_id: str = "" + tool_name: str = "" + arguments: Any = None + + +ToolHandler = Callable[[ToolInvocation], ToolResult | Awaitable[ToolResult]] + + +@dataclass +class Tool: + name: str + description: str + handler: ToolHandler + parameters: dict[str, Any] | None = None + overrides_built_in_tool: bool = False + skip_permission: bool = False + T = TypeVar("T", bound=BaseModel) R = TypeVar("R") diff --git a/python/copilot/types.py b/python/copilot/types.py deleted file mode 100644 index 17be065bc..000000000 --- a/python/copilot/types.py +++ /dev/null @@ -1,1060 +0,0 @@ -""" -Type definitions for the Copilot SDK -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from dataclasses import KW_ONLY, dataclass, field -from typing import Any, Literal, NotRequired, TypedDict - -# Import generated SessionEvent types -from .generated.session_events import ( - PermissionRequest, - SessionEvent, -) - -# SessionEvent is now imported from generated types -# It provides proper type discrimination for all event types - -# Valid reasoning effort levels for models that support it -ReasoningEffort = Literal["low", "medium", "high", "xhigh"] - -# Connection state -ConnectionState = Literal["disconnected", "connecting", "connected", "error"] - -# Log level type -LogLevel = Literal["none", "error", "warning", "info", "debug", "all"] - - -# Selection range for text attachments -class SelectionRange(TypedDict): - line: int - character: int - - -class Selection(TypedDict): - start: SelectionRange - end: SelectionRange - - -# Attachment types - discriminated union based on 'type' field -class FileAttachment(TypedDict): - """File attachment.""" - - type: Literal["file"] - path: str - displayName: NotRequired[str] - - -class DirectoryAttachment(TypedDict): - """Directory attachment.""" - - type: Literal["directory"] - path: str - displayName: NotRequired[str] - - -class SelectionAttachment(TypedDict): - """Selection attachment with text from a file.""" - - type: Literal["selection"] - filePath: str - displayName: str - selection: NotRequired[Selection] - text: NotRequired[str] - - -class BlobAttachment(TypedDict): - """Inline base64-encoded content attachment (e.g. images).""" - - type: Literal["blob"] - data: str - """Base64-encoded content""" - mimeType: str - """MIME type of the inline data""" - displayName: NotRequired[str] - - -# Attachment type - union of all attachment types -Attachment = FileAttachment | DirectoryAttachment | SelectionAttachment | BlobAttachment - - -# Configuration for OpenTelemetry integration with the Copilot CLI. -class TelemetryConfig(TypedDict, total=False): - """Configuration for OpenTelemetry integration with the Copilot CLI.""" - - otlp_endpoint: str - """OTLP HTTP endpoint URL for trace/metric export. Sets OTEL_EXPORTER_OTLP_ENDPOINT.""" - file_path: str - """File path for JSON-lines trace output. Sets COPILOT_OTEL_FILE_EXPORTER_PATH.""" - exporter_type: str - """Exporter backend type: "otlp-http" or "file". Sets COPILOT_OTEL_EXPORTER_TYPE.""" - source_name: str - """Instrumentation scope name. Sets COPILOT_OTEL_SOURCE_NAME.""" - capture_content: bool - """Whether to capture message content. Sets OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.""" # noqa: E501 - - -# Configuration for CopilotClient connection modes - - -@dataclass -class SubprocessConfig: - """Config for spawning a local Copilot CLI subprocess. - - Example: - >>> config = SubprocessConfig(github_token="ghp_...") - >>> client = CopilotClient(config) - - >>> # Custom CLI path with TCP transport - >>> config = SubprocessConfig( - ... cli_path="/usr/local/bin/copilot", - ... use_stdio=False, - ... log_level="debug", - ... ) - """ - - cli_path: str | None = None - """Path to the Copilot CLI executable. ``None`` uses the bundled binary.""" - - cli_args: list[str] = field(default_factory=list) - """Extra arguments passed to the CLI executable (inserted before SDK-managed args).""" - - _: KW_ONLY - - cwd: str | None = None - """Working directory for the CLI process. ``None`` uses the current directory.""" - - use_stdio: bool = True - """Use stdio transport (``True``, default) or TCP (``False``).""" - - port: int = 0 - """TCP port for the CLI server (only when ``use_stdio=False``). 0 means random.""" - - log_level: LogLevel = "info" - """Log level for the CLI process.""" - - env: dict[str, str] | None = None - """Environment variables for the CLI process. ``None`` inherits the current env.""" - - github_token: str | None = None - """GitHub token for authentication. Takes priority over other auth methods.""" - - use_logged_in_user: bool | None = None - """Use the logged-in user for authentication. - - ``None`` (default) resolves to ``True`` unless ``github_token`` is set. - """ - - telemetry: TelemetryConfig | None = None - """OpenTelemetry configuration. Providing this enables telemetry — no separate flag needed.""" - - -@dataclass -class ExternalServerConfig: - """Config for connecting to an existing Copilot CLI server over TCP. - - Example: - >>> config = ExternalServerConfig(url="localhost:3000") - >>> client = CopilotClient(config) - """ - - url: str - """Server URL. Supports ``"host:port"``, ``"http://host:port"``, or just ``"port"``.""" - - -ToolResultType = Literal["success", "failure", "rejected", "denied"] - - -@dataclass -class ToolBinaryResult: - """Binary content returned by a tool.""" - - data: str = "" - mime_type: str = "" - type: str = "" - description: str = "" - - -@dataclass -class ToolResult: - """Result of a tool invocation.""" - - text_result_for_llm: str = "" - result_type: ToolResultType = "success" - error: str | None = None - binary_results_for_llm: list[ToolBinaryResult] | None = None - session_log: str | None = None - tool_telemetry: dict[str, Any] | None = None - - -@dataclass -class ToolInvocation: - """Context passed to a tool handler when invoked.""" - - session_id: str = "" - tool_call_id: str = "" - tool_name: str = "" - arguments: Any = None - - -ToolHandler = Callable[[ToolInvocation], ToolResult | Awaitable[ToolResult]] - - -@dataclass -class Tool: - name: str - description: str - handler: ToolHandler - parameters: dict[str, Any] | None = None - overrides_built_in_tool: bool = False - skip_permission: bool = False - - -# System message configuration (discriminated union) -# Use SystemMessageAppendConfig for default behavior, SystemMessageReplaceConfig for full control - - -class SystemMessageAppendConfig(TypedDict, total=False): - """ - Append mode: Use CLI foundation with optional appended content. - """ - - mode: NotRequired[Literal["append"]] - content: NotRequired[str] - - -class SystemMessageReplaceConfig(TypedDict): - """ - Replace mode: Use caller-provided system message entirely. - Removes all SDK guardrails including security restrictions. - """ - - mode: Literal["replace"] - content: str - - -# Union type - use one or the other -SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig - - -# Permission result types - -PermissionRequestResultKind = Literal[ - "approved", - "denied-by-rules", - "denied-by-content-exclusion-policy", - "denied-no-approval-rule-and-could-not-request-from-user", - "denied-interactively-by-user", - "no-result", -] - - -@dataclass -class PermissionRequestResult: - """Result of a permission request.""" - - kind: PermissionRequestResultKind = "denied-no-approval-rule-and-could-not-request-from-user" - rules: list[Any] | None = None - feedback: str | None = None - message: str | None = None - path: str | None = None - - -_PermissionHandlerFn = Callable[ - [PermissionRequest, dict[str, str]], - PermissionRequestResult | Awaitable[PermissionRequestResult], -] - - -class PermissionHandler: - @staticmethod - def approve_all( - request: PermissionRequest, invocation: dict[str, str] - ) -> PermissionRequestResult: - return PermissionRequestResult(kind="approved") - - -# ============================================================================ -# User Input Request Types -# ============================================================================ - - -class UserInputRequest(TypedDict, total=False): - """Request for user input from the agent (enables ask_user tool)""" - - question: str - choices: list[str] - allowFreeform: bool - - -class UserInputResponse(TypedDict): - """Response to a user input request""" - - answer: str - wasFreeform: bool - - -UserInputHandler = Callable[ - [UserInputRequest, dict[str, str]], - UserInputResponse | Awaitable[UserInputResponse], -] - - -# ============================================================================ -# Hook Types -# ============================================================================ - - -class BaseHookInput(TypedDict): - """Base interface for all hook inputs""" - - timestamp: int - cwd: str - - -class PreToolUseHookInput(TypedDict): - """Input for pre-tool-use hook""" - - timestamp: int - cwd: str - toolName: str - toolArgs: Any - - -class PreToolUseHookOutput(TypedDict, total=False): - """Output for pre-tool-use hook""" - - permissionDecision: Literal["allow", "deny", "ask"] - permissionDecisionReason: str - modifiedArgs: Any - additionalContext: str - suppressOutput: bool - - -PreToolUseHandler = Callable[ - [PreToolUseHookInput, dict[str, str]], - PreToolUseHookOutput | None | Awaitable[PreToolUseHookOutput | None], -] - - -class PostToolUseHookInput(TypedDict): - """Input for post-tool-use hook""" - - timestamp: int - cwd: str - toolName: str - toolArgs: Any - toolResult: Any - - -class PostToolUseHookOutput(TypedDict, total=False): - """Output for post-tool-use hook""" - - modifiedResult: Any - additionalContext: str - suppressOutput: bool - - -PostToolUseHandler = Callable[ - [PostToolUseHookInput, dict[str, str]], - PostToolUseHookOutput | None | Awaitable[PostToolUseHookOutput | None], -] - - -class UserPromptSubmittedHookInput(TypedDict): - """Input for user-prompt-submitted hook""" - - timestamp: int - cwd: str - prompt: str - - -class UserPromptSubmittedHookOutput(TypedDict, total=False): - """Output for user-prompt-submitted hook""" - - modifiedPrompt: str - additionalContext: str - suppressOutput: bool - - -UserPromptSubmittedHandler = Callable[ - [UserPromptSubmittedHookInput, dict[str, str]], - UserPromptSubmittedHookOutput | None | Awaitable[UserPromptSubmittedHookOutput | None], -] - - -class SessionStartHookInput(TypedDict): - """Input for session-start hook""" - - timestamp: int - cwd: str - source: Literal["startup", "resume", "new"] - initialPrompt: NotRequired[str] - - -class SessionStartHookOutput(TypedDict, total=False): - """Output for session-start hook""" - - additionalContext: str - modifiedConfig: dict[str, Any] - - -SessionStartHandler = Callable[ - [SessionStartHookInput, dict[str, str]], - SessionStartHookOutput | None | Awaitable[SessionStartHookOutput | None], -] - - -class SessionEndHookInput(TypedDict): - """Input for session-end hook""" - - timestamp: int - cwd: str - reason: Literal["complete", "error", "abort", "timeout", "user_exit"] - finalMessage: NotRequired[str] - error: NotRequired[str] - - -class SessionEndHookOutput(TypedDict, total=False): - """Output for session-end hook""" - - suppressOutput: bool - cleanupActions: list[str] - sessionSummary: str - - -SessionEndHandler = Callable[ - [SessionEndHookInput, dict[str, str]], - SessionEndHookOutput | None | Awaitable[SessionEndHookOutput | None], -] - - -class ErrorOccurredHookInput(TypedDict): - """Input for error-occurred hook""" - - timestamp: int - cwd: str - error: str - errorContext: Literal["model_call", "tool_execution", "system", "user_input"] - recoverable: bool - - -class ErrorOccurredHookOutput(TypedDict, total=False): - """Output for error-occurred hook""" - - suppressOutput: bool - errorHandling: Literal["retry", "skip", "abort"] - retryCount: int - userNotification: str - - -ErrorOccurredHandler = Callable[ - [ErrorOccurredHookInput, dict[str, str]], - ErrorOccurredHookOutput | None | Awaitable[ErrorOccurredHookOutput | None], -] - - -class SessionHooks(TypedDict, total=False): - """Configuration for session hooks""" - - on_pre_tool_use: PreToolUseHandler - on_post_tool_use: PostToolUseHandler - on_user_prompt_submitted: UserPromptSubmittedHandler - on_session_start: SessionStartHandler - on_session_end: SessionEndHandler - on_error_occurred: ErrorOccurredHandler - - -# ============================================================================ -# MCP Server Configuration Types -# ============================================================================ - - -class MCPLocalServerConfig(TypedDict, total=False): - """Configuration for a local/stdio MCP server.""" - - tools: list[str] # List of tools to include. [] means none. "*" means all. - type: NotRequired[Literal["local", "stdio"]] # Server type - timeout: NotRequired[int] # Timeout in milliseconds - command: str # Command to run - args: list[str] # Command arguments - env: NotRequired[dict[str, str]] # Environment variables - cwd: NotRequired[str] # Working directory - - -class MCPRemoteServerConfig(TypedDict, total=False): - """Configuration for a remote MCP server (HTTP or SSE).""" - - tools: list[str] # List of tools to include. [] means none. "*" means all. - type: Literal["http", "sse"] # Server type - timeout: NotRequired[int] # Timeout in milliseconds - url: str # URL of the remote server - headers: NotRequired[dict[str, str]] # HTTP headers - - -MCPServerConfig = MCPLocalServerConfig | MCPRemoteServerConfig - - -# ============================================================================ -# Custom Agent Configuration Types -# ============================================================================ - - -class CustomAgentConfig(TypedDict, total=False): - """Configuration for a custom agent.""" - - name: str # Unique name of the custom agent - display_name: NotRequired[str] # Display name for UI purposes - description: NotRequired[str] # Description of what the agent does - # List of tool names the agent can use - tools: NotRequired[list[str] | None] - prompt: str # The prompt content for the agent - # MCP servers specific to agent - mcp_servers: NotRequired[dict[str, MCPServerConfig]] - infer: NotRequired[bool] # Whether agent is available for model inference - - -class InfiniteSessionConfig(TypedDict, total=False): - """ - Configuration for infinite sessions with automatic context compaction - and workspace persistence. - - When enabled, sessions automatically manage context window limits through - background compaction and persist state to a workspace directory. - """ - - # Whether infinite sessions are enabled (default: True) - enabled: bool - # Context utilization threshold (0.0-1.0) at which background compaction starts. - # Compaction runs asynchronously, allowing the session to continue processing. - # Default: 0.80 - background_compaction_threshold: float - # Context utilization threshold (0.0-1.0) at which the session blocks until - # compaction completes. This prevents context overflow when compaction hasn't - # finished in time. Default: 0.95 - buffer_exhaustion_threshold: float - - -# Azure-specific provider options -class AzureProviderOptions(TypedDict, total=False): - """Azure-specific provider configuration""" - - api_version: str # Azure API version. Defaults to "2024-10-21". - - -# Configuration for a custom API provider -class ProviderConfig(TypedDict, total=False): - """Configuration for a custom API provider""" - - type: Literal["openai", "azure", "anthropic"] - wire_api: Literal["completions", "responses"] - base_url: str - api_key: str - # Bearer token for authentication. Sets the Authorization header directly. - # Use this for services requiring bearer token auth instead of API key. - # Takes precedence over api_key when both are set. - bearer_token: str - azure: AzureProviderOptions # Azure-specific options - - -# Event handler type -SessionEventHandler = Callable[[SessionEvent], None] - - -# Response from ping -@dataclass -class PingResponse: - """Response from ping""" - - message: str # Echo message with "pong: " prefix - timestamp: int # Server timestamp in milliseconds - protocolVersion: int # Protocol version for SDK compatibility - - @staticmethod - def from_dict(obj: Any) -> PingResponse: - assert isinstance(obj, dict) - message = obj.get("message") - timestamp = obj.get("timestamp") - protocolVersion = obj.get("protocolVersion") - if message is None or timestamp is None or protocolVersion is None: - raise ValueError( - f"Missing required fields in PingResponse: message={message}, " - f"timestamp={timestamp}, protocolVersion={protocolVersion}" - ) - return PingResponse(str(message), int(timestamp), int(protocolVersion)) - - def to_dict(self) -> dict: - result: dict = {} - result["message"] = self.message - result["timestamp"] = self.timestamp - result["protocolVersion"] = self.protocolVersion - return result - - -# Error information from client stop -@dataclass -class StopError(Exception): - """Error that occurred during client stop cleanup.""" - - message: str # Error message describing what failed during cleanup - - def __post_init__(self) -> None: - Exception.__init__(self, self.message) - - @staticmethod - def from_dict(obj: Any) -> StopError: - assert isinstance(obj, dict) - message = obj.get("message") - if message is None: - raise ValueError("Missing required field 'message' in StopError") - return StopError(str(message)) - - def to_dict(self) -> dict: - result: dict = {} - result["message"] = self.message - return result - - -# Response from status.get -@dataclass -class GetStatusResponse: - """Response from status.get""" - - version: str # Package version (e.g., "1.0.0") - protocolVersion: int # Protocol version for SDK compatibility - - @staticmethod - def from_dict(obj: Any) -> GetStatusResponse: - assert isinstance(obj, dict) - version = obj.get("version") - protocolVersion = obj.get("protocolVersion") - if version is None or protocolVersion is None: - raise ValueError( - f"Missing required fields in GetStatusResponse: version={version}, " - f"protocolVersion={protocolVersion}" - ) - return GetStatusResponse(str(version), int(protocolVersion)) - - def to_dict(self) -> dict: - result: dict = {} - result["version"] = self.version - result["protocolVersion"] = self.protocolVersion - return result - - -# Response from auth.getStatus -@dataclass -class GetAuthStatusResponse: - """Response from auth.getStatus""" - - isAuthenticated: bool # Whether the user is authenticated - authType: str | None = None # Authentication type - host: str | None = None # GitHub host URL - login: str | None = None # User login name - statusMessage: str | None = None # Human-readable status message - - @staticmethod - def from_dict(obj: Any) -> GetAuthStatusResponse: - assert isinstance(obj, dict) - isAuthenticated = obj.get("isAuthenticated") - if isAuthenticated is None: - raise ValueError("Missing required field 'isAuthenticated' in GetAuthStatusResponse") - authType = obj.get("authType") - host = obj.get("host") - login = obj.get("login") - statusMessage = obj.get("statusMessage") - return GetAuthStatusResponse( - isAuthenticated=bool(isAuthenticated), - authType=authType, - host=host, - login=login, - statusMessage=statusMessage, - ) - - def to_dict(self) -> dict: - result: dict = {} - result["isAuthenticated"] = self.isAuthenticated - if self.authType is not None: - result["authType"] = self.authType - if self.host is not None: - result["host"] = self.host - if self.login is not None: - result["login"] = self.login - if self.statusMessage is not None: - result["statusMessage"] = self.statusMessage - return result - - -# Model capabilities -@dataclass -class ModelVisionLimits: - """Vision-specific limits""" - - supported_media_types: list[str] | None = None - max_prompt_images: int | None = None - max_prompt_image_size: int | None = None - - @staticmethod - def from_dict(obj: Any) -> ModelVisionLimits: - assert isinstance(obj, dict) - supported_media_types = obj.get("supported_media_types") - max_prompt_images = obj.get("max_prompt_images") - max_prompt_image_size = obj.get("max_prompt_image_size") - return ModelVisionLimits( - supported_media_types=supported_media_types, - max_prompt_images=max_prompt_images, - max_prompt_image_size=max_prompt_image_size, - ) - - def to_dict(self) -> dict: - result: dict = {} - if self.supported_media_types is not None: - result["supported_media_types"] = self.supported_media_types - if self.max_prompt_images is not None: - result["max_prompt_images"] = self.max_prompt_images - if self.max_prompt_image_size is not None: - result["max_prompt_image_size"] = self.max_prompt_image_size - return result - - -@dataclass -class ModelLimits: - """Model limits""" - - max_prompt_tokens: int | None = None - max_context_window_tokens: int | None = None - vision: ModelVisionLimits | None = None - - @staticmethod - def from_dict(obj: Any) -> ModelLimits: - assert isinstance(obj, dict) - max_prompt_tokens = obj.get("max_prompt_tokens") - max_context_window_tokens = obj.get("max_context_window_tokens") - vision_dict = obj.get("vision") - vision = ModelVisionLimits.from_dict(vision_dict) if vision_dict else None - return ModelLimits( - max_prompt_tokens=max_prompt_tokens, - max_context_window_tokens=max_context_window_tokens, - vision=vision, - ) - - def to_dict(self) -> dict: - result: dict = {} - if self.max_prompt_tokens is not None: - result["max_prompt_tokens"] = self.max_prompt_tokens - if self.max_context_window_tokens is not None: - result["max_context_window_tokens"] = self.max_context_window_tokens - if self.vision is not None: - result["vision"] = self.vision.to_dict() - return result - - -@dataclass -class ModelSupports: - """Model support flags""" - - vision: bool - reasoning_effort: bool = False # Whether this model supports reasoning effort - - @staticmethod - def from_dict(obj: Any) -> ModelSupports: - assert isinstance(obj, dict) - vision = obj.get("vision") - if vision is None: - raise ValueError("Missing required field 'vision' in ModelSupports") - reasoning_effort = obj.get("reasoningEffort", False) - return ModelSupports(vision=bool(vision), reasoning_effort=bool(reasoning_effort)) - - def to_dict(self) -> dict: - result: dict = {} - result["vision"] = self.vision - result["reasoningEffort"] = self.reasoning_effort - return result - - -@dataclass -class ModelCapabilities: - """Model capabilities and limits""" - - supports: ModelSupports - limits: ModelLimits - - @staticmethod - def from_dict(obj: Any) -> ModelCapabilities: - assert isinstance(obj, dict) - supports_dict = obj.get("supports") - limits_dict = obj.get("limits") - if supports_dict is None or limits_dict is None: - raise ValueError( - f"Missing required fields in ModelCapabilities: supports={supports_dict}, " - f"limits={limits_dict}" - ) - supports = ModelSupports.from_dict(supports_dict) - limits = ModelLimits.from_dict(limits_dict) - return ModelCapabilities(supports=supports, limits=limits) - - def to_dict(self) -> dict: - result: dict = {} - result["supports"] = self.supports.to_dict() - result["limits"] = self.limits.to_dict() - return result - - -@dataclass -class ModelPolicy: - """Model policy state""" - - state: str # "enabled", "disabled", or "unconfigured" - terms: str - - @staticmethod - def from_dict(obj: Any) -> ModelPolicy: - assert isinstance(obj, dict) - state = obj.get("state") - terms = obj.get("terms") - if state is None or terms is None: - raise ValueError( - f"Missing required fields in ModelPolicy: state={state}, terms={terms}" - ) - return ModelPolicy(state=str(state), terms=str(terms)) - - def to_dict(self) -> dict: - result: dict = {} - result["state"] = self.state - result["terms"] = self.terms - return result - - -@dataclass -class ModelBilling: - """Model billing information""" - - multiplier: float - - @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(multiplier=float(multiplier)) - - def to_dict(self) -> dict: - result: dict = {} - result["multiplier"] = self.multiplier - return result - - -@dataclass -class ModelInfo: - """Information about an available model""" - - id: str # Model identifier (e.g., "claude-sonnet-4.5") - name: str # Display name - capabilities: ModelCapabilities # Model capabilities and limits - policy: ModelPolicy | None = None # Policy state - billing: ModelBilling | None = None # Billing information - # Supported reasoning effort levels (only present if model supports reasoning effort) - supported_reasoning_efforts: list[str] | None = None - # Default reasoning effort level (only present if model supports reasoning effort) - default_reasoning_effort: str | None = None - - @staticmethod - def from_dict(obj: Any) -> ModelInfo: - assert isinstance(obj, dict) - id = obj.get("id") - name = obj.get("name") - capabilities_dict = obj.get("capabilities") - if id is None or name is None or capabilities_dict is None: - raise ValueError( - f"Missing required fields in ModelInfo: id={id}, name={name}, " - f"capabilities={capabilities_dict}" - ) - capabilities = ModelCapabilities.from_dict(capabilities_dict) - policy_dict = obj.get("policy") - policy = ModelPolicy.from_dict(policy_dict) if policy_dict else None - billing_dict = obj.get("billing") - billing = ModelBilling.from_dict(billing_dict) if billing_dict else None - supported_reasoning_efforts = obj.get("supportedReasoningEfforts") - default_reasoning_effort = obj.get("defaultReasoningEffort") - return ModelInfo( - id=str(id), - name=str(name), - capabilities=capabilities, - policy=policy, - billing=billing, - supported_reasoning_efforts=supported_reasoning_efforts, - default_reasoning_effort=default_reasoning_effort, - ) - - def to_dict(self) -> dict: - result: dict = {} - result["id"] = self.id - result["name"] = self.name - result["capabilities"] = self.capabilities.to_dict() - if self.policy is not None: - result["policy"] = self.policy.to_dict() - if self.billing is not None: - result["billing"] = self.billing.to_dict() - if self.supported_reasoning_efforts is not None: - result["supportedReasoningEfforts"] = self.supported_reasoning_efforts - if self.default_reasoning_effort is not None: - result["defaultReasoningEffort"] = self.default_reasoning_effort - return result - - -@dataclass -class SessionContext: - """Working directory context for a session""" - - cwd: str # Working directory where the session was created - gitRoot: str | None = None # Git repository root (if in a git repo) - repository: str | None = None # GitHub repository in "owner/repo" format - branch: str | None = None # Current git branch - - @staticmethod - def from_dict(obj: Any) -> SessionContext: - assert isinstance(obj, dict) - cwd = obj.get("cwd") - if cwd is None: - raise ValueError("Missing required field 'cwd' in SessionContext") - return SessionContext( - cwd=str(cwd), - gitRoot=obj.get("gitRoot"), - repository=obj.get("repository"), - branch=obj.get("branch"), - ) - - def to_dict(self) -> dict: - result: dict = {"cwd": self.cwd} - if self.gitRoot is not None: - result["gitRoot"] = self.gitRoot - if self.repository is not None: - result["repository"] = self.repository - if self.branch is not None: - result["branch"] = self.branch - return result - - -@dataclass -class SessionListFilter: - """Filter options for listing sessions""" - - cwd: str | None = None # Filter by exact cwd match - gitRoot: str | None = None # Filter by git root - repository: str | None = None # Filter by repository (owner/repo format) - branch: str | None = None # Filter by branch - - def to_dict(self) -> dict: - result: dict = {} - if self.cwd is not None: - result["cwd"] = self.cwd - if self.gitRoot is not None: - result["gitRoot"] = self.gitRoot - if self.repository is not None: - result["repository"] = self.repository - if self.branch is not None: - result["branch"] = self.branch - return result - - -@dataclass -class SessionMetadata: - """Metadata about a session""" - - sessionId: str # Session identifier - startTime: str # ISO 8601 timestamp when session was created - modifiedTime: str # ISO 8601 timestamp when session was last modified - isRemote: bool # Whether the session is remote - summary: str | None = None # Optional summary of the session - context: SessionContext | None = None # Working directory context - - @staticmethod - def from_dict(obj: Any) -> SessionMetadata: - assert isinstance(obj, dict) - sessionId = obj.get("sessionId") - startTime = obj.get("startTime") - modifiedTime = obj.get("modifiedTime") - isRemote = obj.get("isRemote") - if sessionId is None or startTime is None or modifiedTime is None or isRemote is None: - raise ValueError( - f"Missing required fields in SessionMetadata: sessionId={sessionId}, " - f"startTime={startTime}, modifiedTime={modifiedTime}, isRemote={isRemote}" - ) - summary = obj.get("summary") - context_dict = obj.get("context") - context = SessionContext.from_dict(context_dict) if context_dict else None - return SessionMetadata( - sessionId=str(sessionId), - startTime=str(startTime), - modifiedTime=str(modifiedTime), - isRemote=bool(isRemote), - summary=summary, - context=context, - ) - - def to_dict(self) -> dict: - result: dict = {} - result["sessionId"] = self.sessionId - result["startTime"] = self.startTime - result["modifiedTime"] = self.modifiedTime - result["isRemote"] = self.isRemote - if self.summary is not None: - result["summary"] = self.summary - if self.context is not None: - result["context"] = self.context.to_dict() - return result - - -# Session Lifecycle Types (for TUI+server mode) - -SessionLifecycleEventType = Literal[ - "session.created", - "session.deleted", - "session.updated", - "session.foreground", - "session.background", -] - - -@dataclass -class SessionLifecycleEventMetadata: - """Metadata for session lifecycle events.""" - - startTime: str - modifiedTime: str - summary: str | None = None - - @staticmethod - def from_dict(data: dict) -> SessionLifecycleEventMetadata: - return SessionLifecycleEventMetadata( - startTime=data.get("startTime", ""), - modifiedTime=data.get("modifiedTime", ""), - summary=data.get("summary"), - ) - - -@dataclass -class SessionLifecycleEvent: - """Session lifecycle event notification.""" - - type: SessionLifecycleEventType - sessionId: str - metadata: SessionLifecycleEventMetadata | None = None - - @staticmethod - def from_dict(data: dict) -> SessionLifecycleEvent: - metadata = None - if "metadata" in data and data["metadata"]: - metadata = SessionLifecycleEventMetadata.from_dict(data["metadata"]) - return SessionLifecycleEvent( - type=data.get("type", "session.updated"), - sessionId=data.get("sessionId", ""), - metadata=metadata, - ) - - -# Handler types for session lifecycle events -SessionLifecycleHandler = Callable[[SessionLifecycleEvent], None] diff --git a/python/e2e/test_agent_and_compact_rpc.py b/python/e2e/test_agent_and_compact_rpc.py index 63d3e7322..ce946d2f3 100644 --- a/python/e2e/test_agent_and_compact_rpc.py +++ b/python/e2e/test_agent_and_compact_rpc.py @@ -2,8 +2,10 @@ import pytest -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig from copilot.generated.rpc import SessionAgentSelectParams +from copilot.session import PermissionHandler from .testharness import CLI_PATH, E2ETestContext @@ -147,7 +149,7 @@ async def test_should_deselect_current_agent(self): @pytest.mark.asyncio async def test_should_return_empty_list_when_no_custom_agents_configured(self): - """Test listing agents returns empty when none configured.""" + """Test listing agents returns no custom agents when none configured.""" client = CopilotClient(SubprocessConfig(cli_path=CLI_PATH, use_stdio=True)) try: @@ -157,7 +159,13 @@ async def test_should_return_empty_list_when_no_custom_agents_configured(self): ) result = await session.rpc.agent.list() - assert result.agents == [] + # The CLI may return built-in/default agents even when no custom agents + # are configured. Verify no custom test agents appear in the list. + custom_names = {"test-agent", "another-agent"} + for agent in result.agents: + assert agent.name not in custom_names, ( + f"Expected no custom agents, but found {agent.name!r}" + ) await session.disconnect() await client.stop() diff --git a/python/e2e/test_ask_user.py b/python/e2e/test_ask_user.py index fc4cc60b5..0a764029c 100644 --- a/python/e2e/test_ask_user.py +++ b/python/e2e/test_ask_user.py @@ -4,7 +4,7 @@ import pytest -from copilot import PermissionHandler +from copilot.session import PermissionHandler from .testharness import E2ETestContext diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index d266991f7..4ea3fc843 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -2,7 +2,9 @@ import pytest -from copilot import CopilotClient, PermissionHandler, StopError, SubprocessConfig +from copilot import CopilotClient +from copilot.client import StopError, SubprocessConfig +from copilot.session import PermissionHandler from .testharness import CLI_PATH diff --git a/python/e2e/test_compaction.py b/python/e2e/test_compaction.py index beb51e74b..c6df2bffa 100644 --- a/python/e2e/test_compaction.py +++ b/python/e2e/test_compaction.py @@ -2,8 +2,8 @@ import pytest -from copilot import PermissionHandler from copilot.generated.session_events import SessionEventType +from copilot.session import PermissionHandler from .testharness import E2ETestContext diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index 2858d40f2..e355f3a80 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -4,7 +4,7 @@ import pytest -from copilot import PermissionHandler +from copilot.session import PermissionHandler from .testharness import E2ETestContext from .testharness.helper import write_file diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index c4bd89414..c6a590d6c 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -6,7 +6,7 @@ import pytest -from copilot import CustomAgentConfig, MCPServerConfig, PermissionHandler +from copilot.session import CustomAgentConfig, MCPServerConfig, PermissionHandler from .testharness import E2ETestContext, get_final_assistant_message diff --git a/python/e2e/test_multi_client.py b/python/e2e/test_multi_client.py index c77ae86e1..2d866e8aa 100644 --- a/python/e2e/test_multi_client.py +++ b/python/e2e/test_multi_client.py @@ -13,15 +13,10 @@ import pytest_asyncio from pydantic import BaseModel, Field -from copilot import ( - CopilotClient, - ExternalServerConfig, - PermissionHandler, - PermissionRequestResult, - SubprocessConfig, - ToolInvocation, - define_tool, -) +from copilot import CopilotClient, define_tool +from copilot.client import ExternalServerConfig, SubprocessConfig +from copilot.session import PermissionHandler, PermissionRequestResult +from copilot.tools import ToolInvocation from .testharness import get_final_assistant_message from .testharness.proxy import CapiProxy diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index a673d63b5..692c600e0 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -6,7 +6,7 @@ import pytest -from copilot import PermissionHandler, PermissionRequest, PermissionRequestResult +from copilot.session import PermissionHandler, PermissionRequest, PermissionRequestResult from .testharness import E2ETestContext from .testharness.helper import read_file, write_file diff --git a/python/e2e/test_rpc.py b/python/e2e/test_rpc.py index 814da067d..a86f874db 100644 --- a/python/e2e/test_rpc.py +++ b/python/e2e/test_rpc.py @@ -2,8 +2,10 @@ import pytest -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig from copilot.generated.rpc import PingParams +from copilot.session import PermissionHandler from .testharness import CLI_PATH, E2ETestContext diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index ffb0cd2bc..c1a65e494 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -4,8 +4,10 @@ import pytest -from copilot import CopilotClient, PermissionHandler, SubprocessConfig -from copilot.types import Tool, ToolResult +from copilot import CopilotClient +from copilot.client import SubprocessConfig +from copilot.session import PermissionHandler +from copilot.tools import Tool, ToolResult from .testharness import E2ETestContext, get_final_assistant_message, get_next_event_of_type @@ -82,6 +84,33 @@ async def test_should_create_a_session_with_replaced_systemMessage_config( system_message = _get_system_message(traffic[0]) assert system_message == test_system_message # Exact match + async def test_should_create_a_session_with_customized_systemMessage_config( + self, ctx: E2ETestContext + ): + custom_tone = "Respond in a warm, professional tone. Be thorough in explanations." + appended_content = "Always mention quarterly earnings." + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + system_message={ + "mode": "customize", + "sections": { + "tone": {"action": "replace", "content": custom_tone}, + "code_change_rules": {"action": "remove"}, + }, + "content": appended_content, + }, + ) + + assistant_message = await session.send_and_wait("Who are you?") + assert assistant_message is not None + + # Validate the system message sent to the model + traffic = await ctx.get_exchanges() + system_message = _get_system_message(traffic[0]) + assert custom_tone in system_message + assert appended_content in system_message + assert "" not in system_message + async def test_should_create_a_session_with_availableTools(self, ctx: E2ETestContext): session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index 9b0599975..feacae73b 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -7,7 +7,7 @@ import pytest -from copilot import PermissionHandler +from copilot.session import PermissionHandler from .testharness import E2ETestContext diff --git a/python/e2e/test_streaming_fidelity.py b/python/e2e/test_streaming_fidelity.py index 05e977e12..c2e79814a 100644 --- a/python/e2e/test_streaming_fidelity.py +++ b/python/e2e/test_streaming_fidelity.py @@ -4,7 +4,9 @@ import pytest -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig +from copilot.session import PermissionHandler from .testharness import E2ETestContext diff --git a/python/e2e/test_system_message_transform.py b/python/e2e/test_system_message_transform.py new file mode 100644 index 000000000..9ae170637 --- /dev/null +++ b/python/e2e/test_system_message_transform.py @@ -0,0 +1,123 @@ +""" +Copyright (c) Microsoft Corporation. + +Tests for system message transform functionality +""" + +import pytest + +from copilot import PermissionHandler + +from .testharness import E2ETestContext +from .testharness.helper import write_file + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestSystemMessageTransform: + async def test_should_invoke_transform_callbacks_with_section_content( + self, ctx: E2ETestContext + ): + """Test that transform callbacks are invoked with the section content""" + identity_contents = [] + tone_contents = [] + + async def identity_transform(content: str) -> str: + identity_contents.append(content) + return content + + async def tone_transform(content: str) -> str: + tone_contents.append(content) + return content + + session = await ctx.client.create_session( + system_message={ + "mode": "customize", + "sections": { + "identity": {"action": identity_transform}, + "tone": {"action": tone_transform}, + }, + }, + on_permission_request=PermissionHandler.approve_all, + ) + + write_file(ctx.work_dir, "test.txt", "Hello transform!") + + await session.send_and_wait("Read the contents of test.txt and tell me what it says") + + # Both transform callbacks should have been invoked + assert len(identity_contents) > 0 + assert len(tone_contents) > 0 + + # Callbacks should have received non-empty content + assert all(len(c) > 0 for c in identity_contents) + assert all(len(c) > 0 for c in tone_contents) + + await session.disconnect() + + async def test_should_apply_transform_modifications_to_section_content( + self, ctx: E2ETestContext + ): + """Test that transform modifications are applied to the section content""" + + async def identity_transform(content: str) -> str: + return content + "\nTRANSFORM_MARKER" + + session = await ctx.client.create_session( + system_message={ + "mode": "customize", + "sections": { + "identity": {"action": identity_transform}, + }, + }, + on_permission_request=PermissionHandler.approve_all, + ) + + write_file(ctx.work_dir, "hello.txt", "Hello!") + + await session.send_and_wait("Read the contents of hello.txt") + + # Verify the transform result was actually applied to the system message + traffic = await ctx.get_exchanges() + system_message = _get_system_message(traffic[0]) + assert "TRANSFORM_MARKER" in system_message + + await session.disconnect() + + async def test_should_work_with_static_overrides_and_transforms_together( + self, ctx: E2ETestContext + ): + """Test that static overrides and transforms work together""" + identity_contents = [] + + async def identity_transform(content: str) -> str: + identity_contents.append(content) + return content + + session = await ctx.client.create_session( + system_message={ + "mode": "customize", + "sections": { + "safety": {"action": "remove"}, + "identity": {"action": identity_transform}, + }, + }, + on_permission_request=PermissionHandler.approve_all, + ) + + write_file(ctx.work_dir, "combo.txt", "Combo test!") + + await session.send_and_wait("Read the contents of combo.txt and tell me what it says") + + # The transform callback should have been invoked + assert len(identity_contents) > 0 + + await session.disconnect() + + +def _get_system_message(exchange: dict) -> str: + messages = exchange.get("request", {}).get("messages", []) + for msg in messages: + if msg.get("role") == "system": + return msg.get("content", "") + return "" diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 458897d49..4bb853976 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -5,12 +5,9 @@ import pytest from pydantic import BaseModel, Field -from copilot import ( - PermissionHandler, - PermissionRequestResult, - ToolInvocation, - define_tool, -) +from copilot import define_tool +from copilot.session import PermissionHandler, PermissionRequestResult +from copilot.tools import ToolInvocation from .testharness import E2ETestContext, get_final_assistant_message diff --git a/python/e2e/test_tools_unit.py b/python/e2e/test_tools_unit.py index c1a9163e1..c9c996f0e 100644 --- a/python/e2e/test_tools_unit.py +++ b/python/e2e/test_tools_unit.py @@ -5,8 +5,8 @@ import pytest from pydantic import BaseModel, Field -from copilot import ToolInvocation, ToolResult, define_tool -from copilot.tools import _normalize_result +from copilot import define_tool +from copilot.tools import ToolInvocation, ToolResult, _normalize_result class TestDefineTool: diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 27dce38a1..6a4bac6d2 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -10,7 +10,8 @@ import tempfile from pathlib import Path -from copilot import CopilotClient, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig from .proxy import CapiProxy diff --git a/python/samples/chat.py b/python/samples/chat.py index ee94c21fe..890191b19 100644 --- a/python/samples/chat.py +++ b/python/samples/chat.py @@ -1,6 +1,7 @@ import asyncio -from copilot import CopilotClient, PermissionHandler +from copilot import CopilotClient +from copilot.session import PermissionHandler BLUE = "\033[34m" RESET = "\033[0m" diff --git a/python/test_client.py b/python/test_client.py index 9f8f38423..41f536d28 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -6,15 +6,16 @@ import pytest -from copilot import ( - CopilotClient, +from copilot import CopilotClient, define_tool +from copilot.client import ( ExternalServerConfig, - PermissionHandler, - PermissionRequestResult, + ModelCapabilities, + ModelInfo, + ModelLimits, + ModelSupports, SubprocessConfig, - define_tool, ) -from copilot.types import ModelCapabilities, ModelInfo, ModelLimits, ModelSupports +from copilot.session import PermissionHandler, PermissionRequestResult from e2e.testharness import CLI_PATH diff --git a/python/test_telemetry.py b/python/test_telemetry.py index aec38f816..d10ffeb9f 100644 --- a/python/test_telemetry.py +++ b/python/test_telemetry.py @@ -5,7 +5,7 @@ from unittest.mock import patch from copilot._telemetry import get_trace_context, trace_context -from copilot.types import SubprocessConfig, TelemetryConfig +from copilot.client import SubprocessConfig, TelemetryConfig class TestGetTraceContext: diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index c44973fb1..a48ed47b6 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -522,11 +522,11 @@ namespace GitHub.Copilot.SDK; lines.push(`/// Provides the base class from which all session events derive.`); lines.push(`/// `); lines.push(`[DebuggerDisplay("{DebuggerDisplay,nq}")]`); - lines.push(`[JsonPolymorphic(`, ` TypeDiscriminatorPropertyName = "type",`, ` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]`); + lines.push(`[JsonPolymorphic(`, ` TypeDiscriminatorPropertyName = "type",`, ` IgnoreUnrecognizedTypeDiscriminators = true)]`); for (const variant of [...variants].sort((a, b) => a.typeName.localeCompare(b.typeName))) { lines.push(`[JsonDerivedType(typeof(${variant.className}), "${variant.typeName}")]`); } - lines.push(`public abstract partial class SessionEvent`, `{`); + lines.push(`public partial class SessionEvent`, `{`); lines.push(...xmlDocComment(baseDesc("id"), " ")); lines.push(` [JsonPropertyName("id")]`, ` public Guid Id { get; set; }`, ""); lines.push(...xmlDocComment(baseDesc("timestamp"), " ")); @@ -536,7 +536,7 @@ namespace GitHub.Copilot.SDK; lines.push(...xmlDocComment(baseDesc("ephemeral"), " ")); lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, ` [JsonPropertyName("ephemeral")]`, ` public bool? Ephemeral { get; set; }`, ""); lines.push(` /// `, ` /// The event type discriminator.`, ` /// `); - lines.push(` [JsonIgnore]`, ` public abstract string Type { get; }`, ""); + lines.push(` [JsonIgnore]`, ` public virtual string Type => "unknown";`, ""); lines.push(` /// Deserializes a JSON string into a .`); lines.push(` public static SessionEvent FromJson(string json) =>`, ` JsonSerializer.Deserialize(json, SessionEventsJsonContext.Default.SessionEvent)!;`, ""); lines.push(` /// Serializes this event to a JSON string.`); diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index c467761d0..59abee298 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -45,6 +45,77 @@ function toGoFieldName(jsonName: string): string { .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); + } + } + + // Replace each const block in place, then fix switch-case references + // in marshal/unmarshal functions. This avoids renaming struct fields. + + // 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; + }); + + return code; +} + +/** + * Extract a mapping from (structName, jsonFieldName) → goFieldName + * so the wrapper code references the actual quicktype-generated field names. + */ +function extractFieldNames(qtCode: string): Map> { + const result = new Map>(); + const structRe = /^type\s+(\w+)\s+struct\s*\{([^}]*)\}/gm; + let sm; + while ((sm = structRe.exec(qtCode)) !== null) { + const [, structName, body] = sm; + const fields = new Map(); + const fieldRe = /^\s+(\w+)\s+[^`\n]+`json:"([^",]+)/gm; + let fm; + while ((fm = fieldRe.exec(body)) !== null) { + fields.set(fm[2], fm[1]); + } + result.set(structName, fields); + } + return result; +} + async function formatGoFile(filePath: string): Promise { try { await execFileAsync("go", ["fmt", filePath]); @@ -93,7 +164,7 @@ async function generateSessionEvents(schemaPath?: string): Promise { `; - const outPath = await writeGeneratedFile("go/generated_session_events.go", banner + result.lines.join("\n")); + const outPath = await writeGeneratedFile("go/generated_session_events.go", banner + postProcessEnumConstants(result.lines.join("\n"))); console.log(` ✓ ${outPath}`); await formatGoFile(outPath); @@ -154,22 +225,25 @@ async function generateRpc(schemaPath?: string): Promise { rendererOptions: { package: "copilot", "just-types": "true" }, }); - // Build method wrappers - const lines: string[] = []; - lines.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); - lines.push(`// Generated from: api.schema.json`); - lines.push(``); - lines.push(`package rpc`); - lines.push(``); - lines.push(`import (`); - lines.push(`\t"context"`); - lines.push(`\t"encoding/json"`); - lines.push(``); - lines.push(`\t"github.com/github/copilot-sdk/go/internal/jsonrpc2"`); - lines.push(`)`); - lines.push(``); + // Post-process quicktype output: fix enum constant names + let qtCode = qtResult.lines.filter((l) => !l.startsWith("package ")).join("\n"); + qtCode = postProcessEnumConstants(qtCode); + // Strip trailing whitespace from quicktype output (gofmt requirement) + qtCode = qtCode.replace(/[ \t]+$/gm, ""); - // Add quicktype-generated types (skip package line), annotating experimental types + // Extract actual type names generated by quicktype (may differ from toPascalCase) + const actualTypeNames = new Map(); + const structRe = /^type\s+(\w+)\s+struct\b/gm; + let sm; + while ((sm = structRe.exec(qtCode)) !== 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); + + // Annotate experimental data types const experimentalTypeNames = new Set(); for (const method of allMethods) { if (method.stability !== "experimental") continue; @@ -179,9 +253,6 @@ async function generateRpc(schemaPath?: string): Promise { experimentalTypeNames.add(baseName + "Params"); } } - let qtCode = qtResult.lines.filter((l) => !l.startsWith("package ")).join("\n"); - // Strip trailing whitespace from quicktype output (gofmt requirement) - qtCode = qtCode.replace(/[ \t]+$/gm, ""); for (const typeName of experimentalTypeNames) { qtCode = qtCode.replace( new RegExp(`^(type ${typeName} struct)`, "m"), @@ -190,17 +261,33 @@ async function generateRpc(schemaPath?: string): Promise { } // Remove trailing blank lines from quicktype output before appending qtCode = qtCode.replace(/\n+$/, ""); + + // Build method wrappers + const lines: string[] = []; + lines.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); + lines.push(`// Generated from: api.schema.json`); + lines.push(``); + lines.push(`package rpc`); + lines.push(``); + lines.push(`import (`); + lines.push(`\t"context"`); + lines.push(`\t"encoding/json"`); + lines.push(``); + lines.push(`\t"github.com/github/copilot-sdk/go/internal/jsonrpc2"`); + lines.push(`)`); + lines.push(``); + lines.push(qtCode); lines.push(``); // Emit ServerRpc if (schema.server) { - emitRpcWrapper(lines, schema.server, false); + emitRpcWrapper(lines, schema.server, false, resolveType, fieldNames); } // Emit SessionRpc if (schema.session) { - emitRpcWrapper(lines, schema.session, true); + emitRpcWrapper(lines, schema.session, true, resolveType, fieldNames); } const outPath = await writeGeneratedFile("go/rpc/generated_rpc.go", lines.join("\n")); @@ -209,7 +296,7 @@ async function generateRpc(schemaPath?: string): Promise { await formatGoFile(outPath); } -function emitRpcWrapper(lines: string[], node: Record, isSession: boolean): void { +function emitRpcWrapper(lines: string[], node: Record, isSession: boolean, resolveType: (name: string) => string, fieldNames: Map>): void { const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); @@ -235,7 +322,7 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(``); for (const [key, value] of Object.entries(groupNode as Record)) { if (!isRpcMethod(value)) continue; - emitMethod(lines, apiName, key, value, isSession, groupExperimental); + emitMethod(lines, apiName, key, value, isSession, resolveType, fieldNames, groupExperimental); } } @@ -260,7 +347,7 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio // Top-level methods (server only) for (const [key, value] of topLevelMethods) { if (!isRpcMethod(value)) continue; - emitMethod(lines, wrapperName, key, value, isSession, false); + emitMethod(lines, wrapperName, key, value, isSession, resolveType, fieldNames, false); } // Compute key alignment for constructor composite literal (gofmt aligns key: value) @@ -284,15 +371,15 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(``); } -function emitMethod(lines: string[], receiver: string, name: string, method: RpcMethod, isSession: boolean, groupExperimental = false): void { +function emitMethod(lines: string[], receiver: string, name: string, method: RpcMethod, isSession: boolean, resolveType: (name: string) => string, fieldNames: Map>, groupExperimental = false): void { const methodName = toPascalCase(name); - const resultType = toPascalCase(method.rpcMethod) + "Result"; + const resultType = resolveType(toPascalCase(method.rpcMethod) + "Result"); const paramProps = method.params?.properties || {}; const requiredParams = new Set(method.params?.required || []); const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; - const paramsType = hasParams ? toPascalCase(method.rpcMethod) + "Params" : ""; + const paramsType = hasParams ? resolveType(toPascalCase(method.rpcMethod) + "Params") : ""; if (method.stability === "experimental" && !groupExperimental) { lines.push(`// Experimental: ${methodName} is an experimental API and may change or be removed in future versions.`); @@ -308,7 +395,7 @@ function emitMethod(lines: string[], receiver: string, name: string, method: Rpc if (hasParams) { lines.push(`\tif params != nil {`); for (const pName of nonSessionParams) { - const goField = toGoFieldName(pName); + const goField = fieldNames.get(paramsType)?.get(pName) ?? toGoFieldName(pName); const isOptional = !requiredParams.has(pName); if (isOptional) { // Optional fields are pointers - only add when non-nil and dereference diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index cbbc3df38..0340cf1f1 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -32,12 +32,37 @@ import { * - Callable from collections.abc instead of typing * - Clean up unused typing imports */ +function replaceBalancedBrackets(code: string, prefix: string, replacer: (inner: string) => string): string { + let result = ""; + let i = 0; + while (i < code.length) { + const idx = code.indexOf(prefix + "[", i); + if (idx === -1) { + result += code.slice(i); + break; + } + result += code.slice(i, idx); + const start = idx + prefix.length + 1; // after '[' + let depth = 1; + let j = start; + while (j < code.length && depth > 0) { + if (code[j] === "[") depth++; + else if (code[j] === "]") depth--; + j++; + } + const inner = code.slice(start, j - 1); + result += replacer(inner); + i = j; + } + return result; +} + function modernizePython(code: string): string { - // Replace Optional[X] with X | None (handles nested brackets) - code = code.replace(/Optional\[([^\[\]]*(?:\[[^\[\]]*\])*[^\[\]]*)\]/g, "$1 | None"); + // Replace Optional[X] with X | None (handles arbitrarily nested brackets) + code = replaceBalancedBrackets(code, "Optional", (inner) => `${inner} | None`); // Replace Union[X, Y] with X | Y - code = code.replace(/Union\[([^\[\]]*(?:\[[^\[\]]*\])*[^\[\]]*)\]/g, (_match, inner: string) => { + code = replaceBalancedBrackets(code, "Union", (inner) => { return inner.split(",").map((s: string) => s.trim()).join(" | "); }); @@ -234,6 +259,16 @@ async function generateRpc(schemaPath?: string): Promise { ); } + // Extract actual class names generated by quicktype (may differ from toPascalCase, + // e.g. quicktype produces "SessionMCPList" not "SessionMcpList") + const actualTypeNames = new Map(); + const classRe = /^class\s+(\w+)\b/gm; + let cm; + while ((cm = classRe.exec(typesCode)) !== null) { + actualTypeNames.set(cm[1].toLowerCase(), cm[1]); + } + const resolveType = (name: string): string => actualTypeNames.get(name.toLowerCase()) ?? name; + const lines: string[] = []; lines.push(`""" AUTO-GENERATED FILE - DO NOT EDIT @@ -258,17 +293,17 @@ def _timeout_kwargs(timeout: float | None) -> dict: // Emit RPC wrapper classes if (schema.server) { - emitRpcWrapper(lines, schema.server, false); + emitRpcWrapper(lines, schema.server, false, resolveType); } if (schema.session) { - emitRpcWrapper(lines, schema.session, true); + emitRpcWrapper(lines, schema.session, true, resolveType); } const outPath = await writeGeneratedFile("python/copilot/generated/rpc.py", lines.join("\n")); console.log(` ✓ ${outPath}`); } -function emitRpcWrapper(lines: string[], node: Record, isSession: boolean): void { +function emitRpcWrapper(lines: string[], node: Record, isSession: boolean, resolveType: (name: string) => 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)); @@ -298,7 +333,7 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(``); for (const [key, value] of Object.entries(groupNode as Record)) { if (!isRpcMethod(value)) continue; - emitMethod(lines, key, value, isSession, groupExperimental); + emitMethod(lines, key, value, isSession, resolveType, groupExperimental); } lines.push(``); } @@ -327,19 +362,19 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio // Top-level methods for (const [key, value] of topLevelMethods) { if (!isRpcMethod(value)) continue; - emitMethod(lines, key, value, isSession, false); + emitMethod(lines, key, value, isSession, resolveType, false); } lines.push(``); } -function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: boolean, groupExperimental = false): void { +function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: boolean, resolveType: (name: string) => string, groupExperimental = false): void { const methodName = toSnakeCase(name); - const resultType = toPascalCase(method.rpcMethod) + "Result"; + const resultType = resolveType(toPascalCase(method.rpcMethod) + "Result"); const paramProps = method.params?.properties || {}; const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; - const paramsType = toPascalCase(method.rpcMethod) + "Params"; + const paramsType = resolveType(toPascalCase(method.rpcMethod) + "Params"); // Build signature with typed params + optional timeout const sig = hasParams diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index bf9564d9a..c8ec038fb 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.4", + "@github/copilot": "^1.0.10-0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "openai": "^6.17.0", @@ -462,27 +462,27 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.4.tgz", - "integrity": "sha512-IpPg+zYplLu4F4lmatEDdR/1Y/jJ9cGWt89m3K3H4YSfYrZ5Go4UlM28llulYCG7sVdQeIGauQN1/KiBI/Rocg==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.10-0.tgz", + "integrity": "sha512-LmVe3yVDamZc4cbZeyprZ6WjTME9Z4UcB5YWnEagtXJ19KP5PBKbBZVG7pZnQHL2/IHZ/dqcZW3IHMgYDoqDvg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.4", - "@github/copilot-darwin-x64": "1.0.4", - "@github/copilot-linux-arm64": "1.0.4", - "@github/copilot-linux-x64": "1.0.4", - "@github/copilot-win32-arm64": "1.0.4", - "@github/copilot-win32-x64": "1.0.4" + "@github/copilot-darwin-arm64": "1.0.10-0", + "@github/copilot-darwin-x64": "1.0.10-0", + "@github/copilot-linux-arm64": "1.0.10-0", + "@github/copilot-linux-x64": "1.0.10-0", + "@github/copilot-win32-arm64": "1.0.10-0", + "@github/copilot-win32-x64": "1.0.10-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-/YGGhv6cp0ItolsF0HsLq2KmesA4atn0IEYApBs770fzJ8OP2pkOEzrxo3gWU3wc7fHF2uDB1RrJEZ7QSFLdEQ==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.10-0.tgz", + "integrity": "sha512-u5CbflcTpvc4E48E0jrqbN3Y5hWzValMs21RR6L+GDjQpPI2pvDeUWAJZ03Y7qQ2Uk3KZ+hOIJWJvje9VHxrDQ==", "cpu": [ "arm64" ], @@ -497,9 +497,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.4.tgz", - "integrity": "sha512-gwn2QjZbc1SqPVSAtDMesU1NopyHZT8Qsn37xPfznpV9s94KVyX4TTiDZaUwfnI0wr8kVHBL46RPLNz6I8kR9A==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.10-0.tgz", + "integrity": "sha512-4y5OXhAfWX+il9slhrq7v8ONzq+Hpw46ktnz7l1fAZKdmn+dzmFVCvr6pJPr5Az78cAKBuN+Gt4eeSNaxuKCmA==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.4.tgz", - "integrity": "sha512-92vzHKxN55BpI76sP/5fXIXfat1gzAhsq4bNLqLENGfZyMP/25OiVihCZuQHnvxzXaHBITFGUvtxfdll2kbcng==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.10-0.tgz", + "integrity": "sha512-j+Z/ZahEIT5SCblUqOJ2+2glWeIIUPKXXFS5bbu5kFZ9Xyag37FBvTjyxDeB02dpSKKDD4xbMVjcijFbtyr1PA==", "cpu": [ "arm64" ], @@ -531,9 +531,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.4.tgz", - "integrity": "sha512-wQvpwf4/VMTnSmWyYzq07Xg18Vxg7aZ5NVkkXqlLTuXRASW0kvCCb5USEtXHHzR7E6rJztkhCjFRE1bZW8jAGw==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.10-0.tgz", + "integrity": "sha512-S8IfuiMZWwnFW1v0vOGHalPIXq/75kL/RpZCYd1sleQA/yztCNNjxH9tNpXsdZnhYrAgU/3hqseWq5hbz8xjxA==", "cpu": [ "x64" ], @@ -548,9 +548,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.4.tgz", - "integrity": "sha512-zOvD/5GVxDf0ZdlTkK+m55Vs55xuHNmACX50ZO2N23ZGG2dmkdS4mkruL59XB5ISgrOfeqvnqrwTFHbmPZtLfw==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.10-0.tgz", + "integrity": "sha512-6HJErp91fLrwIkoXegLK8SXjHzLgbl9GF+QdOtUGqZ915UUfXcchef0tQjN8u35yNLEW82VnAmft/PJ9Ok2UhQ==", "cpu": [ "arm64" ], @@ -565,9 +565,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.4.tgz", - "integrity": "sha512-yQenHMdkV0b77mF6aLM60TuwtNZ592TluptVDF+80Sj2zPfCpLyvrRh2FCIHRtuwTy4BfxETh2hCFHef8E6IOw==", + "version": "1.0.10-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.10-0.tgz", + "integrity": "sha512-AQwZYHoarRACbmPUPmH7gPOEomTAtDusCn65ancI3BoWGj9fzAgZEZ5JSaR3N/VUoXWoEbSe+PcH380ZYwsPag==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 9f336dfd4..25117cac9 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^1.0.4", + "@github/copilot": "^1.0.10-0", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "openai": "^6.17.0", diff --git a/test/scenarios/auth/byok-anthropic/python/main.py b/test/scenarios/auth/byok-anthropic/python/main.py index 995002070..3ad893ba5 100644 --- a/test/scenarios/auth/byok-anthropic/python/main.py +++ b/test/scenarios/auth/byok-anthropic/python/main.py @@ -1,7 +1,8 @@ import asyncio import os import sys -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY") ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514") @@ -18,20 +19,19 @@ async def main(): )) try: - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model=ANTHROPIC_MODEL, - provider={ + session = await client.create_session({ + "model": ANTHROPIC_MODEL, + "provider": { "type": "anthropic", "base_url": ANTHROPIC_BASE_URL, "api_key": ANTHROPIC_API_KEY, }, - available_tools=[], - system_message={ + "available_tools": [], + "system_message": { "mode": "replace", "content": "You are a helpful assistant. Answer concisely.", }, - ) + }) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/auth/byok-azure/python/main.py b/test/scenarios/auth/byok-azure/python/main.py index 57a49f2a5..1ae214261 100644 --- a/test/scenarios/auth/byok-azure/python/main.py +++ b/test/scenarios/auth/byok-azure/python/main.py @@ -1,7 +1,8 @@ import asyncio import os import sys -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig AZURE_OPENAI_ENDPOINT = os.environ.get("AZURE_OPENAI_ENDPOINT") AZURE_OPENAI_API_KEY = os.environ.get("AZURE_OPENAI_API_KEY") @@ -19,10 +20,9 @@ async def main(): )) try: - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model=AZURE_OPENAI_MODEL, - provider={ + session = await client.create_session({ + "model": AZURE_OPENAI_MODEL, + "provider": { "type": "azure", "base_url": AZURE_OPENAI_ENDPOINT, "api_key": AZURE_OPENAI_API_KEY, @@ -30,12 +30,12 @@ async def main(): "api_version": AZURE_API_VERSION, }, }, - available_tools=[], - system_message={ + "available_tools": [], + "system_message": { "mode": "replace", "content": "You are a helpful assistant. Answer concisely.", }, - ) + }) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/auth/byok-ollama/python/main.py b/test/scenarios/auth/byok-ollama/python/main.py index 87dad5866..78019acd7 100644 --- a/test/scenarios/auth/byok-ollama/python/main.py +++ b/test/scenarios/auth/byok-ollama/python/main.py @@ -1,7 +1,8 @@ import asyncio import os import sys -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1") OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2:3b") @@ -17,19 +18,18 @@ async def main(): )) try: - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model=OLLAMA_MODEL, - provider={ + session = await client.create_session({ + "model": OLLAMA_MODEL, + "provider": { "type": "openai", "base_url": OLLAMA_BASE_URL, }, - available_tools=[], - system_message={ + "available_tools": [], + "system_message": { "mode": "replace", "content": COMPACT_SYSTEM_PROMPT, }, - ) + }) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/auth/byok-openai/python/main.py b/test/scenarios/auth/byok-openai/python/main.py index fadd1c79d..8362963b2 100644 --- a/test/scenarios/auth/byok-openai/python/main.py +++ b/test/scenarios/auth/byok-openai/python/main.py @@ -1,7 +1,8 @@ import asyncio import os import sys -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "claude-haiku-4.5") @@ -18,15 +19,14 @@ async def main(): )) try: - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model=OPENAI_MODEL, - provider={ + session = await client.create_session({ + "model": OPENAI_MODEL, + "provider": { "type": "openai", "base_url": OPENAI_BASE_URL, "api_key": OPENAI_API_KEY, }, - ) + }) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/auth/gh-app/python/main.py b/test/scenarios/auth/gh-app/python/main.py index e7f640ae9..afba29254 100644 --- a/test/scenarios/auth/gh-app/python/main.py +++ b/test/scenarios/auth/gh-app/python/main.py @@ -4,7 +4,8 @@ import time import urllib.request -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig DEVICE_CODE_URL = "https://github.com/login/device/code" @@ -84,7 +85,7 @@ async def main(): )) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({"model": "claude-haiku-4.5"}) response = await session.send_and_wait("What is the capital of France?") if response: print(response.data.content) diff --git a/test/scenarios/bundling/app-backend-to-server/python/main.py b/test/scenarios/bundling/app-backend-to-server/python/main.py index c9ab35bce..2684a30b8 100644 --- a/test/scenarios/bundling/app-backend-to-server/python/main.py +++ b/test/scenarios/bundling/app-backend-to-server/python/main.py @@ -5,7 +5,8 @@ import urllib.request from flask import Flask, request, jsonify -from copilot import CopilotClient, PermissionHandler, ExternalServerConfig +from copilot import CopilotClient +from copilot.client import ExternalServerConfig app = Flask(__name__) @@ -16,7 +17,7 @@ async def ask_copilot(prompt: str) -> str: client = CopilotClient(ExternalServerConfig(url=CLI_URL)) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({"model": "claude-haiku-4.5"}) response = await session.send_and_wait(prompt) diff --git a/test/scenarios/bundling/app-direct-server/python/main.py b/test/scenarios/bundling/app-direct-server/python/main.py index 07eb74e20..b441bec51 100644 --- a/test/scenarios/bundling/app-direct-server/python/main.py +++ b/test/scenarios/bundling/app-direct-server/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, ExternalServerConfig +from copilot import CopilotClient +from copilot.client import ExternalServerConfig async def main(): @@ -9,7 +10,7 @@ async def main(): )) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({"model": "claude-haiku-4.5"}) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/bundling/container-proxy/python/main.py b/test/scenarios/bundling/container-proxy/python/main.py index 07eb74e20..b441bec51 100644 --- a/test/scenarios/bundling/container-proxy/python/main.py +++ b/test/scenarios/bundling/container-proxy/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, ExternalServerConfig +from copilot import CopilotClient +from copilot.client import ExternalServerConfig async def main(): @@ -9,7 +10,7 @@ async def main(): )) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({"model": "claude-haiku-4.5"}) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/bundling/fully-bundled/python/main.py b/test/scenarios/bundling/fully-bundled/python/main.py index 382f9c4f9..39ce2bb81 100644 --- a/test/scenarios/bundling/fully-bundled/python/main.py +++ b/test/scenarios/bundling/fully-bundled/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -10,7 +11,7 @@ async def main(): )) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({"model": "claude-haiku-4.5"}) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/callbacks/hooks/python/main.py b/test/scenarios/callbacks/hooks/python/main.py index bc9782b6b..dbfceb22a 100644 --- a/test/scenarios/callbacks/hooks/python/main.py +++ b/test/scenarios/callbacks/hooks/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig hook_log: list[str] = [] @@ -47,16 +48,18 @@ async def main(): try: session = await client.create_session( - on_permission_request=auto_approve_permission, - model="claude-haiku-4.5", - hooks={ - "on_session_start": on_session_start, - "on_session_end": on_session_end, - "on_pre_tool_use": on_pre_tool_use, - "on_post_tool_use": on_post_tool_use, - "on_user_prompt_submitted": on_user_prompt_submitted, - "on_error_occurred": on_error_occurred, - }, + { + "model": "claude-haiku-4.5", + "on_permission_request": auto_approve_permission, + "hooks": { + "on_session_start": on_session_start, + "on_session_end": on_session_end, + "on_pre_tool_use": on_pre_tool_use, + "on_post_tool_use": on_post_tool_use, + "on_user_prompt_submitted": on_user_prompt_submitted, + "on_error_occurred": on_error_occurred, + }, + } ) response = await session.send_and_wait( diff --git a/test/scenarios/callbacks/permissions/python/main.py b/test/scenarios/callbacks/permissions/python/main.py index e4de98a9a..de788e5fb 100644 --- a/test/scenarios/callbacks/permissions/python/main.py +++ b/test/scenarios/callbacks/permissions/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig # Track which tools requested permission permission_log: list[str] = [] @@ -23,9 +24,11 @@ async def main(): try: session = await client.create_session( - on_permission_request=log_permission, - model="claude-haiku-4.5", - hooks={"on_pre_tool_use": auto_approve_tool}, + { + "model": "claude-haiku-4.5", + "on_permission_request": log_permission, + "hooks": {"on_pre_tool_use": auto_approve_tool}, + } ) response = await session.send_and_wait( diff --git a/test/scenarios/callbacks/user-input/python/main.py b/test/scenarios/callbacks/user-input/python/main.py index 92981861d..0c23e6b15 100644 --- a/test/scenarios/callbacks/user-input/python/main.py +++ b/test/scenarios/callbacks/user-input/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig input_log: list[str] = [] @@ -27,10 +28,12 @@ async def main(): try: session = await client.create_session( - on_permission_request=auto_approve_permission, - model="claude-haiku-4.5", - on_user_input_request=handle_user_input, - hooks={"on_pre_tool_use": auto_approve_tool}, + { + "model": "claude-haiku-4.5", + "on_permission_request": auto_approve_permission, + "on_user_input_request": handle_user_input, + "hooks": {"on_pre_tool_use": auto_approve_tool}, + } ) response = await session.send_and_wait( diff --git a/test/scenarios/modes/default/python/main.py b/test/scenarios/modes/default/python/main.py index 55f1cb394..ece50a662 100644 --- a/test/scenarios/modes/default/python/main.py +++ b/test/scenarios/modes/default/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -10,7 +11,9 @@ async def main(): )) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({ + "model": "claude-haiku-4.5", + }) response = await session.send_and_wait("Use the grep tool to search for the word 'SDK' in README.md and show the matching lines.") if response: diff --git a/test/scenarios/modes/minimal/python/main.py b/test/scenarios/modes/minimal/python/main.py index 22f321b22..722c1e5e1 100644 --- a/test/scenarios/modes/minimal/python/main.py +++ b/test/scenarios/modes/minimal/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -10,15 +11,14 @@ async def main(): )) try: - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - available_tools=[], - system_message={ + session = await client.create_session({ + "model": "claude-haiku-4.5", + "available_tools": [], + "system_message": { "mode": "replace", "content": "You have no tools. Respond with text only.", }, - ) + }) response = await session.send_and_wait("Use the grep tool to search for 'SDK' in README.md.") if response: diff --git a/test/scenarios/prompts/attachments/README.md b/test/scenarios/prompts/attachments/README.md index d61a26e57..76b76751d 100644 --- a/test/scenarios/prompts/attachments/README.md +++ b/test/scenarios/prompts/attachments/README.md @@ -39,7 +39,7 @@ Demonstrates sending **file attachments** alongside a prompt using the Copilot S |----------|------------------------| | 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.Blob, Data: &data, MIMEType: &mime}}` | +| Go | `Attachments: []copilot.Attachment{{Type: copilot.AttachmentTypeBlob, Data: &data, MIMEType: &mime}}` | ## Sample Data diff --git a/test/scenarios/prompts/attachments/python/main.py b/test/scenarios/prompts/attachments/python/main.py index 37654e269..fdf259c6a 100644 --- a/test/scenarios/prompts/attachments/python/main.py +++ b/test/scenarios/prompts/attachments/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig SYSTEM_PROMPT = """You are a helpful assistant. Answer questions about attached files concisely.""" @@ -13,10 +14,11 @@ async def main(): try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - system_message={"mode": "replace", "content": SYSTEM_PROMPT}, - available_tools=[], + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": SYSTEM_PROMPT}, + "available_tools": [], + } ) sample_file = os.path.join(os.path.dirname(__file__), "..", "sample-data.txt") diff --git a/test/scenarios/prompts/reasoning-effort/python/main.py b/test/scenarios/prompts/reasoning-effort/python/main.py index 8baed649d..122f44895 100644 --- a/test/scenarios/prompts/reasoning-effort/python/main.py +++ b/test/scenarios/prompts/reasoning-effort/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -10,16 +11,15 @@ async def main(): )) try: - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-opus-4.6", - reasoning_effort="low", - available_tools=[], - system_message={ + session = await client.create_session({ + "model": "claude-opus-4.6", + "reasoning_effort": "low", + "available_tools": [], + "system_message": { "mode": "replace", "content": "You are a helpful assistant. Answer concisely.", }, - ) + }) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/prompts/system-message/python/main.py b/test/scenarios/prompts/system-message/python/main.py index 15d354258..b77c1e4a1 100644 --- a/test/scenarios/prompts/system-message/python/main.py +++ b/test/scenarios/prompts/system-message/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig PIRATE_PROMPT = """You are a pirate. Always respond in pirate speak. Say 'Arrr!' in every response. Use nautical terms and pirate slang throughout.""" @@ -13,10 +14,11 @@ async def main(): try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - system_message={"mode": "replace", "content": PIRATE_PROMPT}, - available_tools=[], + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": PIRATE_PROMPT}, + "available_tools": [], + } ) response = await session.send_and_wait( diff --git a/test/scenarios/sessions/concurrent-sessions/python/main.py b/test/scenarios/sessions/concurrent-sessions/python/main.py index 5c3994c4c..a32dc5e10 100644 --- a/test/scenarios/sessions/concurrent-sessions/python/main.py +++ b/test/scenarios/sessions/concurrent-sessions/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig PIRATE_PROMPT = "You are a pirate. Always say Arrr!" ROBOT_PROMPT = "You are a robot. Always say BEEP BOOP!" @@ -15,16 +16,18 @@ async def main(): try: session1, session2 = await asyncio.gather( client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - system_message={"mode": "replace", "content": PIRATE_PROMPT}, - available_tools=[], + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": PIRATE_PROMPT}, + "available_tools": [], + } ), client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - system_message={"mode": "replace", "content": ROBOT_PROMPT}, - available_tools=[], + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": ROBOT_PROMPT}, + "available_tools": [], + } ), ) diff --git a/test/scenarios/sessions/infinite-sessions/python/main.py b/test/scenarios/sessions/infinite-sessions/python/main.py index 30aa40cd1..724dc155d 100644 --- a/test/scenarios/sessions/infinite-sessions/python/main.py +++ b/test/scenarios/sessions/infinite-sessions/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -10,20 +11,19 @@ async def main(): )) try: - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - available_tools=[], - system_message={ + session = await client.create_session({ + "model": "claude-haiku-4.5", + "available_tools": [], + "system_message": { "mode": "replace", "content": "You are a helpful assistant. Answer concisely in one sentence.", }, - infinite_sessions={ + "infinite_sessions": { "enabled": True, "background_compaction_threshold": 0.80, "buffer_exhaustion_threshold": 0.95, }, - ) + }) prompts = [ "What is the capital of France?", diff --git a/test/scenarios/sessions/session-resume/python/main.py b/test/scenarios/sessions/session-resume/python/main.py index 049ca1f83..ccb9c69f0 100644 --- a/test/scenarios/sessions/session-resume/python/main.py +++ b/test/scenarios/sessions/session-resume/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -12,9 +13,10 @@ async def main(): try: # 1. Create a session session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - available_tools=[], + { + "model": "claude-haiku-4.5", + "available_tools": [], + } ) # 2. Send the secret word @@ -26,7 +28,7 @@ async def main(): session_id = session.session_id # 4. Resume the session with the same ID - resumed = await client.resume_session(session_id, on_permission_request=PermissionHandler.approve_all) + resumed = await client.resume_session(session_id) print("Session resumed") # 5. Ask for the secret word diff --git a/test/scenarios/sessions/streaming/python/main.py b/test/scenarios/sessions/streaming/python/main.py index 20fd4902e..e2312cd14 100644 --- a/test/scenarios/sessions/streaming/python/main.py +++ b/test/scenarios/sessions/streaming/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -11,9 +12,10 @@ async def main(): try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - streaming=True, + { + "model": "claude-haiku-4.5", + "streaming": True, + } ) chunk_count = 0 diff --git a/test/scenarios/tools/custom-agents/python/main.py b/test/scenarios/tools/custom-agents/python/main.py index c30107a2f..d4c45950f 100644 --- a/test/scenarios/tools/custom-agents/python/main.py +++ b/test/scenarios/tools/custom-agents/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -11,17 +12,18 @@ async def main(): try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - custom_agents=[ - { - "name": "researcher", - "display_name": "Research Agent", - "description": "A research agent that can only read and search files, not modify them", - "tools": ["grep", "glob", "view"], - "prompt": "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", - }, - ], + { + "model": "claude-haiku-4.5", + "custom_agents": [ + { + "name": "researcher", + "display_name": "Research Agent", + "description": "A research agent that can only read and search files, not modify them", + "tools": ["grep", "glob", "view"], + "prompt": "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", + }, + ], + } ) response = await session.send_and_wait( diff --git a/test/scenarios/tools/mcp-servers/python/main.py b/test/scenarios/tools/mcp-servers/python/main.py index 9edd04115..2fa81b82d 100644 --- a/test/scenarios/tools/mcp-servers/python/main.py +++ b/test/scenarios/tools/mcp-servers/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -22,7 +23,8 @@ async def main(): "args": args, } - session_kwargs = { + session_config = { + "model": "claude-haiku-4.5", "available_tools": [], "system_message": { "mode": "replace", @@ -30,11 +32,9 @@ async def main(): }, } if mcp_servers: - session_kwargs["mcp_servers"] = mcp_servers + session_config["mcp_servers"] = mcp_servers - session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5", **session_kwargs - ) + session = await client.create_session(session_config) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/tools/no-tools/python/main.py b/test/scenarios/tools/no-tools/python/main.py index c9a8047ec..c3eeb6a17 100644 --- a/test/scenarios/tools/no-tools/python/main.py +++ b/test/scenarios/tools/no-tools/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig SYSTEM_PROMPT = """You are a minimal assistant with no tools available. You cannot execute code, read files, edit files, search, or perform any actions. @@ -16,10 +17,11 @@ async def main(): try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - system_message={"mode": "replace", "content": SYSTEM_PROMPT}, - available_tools=[], + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": SYSTEM_PROMPT}, + "available_tools": [], + } ) response = await session.send_and_wait( diff --git a/test/scenarios/tools/skills/python/main.py b/test/scenarios/tools/skills/python/main.py index afa871d83..3ec9fb2ee 100644 --- a/test/scenarios/tools/skills/python/main.py +++ b/test/scenarios/tools/skills/python/main.py @@ -2,7 +2,8 @@ import os from pathlib import Path -from copilot import CopilotClient, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): diff --git a/test/scenarios/tools/tool-filtering/python/main.py b/test/scenarios/tools/tool-filtering/python/main.py index 668bca197..9da4ca571 100644 --- a/test/scenarios/tools/tool-filtering/python/main.py +++ b/test/scenarios/tools/tool-filtering/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig SYSTEM_PROMPT = """You are a helpful assistant. You have access to a limited set of tools. When asked about your tools, list exactly which tools you have available.""" @@ -13,10 +14,11 @@ async def main(): try: session = await client.create_session( - on_permission_request=PermissionHandler.approve_all, - model="claude-haiku-4.5", - system_message={"mode": "replace", "content": SYSTEM_PROMPT}, - available_tools=["grep", "glob", "view"], + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": SYSTEM_PROMPT}, + "available_tools": ["grep", "glob", "view"], + } ) response = await session.send_and_wait( diff --git a/test/scenarios/tools/tool-overrides/python/main.py b/test/scenarios/tools/tool-overrides/python/main.py index 73c539fe1..687933973 100644 --- a/test/scenarios/tools/tool-overrides/python/main.py +++ b/test/scenarios/tools/tool-overrides/python/main.py @@ -3,7 +3,9 @@ from pydantic import BaseModel, Field -from copilot import CopilotClient, PermissionHandler, SubprocessConfig, define_tool +from copilot import CopilotClient, define_tool +from copilot.client import SubprocessConfig +from copilot.session import PermissionHandler class GrepParams(BaseModel): diff --git a/test/scenarios/tools/virtual-filesystem/python/main.py b/test/scenarios/tools/virtual-filesystem/python/main.py index 92f2593a6..f7635c6c6 100644 --- a/test/scenarios/tools/virtual-filesystem/python/main.py +++ b/test/scenarios/tools/virtual-filesystem/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, SubprocessConfig, define_tool +from copilot import CopilotClient, define_tool +from copilot.client import SubprocessConfig from pydantic import BaseModel, Field # In-memory virtual filesystem diff --git a/test/scenarios/transport/reconnect/python/main.py b/test/scenarios/transport/reconnect/python/main.py index d1b8a5696..d1d4505a8 100644 --- a/test/scenarios/transport/reconnect/python/main.py +++ b/test/scenarios/transport/reconnect/python/main.py @@ -1,7 +1,8 @@ import asyncio import os import sys -from copilot import CopilotClient, PermissionHandler, ExternalServerConfig +from copilot import CopilotClient +from copilot.client import ExternalServerConfig async def main(): @@ -12,7 +13,7 @@ async def main(): try: # First session print("--- Session 1 ---") - session1 = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session1 = await client.create_session({"model": "claude-haiku-4.5"}) response1 = await session1.send_and_wait( "What is the capital of France?" @@ -29,7 +30,7 @@ async def main(): # Second session — tests that the server accepts new sessions print("--- Session 2 ---") - session2 = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session2 = await client.create_session({"model": "claude-haiku-4.5"}) response2 = await session2.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/transport/stdio/python/main.py b/test/scenarios/transport/stdio/python/main.py index 382f9c4f9..39ce2bb81 100644 --- a/test/scenarios/transport/stdio/python/main.py +++ b/test/scenarios/transport/stdio/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, SubprocessConfig +from copilot import CopilotClient +from copilot.client import SubprocessConfig async def main(): @@ -10,7 +11,7 @@ async def main(): )) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({"model": "claude-haiku-4.5"}) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/scenarios/transport/tcp/python/main.py b/test/scenarios/transport/tcp/python/main.py index 07eb74e20..b441bec51 100644 --- a/test/scenarios/transport/tcp/python/main.py +++ b/test/scenarios/transport/tcp/python/main.py @@ -1,6 +1,7 @@ import asyncio import os -from copilot import CopilotClient, PermissionHandler, ExternalServerConfig +from copilot import CopilotClient +from copilot.client import ExternalServerConfig async def main(): @@ -9,7 +10,7 @@ async def main(): )) try: - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="claude-haiku-4.5") + session = await client.create_session({"model": "claude-haiku-4.5"}) response = await session.send_and_wait( "What is the capital of France?" diff --git a/test/snapshots/session/should_create_a_session_with_customized_systemmessage_config.yaml b/test/snapshots/session/should_create_a_session_with_customized_systemmessage_config.yaml new file mode 100644 index 000000000..f3ce077a6 --- /dev/null +++ b/test/snapshots/session/should_create_a_session_with_customized_systemmessage_config.yaml @@ -0,0 +1,35 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Who are you? + - role: assistant + content: >- + I'm **GitHub Copilot CLI**, a terminal assistant built by GitHub. I'm powered by claude-sonnet-4.5 (model ID: + claude-sonnet-4.5). + + + I'm here to help you with software engineering tasks, including: + + - Writing, debugging, and refactoring code + + - Running commands and managing development workflows + + - Exploring codebases and understanding how things work + + - Setting up projects, installing dependencies, and configuring tools + + - Working with Git, testing, and deployment tasks + + - Planning and implementing features + + + I have access to a variety of tools including file operations, shell commands, code search, and specialized + sub-agents for specific tasks. I can work with multiple languages and frameworks, and I'm designed to be + efficient by running tasks in parallel when possible. + + + How can I help you today? diff --git a/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml b/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml new file mode 100644 index 000000000..98004f2b0 --- /dev/null +++ b/test/snapshots/system_message_transform/should_apply_transform_modifications_to_section_content.yaml @@ -0,0 +1,33 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of hello.txt + - role: assistant + content: I'll read the hello.txt file for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading hello.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Hello! + - role: assistant + content: |- + The file hello.txt contains: + ``` + Hello! + ``` diff --git a/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml b/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml new file mode 100644 index 000000000..631a8eef8 --- /dev/null +++ b/test/snapshots/system_message_transform/should_invoke_transform_callbacks_with_section_content.yaml @@ -0,0 +1,54 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of test.txt and tell me what it says + - role: assistant + content: I'll read the test.txt file for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading test.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/test.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of test.txt and tell me what it says + - role: assistant + content: I'll read the test.txt file for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading test.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/test.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Hello transform! + - role: assistant + content: |- + The file test.txt contains: + ``` + Hello transform! + ``` diff --git a/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml b/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml new file mode 100644 index 000000000..9cb515694 --- /dev/null +++ b/test/snapshots/system_message_transform/should_work_with_static_overrides_and_transforms_together.yaml @@ -0,0 +1,50 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of combo.txt and tell me what it says + - role: assistant + content: I'll read the contents of combo.txt for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/combo.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the contents of combo.txt and tell me what it says + - role: assistant + content: I'll read the contents of combo.txt for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/combo.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Combo test! + - role: assistant + content: The file combo.txt contains a single line that says "Combo test!"