diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 63f2eeb23..40f762f67 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,34 +1,31 @@ version: 2 +multi-ecosystem-groups: + all: + schedule: + interval: 'weekly' updates: - package-ecosystem: 'github-actions' directory: '/' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' - package-ecosystem: 'devcontainers' directory: '/' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' # Node.js dependencies - package-ecosystem: 'npm' directory: '/nodejs' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' - package-ecosystem: 'npm' directory: '/test/harness' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' # Python dependencies - package-ecosystem: 'pip' directory: '/python' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' # Go dependencies - package-ecosystem: 'gomod' directory: '/go' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' # .NET dependencies - package-ecosystem: 'nuget' directory: '/dotnet' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' diff --git a/docs/auth/byok.md b/docs/auth/byok.md index b244c4532..13ad8b055 100644 --- a/docs/auth/byok.md +++ b/docs/auth/byok.md @@ -10,6 +10,7 @@ BYOK allows you to use the Copilot SDK with your own API keys from model provide | Azure OpenAI / Azure AI Foundry | `"azure"` | Azure-hosted models | | Anthropic | `"anthropic"` | Claude models | | Ollama | `"openai"` | Local models via OpenAI-compatible API | +| Microsoft Foundry Local | `"openai"` | Run AI models locally on your device via OpenAI-compatible API | | Other OpenAI-compatible | `"openai"` | vLLM, LiteLLM, etc. | ## Quick Start: Azure AI Foundry @@ -250,6 +251,37 @@ provider: { } ``` +### Microsoft Foundry Local + +[Microsoft Foundry Local](https://foundrylocal.ai) lets you run AI models locally on your own device with an OpenAI-compatible API. Install it via the Foundry Local CLI, then point the SDK at your local endpoint: + +```typescript +provider: { + type: "openai", + baseUrl: "http://localhost:/v1", + // No apiKey needed for local Foundry Local +} +``` + +> **Note:** Foundry Local starts on a **dynamic port** — the port is not fixed. Use `foundry service status` to confirm the port the service is currently listening on, then use that port in your `baseUrl`. + +To get started with Foundry Local: + +```bash +# Windows: Install Foundry Local CLI (requires winget) +winget install Microsoft.FoundryLocal + +# macOS / Linux: see https://foundrylocal.ai for installation instructions +# List available models +foundry model list + +# Run a model (starts the local server automatically) +foundry model run phi-4-mini + +# Check the port the service is running on +foundry service status +``` + ### Anthropic ```typescript @@ -305,6 +337,7 @@ Some Copilot features may behave differently with BYOK: |----------|-------------| | Azure AI Foundry | No Entra ID auth; must use API keys | | Ollama | No API key; local only; model support varies | +| [Microsoft Foundry Local](https://foundrylocal.ai) | Local only; model availability depends on device hardware; no API key required | | OpenAI | Subject to OpenAI rate limits and quotas | ## Troubleshooting @@ -368,6 +401,21 @@ curl http://localhost:11434/v1/models ollama serve ``` +### Connection Refused (Foundry Local) + +Foundry Local uses a dynamic port that may change between restarts. Confirm the active port: + +```bash +# Check the service status and port +foundry service status +``` + +Update your `baseUrl` to match the port shown in the output. If the service is not running, start a model to launch it: + +```bash +foundry model run phi-4-mini +``` + ### Authentication Failed 1. Verify your API key is correct and not expired diff --git a/docs/getting-started.md b/docs/getting-started.md index f615e923b..56c6a9c46 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1193,14 +1193,14 @@ Once the CLI is running in server mode, configure your SDK client to connect to Node.js / TypeScript ```typescript -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient, approveAll } from "@github/copilot-sdk"; const client = new CopilotClient({ cliUrl: "localhost:4321" }); // Use the client normally -const session = await client.createSession(); +const session = await client.createSession({ onPermissionRequest: approveAll }); // ... ``` @@ -1210,7 +1210,7 @@ const session = await client.createSession(); Python ```python -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler client = CopilotClient({ "cli_url": "localhost:4321" @@ -1218,7 +1218,7 @@ client = CopilotClient({ await client.start() # Use the client normally -session = await client.create_session() +session = await client.create_session({"on_permission_request": PermissionHandler.approve_all}) # ... ``` @@ -1241,7 +1241,9 @@ if err := client.Start(ctx); err != nil { defer client.Stop() // Use the client normally -session, err := client.CreateSession(ctx, nil) +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +}) // ... ``` @@ -1260,7 +1262,10 @@ using var client = new CopilotClient(new CopilotClientOptions }); // Use the client normally -await using var session = await client.CreateSessionAsync(); +await using var session = await client.CreateSessionAsync(new() +{ + OnPermissionRequest = PermissionHandler.ApproveAll +}); // ... ``` diff --git a/docs/guides/skills.md b/docs/guides/skills.md index e17a62093..b2ea3ae7a 100644 --- a/docs/guides/skills.md +++ b/docs/guides/skills.md @@ -1,13 +1,10 @@ # Custom Skills -Skills are reusable collections of prompts, tools, and configuration that extend Copilot's capabilities. Load skills from directories to give Copilot specialized abilities for specific domains or workflows. +Skills are reusable prompt modules that extend Copilot's capabilities. Load skills from directories to give Copilot specialized abilities for specific domains or workflows. ## Overview -A skill is a directory containing: -- **Prompt files** - Instructions that guide Copilot's behavior -- **Tool definitions** - Custom tools the skill provides -- **Configuration** - Metadata about the skill +A skill is a named directory containing a `SKILL.md` file — a markdown document that provides instructions to Copilot. When loaded, the skill's content is injected into the session context. Skills allow you to: - Package domain expertise into reusable modules @@ -31,8 +28,8 @@ const session = await client.createSession({ skillDirectories: [ "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", // User-level skills ], + onPermissionRequest: async () => ({ kind: "approved" }), }); // Copilot now has access to skills in those directories @@ -56,8 +53,8 @@ async def main(): "skill_directories": [ "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", # User-level skills ], + "on_permission_request": lambda req: {"kind": "approved"}, }) # Copilot now has access to skills in those directories @@ -93,7 +90,9 @@ func main() { SkillDirectories: []string{ "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", // User-level skills + }, + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil }, }) if err != nil { @@ -126,8 +125,9 @@ await using var session = await client.CreateSessionAsync(new SessionConfig { "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", // User-level skills }, + OnPermissionRequest = (req, inv) => + Task.FromResult(new PermissionRequestResult { Kind = "approved" }), }); // Copilot now has access to skills in those directories @@ -196,41 +196,28 @@ var session = await client.CreateSessionAsync(new SessionConfig ## Skill Directory Structure -A typical skill directory contains: +Each skill is a named subdirectory containing a `SKILL.md` file: ``` skills/ -└── code-review/ - ├── skill.json # Skill metadata and configuration - ├── prompts/ - │ ├── system.md # System prompt additions - │ └── examples.md # Few-shot examples - └── tools/ - └── lint.json # Tool definitions +├── code-review/ +│ └── SKILL.md +└── documentation/ + └── SKILL.md ``` -### skill.json - -The skill manifest file: +The `skillDirectories` option points to the parent directory (e.g., `./skills`). The CLI discovers all `SKILL.md` files in immediate subdirectories. -```json -{ - "name": "code-review", - "displayName": "Code Review Assistant", - "description": "Specialized code review capabilities", - "version": "1.0.0", - "author": "Your Team", - "prompts": ["prompts/system.md"], - "tools": ["tools/lint.json"] -} -``` +### SKILL.md Format -### Prompt Files - -Markdown files that provide context to Copilot: +A `SKILL.md` file is a markdown document with optional YAML frontmatter: ```markdown - +--- +name: code-review +description: Specialized code review capabilities +--- + # Code Review Guidelines When reviewing code, always check for: @@ -243,6 +230,12 @@ When reviewing code, always check for: Provide specific line-number references and suggested fixes. ``` +The frontmatter fields: +- **`name`** — The skill's identifier (used with `disabledSkills` to selectively disable it). If omitted, the directory name is used. +- **`description`** — A short description of what the skill does. + +The markdown body contains the instructions that are injected into the session context when the skill is loaded. + ## Configuration Options ### SessionConfig Skill Fields @@ -262,7 +255,7 @@ Provide specific line-number references and suggested fixes. 1. **Organize by domain** - Group related skills together (e.g., `skills/security/`, `skills/testing/`) -2. **Version your skills** - Include version numbers in `skill.json` for compatibility tracking +2. **Use frontmatter** - Include `name` and `description` in YAML frontmatter for clarity 3. **Document dependencies** - Note any tools or MCP servers a skill requires @@ -284,6 +277,7 @@ const session = await client.createSession({ description: "Security-focused code reviewer", prompt: "Focus on OWASP Top 10 vulnerabilities", }], + onPermissionRequest: async () => ({ kind: "approved" }), }); ``` @@ -302,6 +296,7 @@ const session = await client.createSession({ tools: ["*"], }, }, + onPermissionRequest: async () => ({ kind: "approved" }), }); ``` @@ -309,16 +304,16 @@ const session = await client.createSession({ ### Skills Not Loading -1. **Check path exists** - Verify the directory path is correct +1. **Check path exists** - Verify the skill directory path is correct and contains subdirectories with `SKILL.md` files 2. **Check permissions** - Ensure the SDK can read the directory -3. **Validate skill.json** - Check for JSON syntax errors +3. **Check SKILL.md format** - Verify the markdown is well-formed and any YAML frontmatter uses valid syntax 4. **Enable debug logging** - Set `logLevel: "debug"` to see skill loading logs ### Skill Conflicts -If multiple skills define the same tool: -- Later directories in the array take precedence +If multiple skills provide conflicting instructions: - Use `disabledSkills` to exclude conflicting skills +- Reorganize skill directories to avoid overlaps ## See Also diff --git a/dotnet/README.md b/dotnet/README.md index bda10059d..fe226f77f 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -77,8 +77,8 @@ new CopilotClient(CopilotClientOptions? options = null) - `Cwd` - Working directory for the CLI process - `Environment` - Environment variables to pass to the CLI process - `Logger` - `ILogger` instance for SDK logging -- `GithubToken` - GitHub token for authentication. When provided, takes priority over other auth methods. -- `UseLoggedInUser` - Whether to use logged-in user for authentication (default: true, but false when `GithubToken` is provided). Cannot be used with `CliUrl`. +- `GitHubToken` - GitHub token for authentication. When provided, takes priority over other auth methods. +- `UseLoggedInUser` - Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `CliUrl`. #### Methods diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8c70a4a2b..1f3a7fb43 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -38,7 +38,7 @@ namespace GitHub.Copilot.SDK; /// await using var client = new CopilotClient(); /// /// // Create a session -/// await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-4" }); +/// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" }); /// /// // Handle events /// using var subscription = session.On(evt => @@ -117,9 +117,9 @@ public CopilotClient(CopilotClientOptions? options = null) } // Validate auth options with external server - if (!string.IsNullOrEmpty(_options.CliUrl) && (!string.IsNullOrEmpty(_options.GithubToken) || _options.UseLoggedInUser != null)) + if (!string.IsNullOrEmpty(_options.CliUrl) && (!string.IsNullOrEmpty(_options.GitHubToken) || _options.UseLoggedInUser != null)) { - throw new ArgumentException("GithubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); + throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); } _logger = _options.Logger ?? NullLogger.Instance; @@ -340,10 +340,9 @@ private async Task CleanupConnectionAsync(List? errors) /// /// Creates a new Copilot session with the specified configuration. /// - /// Configuration for the session. If null, default settings are used. + /// Configuration for the session, including the required handler. /// A that can be used to cancel the operation. /// A task that resolves to provide the . - /// Thrown when the client is not connected and AutoStart is disabled, or when a session with the same ID already exists. /// /// Sessions maintain conversation state, handle events, and manage tool execution. /// If the client is not connected and is enabled (default), @@ -352,21 +351,29 @@ private async Task CleanupConnectionAsync(List? errors) /// /// /// // Basic session - /// var session = await client.CreateSessionAsync(); + /// var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Session with model and tools - /// var session = await client.CreateSessionAsync(new SessionConfig + /// var session = await client.CreateSessionAsync(new() /// { + /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Model = "gpt-4", /// Tools = [AIFunctionFactory.Create(MyToolMethod)] /// }); /// /// - public async Task CreateSessionAsync(SessionConfig? config = null, CancellationToken cancellationToken = default) + public async Task CreateSessionAsync(SessionConfig config, CancellationToken cancellationToken = default) { + if (config.OnPermissionRequest == null) + { + throw new ArgumentException( + "An OnPermissionRequest handler is required when creating a session. " + + "For example, to allow all permissions, use CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll });"); + } + var connection = await EnsureConnectedAsync(cancellationToken); - var hasHooks = config?.Hooks != null && ( + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || @@ -375,42 +382,39 @@ public async Task CreateSessionAsync(SessionConfig? config = nul config.Hooks.OnErrorOccurred != null); var request = new CreateSessionRequest( - config?.Model, - config?.SessionId, - config?.ClientName, - config?.ReasoningEffort, - config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config?.SystemMessage, - config?.AvailableTools, - config?.ExcludedTools, - config?.Provider, + config.Model, + config.SessionId, + config.ClientName, + config.ReasoningEffort, + config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.SystemMessage, + config.AvailableTools, + config.ExcludedTools, + config.Provider, (bool?)true, - config?.OnUserInputRequest != null ? true : null, + config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, - config?.WorkingDirectory, - config?.Streaming == true ? true : null, - config?.McpServers, + config.WorkingDirectory, + config.Streaming is true ? true : null, + config.McpServers, "direct", - config?.CustomAgents, - config?.ConfigDir, - config?.SkillDirectories, - config?.DisabledSkills, - config?.InfiniteSessions); + config.CustomAgents, + config.ConfigDir, + config.SkillDirectories, + config.DisabledSkills, + config.InfiniteSessions); var response = await InvokeRpcAsync( connection.Rpc, "session.create", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); - session.RegisterTools(config?.Tools ?? []); - if (config?.OnPermissionRequest != null) - { - session.RegisterPermissionHandler(config.OnPermissionRequest); - } - if (config?.OnUserInputRequest != null) + session.RegisterTools(config.Tools ?? []); + session.RegisterPermissionHandler(config.OnPermissionRequest); + if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } - if (config?.Hooks != null) + if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } @@ -427,9 +431,10 @@ public async Task CreateSessionAsync(SessionConfig? config = nul /// Resumes an existing Copilot session with the specified configuration. /// /// The ID of the session to resume. - /// Configuration for the resumed session. If null, default settings are used. + /// Configuration for the resumed session, including the required handler. /// A that can be used to cancel the operation. /// A task that resolves to provide the . + /// Thrown when is not set. /// Thrown when the session does not exist or the client is not connected. /// /// This allows you to continue a previous conversation, maintaining all conversation history. @@ -438,20 +443,28 @@ public async Task CreateSessionAsync(SessionConfig? config = nul /// /// /// // Resume a previous session - /// var session = await client.ResumeSessionAsync("session-123"); + /// var session = await client.ResumeSessionAsync("session-123", new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Resume with new tools - /// var session = await client.ResumeSessionAsync("session-123", new ResumeSessionConfig + /// var session = await client.ResumeSessionAsync("session-123", new() /// { + /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Tools = [AIFunctionFactory.Create(MyNewToolMethod)] /// }); /// /// - public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null, CancellationToken cancellationToken = default) + public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig config, CancellationToken cancellationToken = default) { + if (config.OnPermissionRequest == null) + { + throw new ArgumentException( + "An OnPermissionRequest handler is required when resuming a session. " + + "For example, to allow all permissions, use new() { OnPermissionRequest = PermissionHandler.ApproveAll }."); + } + var connection = await EnsureConnectedAsync(cancellationToken); - var hasHooks = config?.Hooks != null && ( + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || @@ -461,42 +474,39 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var request = new ResumeSessionRequest( sessionId, - config?.ClientName, - config?.Model, - config?.ReasoningEffort, - config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config?.SystemMessage, - config?.AvailableTools, - config?.ExcludedTools, - config?.Provider, + config.ClientName, + config.Model, + config.ReasoningEffort, + config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.SystemMessage, + config.AvailableTools, + config.ExcludedTools, + config.Provider, (bool?)true, - config?.OnUserInputRequest != null ? true : null, + config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, - config?.WorkingDirectory, - config?.ConfigDir, - config?.DisableResume == true ? true : null, - config?.Streaming == true ? true : null, - config?.McpServers, + config.WorkingDirectory, + config.ConfigDir, + config.DisableResume is true ? true : null, + config.Streaming is true ? true : null, + config.McpServers, "direct", - config?.CustomAgents, - config?.SkillDirectories, - config?.DisabledSkills, - config?.InfiniteSessions); + config.CustomAgents, + config.SkillDirectories, + config.DisabledSkills, + config.InfiniteSessions); var response = await InvokeRpcAsync( connection.Rpc, "session.resume", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); - session.RegisterTools(config?.Tools ?? []); - if (config?.OnPermissionRequest != null) - { - session.RegisterPermissionHandler(config.OnPermissionRequest); - } - if (config?.OnUserInputRequest != null) + session.RegisterTools(config.Tools ?? []); + session.RegisterPermissionHandler(config.OnPermissionRequest); + if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } - if (config?.Hooks != null) + if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } @@ -516,7 +526,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes /// /// if (client.State == ConnectionState.Connected) /// { - /// var session = await client.CreateSessionAsync(); + /// var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// } /// /// @@ -630,7 +640,7 @@ public async Task> ListModelsAsync(CancellationToken cancellatio /// var lastId = await client.GetLastSessionIdAsync(); /// if (lastId != null) /// { - /// var session = await client.ResumeSessionAsync(lastId); + /// var session = await client.ResumeSessionAsync(lastId, new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// } /// /// @@ -944,13 +954,13 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } // Add auth-related flags - if (!string.IsNullOrEmpty(options.GithubToken)) + if (!string.IsNullOrEmpty(options.GitHubToken)) { args.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]); } - // Default UseLoggedInUser to false when GithubToken is provided - var useLoggedInUser = options.UseLoggedInUser ?? string.IsNullOrEmpty(options.GithubToken); + // Default UseLoggedInUser to false when GitHubToken is provided + var useLoggedInUser = options.UseLoggedInUser ?? string.IsNullOrEmpty(options.GitHubToken); if (!useLoggedInUser) { args.Add("--no-auto-login"); @@ -982,9 +992,9 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio startInfo.Environment.Remove("NODE_DEBUG"); // Set auth token in environment if provided - if (!string.IsNullOrEmpty(options.GithubToken)) + if (!string.IsNullOrEmpty(options.GitHubToken)) { - startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GithubToken; + startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GitHubToken; } var cliProcess = new Process { StartInfo = startInfo }; diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index ac010ed86..677aecfd2 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -149,7 +149,7 @@ public class ToolsListResult public List Tools { get; set; } = new(); } -internal class ListRequest +internal class ToolsListRequest { [JsonPropertyName("model")] public string? Model { get; set; } @@ -195,7 +195,7 @@ public class SessionModelGetCurrentResult public string? ModelId { get; set; } } -internal class GetCurrentRequest +internal class SessionModelGetCurrentRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -207,7 +207,7 @@ public class SessionModelSwitchToResult public string? ModelId { get; set; } } -internal class SwitchToRequest +internal class SessionModelSwitchToRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -223,7 +223,7 @@ public class SessionModeGetResult public SessionModeGetResultMode Mode { get; set; } } -internal class GetRequest +internal class SessionModeGetRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -236,7 +236,7 @@ public class SessionModeSetResult public SessionModeGetResultMode Mode { get; set; } } -internal class SetRequest +internal class SessionModeSetRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -256,7 +256,7 @@ public class SessionPlanReadResult public string? Content { get; set; } } -internal class ReadRequest +internal class SessionPlanReadRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -266,7 +266,7 @@ public class SessionPlanUpdateResult { } -internal class UpdateRequest +internal class SessionPlanUpdateRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -279,7 +279,7 @@ public class SessionPlanDeleteResult { } -internal class DeleteRequest +internal class SessionPlanDeleteRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -292,7 +292,7 @@ public class SessionWorkspaceListFilesResult public List Files { get; set; } = new(); } -internal class ListFilesRequest +internal class SessionWorkspaceListFilesRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -305,7 +305,7 @@ public class SessionWorkspaceReadFileResult public string Content { get; set; } = string.Empty; } -internal class ReadFileRequest +internal class SessionWorkspaceReadFileRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -318,7 +318,7 @@ public class SessionWorkspaceCreateFileResult { } -internal class CreateFileRequest +internal class SessionWorkspaceCreateFileRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -337,7 +337,7 @@ public class SessionFleetStartResult public bool Started { get; set; } } -internal class StartRequest +internal class SessionFleetStartRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -346,6 +346,125 @@ internal class StartRequest public string? Prompt { get; set; } } +public class Agent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentListResult +{ + /// Available custom agents + [JsonPropertyName("agents")] + public List Agents { get; set; } = new(); +} + +internal class SessionAgentListRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionAgentGetCurrentResultAgent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentGetCurrentResult +{ + /// Currently selected custom agent, or null if using the default agent + [JsonPropertyName("agent")] + public SessionAgentGetCurrentResultAgent? Agent { get; set; } +} + +internal class SessionAgentGetCurrentRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// The newly selected custom agent +public class SessionAgentSelectResultAgent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentSelectResult +{ + /// The newly selected custom agent + [JsonPropertyName("agent")] + public SessionAgentSelectResultAgent Agent { get; set; } = new(); +} + +internal class SessionAgentSelectRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +public class SessionAgentDeselectResult +{ +} + +internal class SessionAgentDeselectRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionCompactionCompactResult +{ + /// Whether compaction completed successfully + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// Number of tokens freed by compaction + [JsonPropertyName("tokensRemoved")] + public double TokensRemoved { get; set; } + + /// Number of messages removed during compaction + [JsonPropertyName("messagesRemoved")] + public double MessagesRemoved { get; set; } +} + +internal class SessionCompactionCompactRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionModeGetResultMode { @@ -418,7 +537,7 @@ internal ToolsApi(JsonRpc rpc) /// Calls "tools.list". public async Task ListAsync(string? model = null, CancellationToken cancellationToken = default) { - var request = new ListRequest { Model = model }; + var request = new ToolsListRequest { Model = model }; return await CopilotClient.InvokeRpcAsync(_rpc, "tools.list", [request], cancellationToken); } } @@ -455,6 +574,8 @@ internal SessionRpc(JsonRpc rpc, string sessionId) Plan = new PlanApi(rpc, sessionId); Workspace = new WorkspaceApi(rpc, sessionId); Fleet = new FleetApi(rpc, sessionId); + Agent = new AgentApi(rpc, sessionId); + Compaction = new CompactionApi(rpc, sessionId); } public ModelApi Model { get; } @@ -466,6 +587,10 @@ internal SessionRpc(JsonRpc rpc, string sessionId) public WorkspaceApi Workspace { get; } public FleetApi Fleet { get; } + + public AgentApi Agent { get; } + + public CompactionApi Compaction { get; } } public class ModelApi @@ -482,14 +607,14 @@ internal ModelApi(JsonRpc rpc, string sessionId) /// Calls "session.model.getCurrent". public async Task GetCurrentAsync(CancellationToken cancellationToken = default) { - var request = new GetCurrentRequest { SessionId = _sessionId }; + var request = new SessionModelGetCurrentRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.getCurrent", [request], cancellationToken); } /// Calls "session.model.switchTo". public async Task SwitchToAsync(string modelId, CancellationToken cancellationToken = default) { - var request = new SwitchToRequest { SessionId = _sessionId, ModelId = modelId }; + var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.switchTo", [request], cancellationToken); } } @@ -508,14 +633,14 @@ internal ModeApi(JsonRpc rpc, string sessionId) /// Calls "session.mode.get". public async Task GetAsync(CancellationToken cancellationToken = default) { - var request = new GetRequest { SessionId = _sessionId }; + var request = new SessionModeGetRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.get", [request], cancellationToken); } /// Calls "session.mode.set". public async Task SetAsync(SessionModeGetResultMode mode, CancellationToken cancellationToken = default) { - var request = new SetRequest { SessionId = _sessionId, Mode = mode }; + var request = new SessionModeSetRequest { SessionId = _sessionId, Mode = mode }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.set", [request], cancellationToken); } } @@ -534,21 +659,21 @@ internal PlanApi(JsonRpc rpc, string sessionId) /// Calls "session.plan.read". public async Task ReadAsync(CancellationToken cancellationToken = default) { - var request = new ReadRequest { SessionId = _sessionId }; + var request = new SessionPlanReadRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.read", [request], cancellationToken); } /// Calls "session.plan.update". public async Task UpdateAsync(string content, CancellationToken cancellationToken = default) { - var request = new UpdateRequest { SessionId = _sessionId, Content = content }; + var request = new SessionPlanUpdateRequest { SessionId = _sessionId, Content = content }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.update", [request], cancellationToken); } /// Calls "session.plan.delete". public async Task DeleteAsync(CancellationToken cancellationToken = default) { - var request = new DeleteRequest { SessionId = _sessionId }; + var request = new SessionPlanDeleteRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.delete", [request], cancellationToken); } } @@ -567,21 +692,21 @@ internal WorkspaceApi(JsonRpc rpc, string sessionId) /// Calls "session.workspace.listFiles". public async Task ListFilesAsync(CancellationToken cancellationToken = default) { - var request = new ListFilesRequest { SessionId = _sessionId }; + var request = new SessionWorkspaceListFilesRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.listFiles", [request], cancellationToken); } /// Calls "session.workspace.readFile". public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) { - var request = new ReadFileRequest { SessionId = _sessionId, Path = path }; + var request = new SessionWorkspaceReadFileRequest { SessionId = _sessionId, Path = path }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.readFile", [request], cancellationToken); } /// Calls "session.workspace.createFile". public async Task CreateFileAsync(string path, string content, CancellationToken cancellationToken = default) { - var request = new CreateFileRequest { SessionId = _sessionId, Path = path, Content = content }; + var request = new SessionWorkspaceCreateFileRequest { SessionId = _sessionId, Path = path, Content = content }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.createFile", [request], cancellationToken); } } @@ -600,23 +725,77 @@ internal FleetApi(JsonRpc rpc, string sessionId) /// Calls "session.fleet.start". public async Task StartAsync(string? prompt, CancellationToken cancellationToken = default) { - var request = new StartRequest { SessionId = _sessionId, Prompt = prompt }; + var request = new SessionFleetStartRequest { SessionId = _sessionId, Prompt = prompt }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.fleet.start", [request], cancellationToken); } } +public class AgentApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal AgentApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.agent.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentListRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.list", [request], cancellationToken); + } + + /// Calls "session.agent.getCurrent". + public async Task GetCurrentAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentGetCurrentRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.getCurrent", [request], cancellationToken); + } + + /// Calls "session.agent.select". + public async Task SelectAsync(string name, CancellationToken cancellationToken = default) + { + var request = new SessionAgentSelectRequest { SessionId = _sessionId, Name = name }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.select", [request], cancellationToken); + } + + /// Calls "session.agent.deselect". + public async Task DeselectAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentDeselectRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.deselect", [request], cancellationToken); + } +} + +public class CompactionApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal CompactionApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.compaction.compact". + public async Task CompactAsync(CancellationToken cancellationToken = default) + { + var request = new SessionCompactionCompactRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.compaction.compact", [request], cancellationToken); + } +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(AccountGetQuotaResult))] [JsonSerializable(typeof(AccountGetQuotaResultQuotaSnapshotsValue))] -[JsonSerializable(typeof(CreateFileRequest))] -[JsonSerializable(typeof(DeleteRequest))] -[JsonSerializable(typeof(GetCurrentRequest))] -[JsonSerializable(typeof(GetRequest))] -[JsonSerializable(typeof(ListFilesRequest))] -[JsonSerializable(typeof(ListRequest))] +[JsonSerializable(typeof(Agent))] [JsonSerializable(typeof(Model))] [JsonSerializable(typeof(ModelBilling))] [JsonSerializable(typeof(ModelCapabilities))] @@ -626,23 +805,41 @@ public async Task StartAsync(string? prompt, Cancellati [JsonSerializable(typeof(ModelsListResult))] [JsonSerializable(typeof(PingRequest))] [JsonSerializable(typeof(PingResult))] -[JsonSerializable(typeof(ReadFileRequest))] -[JsonSerializable(typeof(ReadRequest))] +[JsonSerializable(typeof(SessionAgentDeselectRequest))] +[JsonSerializable(typeof(SessionAgentDeselectResult))] +[JsonSerializable(typeof(SessionAgentGetCurrentRequest))] +[JsonSerializable(typeof(SessionAgentGetCurrentResult))] +[JsonSerializable(typeof(SessionAgentGetCurrentResultAgent))] +[JsonSerializable(typeof(SessionAgentListRequest))] +[JsonSerializable(typeof(SessionAgentListResult))] +[JsonSerializable(typeof(SessionAgentSelectRequest))] +[JsonSerializable(typeof(SessionAgentSelectResult))] +[JsonSerializable(typeof(SessionAgentSelectResultAgent))] +[JsonSerializable(typeof(SessionCompactionCompactRequest))] +[JsonSerializable(typeof(SessionCompactionCompactResult))] +[JsonSerializable(typeof(SessionFleetStartRequest))] [JsonSerializable(typeof(SessionFleetStartResult))] +[JsonSerializable(typeof(SessionModeGetRequest))] [JsonSerializable(typeof(SessionModeGetResult))] +[JsonSerializable(typeof(SessionModeSetRequest))] [JsonSerializable(typeof(SessionModeSetResult))] +[JsonSerializable(typeof(SessionModelGetCurrentRequest))] [JsonSerializable(typeof(SessionModelGetCurrentResult))] +[JsonSerializable(typeof(SessionModelSwitchToRequest))] [JsonSerializable(typeof(SessionModelSwitchToResult))] +[JsonSerializable(typeof(SessionPlanDeleteRequest))] [JsonSerializable(typeof(SessionPlanDeleteResult))] +[JsonSerializable(typeof(SessionPlanReadRequest))] [JsonSerializable(typeof(SessionPlanReadResult))] +[JsonSerializable(typeof(SessionPlanUpdateRequest))] [JsonSerializable(typeof(SessionPlanUpdateResult))] +[JsonSerializable(typeof(SessionWorkspaceCreateFileRequest))] [JsonSerializable(typeof(SessionWorkspaceCreateFileResult))] +[JsonSerializable(typeof(SessionWorkspaceListFilesRequest))] [JsonSerializable(typeof(SessionWorkspaceListFilesResult))] +[JsonSerializable(typeof(SessionWorkspaceReadFileRequest))] [JsonSerializable(typeof(SessionWorkspaceReadFileResult))] -[JsonSerializable(typeof(SetRequest))] -[JsonSerializable(typeof(StartRequest))] -[JsonSerializable(typeof(SwitchToRequest))] [JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(ToolsListRequest))] [JsonSerializable(typeof(ToolsListResult))] -[JsonSerializable(typeof(UpdateRequest))] internal partial class RpcJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index c2549803a..55e221e80 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -22,6 +22,7 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(AssistantMessageDeltaEvent), "assistant.message_delta")] [JsonDerivedType(typeof(AssistantReasoningEvent), "assistant.reasoning")] [JsonDerivedType(typeof(AssistantReasoningDeltaEvent), "assistant.reasoning_delta")] +[JsonDerivedType(typeof(AssistantStreamingDeltaEvent), "assistant.streaming_delta")] [JsonDerivedType(typeof(AssistantTurnEndEvent), "assistant.turn_end")] [JsonDerivedType(typeof(AssistantTurnStartEvent), "assistant.turn_start")] [JsonDerivedType(typeof(AssistantUsageEvent), "assistant.usage")] @@ -42,6 +43,7 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(SessionShutdownEvent), "session.shutdown")] [JsonDerivedType(typeof(SessionSnapshotRewindEvent), "session.snapshot_rewind")] [JsonDerivedType(typeof(SessionStartEvent), "session.start")] +[JsonDerivedType(typeof(SessionTaskCompleteEvent), "session.task_complete")] [JsonDerivedType(typeof(SessionTitleChangedEvent), "session.title_changed")] [JsonDerivedType(typeof(SessionTruncationEvent), "session.truncation")] [JsonDerivedType(typeof(SessionUsageInfoEvent), "session.usage_info")] @@ -315,6 +317,18 @@ public partial class SessionCompactionCompleteEvent : SessionEvent public required SessionCompactionCompleteData Data { get; set; } } +/// +/// Event: session.task_complete +/// +public partial class SessionTaskCompleteEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.task_complete"; + + [JsonPropertyName("data")] + public required SessionTaskCompleteData Data { get; set; } +} + /// /// Event: user.message /// @@ -387,6 +401,18 @@ public partial class AssistantReasoningDeltaEvent : SessionEvent public required AssistantReasoningDeltaData Data { get; set; } } +/// +/// Event: assistant.streaming_delta +/// +public partial class AssistantStreamingDeltaEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "assistant.streaming_delta"; + + [JsonPropertyName("data")] + public required AssistantStreamingDeltaData Data { get; set; } +} + /// /// Event: assistant.message /// @@ -899,6 +925,13 @@ public partial class SessionCompactionCompleteData public string? RequestId { get; set; } } +public partial class SessionTaskCompleteData +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("summary")] + public string? Summary { get; set; } +} + public partial class UserMessageData { [JsonPropertyName("content")] @@ -955,6 +988,12 @@ public partial class AssistantReasoningDeltaData public required string DeltaContent { get; set; } } +public partial class AssistantStreamingDeltaData +{ + [JsonPropertyName("totalResponseSizeBytes")] + public required double TotalResponseSizeBytes { get; set; } +} + public partial class AssistantMessageData { [JsonPropertyName("messageId")] @@ -996,10 +1035,6 @@ public partial class AssistantMessageDeltaData [JsonPropertyName("deltaContent")] public required string DeltaContent { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("totalResponseSizeBytes")] - public double? TotalResponseSizeBytes { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("parentToolCallId")] public string? ParentToolCallId { get; set; } @@ -1736,6 +1771,8 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(AssistantReasoningDeltaData))] [JsonSerializable(typeof(AssistantReasoningDeltaEvent))] [JsonSerializable(typeof(AssistantReasoningEvent))] +[JsonSerializable(typeof(AssistantStreamingDeltaData))] +[JsonSerializable(typeof(AssistantStreamingDeltaEvent))] [JsonSerializable(typeof(AssistantTurnEndData))] [JsonSerializable(typeof(AssistantTurnEndEvent))] [JsonSerializable(typeof(AssistantTurnStartData))] @@ -1783,6 +1820,8 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SessionStartData))] [JsonSerializable(typeof(SessionStartDataContext))] [JsonSerializable(typeof(SessionStartEvent))] +[JsonSerializable(typeof(SessionTaskCompleteData))] +[JsonSerializable(typeof(SessionTaskCompleteEvent))] [JsonSerializable(typeof(SessionTitleChangedData))] [JsonSerializable(typeof(SessionTitleChangedEvent))] [JsonSerializable(typeof(SessionTruncationData))] diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 4feeb9f95..923b193cc 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -27,7 +27,7 @@ namespace GitHub.Copilot.SDK; /// /// /// -/// await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-4" }); +/// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" }); /// /// // Subscribe to events /// using var subscription = session.On(evt => @@ -147,6 +147,7 @@ public async Task SendAsync(MessageOptions options, CancellationToken ca /// A that can be used to cancel the operation. /// A task that resolves with the final assistant message event, or null if none was received. /// Thrown if the timeout is reached before the session becomes idle. + /// Thrown if the is cancelled. /// Thrown if the session has been disposed. /// /// @@ -201,7 +202,12 @@ void Handler(SessionEvent evt) cts.CancelAfter(effectiveTimeout); using var registration = cts.Token.Register(() => - tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}"))); + { + if (cancellationToken.IsCancellationRequested) + tcs.TrySetCanceled(cancellationToken); + else + tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}")); + }); return await tcs.Task; } @@ -551,10 +557,10 @@ await InvokeRpcAsync( /// /// /// // Using 'await using' for automatic disposal - /// await using var session = await client.CreateSessionAsync(); + /// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Or manually dispose - /// var session2 = await client.CreateSessionAsync(); + /// var session2 = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// // ... use the session ... /// await session2.DisposeAsync(); /// diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index acf03b4d2..1b716cd41 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -44,7 +44,7 @@ protected CopilotClientOptions(CopilotClientOptions? other) CliUrl = other.CliUrl; Cwd = other.Cwd; Environment = other.Environment; - GithubToken = other.GithubToken; + GitHubToken = other.GitHubToken; Logger = other.Logger; LogLevel = other.LogLevel; Port = other.Port; @@ -72,13 +72,23 @@ protected CopilotClientOptions(CopilotClientOptions? other) /// When provided, the token is passed to the CLI server via environment variable. /// This takes priority over other authentication methods. /// - public string? GithubToken { get; set; } + public string? GitHubToken { get; set; } + + /// + /// Obsolete. Use instead. + /// + [Obsolete("Use GitHubToken instead.", error: false)] + public string? GithubToken + { + get => GitHubToken; + set => GitHubToken = value; + } /// /// Whether to use the logged-in user for authentication. /// When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. - /// When false, only explicit tokens (GithubToken or environment variables) are used. - /// Default: true (but defaults to false when GithubToken is provided). + /// When false, only explicit tokens (GitHubToken or environment variables) are used. + /// Default: true (but defaults to false when GitHubToken is provided). /// public bool? UseLoggedInUser { get; set; } diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index b290e2b9c..9bc98f0f7 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -66,6 +66,8 @@ <_CopilotArchivePath>$(_CopilotCacheDir)\copilot.tgz <_CopilotNormalizedRegistryUrl>$([System.String]::Copy('$(CopilotNpmRegistryUrl)').TrimEnd('/')) <_CopilotDownloadUrl>$(_CopilotNormalizedRegistryUrl)/@github/copilot-$(_CopilotPlatform)/-/copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz + + <_CopilotCliDownloadTimeoutMs>$([System.Convert]::ToInt32($([MSBuild]::Multiply($(CopilotCliDownloadTimeout), 1000)))) @@ -75,7 +77,7 @@ diff --git a/dotnet/test/AgentAndCompactRpcTests.cs b/dotnet/test/AgentAndCompactRpcTests.cs new file mode 100644 index 000000000..5f40d4e2b --- /dev/null +++ b/dotnet/test/AgentAndCompactRpcTests.cs @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Rpc; +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class AgentAndCompactRpcTests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "agent_and_compact_rpc", output) +{ + [Fact] + public async Task Should_List_Available_Custom_Agents() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + }, + new() + { + Name = "another-agent", + DisplayName = "Another Agent", + Description = "Another test agent", + Prompt = "You are another agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + var result = await session.Rpc.Agent.ListAsync(); + Assert.NotNull(result.Agents); + Assert.Equal(2, result.Agents.Count); + Assert.Equal("test-agent", result.Agents[0].Name); + Assert.Equal("Test Agent", result.Agents[0].DisplayName); + Assert.Equal("A test agent", result.Agents[0].Description); + Assert.Equal("another-agent", result.Agents[1].Name); + } + + [Fact] + public async Task Should_Return_Null_When_No_Agent_Is_Selected() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + var result = await session.Rpc.Agent.GetCurrentAsync(); + Assert.Null(result.Agent); + } + + [Fact] + public async Task Should_Select_And_Get_Current_Agent() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + // Select the agent + var selectResult = await session.Rpc.Agent.SelectAsync("test-agent"); + Assert.NotNull(selectResult.Agent); + Assert.Equal("test-agent", selectResult.Agent.Name); + Assert.Equal("Test Agent", selectResult.Agent.DisplayName); + + // Verify getCurrent returns the selected agent + var currentResult = await session.Rpc.Agent.GetCurrentAsync(); + Assert.NotNull(currentResult.Agent); + Assert.Equal("test-agent", currentResult.Agent.Name); + } + + [Fact] + public async Task Should_Deselect_Current_Agent() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + // Select then deselect + await session.Rpc.Agent.SelectAsync("test-agent"); + await session.Rpc.Agent.DeselectAsync(); + + // Verify no agent is selected + var currentResult = await session.Rpc.Agent.GetCurrentAsync(); + Assert.Null(currentResult.Agent); + } + + [Fact] + public async Task Should_Return_Empty_List_When_No_Custom_Agents_Configured() + { + var session = await CreateSessionAsync(); + + var result = await session.Rpc.Agent.ListAsync(); + Assert.Empty(result.Agents); + } + + [Fact] + public async Task Should_Compact_Session_History_After_Messages() + { + var session = await CreateSessionAsync(); + + // Send a message to create some history + await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + + // Compact the session + var result = await session.Rpc.Compaction.CompactAsync(); + Assert.NotNull(result); + } +} diff --git a/dotnet/test/AskUserTests.cs b/dotnet/test/AskUserTests.cs index 55a563674..d3f273996 100644 --- a/dotnet/test/AskUserTests.cs +++ b/dotnet/test/AskUserTests.cs @@ -15,7 +15,7 @@ public async Task Should_Invoke_User_Input_Handler_When_Model_Uses_Ask_User_Tool { var userInputRequests = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnUserInputRequest = (request, invocation) => { @@ -49,7 +49,7 @@ public async Task Should_Receive_Choices_In_User_Input_Request() { var userInputRequests = new List(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnUserInputRequest = (request, invocation) => { @@ -82,7 +82,7 @@ public async Task Should_Handle_Freeform_User_Input_Response() var userInputRequests = new List(); var freeformAnswer = "This is my custom freeform answer that was not in the choices"; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnUserInputRequest = (request, invocation) => { diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index ee5b73bc7..91b7f9241 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -59,7 +59,7 @@ public async Task Should_Force_Stop_Without_Cleanup() { using var client = new CopilotClient(new CopilotClientOptions()); - await client.CreateSessionAsync(); + await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await client.ForceStopAsync(); Assert.Equal(ConnectionState.Disconnected, client.State); @@ -149,14 +149,14 @@ public async Task Should_List_Models_When_Authenticated() } [Fact] - public void Should_Accept_GithubToken_Option() + public void Should_Accept_GitHubToken_Option() { var options = new CopilotClientOptions { - GithubToken = "gho_test_token" + GitHubToken = "gho_test_token" }; - Assert.Equal("gho_test_token", options.GithubToken); + Assert.Equal("gho_test_token", options.GitHubToken); } [Fact] @@ -179,11 +179,11 @@ public void Should_Allow_Explicit_UseLoggedInUser_False() } [Fact] - public void Should_Allow_Explicit_UseLoggedInUser_True_With_GithubToken() + public void Should_Allow_Explicit_UseLoggedInUser_True_With_GitHubToken() { var options = new CopilotClientOptions { - GithubToken = "gho_test_token", + GitHubToken = "gho_test_token", UseLoggedInUser = true }; @@ -191,14 +191,14 @@ public void Should_Allow_Explicit_UseLoggedInUser_True_With_GithubToken() } [Fact] - public void Should_Throw_When_GithubToken_Used_With_CliUrl() + public void Should_Throw_When_GitHubToken_Used_With_CliUrl() { Assert.Throws(() => { _ = new CopilotClient(new CopilotClientOptions { CliUrl = "localhost:8080", - GithubToken = "gho_test_token" + GitHubToken = "gho_test_token" }); }); } @@ -220,7 +220,7 @@ public void Should_Throw_When_UseLoggedInUser_Used_With_CliUrl() public async Task Should_Not_Throw_When_Disposing_Session_After_Stopping_Client() { await using var client = new CopilotClient(new CopilotClientOptions()); - await using var session = await client.CreateSessionAsync(); + await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await client.StopAsync(); } @@ -247,7 +247,7 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start() // Verify subsequent calls also fail (don't hang) var ex2 = await Assert.ThrowsAnyAsync(async () => { - var session = await client.CreateSessionAsync(); + var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await session.SendAsync(new MessageOptions { Prompt = "test" }); }); Assert.Contains("exited", ex2.Message, StringComparison.OrdinalIgnoreCase); @@ -255,4 +255,32 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start() // Cleanup - ForceStop should handle the disconnected state gracefully try { await client.ForceStopAsync(); } catch (Exception) { /* Expected */ } } + + [Fact] + public async Task Should_Throw_When_CreateSession_Called_Without_PermissionHandler() + { + using var client = new CopilotClient(new CopilotClientOptions()); + + var ex = await Assert.ThrowsAsync(async () => + { + await client.CreateSessionAsync(new SessionConfig()); + }); + + Assert.Contains("OnPermissionRequest", ex.Message); + Assert.Contains("is required", ex.Message); + } + + [Fact] + public async Task Should_Throw_When_ResumeSession_Called_Without_PermissionHandler() + { + using var client = new CopilotClient(new CopilotClientOptions()); + + var ex = await Assert.ThrowsAsync(async () => + { + await client.ResumeSessionAsync("some-session-id", new ResumeSessionConfig()); + }); + + Assert.Contains("OnPermissionRequest", ex.Message); + Assert.Contains("is required", ex.Message); + } } diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs index 45eaaae16..8982c5d64 100644 --- a/dotnet/test/CloneTests.cs +++ b/dotnet/test/CloneTests.cs @@ -24,7 +24,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() AutoStart = false, AutoRestart = false, Environment = new Dictionary { ["KEY"] = "value" }, - GithubToken = "ghp_test", + GitHubToken = "ghp_test", UseLoggedInUser = false, }; @@ -40,7 +40,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() Assert.Equal(original.AutoStart, clone.AutoStart); Assert.Equal(original.AutoRestart, clone.AutoRestart); Assert.Equal(original.Environment, clone.Environment); - Assert.Equal(original.GithubToken, clone.GithubToken); + Assert.Equal(original.GitHubToken, clone.GitHubToken); Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser); } diff --git a/dotnet/test/CompactionTests.cs b/dotnet/test/CompactionTests.cs index af76508c7..c1cbc42df 100644 --- a/dotnet/test/CompactionTests.cs +++ b/dotnet/test/CompactionTests.cs @@ -15,7 +15,7 @@ public class CompactionTests(E2ETestFixture fixture, ITestOutputHelper output) : public async Task Should_Trigger_Compaction_With_Low_Threshold_And_Emit_Events() { // Create session with very low compaction thresholds to trigger compaction quickly - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { InfiniteSessions = new InfiniteSessionConfig { @@ -45,7 +45,7 @@ public async Task Should_Trigger_Compaction_With_Low_Threshold_And_Emit_Events() // Send multiple messages to fill up the context window await session.SendAndWaitAsync(new MessageOptions { - Prompt = "Tell me a long story about a dragon. Be very detailed." + Prompt = "Tell me a story about a dragon. Be detailed." }); await session.SendAndWaitAsync(new MessageOptions { @@ -84,7 +84,7 @@ await session.SendAndWaitAsync(new MessageOptions [Fact] public async Task Should_Not_Emit_Compaction_Events_When_Infinite_Sessions_Disabled() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { InfiniteSessions = new InfiniteSessionConfig { diff --git a/dotnet/test/GitHub.Copilot.SDK.Test.csproj b/dotnet/test/GitHub.Copilot.SDK.Test.csproj index cba1d863e..654a988a0 100644 --- a/dotnet/test/GitHub.Copilot.SDK.Test.csproj +++ b/dotnet/test/GitHub.Copilot.SDK.Test.csproj @@ -19,7 +19,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/dotnet/test/Harness/E2ETestBase.cs b/dotnet/test/Harness/E2ETestBase.cs index 8727e1239..dc1fa465d 100644 --- a/dotnet/test/Harness/E2ETestBase.cs +++ b/dotnet/test/Harness/E2ETestBase.cs @@ -42,6 +42,28 @@ public async Task InitializeAsync() public Task DisposeAsync() => Task.CompletedTask; + /// + /// Creates a session with a default config that approves all permissions. + /// Convenience wrapper for E2E tests. + /// + protected Task CreateSessionAsync(SessionConfig? config = null) + { + config ??= new SessionConfig(); + config.OnPermissionRequest ??= PermissionHandler.ApproveAll; + return Client.CreateSessionAsync(config); + } + + /// + /// Resumes a session with a default config that approves all permissions. + /// Convenience wrapper for E2E tests. + /// + protected Task ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null) + { + config ??= new ResumeSessionConfig(); + config.OnPermissionRequest ??= PermissionHandler.ApproveAll; + return Client.ResumeSessionAsync(sessionId, config); + } + protected static string GetSystemMessage(ParsedHttpExchange exchange) => exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.Content ?? string.Empty; diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index b8f3bdeb1..00fc32075 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -94,7 +94,7 @@ public IReadOnlyDictionary GetEnvironment() Cwd = WorkDir, CliPath = GetCliPath(_repoRoot), Environment = GetEnvironment(), - GithubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null, + GitHubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null, }); public async ValueTask DisposeAsync() diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index 44a6e66c2..a37ef3c15 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -15,7 +15,7 @@ public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() { var preToolUseInputs = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks @@ -51,7 +51,7 @@ public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() { var postToolUseInputs = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks @@ -89,7 +89,7 @@ public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single var preToolUseInputs = new List(); var postToolUseInputs = new List(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks @@ -131,7 +131,7 @@ public async Task Should_Deny_Tool_Execution_When_PreToolUse_Returns_Deny() { var preToolUseInputs = new List(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks diff --git a/dotnet/test/McpAndAgentsTests.cs b/dotnet/test/McpAndAgentsTests.cs index 644a70bf3..1d35ffda4 100644 --- a/dotnet/test/McpAndAgentsTests.cs +++ b/dotnet/test/McpAndAgentsTests.cs @@ -24,7 +24,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Create() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers }); @@ -45,7 +45,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Create() public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume() { // Create a session first - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -61,7 +61,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume() } }; - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { McpServers = mcpServers }); @@ -96,7 +96,7 @@ public async Task Should_Handle_Multiple_MCP_Servers() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers }); @@ -120,7 +120,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Create() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -141,7 +141,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Create() public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume() { // Create a session first - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -157,7 +157,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume() } }; - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { CustomAgents = customAgents }); @@ -187,7 +187,7 @@ public async Task Should_Handle_Custom_Agent_With_Tools_Configuration() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -220,7 +220,7 @@ public async Task Should_Handle_Custom_Agent_With_MCP_Servers() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -251,7 +251,7 @@ public async Task Should_Handle_Multiple_Custom_Agents() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -277,7 +277,7 @@ public async Task Should_Pass_Literal_Env_Values_To_Mcp_Server_Subprocess() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers, OnPermissionRequest = PermissionHandler.ApproveAll, @@ -321,7 +321,7 @@ public async Task Should_Accept_Both_MCP_Servers_And_Custom_Agents() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers, CustomAgents = customAgents diff --git a/dotnet/test/PermissionTests.cs b/dotnet/test/PermissionTests.cs index b1295be91..d2c04d1e8 100644 --- a/dotnet/test/PermissionTests.cs +++ b/dotnet/test/PermissionTests.cs @@ -15,7 +15,7 @@ public async Task Should_Invoke_Permission_Handler_For_Write_Operations() { var permissionRequests = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { @@ -44,7 +44,7 @@ await session.SendAsync(new MessageOptions [Fact] public async Task Should_Deny_Permission_When_Handler_Returns_Denied() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { @@ -71,9 +71,13 @@ await session.SendAsync(new MessageOptions } [Fact] - public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided() + public async Task Should_Deny_Tool_Operations_When_Handler_Explicitly_Denies() { - var session = await Client.CreateSessionAsync(new SessionConfig()); + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (_, _) => + Task.FromResult(new PermissionRequestResult { Kind = "denied-no-approval-rule-and-could-not-request-from-user" }) + }); var permissionDenied = false; session.On(evt => @@ -95,10 +99,9 @@ await session.SendAndWaitAsync(new MessageOptions } [Fact] - public async Task Should_Work_Without_Permission_Handler__Default_Behavior_() + public async Task Should_Work_With_Approve_All_Permission_Handler() { - // Create session without permission handler - var session = await Client.CreateSessionAsync(new SessionConfig()); + var session = await CreateSessionAsync(new SessionConfig()); await session.SendAsync(new MessageOptions { @@ -113,7 +116,7 @@ await session.SendAsync(new MessageOptions public async Task Should_Handle_Async_Permission_Handler() { var permissionRequestReceived = false; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = async (request, invocation) => { @@ -140,12 +143,12 @@ public async Task Should_Resume_Session_With_Permission_Handler() var permissionRequestReceived = false; // Create session without permission handler - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); // Resume with permission handler - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { OnPermissionRequest = (request, invocation) => { @@ -165,7 +168,7 @@ await session2.SendAndWaitAsync(new MessageOptions [Fact] public async Task Should_Handle_Permission_Handler_Errors_Gracefully() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { @@ -186,16 +189,20 @@ await session.SendAsync(new MessageOptions } [Fact] - public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided_After_Resume() + public async Task Should_Deny_Tool_Operations_When_Handler_Explicitly_Denies_After_Resume() { - var session1 = await Client.CreateSessionAsync(new SessionConfig + var session1 = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); - var session2 = await Client.ResumeSessionAsync(sessionId); + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + OnPermissionRequest = (_, _) => + Task.FromResult(new PermissionRequestResult { Kind = "denied-no-approval-rule-and-could-not-request-from-user" }) + }); var permissionDenied = false; session2.On(evt => @@ -220,7 +227,7 @@ await session2.SendAndWaitAsync(new MessageOptions public async Task Should_Receive_ToolCallId_In_Permission_Requests() { var receivedToolCallId = false; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { diff --git a/dotnet/test/RpcTests.cs b/dotnet/test/RpcTests.cs index 818bc8760..a13695589 100644 --- a/dotnet/test/RpcTests.cs +++ b/dotnet/test/RpcTests.cs @@ -55,7 +55,7 @@ public async Task Should_Call_Rpc_Account_GetQuota_When_Authenticated() [Fact(Skip = "session.model.getCurrent not yet implemented in CLI")] public async Task Should_Call_Session_Rpc_Model_GetCurrent() { - var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); var result = await session.Rpc.Model.GetCurrentAsync(); Assert.NotNull(result.ModelId); @@ -66,7 +66,7 @@ public async Task Should_Call_Session_Rpc_Model_GetCurrent() [Fact(Skip = "session.model.switchTo not yet implemented in CLI")] public async Task Should_Call_Session_Rpc_Model_SwitchTo() { - var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); // Get initial model var before = await session.Rpc.Model.GetCurrentAsync(); @@ -84,7 +84,7 @@ public async Task Should_Call_Session_Rpc_Model_SwitchTo() [Fact] public async Task Should_Get_And_Set_Session_Mode() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Get initial mode (default should be interactive) var initial = await session.Rpc.Mode.GetAsync(); @@ -106,7 +106,7 @@ public async Task Should_Get_And_Set_Session_Mode() [Fact] public async Task Should_Read_Update_And_Delete_Plan() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Initially plan should not exist var initial = await session.Rpc.Plan.ReadAsync(); @@ -134,7 +134,7 @@ public async Task Should_Read_Update_And_Delete_Plan() [Fact] public async Task Should_Create_List_And_Read_Workspace_Files() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Initially no files var initialFiles = await session.Rpc.Workspace.ListFilesAsync(); diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index c9a152ce9..e4b13fff7 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -15,7 +15,7 @@ public class SessionTests(E2ETestFixture fixture, ITestOutputHelper output) : E2 [Fact] public async Task ShouldCreateAndDestroySessions() { - var session = await Client.CreateSessionAsync(new SessionConfig { Model = "fake-test-model" }); + var session = await CreateSessionAsync(new SessionConfig { Model = "fake-test-model" }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); @@ -33,7 +33,7 @@ public async Task ShouldCreateAndDestroySessions() [Fact] public async Task Should_Have_Stateful_Conversation() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var assistantMessage = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); Assert.NotNull(assistantMessage); @@ -48,7 +48,7 @@ public async Task Should_Have_Stateful_Conversation() public async Task Should_Create_A_Session_With_Appended_SystemMessage_Config() { var systemMessageSuffix = "End each response with the phrase 'Have a nice day!'"; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = systemMessageSuffix } }); @@ -72,7 +72,7 @@ public async Task Should_Create_A_Session_With_Appended_SystemMessage_Config() public async Task Should_Create_A_Session_With_Replaced_SystemMessage_Config() { var testSystemMessage = "You are an assistant called Testy McTestface. Reply succinctly."; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Replace, Content = testSystemMessage } }); @@ -93,7 +93,7 @@ public async Task Should_Create_A_Session_With_Replaced_SystemMessage_Config() [Fact] public async Task Should_Create_A_Session_With_AvailableTools() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { AvailableTools = new List { "view", "edit" } }); @@ -113,7 +113,7 @@ public async Task Should_Create_A_Session_With_AvailableTools() [Fact] public async Task Should_Create_A_Session_With_ExcludedTools() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { ExcludedTools = new List { "view" } }); @@ -133,7 +133,7 @@ public async Task Should_Create_A_Session_With_ExcludedTools() [Fact] public async Task Should_Create_Session_With_Custom_Tool() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [ @@ -153,7 +153,7 @@ public async Task Should_Create_Session_With_Custom_Tool() [Fact] public async Task Should_Resume_A_Session_Using_The_Same_Client() { - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -161,7 +161,7 @@ public async Task Should_Resume_A_Session_Using_The_Same_Client() Assert.NotNull(answer); Assert.Contains("2", answer!.Data.Content ?? string.Empty); - var session2 = await Client.ResumeSessionAsync(sessionId); + var session2 = await ResumeSessionAsync(sessionId); Assert.Equal(sessionId, session2.SessionId); var answer2 = await TestHelper.GetFinalAssistantMessageAsync(session2); @@ -172,7 +172,7 @@ public async Task Should_Resume_A_Session_Using_The_Same_Client() [Fact] public async Task Should_Resume_A_Session_Using_A_New_Client() { - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -181,7 +181,7 @@ public async Task Should_Resume_A_Session_Using_A_New_Client() Assert.Contains("2", answer!.Data.Content ?? string.Empty); using var newClient = Ctx.CreateClient(); - var session2 = await newClient.ResumeSessionAsync(sessionId); + var session2 = await newClient.ResumeSessionAsync(sessionId, new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); Assert.Equal(sessionId, session2.SessionId); var messages = await session2.GetMessagesAsync(); @@ -193,13 +193,13 @@ public async Task Should_Resume_A_Session_Using_A_New_Client() public async Task Should_Throw_Error_When_Resuming_Non_Existent_Session() { await Assert.ThrowsAsync(() => - Client.ResumeSessionAsync("non-existent-session-id")); + ResumeSessionAsync("non-existent-session-id")); } [Fact] public async Task Should_Abort_A_Session() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Set up wait for tool execution to start BEFORE sending var toolStartTask = TestHelper.GetNextEventOfTypeAsync(session); @@ -237,7 +237,7 @@ await session.SendAsync(new MessageOptions [Fact(Skip = "Requires schema update for AssistantMessageDeltaEvent type")] public async Task Should_Receive_Streaming_Delta_Events_When_Streaming_Is_Enabled() { - var session = await Client.CreateSessionAsync(new SessionConfig { Streaming = true }); + var session = await CreateSessionAsync(new SessionConfig { Streaming = true }); var deltaContents = new List(); var doneEvent = new TaskCompletionSource(); @@ -282,7 +282,7 @@ public async Task Should_Receive_Streaming_Delta_Events_When_Streaming_Is_Enable public async Task Should_Pass_Streaming_Option_To_Session_Creation() { // Verify that the streaming option is accepted without errors - var session = await Client.CreateSessionAsync(new SessionConfig { Streaming = true }); + var session = await CreateSessionAsync(new SessionConfig { Streaming = true }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); @@ -296,7 +296,7 @@ public async Task Should_Pass_Streaming_Option_To_Session_Creation() [Fact] public async Task Should_Receive_Session_Events() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var receivedEvents = new List(); var idleReceived = new TaskCompletionSource(); @@ -333,7 +333,7 @@ public async Task Should_Receive_Session_Events() [Fact] public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, }); @@ -358,7 +358,7 @@ public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() [Fact] public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assistant_Message() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var events = new List(); session.On(evt => events.Add(evt.Type)); @@ -376,7 +376,7 @@ public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assist [Fact(Skip = "Needs test harness CAPI proxy support")] public async Task Should_List_Sessions_With_Context() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var sessions = await Client.ListSessionsAsync(); Assert.NotEmpty(sessions); @@ -394,7 +394,7 @@ public async Task Should_List_Sessions_With_Context() [Fact] public async Task SendAndWait_Throws_On_Timeout() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Use a slow command to ensure timeout triggers before completion var ex = await Assert.ThrowsAsync(() => @@ -403,11 +403,35 @@ public async Task SendAndWait_Throws_On_Timeout() Assert.Contains("timed out", ex.Message); } + [Fact] + public async Task SendAndWait_Throws_OperationCanceledException_When_Token_Cancelled() + { + var session = await CreateSessionAsync(); + + // Set up wait for tool execution to start BEFORE sending + var toolStartTask = TestHelper.GetNextEventOfTypeAsync(session); + + using var cts = new CancellationTokenSource(); + + // Start SendAndWaitAsync - don't await it yet + var sendTask = session.SendAndWaitAsync( + new MessageOptions { Prompt = "run the shell command 'sleep 10' (note this works on both bash and PowerShell)" }, + cancellationToken: cts.Token); + + // Wait for the tool to begin executing before cancelling + await toolStartTask; + + // Cancel the token + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => sendTask); + } + [Fact] public async Task Should_Create_Session_With_Custom_Config_Dir() { var customConfigDir = Path.Join(Ctx.HomeDir, "custom-config"); - var session = await Client.CreateSessionAsync(new SessionConfig { ConfigDir = customConfigDir }); + var session = await CreateSessionAsync(new SessionConfig { ConfigDir = customConfigDir }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index bba5e1e5f..d68eed79d 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -52,7 +52,7 @@ private string CreateSkillDir() public async Task Should_Load_And_Apply_Skill_From_SkillDirectories() { var skillsDir = CreateSkillDir(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir] }); @@ -71,7 +71,7 @@ public async Task Should_Load_And_Apply_Skill_From_SkillDirectories() public async Task Should_Not_Apply_Skill_When_Disabled_Via_DisabledSkills() { var skillsDir = CreateSkillDir(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir], DisabledSkills = ["test-skill"] @@ -93,7 +93,7 @@ public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories() var skillsDir = CreateSkillDir(); // Create a session without skills first - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; // First message without skill - marker should not appear @@ -102,7 +102,7 @@ public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories() Assert.DoesNotContain(SkillMarker, message1!.Data.Content); // Resume with skillDirectories - skill should now be active - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { SkillDirectories = [skillsDir] }); diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index ad1ab7a21..c6449ec8f 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -5,6 +5,7 @@ using GitHub.Copilot.SDK.Test.Harness; using Microsoft.Extensions.AI; using System.ComponentModel; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Xunit; @@ -21,7 +22,7 @@ await File.WriteAllTextAsync( Path.Combine(Ctx.WorkDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, }); @@ -39,9 +40,10 @@ await session.SendAsync(new MessageOptions [Fact] public async Task Invokes_Custom_Tool() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(EncryptString, "encrypt_string")], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions @@ -64,9 +66,10 @@ public async Task Handles_Tool_Calling_Errors() var getUserLocation = AIFunctionFactory.Create( () => { throw new Exception("Melbourne"); }, "get_user_location", "Gets the user's location"); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { - Tools = [getUserLocation] + Tools = [getUserLocation], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions { Prompt = "What is my location? If you can't find out, just say 'unknown'." }); @@ -105,9 +108,10 @@ public async Task Handles_Tool_Calling_Errors() public async Task Can_Receive_And_Return_Complex_Types() { ToolInvocation? receivedInvocation = null; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(PerformDbQuery, "db_query", serializerOptions: ToolsTestsJsonContext.Default.Options)], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions @@ -151,9 +155,10 @@ private partial class ToolsTestsJsonContext : JsonSerializerContext; [Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")] public async Task Can_Return_Binary_Result() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(GetImage, "get_image")], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions @@ -177,4 +182,72 @@ await session.SendAsync(new MessageOptions SessionLog = "Returned an image", }); } + + [Fact] + public async Task Invokes_Custom_Tool_With_Permission_Handler() + { + var permissionRequests = new List(); + + var session = await Client.CreateSessionAsync(new SessionConfig + { + Tools = [AIFunctionFactory.Create(EncryptStringForPermission, "encrypt_string")], + OnPermissionRequest = (request, invocation) => + { + permissionRequests.Add(request); + return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + }, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use encrypt_string to encrypt this string: Hello" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + Assert.Contains("HELLO", assistantMessage!.Data.Content ?? string.Empty); + + // Should have received a custom-tool permission request with the correct tool name + var customToolRequest = permissionRequests.FirstOrDefault(r => r.Kind == "custom-tool"); + Assert.NotNull(customToolRequest); + Assert.True(customToolRequest!.ExtensionData?.ContainsKey("toolName") ?? false); + var toolName = ((JsonElement)customToolRequest.ExtensionData!["toolName"]).GetString(); + Assert.Equal("encrypt_string", toolName); + + [Description("Encrypts a string")] + static string EncryptStringForPermission([Description("String to encrypt")] string input) + => input.ToUpperInvariant(); + } + + [Fact] + public async Task Denies_Custom_Tool_When_Permission_Denied() + { + var toolHandlerCalled = false; + + var session = await Client.CreateSessionAsync(new SessionConfig + { + Tools = [AIFunctionFactory.Create(EncryptStringDenied, "encrypt_string")], + OnPermissionRequest = (request, invocation) => + { + return Task.FromResult(new PermissionRequestResult { Kind = "denied-interactively-by-user" }); + }, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use encrypt_string to encrypt this string: Hello" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // The tool handler should NOT have been called since permission was denied + Assert.False(toolHandlerCalled); + + [Description("Encrypts a string")] + string EncryptStringDenied([Description("String to encrypt")] string input) + { + toolHandlerCalled = true; + return input.ToUpperInvariant(); + } + } } diff --git a/go/README.md b/go/README.md index 37cb7ce07..e9355d559 100644 --- a/go/README.md +++ b/go/README.md @@ -99,7 +99,7 @@ That's it! When your application calls `copilot.NewClient` without a `CLIPath` n - `Stop() error` - Stop the CLI server - `ForceStop()` - Forcefully stop without graceful cleanup - `CreateSession(config *SessionConfig) (*Session, error)` - Create a new session -- `ResumeSession(sessionID string) (*Session, error)` - Resume an existing session +- `ResumeSession(sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume an existing session - `ResumeSessionWithOptions(sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume with additional configuration - `ListSessions(filter *SessionListFilter) ([]SessionMetadata, error)` - List sessions (with optional filter) - `DeleteSession(sessionID string) error` - Delete a session permanently @@ -138,8 +138,8 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `AutoStart` (\*bool): Auto-start server on first use (default: true). Use `Bool(false)` to disable. - `AutoRestart` (\*bool): Auto-restart on crash (default: true). Use `Bool(false)` to disable. - `Env` ([]string): Environment variables for CLI process (default: inherits from current process) -- `GithubToken` (string): GitHub token for authentication. When provided, takes priority over other auth methods. -- `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GithubToken` is provided). Cannot be used with `CLIUrl`. +- `GitHubToken` (string): GitHub token for authentication. When provided, takes priority over other auth methods. +- `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `CLIUrl`. **SessionConfig:** diff --git a/go/client.go b/go/client.go index 68f58d859..50e6b4ccb 100644 --- a/go/client.go +++ b/go/client.go @@ -12,6 +12,7 @@ // defer client.Stop() // // session, err := client.CreateSession(&copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Model: "gpt-4", // }) // if err != nil { @@ -134,8 +135,8 @@ func NewClient(options *ClientOptions) *Client { } // Validate auth options with external server - if options.CLIUrl != "" && (options.GithubToken != "" || options.UseLoggedInUser != nil) { - panic("GithubToken and UseLoggedInUser cannot be used with CLIUrl (external server manages its own auth)") + if options.CLIUrl != "" && (options.GitHubToken != "" || options.UseLoggedInUser != nil) { + panic("GitHubToken and UseLoggedInUser cannot be used with CLIUrl (external server manages its own auth)") } // Parse CLIUrl if provided @@ -177,8 +178,8 @@ func NewClient(options *ClientOptions) *Client { if options.AutoRestart != nil { client.autoRestart = *options.AutoRestart } - if options.GithubToken != "" { - opts.GithubToken = options.GithubToken + if options.GitHubToken != "" { + opts.GitHubToken = options.GitHubToken } if options.UseLoggedInUser != nil { opts.UseLoggedInUser = options.UseLoggedInUser @@ -426,17 +427,20 @@ func (c *Client) ensureConnected() error { // If the client is not connected and AutoStart is enabled, this will automatically // start the connection. // -// The config parameter is optional; pass nil for default settings. +// The config parameter is required and must include an OnPermissionRequest handler. // // Returns the created session or an error if session creation fails. // // Example: // // // Basic session -// session, err := client.CreateSession(context.Background(), nil) +// session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) // // // Session with model and tools // session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Model: "gpt-4", // Tools: []copilot.Tool{ // { @@ -447,44 +451,46 @@ func (c *Client) ensureConnected() error { // }, // }) 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}") + } + if err := c.ensureConnected(); err != nil { return nil, err } req := createSessionRequest{} - if config != nil { - req.Model = config.Model - req.SessionID = config.SessionID - req.ClientName = config.ClientName - req.ReasoningEffort = config.ReasoningEffort - req.ConfigDir = config.ConfigDir - req.Tools = config.Tools - req.SystemMessage = config.SystemMessage - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools - req.Provider = config.Provider - req.WorkingDirectory = config.WorkingDirectory - req.MCPServers = config.MCPServers - req.EnvValueMode = "direct" - req.CustomAgents = config.CustomAgents - req.SkillDirectories = config.SkillDirectories - req.DisabledSkills = config.DisabledSkills - req.InfiniteSessions = config.InfiniteSessions - - if config.Streaming { - req.Streaming = Bool(true) - } - if config.OnUserInputRequest != nil { - req.RequestUserInput = Bool(true) - } - if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || - config.Hooks.OnPostToolUse != nil || - config.Hooks.OnUserPromptSubmitted != nil || - config.Hooks.OnSessionStart != nil || - config.Hooks.OnSessionEnd != nil || - config.Hooks.OnErrorOccurred != nil) { - req.Hooks = Bool(true) - } + req.Model = config.Model + req.SessionID = config.SessionID + req.ClientName = config.ClientName + req.ReasoningEffort = config.ReasoningEffort + req.ConfigDir = config.ConfigDir + req.Tools = config.Tools + req.SystemMessage = config.SystemMessage + req.AvailableTools = config.AvailableTools + req.ExcludedTools = config.ExcludedTools + req.Provider = config.Provider + req.WorkingDirectory = config.WorkingDirectory + req.MCPServers = config.MCPServers + req.EnvValueMode = "direct" + req.CustomAgents = config.CustomAgents + req.SkillDirectories = config.SkillDirectories + req.DisabledSkills = config.DisabledSkills + req.InfiniteSessions = config.InfiniteSessions + + if config.Streaming { + req.Streaming = Bool(true) + } + if config.OnUserInputRequest != nil { + req.RequestUserInput = Bool(true) + } + if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPostToolUse != nil || + config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnSessionStart != nil || + config.Hooks.OnSessionEnd != nil || + config.Hooks.OnErrorOccurred != nil) { + req.Hooks = Bool(true) } req.RequestPermission = Bool(true) @@ -500,19 +506,13 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses session := newSession(response.SessionID, c.client, response.WorkspacePath) - if config != nil { - session.registerTools(config.Tools) - if config.OnPermissionRequest != nil { - session.registerPermissionHandler(config.OnPermissionRequest) - } - if config.OnUserInputRequest != nil { - session.registerUserInputHandler(config.OnUserInputRequest) - } - if config.Hooks != nil { - session.registerHooks(config.Hooks) - } - } else { - session.registerTools(nil) + session.registerTools(config.Tools) + session.registerPermissionHandler(config.OnPermissionRequest) + if config.OnUserInputRequest != nil { + session.registerUserInputHandler(config.OnUserInputRequest) + } + if config.Hooks != nil { + session.registerHooks(config.Hooks) } c.sessionsMux.Lock() @@ -522,15 +522,18 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses return session, nil } -// ResumeSession resumes an existing conversation session by its ID using default options. +// ResumeSession resumes an existing conversation session by its ID. // -// This is a convenience method that calls [Client.ResumeSessionWithOptions] with nil config. +// This is a convenience method that calls [Client.ResumeSessionWithOptions]. +// The config must include an OnPermissionRequest handler. // // Example: // -// session, err := client.ResumeSession(context.Background(), "session-123") -func (c *Client) ResumeSession(ctx context.Context, sessionID string) (*Session, error) { - return c.ResumeSessionWithOptions(ctx, sessionID, nil) +// session, err := client.ResumeSession(context.Background(), "session-123", &copilot.ResumeSessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) +func (c *Client) ResumeSession(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) { + return c.ResumeSessionWithOptions(ctx, sessionID, config) } // ResumeSessionWithOptions resumes an existing conversation session with additional configuration. @@ -541,50 +544,53 @@ func (c *Client) ResumeSession(ctx context.Context, sessionID string) (*Session, // Example: // // session, err := client.ResumeSessionWithOptions(context.Background(), "session-123", &copilot.ResumeSessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Tools: []copilot.Tool{myNewTool}, // }) func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) { + if config == nil || config.OnPermissionRequest == nil { + return nil, fmt.Errorf("an OnPermissionRequest handler is required when resuming a session. For example, to allow all permissions, use &copilot.ResumeSessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}") + } + if err := c.ensureConnected(); err != nil { return nil, err } var req resumeSessionRequest req.SessionID = sessionID - if config != nil { - req.ClientName = config.ClientName - req.Model = config.Model - req.ReasoningEffort = config.ReasoningEffort - req.SystemMessage = config.SystemMessage - req.Tools = config.Tools - req.Provider = config.Provider - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools - if config.Streaming { - req.Streaming = Bool(true) - } - if config.OnUserInputRequest != nil { - req.RequestUserInput = Bool(true) - } - if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || - config.Hooks.OnPostToolUse != nil || - config.Hooks.OnUserPromptSubmitted != nil || - config.Hooks.OnSessionStart != nil || - config.Hooks.OnSessionEnd != nil || - config.Hooks.OnErrorOccurred != nil) { - req.Hooks = Bool(true) - } - req.WorkingDirectory = config.WorkingDirectory - req.ConfigDir = config.ConfigDir - if config.DisableResume { - req.DisableResume = Bool(true) - } - req.MCPServers = config.MCPServers - req.EnvValueMode = "direct" - req.CustomAgents = config.CustomAgents - req.SkillDirectories = config.SkillDirectories - req.DisabledSkills = config.DisabledSkills - req.InfiniteSessions = config.InfiniteSessions - } + req.ClientName = config.ClientName + req.Model = config.Model + req.ReasoningEffort = config.ReasoningEffort + req.SystemMessage = config.SystemMessage + req.Tools = config.Tools + req.Provider = config.Provider + req.AvailableTools = config.AvailableTools + req.ExcludedTools = config.ExcludedTools + if config.Streaming { + req.Streaming = Bool(true) + } + if config.OnUserInputRequest != nil { + req.RequestUserInput = Bool(true) + } + if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPostToolUse != nil || + config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnSessionStart != nil || + config.Hooks.OnSessionEnd != nil || + config.Hooks.OnErrorOccurred != nil) { + req.Hooks = Bool(true) + } + req.WorkingDirectory = config.WorkingDirectory + req.ConfigDir = config.ConfigDir + if config.DisableResume { + req.DisableResume = Bool(true) + } + req.MCPServers = config.MCPServers + req.EnvValueMode = "direct" + req.CustomAgents = config.CustomAgents + req.SkillDirectories = config.SkillDirectories + req.DisabledSkills = config.DisabledSkills + req.InfiniteSessions = config.InfiniteSessions req.RequestPermission = Bool(true) result, err := c.client.Request("session.resume", req) @@ -598,19 +604,13 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, } session := newSession(response.SessionID, c.client, response.WorkspacePath) - if config != nil { - session.registerTools(config.Tools) - if config.OnPermissionRequest != nil { - session.registerPermissionHandler(config.OnPermissionRequest) - } - if config.OnUserInputRequest != nil { - session.registerUserInputHandler(config.OnUserInputRequest) - } - if config.Hooks != nil { - session.registerHooks(config.Hooks) - } - } else { - session.registerTools(nil) + session.registerTools(config.Tools) + session.registerPermissionHandler(config.OnPermissionRequest) + if config.OnUserInputRequest != nil { + session.registerUserInputHandler(config.OnUserInputRequest) + } + if config.Hooks != nil { + session.registerHooks(config.Hooks) } c.sessionsMux.Lock() @@ -881,7 +881,9 @@ func (c *Client) handleLifecycleEvent(event SessionLifecycleEvent) { // Example: // // if client.State() == copilot.StateConnected { -// session, err := client.CreateSession(context.Background(), nil) +// session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) // } func (c *Client) State() ConnectionState { return c.state @@ -1040,14 +1042,14 @@ func (c *Client) startCLIServer(ctx context.Context) error { } // Add auth-related flags - if c.options.GithubToken != "" { + if c.options.GitHubToken != "" { args = append(args, "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN") } - // Default useLoggedInUser to false when GithubToken is provided + // Default useLoggedInUser to false when GitHubToken is provided useLoggedInUser := true if c.options.UseLoggedInUser != nil { useLoggedInUser = *c.options.UseLoggedInUser - } else if c.options.GithubToken != "" { + } else if c.options.GitHubToken != "" { useLoggedInUser = false } if !useLoggedInUser { @@ -1074,8 +1076,8 @@ func (c *Client) startCLIServer(ctx context.Context) error { // Add auth token if needed. c.process.Env = c.options.Env - if c.options.GithubToken != "" { - c.process.Env = append(c.process.Env, "COPILOT_SDK_AUTH_TOKEN="+c.options.GithubToken) + if c.options.GitHubToken != "" { + c.process.Env = append(c.process.Env, "COPILOT_SDK_AUTH_TOKEN="+c.options.GitHubToken) } if c.useStdio { diff --git a/go/client_test.go b/go/client_test.go index b2e9cdce6..2d198f224 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -21,7 +21,9 @@ func TestClient_HandleToolCallRequest(t *testing.T) { client := NewClient(&ClientOptions{CLIPath: cliPath}) t.Cleanup(func() { client.ForceStop() }) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &SessionConfig{ + OnPermissionRequest: PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -255,17 +257,17 @@ func TestClient_URLParsing(t *testing.T) { } func TestClient_AuthOptions(t *testing.T) { - t.Run("should accept GithubToken option", func(t *testing.T) { + t.Run("should accept GitHubToken option", func(t *testing.T) { client := NewClient(&ClientOptions{ - GithubToken: "gho_test_token", + GitHubToken: "gho_test_token", }) - if client.options.GithubToken != "gho_test_token" { - t.Errorf("Expected GithubToken to be 'gho_test_token', got %q", client.options.GithubToken) + if client.options.GitHubToken != "gho_test_token" { + t.Errorf("Expected GitHubToken to be 'gho_test_token', got %q", client.options.GitHubToken) } }) - t.Run("should default UseLoggedInUser to nil when no GithubToken", func(t *testing.T) { + t.Run("should default UseLoggedInUser to nil when no GitHubToken", func(t *testing.T) { client := NewClient(&ClientOptions{}) if client.options.UseLoggedInUser != nil { @@ -283,9 +285,9 @@ func TestClient_AuthOptions(t *testing.T) { } }) - t.Run("should allow explicit UseLoggedInUser true with GithubToken", func(t *testing.T) { + t.Run("should allow explicit UseLoggedInUser true with GitHubToken", func(t *testing.T) { client := NewClient(&ClientOptions{ - GithubToken: "gho_test_token", + GitHubToken: "gho_test_token", UseLoggedInUser: Bool(true), }) @@ -294,12 +296,12 @@ func TestClient_AuthOptions(t *testing.T) { } }) - t.Run("should throw error when GithubToken is used with CLIUrl", func(t *testing.T) { + t.Run("should throw error when GitHubToken is used with CLIUrl", func(t *testing.T) { defer func() { if r := recover(); r == nil { t.Error("Expected panic for auth options with CLIUrl") } else { - matched, _ := regexp.MatchString("GithubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) + matched, _ := regexp.MatchString("GitHubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) if !matched { t.Errorf("Expected panic message about auth options, got: %v", r) } @@ -308,7 +310,7 @@ func TestClient_AuthOptions(t *testing.T) { NewClient(&ClientOptions{ CLIUrl: "localhost:8080", - GithubToken: "gho_test_token", + GitHubToken: "gho_test_token", }) }) @@ -317,7 +319,7 @@ func TestClient_AuthOptions(t *testing.T) { if r := recover(); r == nil { t.Error("Expected panic for auth options with CLIUrl") } else { - matched, _ := regexp.MatchString("GithubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) + matched, _ := regexp.MatchString("GitHubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) if !matched { t.Errorf("Expected panic message about auth options, got: %v", r) } @@ -444,3 +446,43 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { } }) } + +func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { + t.Run("returns error when config is nil", func(t *testing.T) { + client := NewClient(nil) + _, err := client.CreateSession(t.Context(), nil) + if err == nil { + t.Fatal("Expected error when OnPermissionRequest is nil") + } + matched, _ := regexp.MatchString("OnPermissionRequest.*is required", err.Error()) + if !matched { + t.Errorf("Expected error about OnPermissionRequest being required, got: %v", err) + } + }) + + t.Run("returns error when OnPermissionRequest is not set", func(t *testing.T) { + client := NewClient(nil) + _, err := client.CreateSession(t.Context(), &SessionConfig{}) + if err == nil { + t.Fatal("Expected error when OnPermissionRequest is nil") + } + matched, _ := regexp.MatchString("OnPermissionRequest.*is required", err.Error()) + if !matched { + t.Errorf("Expected error about OnPermissionRequest being required, got: %v", err) + } + }) +} + +func TestClient_ResumeSession_RequiresPermissionHandler(t *testing.T) { + t.Run("returns error when config is nil", func(t *testing.T) { + client := NewClient(nil) + _, err := client.ResumeSessionWithOptions(t.Context(), "some-id", nil) + if err == nil { + t.Fatal("Expected error when OnPermissionRequest is nil") + } + matched, _ := regexp.MatchString("OnPermissionRequest.*is required", err.Error()) + if !matched { + t.Errorf("Expected error about OnPermissionRequest being required, got: %v", err) + } + }) +} diff --git a/go/generated_session_events.go b/go/generated_session_events.go index c11a43c5a..92460fda6 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -108,6 +108,7 @@ type Data struct { Intent *string `json:"intent,omitempty"` ReasoningID *string `json:"reasoningId,omitempty"` DeltaContent *string `json:"deltaContent,omitempty"` + TotalResponseSizeBytes *float64 `json:"totalResponseSizeBytes,omitempty"` EncryptedContent *string `json:"encryptedContent,omitempty"` MessageID *string `json:"messageId,omitempty"` ParentToolCallID *string `json:"parentToolCallId,omitempty"` @@ -115,7 +116,6 @@ type Data struct { ReasoningOpaque *string `json:"reasoningOpaque,omitempty"` ReasoningText *string `json:"reasoningText,omitempty"` ToolRequests []ToolRequest `json:"toolRequests,omitempty"` - TotalResponseSizeBytes *float64 `json:"totalResponseSizeBytes,omitempty"` APICallID *string `json:"apiCallId,omitempty"` CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` @@ -368,6 +368,7 @@ const ( 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" @@ -388,6 +389,7 @@ const ( SessionShutdown SessionEventType = "session.shutdown" SessionSnapshotRewind SessionEventType = "session.snapshot_rewind" SessionStart SessionEventType = "session.start" + SessionTaskComplete SessionEventType = "session.task_complete" SessionTitleChanged SessionEventType = "session.title_changed" SessionTruncation SessionEventType = "session.truncation" SessionUsageInfo SessionEventType = "session.usage_info" diff --git a/go/internal/e2e/agent_and_compact_rpc_test.go b/go/internal/e2e/agent_and_compact_rpc_test.go new file mode 100644 index 000000000..338f4da67 --- /dev/null +++ b/go/internal/e2e/agent_and_compact_rpc_test.go @@ -0,0 +1,289 @@ +package e2e + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestAgentSelectionRpc(t *testing.T) { + cliPath := testharness.CLIPath() + if cliPath == "" { + t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") + } + + t.Run("should list available custom agents", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + { + Name: "another-agent", + DisplayName: "Another Agent", + Description: "Another test agent", + Prompt: "You are another agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.List(t.Context()) + if err != nil { + t.Fatalf("Failed to list agents: %v", err) + } + + if len(result.Agents) != 2 { + t.Fatalf("Expected 2 agents, got %d", len(result.Agents)) + } + if result.Agents[0].Name != "test-agent" { + t.Errorf("Expected first agent name 'test-agent', got %q", result.Agents[0].Name) + } + if result.Agents[0].DisplayName != "Test Agent" { + t.Errorf("Expected first agent displayName 'Test Agent', got %q", result.Agents[0].DisplayName) + } + if result.Agents[1].Name != "another-agent" { + t.Errorf("Expected second agent name 'another-agent', got %q", result.Agents[1].Name) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should return null when no agent is selected", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + + if result.Agent != nil { + t.Errorf("Expected no agent selected, got %v", result.Agent) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should select and get current agent", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Select the agent + selectResult, err := session.RPC.Agent.Select(t.Context(), &rpc.SessionAgentSelectParams{Name: "test-agent"}) + if err != nil { + t.Fatalf("Failed to select agent: %v", err) + } + if selectResult.Agent.Name != "test-agent" { + t.Errorf("Expected selected agent 'test-agent', got %q", selectResult.Agent.Name) + } + if selectResult.Agent.DisplayName != "Test Agent" { + t.Errorf("Expected displayName 'Test Agent', got %q", selectResult.Agent.DisplayName) + } + + // Verify getCurrent returns the selected agent + currentResult, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + if currentResult.Agent == nil { + t.Fatal("Expected an agent to be selected") + } + if currentResult.Agent.Name != "test-agent" { + t.Errorf("Expected current agent 'test-agent', got %q", currentResult.Agent.Name) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should deselect current agent", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Select then deselect + _, err = session.RPC.Agent.Select(t.Context(), &rpc.SessionAgentSelectParams{Name: "test-agent"}) + if err != nil { + t.Fatalf("Failed to select agent: %v", err) + } + + _, err = session.RPC.Agent.Deselect(t.Context()) + if err != nil { + t.Fatalf("Failed to deselect agent: %v", err) + } + + // Verify no agent is selected + currentResult, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + if currentResult.Agent != nil { + t.Errorf("Expected no agent selected after deselect, got %v", currentResult.Agent) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should return empty list when no custom agents configured", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.List(t.Context()) + if err != nil { + 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)) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) +} + +func TestSessionCompactionRpc(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + t.Run("should compact session history after messages", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Send a message to create some history + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "What is 2+2?", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Compact the session + result, err := session.RPC.Compaction.Compact(t.Context()) + if err != nil { + t.Fatalf("Failed to compact session: %v", err) + } + + // Verify result has expected fields (just check it returned valid data) + if result == nil { + t.Fatal("Expected non-nil compact result") + } + }) +} diff --git a/go/internal/e2e/ask_user_test.go b/go/internal/e2e/ask_user_test.go index 305d9df8a..d5458483a 100644 --- a/go/internal/e2e/ask_user_test.go +++ b/go/internal/e2e/ask_user_test.go @@ -20,6 +20,7 @@ func TestAskUser(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { mu.Lock() userInputRequests = append(userInputRequests, request) @@ -80,6 +81,7 @@ func TestAskUser(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { mu.Lock() userInputRequests = append(userInputRequests, request) @@ -135,6 +137,7 @@ func TestAskUser(t *testing.T) { freeformAnswer := "This is my custom freeform answer that was not in the choices" session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { mu.Lock() userInputRequests = append(userInputRequests, request) diff --git a/go/internal/e2e/client_test.go b/go/internal/e2e/client_test.go index 8f5cf2495..d2663d2fa 100644 --- a/go/internal/e2e/client_test.go +++ b/go/internal/e2e/client_test.go @@ -94,7 +94,9 @@ func TestClient(t *testing.T) { }) t.Cleanup(func() { client.ForceStop() }) - _, err := client.CreateSession(t.Context(), nil) + _, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -118,7 +120,9 @@ func TestClient(t *testing.T) { }) t.Cleanup(func() { client.ForceStop() }) - _, err := client.CreateSession(t.Context(), nil) + _, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } diff --git a/go/internal/e2e/compaction_test.go b/go/internal/e2e/compaction_test.go index da9ea240c..aee80704d 100644 --- a/go/internal/e2e/compaction_test.go +++ b/go/internal/e2e/compaction_test.go @@ -21,6 +21,7 @@ func TestCompaction(t *testing.T) { bufferThreshold := 0.01 // 1% session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, InfiniteSessions: &copilot.InfiniteSessionConfig{ Enabled: &enabled, BackgroundCompactionThreshold: &backgroundThreshold, @@ -44,7 +45,7 @@ func TestCompaction(t *testing.T) { }) // Send multiple messages to fill up the context window - _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Tell me a long story about a dragon. Be very detailed."}) + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Tell me a story about a dragon. Be detailed."}) if err != nil { t.Fatalf("Failed to send first message: %v", err) } @@ -93,6 +94,7 @@ func TestCompaction(t *testing.T) { enabled := false session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, InfiniteSessions: &copilot.InfiniteSessionConfig{ Enabled: &enabled, }, diff --git a/go/internal/e2e/mcp_and_agents_test.go b/go/internal/e2e/mcp_and_agents_test.go index f8325b9f4..0f49a05c0 100644 --- a/go/internal/e2e/mcp_and_agents_test.go +++ b/go/internal/e2e/mcp_and_agents_test.go @@ -27,7 +27,8 @@ func TestMCPServers(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - MCPServers: mcpServers, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -61,7 +62,7 @@ func TestMCPServers(t *testing.T) { ctx.ConfigureForTest(t) // Create a session first - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -83,7 +84,8 @@ func TestMCPServers(t *testing.T) { } session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ - MCPServers: mcpServers, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -170,7 +172,8 @@ func TestMCPServers(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - MCPServers: mcpServers, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -204,7 +207,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -238,7 +242,7 @@ func TestCustomAgents(t *testing.T) { ctx.ConfigureForTest(t) // Create a session first - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -260,7 +264,8 @@ func TestCustomAgents(t *testing.T) { } session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -298,7 +303,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -332,7 +338,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -368,7 +375,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -409,8 +417,9 @@ func TestCombinedConfiguration(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - MCPServers: mcpServers, - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) diff --git a/go/internal/e2e/permissions_test.go b/go/internal/e2e/permissions_test.go index 1584f0244..d1d9134b1 100644 --- a/go/internal/e2e/permissions_test.go +++ b/go/internal/e2e/permissions_test.go @@ -157,10 +157,14 @@ func TestPermissions(t *testing.T) { } }) - t.Run("should deny tool operations by default when no handler is provided", func(t *testing.T) { + t.Run("should deny tool operations when handler explicitly denies", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "denied-no-approval-rule-and-could-not-request-from-user"}, nil + }, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -192,7 +196,7 @@ func TestPermissions(t *testing.T) { } }) - t.Run("should deny tool operations by default when no handler is provided after resume", func(t *testing.T) { + t.Run("should deny tool operations when handler explicitly denies after resume", func(t *testing.T) { ctx.ConfigureForTest(t) session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -206,7 +210,11 @@ func TestPermissions(t *testing.T) { t.Fatalf("Failed to send message: %v", err) } - session2, err := client.ResumeSession(t.Context(), sessionID) + session2, err := client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "denied-no-approval-rule-and-could-not-request-from-user"}, nil + }, + }) if err != nil { t.Fatalf("Failed to resume session: %v", err) } @@ -238,10 +246,12 @@ func TestPermissions(t *testing.T) { } }) - t.Run("without permission handler", func(t *testing.T) { + t.Run("should work with approve-all permission handler", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } diff --git a/go/internal/e2e/rpc_test.go b/go/internal/e2e/rpc_test.go index 43b7cafa8..1f8f17c16 100644 --- a/go/internal/e2e/rpc_test.go +++ b/go/internal/e2e/rpc_test.go @@ -130,7 +130,8 @@ func TestSessionRpc(t *testing.T) { t.Skip("session.model.getCurrent not yet implemented in CLI") session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Model: "claude-sonnet-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -151,7 +152,8 @@ func TestSessionRpc(t *testing.T) { t.Skip("session.model.switchTo not yet implemented in CLI") session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Model: "claude-sonnet-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -188,7 +190,7 @@ func TestSessionRpc(t *testing.T) { }) t.Run("should get and set session mode", func(t *testing.T) { - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -231,7 +233,7 @@ func TestSessionRpc(t *testing.T) { }) t.Run("should read, update, and delete plan", func(t *testing.T) { - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -287,7 +289,7 @@ func TestSessionRpc(t *testing.T) { }) t.Run("should create, list, and read workspace files", func(t *testing.T) { - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 87341838a..f04307c2d 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -18,7 +18,7 @@ func TestSession(t *testing.T) { t.Run("should create and destroy sessions", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{Model: "fake-test-model"}) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Model: "fake-test-model"}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -58,7 +58,7 @@ func TestSession(t *testing.T) { t.Run("should have stateful conversation", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -87,6 +87,7 @@ func TestSession(t *testing.T) { systemMessageSuffix := "End each response with the phrase 'Have a nice day!'" session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SystemMessage: &copilot.SystemMessageConfig{ Mode: "append", Content: systemMessageSuffix, @@ -135,6 +136,7 @@ func TestSession(t *testing.T) { testSystemMessage := "You are an assistant called Testy McTestface. Reply succinctly." session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SystemMessage: &copilot.SystemMessageConfig{ Mode: "replace", Content: testSystemMessage, @@ -184,7 +186,8 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - AvailableTools: []string{"view", "edit"}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: []string{"view", "edit"}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -222,7 +225,8 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - ExcludedTools: []string{"view"}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + ExcludedTools: []string{"view"}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -260,6 +264,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ { Name: "get_secret_number", @@ -323,7 +328,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) // Create initial session - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -344,7 +349,9 @@ func TestSession(t *testing.T) { } // Resume using the same client - session2, err := client.ResumeSession(t.Context(), sessionID) + session2, err := client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to resume session: %v", err) } @@ -367,7 +374,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) // Create initial session - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -391,7 +398,9 @@ func TestSession(t *testing.T) { newClient := ctx.NewClient() defer newClient.ForceStop() - session2, err := newClient.ResumeSession(t.Context(), sessionID) + session2, err := newClient.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to resume session: %v", err) } @@ -428,7 +437,9 @@ func TestSession(t *testing.T) { t.Run("should throw error when resuming non-existent session", func(t *testing.T) { ctx.ConfigureForTest(t) - _, err := client.ResumeSession(t.Context(), "non-existent-session-id") + _, err := client.ResumeSession(t.Context(), "non-existent-session-id", &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err == nil { t.Error("Expected error when resuming non-existent session") } @@ -437,7 +448,7 @@ func TestSession(t *testing.T) { t.Run("should resume session with a custom provider", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -445,6 +456,7 @@ func TestSession(t *testing.T) { // Resume the session with a provider session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Provider: &copilot.ProviderConfig{ Type: "openai", BaseURL: "https://api.openai.com/v1", @@ -557,7 +569,8 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Streaming: true, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Streaming: true, }) if err != nil { t.Fatalf("Failed to create session with streaming: %v", err) @@ -617,7 +630,8 @@ func TestSession(t *testing.T) { // Verify that the streaming option is accepted without errors session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Streaming: true, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Streaming: true, }) if err != nil { t.Fatalf("Failed to create session with streaming: %v", err) @@ -647,7 +661,7 @@ func TestSession(t *testing.T) { t.Run("should receive session events", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -722,7 +736,8 @@ func TestSession(t *testing.T) { customConfigDir := ctx.HomeDir + "/custom-config" session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - ConfigDir: customConfigDir, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + ConfigDir: customConfigDir, }) if err != nil { t.Fatalf("Failed to create session with custom config dir: %v", err) @@ -753,7 +768,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) // Create a couple of sessions and send messages to persist them - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session1: %v", err) } @@ -763,7 +778,7 @@ func TestSession(t *testing.T) { t.Fatalf("Failed to send message to session1: %v", err) } - session2, err := client.CreateSession(t.Context(), nil) + session2, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session2: %v", err) } @@ -829,7 +844,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) // Create a session and send a message to persist it - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -881,7 +896,9 @@ func TestSession(t *testing.T) { } // Verify we cannot resume the deleted session - _, err = client.ResumeSession(t.Context(), sessionID) + _, err = client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err == nil { t.Error("Expected error when resuming deleted session") } diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index ed3578abd..10cd50028 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -57,7 +57,8 @@ func TestSkills(t *testing.T) { skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - SkillDirectories: []string{skillsDir}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -84,8 +85,9 @@ func TestSkills(t *testing.T) { skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - SkillDirectories: []string{skillsDir}, - DisabledSkills: []string{"test-skill"}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, + DisabledSkills: []string{"test-skill"}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -113,7 +115,7 @@ func TestSkills(t *testing.T) { skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) // Create a session without skills first - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -131,7 +133,8 @@ func TestSkills(t *testing.T) { // Resume with skillDirectories - skill should now be active session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ - SkillDirectories: []string{skillsDir}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, }) if err != nil { t.Fatalf("Failed to resume session: %v", err) diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index 570594edc..cefb87b58 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -167,7 +167,7 @@ func (c *TestContext) NewClient() *copilot.Client { // Use fake token in CI to allow cached responses without real auth if os.Getenv("CI") == "true" { - options.GithubToken = "fake-token-for-e2e-tests" + options.GitHubToken = "fake-token-for-e2e-tests" } return copilot.NewClient(options) diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index d54bdcb14..e5b93fa25 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" copilot "github.com/github/copilot-sdk/go" @@ -55,6 +56,7 @@ func TestTools(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("encrypt_string", "Encrypts a string", func(params EncryptParams, inv copilot.ToolInvocation) (string, error) { @@ -87,6 +89,7 @@ func TestTools(t *testing.T) { type EmptyParams struct{} session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("get_user_location", "Gets the user's location", func(params EmptyParams, inv copilot.ToolInvocation) (any, error) { @@ -189,6 +192,7 @@ func TestTools(t *testing.T) { var receivedInvocation *copilot.ToolInvocation session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("db_query", "Performs a database query", func(params DbQueryParams, inv copilot.ToolInvocation) ([]City, error) { @@ -259,4 +263,103 @@ func TestTools(t *testing.T) { t.Errorf("Expected session ID '%s', got '%s'", session.SessionID, receivedInvocation.SessionID) } }) + + t.Run("invokes custom tool with permission handler", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type EncryptParams struct { + Input string `json:"input" jsonschema:"String to encrypt"` + } + + var permissionRequests []copilot.PermissionRequest + var mu sync.Mutex + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Tools: []copilot.Tool{ + copilot.DefineTool("encrypt_string", "Encrypts a string", + func(params EncryptParams, inv copilot.ToolInvocation) (string, error) { + return strings.ToUpper(params.Input), nil + }), + }, + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + mu.Lock() + permissionRequests = append(permissionRequests, request) + mu.Unlock() + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use encrypt_string to encrypt this string: Hello"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "HELLO") { + t.Errorf("Expected answer to contain 'HELLO', got %v", answer.Data.Content) + } + + // Should have received a custom-tool permission request + mu.Lock() + customToolReqs := 0 + for _, req := range permissionRequests { + if req.Kind == "custom-tool" { + customToolReqs++ + if toolName, ok := req.Extra["toolName"].(string); !ok || toolName != "encrypt_string" { + t.Errorf("Expected toolName 'encrypt_string', got '%v'", req.Extra["toolName"]) + } + } + } + mu.Unlock() + if customToolReqs == 0 { + t.Errorf("Expected at least one custom-tool permission request, got none") + } + }) + + t.Run("denies custom tool when permission denied", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type EncryptParams struct { + Input string `json:"input" jsonschema:"String to encrypt"` + } + + toolHandlerCalled := false + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Tools: []copilot.Tool{ + copilot.DefineTool("encrypt_string", "Encrypts a string", + func(params EncryptParams, inv copilot.ToolInvocation) (string, error) { + toolHandlerCalled = true + return strings.ToUpper(params.Input), nil + }), + }, + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use encrypt_string to encrypt this string: Hello"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + _, err = testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if toolHandlerCalled { + t.Errorf("Tool handler should NOT have been called since permission was denied") + } + }) } diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index c7d9b0c07..eaf409a85 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -200,6 +200,66 @@ type SessionFleetStartParams struct { Prompt *string `json:"prompt,omitempty"` } +type SessionAgentListResult struct { + // Available custom agents + Agents []AgentElement `json:"agents"` +} + +type AgentElement 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"` +} + +type SessionAgentGetCurrentResult struct { + // Currently selected custom agent, or null if using the default agent + Agent *SessionAgentGetCurrentResultAgent `json:"agent"` +} + +type SessionAgentGetCurrentResultAgent 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"` +} + +type SessionAgentSelectResult struct { + // The newly selected custom agent + Agent SessionAgentSelectResultAgent `json:"agent"` +} + +// The newly selected custom agent +type SessionAgentSelectResultAgent 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"` +} + +type SessionAgentSelectParams struct { + // Name of the custom agent to select + Name string `json:"name"` +} + +type SessionAgentDeselectResult struct { +} + +type SessionCompactionCompactResult struct { + // Number of messages removed during compaction + MessagesRemoved float64 `json:"messagesRemoved"` + // Whether compaction completed successfully + Success bool `json:"success"` + // Number of tokens freed by compaction + TokensRemoved float64 `json:"tokensRemoved"` +} + // The current agent mode. // // The agent mode after switching. @@ -472,23 +532,105 @@ func (a *FleetRpcApi) Start(ctx context.Context, params *SessionFleetStartParams return &result, nil } -// SessionRpc provides typed session-scoped RPC methods. -type SessionRpc struct { +type AgentRpcApi struct { client *jsonrpc2.Client sessionID string - Model *ModelRpcApi - Mode *ModeRpcApi - Plan *PlanRpcApi - Workspace *WorkspaceRpcApi - Fleet *FleetRpcApi +} + +func (a *AgentRpcApi) List(ctx context.Context) (*SessionAgentListResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.list", req) + if err != nil { + return nil, err + } + var result SessionAgentListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) GetCurrent(ctx context.Context) (*SessionAgentGetCurrentResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.getCurrent", req) + if err != nil { + return nil, err + } + var result SessionAgentGetCurrentResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) Select(ctx context.Context, params *SessionAgentSelectParams) (*SessionAgentSelectResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["name"] = params.Name + } + raw, err := a.client.Request("session.agent.select", req) + if err != nil { + return nil, err + } + var result SessionAgentSelectResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) Deselect(ctx context.Context) (*SessionAgentDeselectResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.deselect", req) + if err != nil { + return nil, err + } + var result SessionAgentDeselectResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type CompactionRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *CompactionRpcApi) Compact(ctx context.Context) (*SessionCompactionCompactResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.compaction.compact", req) + if err != nil { + return nil, err + } + var result SessionCompactionCompactResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionRpc provides typed session-scoped RPC methods. +type SessionRpc struct { + client *jsonrpc2.Client + sessionID string + Model *ModelRpcApi + Mode *ModeRpcApi + Plan *PlanRpcApi + Workspace *WorkspaceRpcApi + Fleet *FleetRpcApi + Agent *AgentRpcApi + Compaction *CompactionRpcApi } func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { return &SessionRpc{client: client, sessionID: sessionID, - Model: &ModelRpcApi{client: client, sessionID: sessionID}, - Mode: &ModeRpcApi{client: client, sessionID: sessionID}, - Plan: &PlanRpcApi{client: client, sessionID: sessionID}, - Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, - Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + Model: &ModelRpcApi{client: client, sessionID: sessionID}, + Mode: &ModeRpcApi{client: client, sessionID: sessionID}, + Plan: &PlanRpcApi{client: client, sessionID: sessionID}, + Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, + Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + Agent: &AgentRpcApi{client: client, sessionID: sessionID}, + Compaction: &CompactionRpcApi{client: client, sessionID: sessionID}, } } diff --git a/go/types.go b/go/types.go index 6abbf4a12..225cc1266 100644 --- a/go/types.go +++ b/go/types.go @@ -44,14 +44,14 @@ type ClientOptions struct { // If Env contains duplicate environment keys, only the last value in the // slice for each duplicate key is used. Env []string - // GithubToken is the GitHub token to use for authentication. + // GitHubToken is the GitHub token to use for authentication. // When provided, the token is passed to the CLI server via environment variable. // This takes priority over other authentication methods. - GithubToken string + GitHubToken string // UseLoggedInUser controls whether to use the logged-in user for authentication. // When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. - // When false, only explicit tokens (GithubToken or environment variables) are used. - // Default: true (but defaults to false when GithubToken is provided). + // When false, only explicit tokens (GitHubToken or environment variables) are used. + // Default: true (but defaults to false when GitHubToken is provided). // Use Bool(false) to explicitly disable. UseLoggedInUser *bool } @@ -106,6 +106,32 @@ type PermissionRequest struct { Extra map[string]any `json:"-"` // Additional fields vary by kind } +// UnmarshalJSON implements custom JSON unmarshaling for PermissionRequest +// to capture additional fields (varying by kind) into the Extra map. +func (p *PermissionRequest) UnmarshalJSON(data []byte) error { + // Unmarshal known fields via an alias to avoid infinite recursion + type Alias PermissionRequest + var alias Alias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *p = PermissionRequest(alias) + + // Unmarshal all fields into a generic map + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Remove known fields, keep the rest as Extra + delete(raw, "kind") + delete(raw, "toolCallId") + if len(raw) > 0 { + p.Extra = raw + } + return nil +} + // PermissionRequestResult represents the result of a permission request type PermissionRequestResult struct { Kind string `json:"kind"` diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3cba7c816..2123030cd 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.411", + "@github/copilot": "^0.0.416", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", - "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", + "version": "0.0.416", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.416.tgz", + "integrity": "sha512-rohH9dEeLRkYD31BUL7RFnGz2ZU/7CVkNdZlWEut+CSXgha6uhb3GPmIAPty1gZyUeMeHqJOwu+SSEjAxVaXEg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.411", - "@github/copilot-darwin-x64": "0.0.411", - "@github/copilot-linux-arm64": "0.0.411", - "@github/copilot-linux-x64": "0.0.411", - "@github/copilot-win32-arm64": "0.0.411", - "@github/copilot-win32-x64": "0.0.411" + "@github/copilot-darwin-arm64": "0.0.416", + "@github/copilot-darwin-x64": "0.0.416", + "@github/copilot-linux-arm64": "0.0.416", + "@github/copilot-linux-x64": "0.0.416", + "@github/copilot-win32-arm64": "0.0.416", + "@github/copilot-win32-x64": "0.0.416" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", - "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", + "version": "0.0.416", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.416.tgz", + "integrity": "sha512-mipNAS3Pi8Eu2TA9rTHdjEMaDZNNKJl29rL5cvs9imjshsQJBwiJD3zIV/XQvqsFB8IWngn5ZFT9ybML4milkg==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", - "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", + "version": "0.0.416", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.416.tgz", + "integrity": "sha512-22Q1rkpYflX5XMfmfvA/uZ/9ME43rUARy6rQZ+tfl2/Xg2FFR8NXyvhtatqVZ5v+N2DE81IrYUG3V0wfX0U8Iw==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", - "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", + "version": "0.0.416", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.416.tgz", + "integrity": "sha512-iLFzBpjSNQOqOvBIq4AxlSWDGlAW5+Ri0Qz2h1CVBb5JcUOdfSL22Fz/3wWzrV1quvnkVyJQ45tL9zS117DhcA==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", - "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", + "version": "0.0.416", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.416.tgz", + "integrity": "sha512-+dSZOLaCr+A/WJLE++4lCC13PziIO64PzFGJsEmSedQkqw2dnXrAYbsp+cbtXJJ6w0DZpHigeifhn1m3sAWUvw==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", - "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", + "version": "0.0.416", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.416.tgz", + "integrity": "sha512-jeTroTL5CeAysbl3GJeLyPMsb2fEP16Iqo7faeozlbyU9BD5GjaLKtEApmFCE2hP0/CNcJVg1K5cZLTaKYOXCw==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", - "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", + "version": "0.0.416", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.416.tgz", + "integrity": "sha512-62kv5dMNZ+7unVi3puK16trlOItLldFgQEkk2mVqAsRzEA9XqxArNeN+lG6kAIHNbiKA+OKVafdkzy104kHy4w==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index a0c85478b..2ba3b9a3d 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -40,7 +40,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.411", + "@github/copilot": "^0.0.416", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 3272df55b..db5bf57b2 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": "^0.0.411-0", + "@github/copilot": "^0.0.414", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index b32ff0a75..c706cc661 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -93,7 +93,7 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | * const client = new CopilotClient({ cliUrl: "localhost:3000" }); * * // Create a session - * const session = await client.createSession({ model: "gpt-4" }); + * const session = await client.createSession({ onPermissionRequest: approveAll, model: "gpt-4" }); * * // Send messages and handle responses * session.on((event) => { @@ -539,10 +539,11 @@ export class CopilotClient { * @example * ```typescript * // Basic session - * const session = await client.createSession(); + * const session = await client.createSession({ onPermissionRequest: approveAll }); * * // Session with model and tools * const session = await client.createSession({ + * onPermissionRequest: approveAll, * model: "gpt-4", * tools: [{ * name: "get_weather", @@ -553,7 +554,13 @@ export class CopilotClient { * }); * ``` */ - async createSession(config: SessionConfig = {}): Promise { + async createSession(config: SessionConfig): Promise { + if (!config?.onPermissionRequest) { + throw new Error( + "An onPermissionRequest handler is required when creating a session. For example, to allow all permissions, use { onPermissionRequest: approveAll }." + ); + } + if (!this.connection) { if (this.options.autoStart) { await this.start(); @@ -596,9 +603,7 @@ export class CopilotClient { }; const session = new CopilotSession(sessionId, this.connection!, workspacePath); session.registerTools(config.tools); - if (config.onPermissionRequest) { - session.registerPermissionHandler(config.onPermissionRequest); - } + session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); } @@ -625,18 +630,22 @@ export class CopilotClient { * @example * ```typescript * // Resume a previous session - * const session = await client.resumeSession("session-123"); + * const session = await client.resumeSession("session-123", { onPermissionRequest: approveAll }); * * // Resume with new tools * const session = await client.resumeSession("session-123", { + * onPermissionRequest: approveAll, * tools: [myNewTool] * }); * ``` */ - async resumeSession( - sessionId: string, - config: ResumeSessionConfig = {} - ): Promise { + async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise { + if (!config?.onPermissionRequest) { + throw new Error( + "An onPermissionRequest handler is required when resuming a session. For example, to allow all permissions, use { onPermissionRequest: approveAll }." + ); + } + if (!this.connection) { if (this.options.autoStart) { await this.start(); @@ -680,9 +689,7 @@ export class CopilotClient { }; const session = new CopilotSession(resumedSessionId, this.connection!, workspacePath); session.registerTools(config.tools); - if (config.onPermissionRequest) { - session.registerPermissionHandler(config.onPermissionRequest); - } + session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); } @@ -702,7 +709,7 @@ export class CopilotClient { * @example * ```typescript * if (client.getState() === "connected") { - * const session = await client.createSession(); + * const session = await client.createSession({ onPermissionRequest: approveAll }); * } * ``` */ @@ -847,7 +854,7 @@ export class CopilotClient { * ```typescript * const lastId = await client.getLastSessionId(); * if (lastId) { - * const session = await client.resumeSession(lastId); + * const session = await client.resumeSession(lastId, { onPermissionRequest: approveAll }); * } * ``` */ diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 12c992bd6..861292620 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -314,6 +314,122 @@ export interface SessionFleetStartParams { prompt?: string; } +export interface SessionAgentListResult { + /** + * Available 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; + }[]; +} + +export interface SessionAgentListParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionAgentGetCurrentResult { + /** + * Currently selected custom agent, or null if using the default agent + */ + agent: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + } | null; +} + +export interface SessionAgentGetCurrentParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionAgentSelectResult { + /** + * The newly selected custom agent + */ + agent: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + }; +} + +export interface SessionAgentSelectParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Name of the custom agent to select + */ + name: string; +} + +export interface SessionAgentDeselectResult {} + +export interface SessionAgentDeselectParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionCompactionCompactResult { + /** + * Whether compaction completed successfully + */ + success: boolean; + /** + * Number of tokens freed by compaction + */ + tokensRemoved: number; + /** + * Number of messages removed during compaction + */ + messagesRemoved: number; +} + +export interface SessionCompactionCompactParams { + /** + * Target session identifier + */ + sessionId: string; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -369,5 +485,19 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin start: async (params: Omit): Promise => connection.sendRequest("session.fleet.start", { sessionId, ...params }), }, + agent: { + list: async (): Promise => + connection.sendRequest("session.agent.list", { sessionId }), + getCurrent: async (): Promise => + connection.sendRequest("session.agent.getCurrent", { sessionId }), + select: async (params: Omit): Promise => + connection.sendRequest("session.agent.select", { sessionId, ...params }), + deselect: async (): Promise => + connection.sendRequest("session.agent.deselect", { sessionId }), + }, + compaction: { + compact: async (): Promise => + connection.sendRequest("session.compaction.compact", { sessionId }), + }, }; } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 032a1723d..5b6c90aaf 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -281,6 +281,16 @@ export type SessionEvent = requestId?: string; }; } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "session.task_complete"; + data: { + summary?: string; + }; + } | { id: string; timestamp: string; @@ -380,6 +390,16 @@ export type SessionEvent = deltaContent: string; }; } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral: true; + type: "assistant.streaming_delta"; + data: { + totalResponseSizeBytes: number; + }; + } | { id: string; timestamp: string; @@ -411,7 +431,6 @@ export type SessionEvent = data: { messageId: string; deltaContent: string; - totalResponseSizeBytes?: number; parentToolCallId?: string; }; } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 6ed46969f..f66967b4c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -219,7 +219,7 @@ export type SystemMessageConfig = SystemMessageAppendConfig | SystemMessageRepla * Permission request types from the server */ export interface PermissionRequest { - kind: "shell" | "write" | "mcp" | "read" | "url"; + kind: "shell" | "write" | "mcp" | "read" | "url" | "custom-tool"; toolCallId?: string; [key: string]: unknown; } @@ -683,7 +683,7 @@ export interface SessionConfig { * Handler for permission requests from the server. * When provided, the server will call this handler to request permission for operations. */ - onPermissionRequest?: PermissionHandler; + onPermissionRequest: PermissionHandler; /** * Handler for user input requests from the agent. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 1ab89e7c2..59e48e639 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,16 +1,37 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, it, onTestFinished, vi } from "vitest"; -import { CopilotClient } from "../src/index.js"; +import { approveAll, CopilotClient } from "../src/index.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead describe("CopilotClient", () => { + it("throws when createSession is called without onPermissionRequest", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + await expect((client as any).createSession({})).rejects.toThrow( + /onPermissionRequest.*is required/ + ); + }); + + it("throws when resumeSession is called without onPermissionRequest", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + await expect((client as any).resumeSession(session.sessionId, {})).rejects.toThrow( + /onPermissionRequest.*is required/ + ); + }); + it("returns a standardized failure result when a tool is not registered", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const response = await ( client as unknown as { handleToolCallRequest: (typeof client)["handleToolCallRequest"] } @@ -33,7 +54,7 @@ describe("CopilotClient", () => { onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); - await client.createSession({ clientName: "my-app" }); + await client.createSession({ clientName: "my-app", onPermissionRequest: approveAll }); expect(spy).toHaveBeenCalledWith( "session.create", @@ -46,9 +67,12 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi.spyOn((client as any).connection!, "sendRequest"); - await client.resumeSession(session.sessionId, { clientName: "my-app" }); + await client.resumeSession(session.sessionId, { + clientName: "my-app", + onPermissionRequest: approveAll, + }); expect(spy).toHaveBeenCalledWith( "session.resume", diff --git a/nodejs/test/e2e/agent_and_compact_rpc.test.ts b/nodejs/test/e2e/agent_and_compact_rpc.test.ts new file mode 100644 index 000000000..47fc83229 --- /dev/null +++ b/nodejs/test/e2e/agent_and_compact_rpc.test.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import type { CustomAgentConfig } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Agent Selection RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should list available custom agents", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + { + name: "another-agent", + displayName: "Another Agent", + description: "Another test agent", + prompt: "You are another agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + const result = await session.rpc.agent.list(); + expect(result.agents).toBeDefined(); + expect(Array.isArray(result.agents)).toBe(true); + expect(result.agents.length).toBe(2); + expect(result.agents[0].name).toBe("test-agent"); + expect(result.agents[0].displayName).toBe("Test Agent"); + expect(result.agents[0].description).toBe("A test agent"); + expect(result.agents[1].name).toBe("another-agent"); + + await session.destroy(); + }); + + it("should return null when no agent is selected", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + const result = await session.rpc.agent.getCurrent(); + expect(result.agent).toBeNull(); + + await session.destroy(); + }); + + it("should select and get current agent", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + // Select the agent + const selectResult = await session.rpc.agent.select({ name: "test-agent" }); + expect(selectResult.agent).toBeDefined(); + expect(selectResult.agent.name).toBe("test-agent"); + expect(selectResult.agent.displayName).toBe("Test Agent"); + + // Verify getCurrent returns the selected agent + const currentResult = await session.rpc.agent.getCurrent(); + expect(currentResult.agent).not.toBeNull(); + expect(currentResult.agent!.name).toBe("test-agent"); + + await session.destroy(); + }); + + it("should deselect current agent", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + // Select then deselect + await session.rpc.agent.select({ name: "test-agent" }); + await session.rpc.agent.deselect(); + + // Verify no agent is selected + const currentResult = await session.rpc.agent.getCurrent(); + expect(currentResult.agent).toBeNull(); + + await session.destroy(); + }); + + it("should return empty list when no custom agents configured", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const result = await session.rpc.agent.list(); + expect(result.agents).toEqual([]); + + await session.destroy(); + }); +}); + +describe("Session Compact RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should compact session history after messages", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + // Send a message to create some history + await session.sendAndWait({ prompt: "What is 2+2?" }); + + // Compact the session + const result = await session.rpc.compaction.compact(); + expect(typeof result.success).toBe("boolean"); + expect(typeof result.tokensRemoved).toBe("number"); + expect(typeof result.messagesRemoved).toBe("number"); + + await session.destroy(); + }, 60000); +}); diff --git a/nodejs/test/e2e/ask_user.test.ts b/nodejs/test/e2e/ask_user.test.ts index d6c89a249..c58daa00c 100644 --- a/nodejs/test/e2e/ask_user.test.ts +++ b/nodejs/test/e2e/ask_user.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { UserInputRequest, UserInputResponse } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("User input (ask_user)", async () => { @@ -13,6 +14,7 @@ describe("User input (ask_user)", async () => { const userInputRequests: UserInputRequest[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, onUserInputRequest: async (request, invocation) => { userInputRequests.push(request); expect(invocation.sessionId).toBe(session.sessionId); @@ -43,6 +45,7 @@ describe("User input (ask_user)", async () => { const userInputRequests: UserInputRequest[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, onUserInputRequest: async (request) => { userInputRequests.push(request); // Pick the first choice @@ -74,6 +77,7 @@ describe("User input (ask_user)", async () => { const freeformAnswer = "This is my custom freeform answer that was not in the choices"; const session = await client.createSession({ + onPermissionRequest: approveAll, onUserInputRequest: async (request) => { userInputRequests.push(request); // Return a freeform answer (not from choices) diff --git a/nodejs/test/e2e/client.test.ts b/nodejs/test/e2e/client.test.ts index aa8ddcbd6..c7539fc0b 100644 --- a/nodejs/test/e2e/client.test.ts +++ b/nodejs/test/e2e/client.test.ts @@ -1,6 +1,6 @@ import { ChildProcess } from "child_process"; import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, approveAll } from "../../src/index.js"; function onTestFinishedForceStop(client: CopilotClient) { onTestFinished(async () => { @@ -51,9 +51,9 @@ describe("Client", () => { // the process has exited. const client = new CopilotClient({ useStdio: false }); - await client.createSession(); + await client.createSession({ onPermissionRequest: approveAll }); - // Kill the server process to force cleanup to fail + // Kill the server processto force cleanup to fail // eslint-disable-next-line @typescript-eslint/no-explicit-any const cliProcess = (client as any).cliProcess as ChildProcess; expect(cliProcess).toBeDefined(); @@ -69,7 +69,7 @@ describe("Client", () => { const client = new CopilotClient({}); onTestFinishedForceStop(client); - await client.createSession(); + await client.createSession({ onPermissionRequest: approveAll }); await client.forceStop(); expect(client.getState()).toBe("disconnected"); }); @@ -152,7 +152,7 @@ describe("Client", () => { // Verify subsequent calls also fail (don't hang) try { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); await session.send("test"); expect.fail("Expected send() to throw an error after CLI exit"); } catch (error) { diff --git a/nodejs/test/e2e/compaction.test.ts b/nodejs/test/e2e/compaction.test.ts index 820b72ffb..13e836644 100644 --- a/nodejs/test/e2e/compaction.test.ts +++ b/nodejs/test/e2e/compaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { SessionEvent } from "../../src/index.js"; +import { SessionEvent, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Compaction", async () => { @@ -8,6 +8,7 @@ describe("Compaction", async () => { it("should trigger compaction with low threshold and emit events", async () => { // Create session with very low compaction thresholds to trigger compaction quickly const session = await client.createSession({ + onPermissionRequest: approveAll, infiniteSessions: { enabled: true, // Trigger background compaction at 0.5% context usage (~1000 tokens) @@ -25,7 +26,7 @@ describe("Compaction", async () => { // Send multiple messages to fill up the context window // With such low thresholds, even a few messages should trigger compaction await session.sendAndWait({ - prompt: "Tell me a long story about a dragon. Be very detailed.", + prompt: "Tell me a story about a dragon. Be detailed.", }); await session.sendAndWait({ prompt: "Continue the story with more details about the dragon's castle.", @@ -63,6 +64,7 @@ describe("Compaction", async () => { it("should not emit compaction events when infinite sessions disabled", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, infiniteSessions: { enabled: false, }, diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 18cc9fea0..b7d8d4dcd 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -120,6 +120,7 @@ describe("Session hooks", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index 7b7aabf06..cc626e325 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -28,6 +28,7 @@ describe("MCP Servers and Custom Agents", async () => { }; const session = await client.createSession({ + onPermissionRequest: approveAll, mcpServers, }); @@ -44,7 +45,7 @@ describe("MCP Servers and Custom Agents", async () => { it("should accept MCP server configuration on session resume", async () => { // Create a session first - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); @@ -59,6 +60,7 @@ describe("MCP Servers and Custom Agents", async () => { }; const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, mcpServers, }); @@ -89,6 +91,7 @@ describe("MCP Servers and Custom Agents", async () => { }; const session = await client.createSession({ + onPermissionRequest: approveAll, mcpServers, }); @@ -136,6 +139,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); @@ -152,7 +156,7 @@ describe("MCP Servers and Custom Agents", async () => { it("should accept custom agent configuration on session resume", async () => { // Create a session first - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); @@ -167,6 +171,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, customAgents, }); @@ -193,6 +198,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); @@ -219,6 +225,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); @@ -244,6 +251,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); @@ -273,6 +281,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, mcpServers, customAgents, }); diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index b68446ee9..ea23bc071 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -64,10 +64,14 @@ describe("Permission callbacks", async () => { await session.destroy(); }); - it("should deny tool operations by default when no handler is provided", async () => { + it("should deny tool operations when handler explicitly denies", async () => { let permissionDenied = false; - const session = await client.createSession(); + const session = await client.createSession({ + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }); session.on((event) => { if ( event.type === "tool.execution_complete" && @@ -85,12 +89,16 @@ describe("Permission callbacks", async () => { await session.destroy(); }); - it("should deny tool operations by default when no handler is provided after resume", async () => { + it("should deny tool operations when handler explicitly denies after resume", async () => { const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); - const session2 = await client.resumeSession(sessionId); + const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }); let permissionDenied = false; session2.on((event) => { if ( @@ -109,9 +117,8 @@ describe("Permission callbacks", async () => { await session2.destroy(); }); - it("should work without permission handler (default behavior)", async () => { - // Create session without onPermissionRequest handler - const session = await client.createSession(); + it("should work with approve-all permission handler", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); const message = await session.sendAndWait({ prompt: "What is 2+2?", @@ -147,8 +154,8 @@ describe("Permission callbacks", async () => { it("should resume session with permission handler", async () => { const permissionRequests: PermissionRequest[] = []; - // Create session without permission handler - const session1 = await client.createSession(); + // Create initial session + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); diff --git a/nodejs/test/e2e/rpc.test.ts b/nodejs/test/e2e/rpc.test.ts index b7acbaf66..62a885d05 100644 --- a/nodejs/test/e2e/rpc.test.ts +++ b/nodejs/test/e2e/rpc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; function onTestFinishedForceStop(client: CopilotClient) { @@ -71,7 +71,10 @@ describe("Session RPC", async () => { // session.model.getCurrent is defined in schema but not yet implemented in CLI it.skip("should call session.rpc.model.getCurrent", async () => { - const session = await client.createSession({ model: "claude-sonnet-4.5" }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); const result = await session.rpc.model.getCurrent(); expect(result.modelId).toBeDefined(); @@ -80,7 +83,10 @@ describe("Session RPC", async () => { // session.model.switchTo is defined in schema but not yet implemented in CLI it.skip("should call session.rpc.model.switchTo", async () => { - const session = await client.createSession({ model: "claude-sonnet-4.5" }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); // Get initial model const before = await session.rpc.model.getCurrent(); @@ -96,7 +102,7 @@ describe("Session RPC", async () => { }); it("should get and set session mode", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Get initial mode (default should be interactive) const initial = await session.rpc.mode.get(); @@ -116,7 +122,7 @@ describe("Session RPC", async () => { }); it("should read, update, and delete plan", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Initially plan should not exist const initial = await session.rpc.plan.read(); @@ -142,7 +148,7 @@ describe("Session RPC", async () => { }); it("should create, list, and read workspace files", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Initially no files const initialFiles = await session.rpc.workspace.listFiles(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 09c293a53..1bf095085 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -8,7 +8,10 @@ describe("Sessions", async () => { const { copilotClient: client, openAiEndpoint, homeDir, env } = await createSdkTestContext(); it("should create and destroy sessions", async () => { - const session = await client.createSession({ model: "fake-test-model" }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "fake-test-model", + }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); expect(await session.getMessages()).toMatchObject([ @@ -25,7 +28,7 @@ describe("Sessions", async () => { // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle it.skip("should list sessions with context field", { timeout: 60000 }, async () => { // Create a session — just creating it is enough for it to appear in listSessions - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); // Verify it has a start event (confirms session is active) @@ -44,7 +47,7 @@ describe("Sessions", async () => { }); it("should have stateful conversation", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); expect(assistantMessage?.data.content).toContain("2"); @@ -57,6 +60,7 @@ describe("Sessions", async () => { it("should create a session with appended systemMessage config", async () => { const systemMessageSuffix = "End each response with the phrase 'Have a nice day!'"; const session = await client.createSession({ + onPermissionRequest: approveAll, systemMessage: { mode: "append", content: systemMessageSuffix, @@ -77,6 +81,7 @@ describe("Sessions", async () => { it("should create a session with replaced systemMessage config", async () => { const testSystemMessage = "You are an assistant called Testy McTestface. Reply succinctly."; const session = await client.createSession({ + onPermissionRequest: approveAll, systemMessage: { mode: "replace", content: testSystemMessage }, }); @@ -92,6 +97,7 @@ describe("Sessions", async () => { it("should create a session with availableTools", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, availableTools: ["view", "edit"], }); @@ -107,6 +113,7 @@ describe("Sessions", async () => { it("should create a session with excludedTools", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, excludedTools: ["view"], }); @@ -128,9 +135,9 @@ describe("Sessions", async () => { // we stopped all the clients (one or more child processes were left orphaned). it.skip("should handle multiple concurrent sessions", async () => { const [s1, s2, s3] = await Promise.all([ - client.createSession(), - client.createSession(), - client.createSession(), + client.createSession({ onPermissionRequest: approveAll }), + client.createSession({ onPermissionRequest: approveAll }), + client.createSession({ onPermissionRequest: approveAll }), ]); // All sessions should have unique IDs @@ -156,13 +163,13 @@ describe("Sessions", async () => { it("should resume a session using the same client", async () => { // Create initial session - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; const answer = await session1.sendAndWait({ prompt: "What is 1+1?" }); expect(answer?.data.content).toContain("2"); // Resume using the same client - const session2 = await client.resumeSession(sessionId); + const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll }); expect(session2.sessionId).toBe(sessionId); const messages = await session2.getMessages(); const assistantMessages = messages.filter((m) => m.type === "assistant.message"); @@ -171,7 +178,7 @@ describe("Sessions", async () => { it("should resume a session using a new client", async () => { // Create initial session - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; const answer = await session1.sendAndWait({ prompt: "What is 1+1?" }); expect(answer?.data.content).toContain("2"); @@ -183,7 +190,9 @@ describe("Sessions", async () => { }); onTestFinished(() => newClient.forceStop()); - const session2 = await newClient.resumeSession(sessionId); + const session2 = await newClient.resumeSession(sessionId, { + onPermissionRequest: approveAll, + }); expect(session2.sessionId).toBe(sessionId); // TODO: There's an inconsistency here. When resuming with a new client, we don't see @@ -195,11 +204,14 @@ describe("Sessions", async () => { }); it("should throw error when resuming non-existent session", async () => { - await expect(client.resumeSession("non-existent-session-id")).rejects.toThrow(); + await expect( + client.resumeSession("non-existent-session-id", { onPermissionRequest: approveAll }) + ).rejects.toThrow(); }); it("should create session with custom tool", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ { name: "get_secret_number", @@ -229,11 +241,12 @@ describe("Sessions", async () => { }); it("should resume session with a custom provider", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session.sessionId; // Resume the session with a provider const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, provider: { type: "openai", baseUrl: "https://api.openai.com/v1", @@ -245,7 +258,7 @@ describe("Sessions", async () => { }); it("should abort a session", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Set up event listeners BEFORE sending to avoid race conditions const nextToolCallStart = getNextEventOfType(session, "tool.execution_start"); @@ -272,6 +285,7 @@ describe("Sessions", async () => { it("should receive streaming delta events when streaming is enabled", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, streaming: true, }); @@ -308,6 +322,7 @@ describe("Sessions", async () => { it("should pass streaming option to session creation", async () => { // Verify that the streaming option is accepted without errors const session = await client.createSession({ + onPermissionRequest: approveAll, streaming: true, }); @@ -319,7 +334,7 @@ describe("Sessions", async () => { }); it("should receive session events", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const receivedEvents: Array<{ type: string }> = []; session.on((event) => { @@ -342,6 +357,7 @@ describe("Sessions", async () => { it("should create session with custom config dir", async () => { const customConfigDir = `${homeDir}/custom-config`; const session = await client.createSession({ + onPermissionRequest: approveAll, configDir: customConfigDir, }); @@ -390,7 +406,7 @@ describe("Send Blocking Behavior", async () => { }); it("sendAndWait blocks until session.idle and returns final assistant message", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const events: string[] = []; session.on((event) => { @@ -409,7 +425,7 @@ describe("Send Blocking Behavior", async () => { // This test validates client-side timeout behavior. // The snapshot has no assistant response since we expect timeout before completion. it("sendAndWait throws on timeout", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Use a slow command to ensure timeout triggers before completion await expect( diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index 92186ec0b..654f429aa 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import { beforeEach, describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Skills Configuration", async () => { @@ -44,6 +45,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY it("should load and apply skill from skillDirectories", async () => { const skillsDir = createSkillDir(); const session = await client.createSession({ + onPermissionRequest: approveAll, skillDirectories: [skillsDir], }); @@ -62,6 +64,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY it("should not apply skill when disabled via disabledSkills", async () => { const skillsDir = createSkillDir(); const session = await client.createSession({ + onPermissionRequest: approveAll, skillDirectories: [skillsDir], disabledSkills: ["test-skill"], }); @@ -93,7 +96,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY const skillsDir = createSkillDir(); // Create a session without skills first - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; // First message without skill - marker should not appear @@ -102,6 +105,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY // Resume with skillDirectories - skill should now be active const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, skillDirectories: [skillsDir], }); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 3db24dff7..feab2fbfa 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -7,6 +7,7 @@ import { join } from "path"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; import { defineTool, approveAll } from "../../src/index.js"; +import type { PermissionRequest } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; describe("Custom tools", async () => { @@ -26,6 +27,7 @@ describe("Custom tools", async () => { it("invokes custom tool", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ defineTool("encrypt_string", { description: "Encrypts a string", @@ -35,6 +37,7 @@ describe("Custom tools", async () => { handler: ({ input }) => input.toUpperCase(), }), ], + onPermissionRequest: approveAll, }); const assistantMessage = await session.sendAndWait({ @@ -45,6 +48,7 @@ describe("Custom tools", async () => { it("handles tool calling errors", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ defineTool("get_user_location", { description: "Gets the user's location", @@ -53,6 +57,7 @@ describe("Custom tools", async () => { }, }), ], + onPermissionRequest: approveAll, }); const answer = await session.sendAndWait({ @@ -85,6 +90,7 @@ describe("Custom tools", async () => { it("can receive and return complex types", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ defineTool("db_query", { description: "Performs a database query", @@ -108,6 +114,7 @@ describe("Custom tools", async () => { }, }), ], + onPermissionRequest: approveAll, }); const assistantMessage = await session.sendAndWait({ @@ -124,4 +131,63 @@ describe("Custom tools", async () => { expect(responseContent.replace(/,/g, "")).toContain("135460"); expect(responseContent.replace(/,/g, "")).toContain("204356"); }); + + it("invokes custom tool with permission handler", async () => { + const permissionRequests: PermissionRequest[] = []; + + const session = await client.createSession({ + tools: [ + defineTool("encrypt_string", { + description: "Encrypts a string", + parameters: z.object({ + input: z.string().describe("String to encrypt"), + }), + handler: ({ input }) => input.toUpperCase(), + }), + ], + onPermissionRequest: (request) => { + permissionRequests.push(request); + return { kind: "approved" }; + }, + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Use encrypt_string to encrypt this string: Hello", + }); + expect(assistantMessage?.data.content).toContain("HELLO"); + + // Should have received a custom-tool permission request + const customToolRequests = permissionRequests.filter((req) => req.kind === "custom-tool"); + expect(customToolRequests.length).toBeGreaterThan(0); + expect(customToolRequests[0].toolName).toBe("encrypt_string"); + }); + + it("denies custom tool when permission denied", async () => { + let toolHandlerCalled = false; + + const session = await client.createSession({ + tools: [ + defineTool("encrypt_string", { + description: "Encrypts a string", + parameters: z.object({ + input: z.string().describe("String to encrypt"), + }), + handler: ({ input }) => { + toolHandlerCalled = true; + return input.toUpperCase(); + }, + }), + ], + onPermissionRequest: () => { + return { kind: "denied-interactively-by-user" }; + }, + }); + + await session.sendAndWait({ + prompt: "Use encrypt_string to encrypt this string: Hello", + }); + + // The tool handler should NOT have been called since permission was denied + expect(toolHandlerCalled).toBe(false); + }); }); diff --git a/python/copilot/client.py b/python/copilot/client.py index 90260ffbd..774569afb 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -91,7 +91,10 @@ class CopilotClient: >>> await client.start() >>> >>> # Create a session and send a message - >>> session = await client.create_session({"model": "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({"prompt": "Hello!"}) >>> @@ -414,7 +417,7 @@ async def force_stop(self) -> None: if not self._is_external_server: self._actual_port = None - async def create_session(self, config: Optional[SessionConfig] = None) -> CopilotSession: + async def create_session(self, config: SessionConfig) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -434,10 +437,12 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo Example: >>> # Basic session - >>> session = await client.create_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 ... }) @@ -448,7 +453,14 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo else: raise RuntimeError("Client not connected. Call start() first.") - cfg = config or {} + 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") @@ -568,8 +580,7 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo workspace_path = response.get("workspacePath") session = CopilotSession(session_id, self._client, workspace_path) session._register_tools(tools) - if on_permission_request: - session._register_permission_handler(on_permission_request) + session._register_permission_handler(on_permission_request) if on_user_input_request: session._register_user_input_handler(on_user_input_request) if hooks: @@ -579,9 +590,7 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo return session - async def resume_session( - self, session_id: str, config: Optional[ResumeSessionConfig] = None - ) -> CopilotSession: + async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -601,10 +610,12 @@ async def resume_session( Example: >>> # Resume a previous session - >>> session = await client.resume_session("session-123") + >>> config = {"on_permission_request": PermissionHandler.approve_all} + >>> session = await client.resume_session("session-123", config) >>> >>> # Resume with new tools >>> session = await client.resume_session("session-123", { + ... "on_permission_request": PermissionHandler.approve_all, ... "tools": [my_new_tool] ... }) """ @@ -614,7 +625,14 @@ async def resume_session( else: raise RuntimeError("Client not connected. Call start() first.") - cfg = config or {} + 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") @@ -744,8 +762,7 @@ async def resume_session( workspace_path = response.get("workspacePath") session = CopilotSession(resumed_session_id, self._client, workspace_path) session._register_tools(cfg.get("tools")) - if on_permission_request: - session._register_permission_handler(on_permission_request) + session._register_permission_handler(on_permission_request) if on_user_input_request: session._register_user_input_handler(on_user_input_request) if hooks: diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 3b87bea55..27a2bca2c 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -724,6 +724,196 @@ def to_dict(self) -> dict: return result +@dataclass +class AgentElement: + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'AgentElement': + 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) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentListResult: + agents: List[AgentElement] + """Available custom agents""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentListResult': + assert isinstance(obj, dict) + agents = from_list(AgentElement.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) + return result + + +@dataclass +class SessionAgentGetCurrentResultAgent: + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentGetCurrentResultAgent': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return SessionAgentGetCurrentResultAgent(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentGetCurrentResult: + agent: Optional[SessionAgentGetCurrentResultAgent] = None + """Currently selected custom agent, or null if using the default agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentGetCurrentResult': + assert isinstance(obj, dict) + agent = from_union([SessionAgentGetCurrentResultAgent.from_dict, from_none], obj.get("agent")) + return SessionAgentGetCurrentResult(agent) + + def to_dict(self) -> dict: + result: dict = {} + result["agent"] = from_union([lambda x: to_class(SessionAgentGetCurrentResultAgent, x), from_none], self.agent) + return result + + +@dataclass +class SessionAgentSelectResultAgent: + """The newly selected custom agent""" + + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectResultAgent': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return SessionAgentSelectResultAgent(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentSelectResult: + agent: SessionAgentSelectResultAgent + """The newly selected custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectResult': + assert isinstance(obj, dict) + agent = SessionAgentSelectResultAgent.from_dict(obj.get("agent")) + return SessionAgentSelectResult(agent) + + def to_dict(self) -> dict: + result: dict = {} + result["agent"] = to_class(SessionAgentSelectResultAgent, self.agent) + return result + + +@dataclass +class SessionAgentSelectParams: + name: str + """Name of the custom agent to select""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectParams': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + return SessionAgentSelectParams(name) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentDeselectResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentDeselectResult': + assert isinstance(obj, dict) + return SessionAgentDeselectResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +@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 + + def ping_result_from_dict(s: Any) -> PingResult: return PingResult.from_dict(s) @@ -908,6 +1098,54 @@ def session_fleet_start_params_to_dict(x: SessionFleetStartParams) -> Any: return to_class(SessionFleetStartParams, x) +def session_agent_list_result_from_dict(s: Any) -> SessionAgentListResult: + return SessionAgentListResult.from_dict(s) + + +def session_agent_list_result_to_dict(x: SessionAgentListResult) -> Any: + return to_class(SessionAgentListResult, x) + + +def session_agent_get_current_result_from_dict(s: Any) -> SessionAgentGetCurrentResult: + return SessionAgentGetCurrentResult.from_dict(s) + + +def session_agent_get_current_result_to_dict(x: SessionAgentGetCurrentResult) -> Any: + return to_class(SessionAgentGetCurrentResult, x) + + +def session_agent_select_result_from_dict(s: Any) -> SessionAgentSelectResult: + return SessionAgentSelectResult.from_dict(s) + + +def session_agent_select_result_to_dict(x: SessionAgentSelectResult) -> Any: + return to_class(SessionAgentSelectResult, x) + + +def session_agent_select_params_from_dict(s: Any) -> SessionAgentSelectParams: + return SessionAgentSelectParams.from_dict(s) + + +def session_agent_select_params_to_dict(x: SessionAgentSelectParams) -> Any: + return to_class(SessionAgentSelectParams, x) + + +def session_agent_deselect_result_from_dict(s: Any) -> SessionAgentDeselectResult: + return SessionAgentDeselectResult.from_dict(s) + + +def session_agent_deselect_result_to_dict(x: SessionAgentDeselectResult) -> Any: + return to_class(SessionAgentDeselectResult, x) + + +def session_compaction_compact_result_from_dict(s: Any) -> SessionCompactionCompactResult: + return SessionCompactionCompactResult.from_dict(s) + + +def session_compaction_compact_result_to_dict(x: SessionCompactionCompactResult) -> Any: + return to_class(SessionCompactionCompactResult, x) + + class ModelsApi: def __init__(self, client: "JsonRpcClient"): self._client = client @@ -1021,6 +1259,35 @@ async def start(self, params: SessionFleetStartParams) -> SessionFleetStartResul return SessionFleetStartResult.from_dict(await self._client.request("session.fleet.start", params_dict)) +class AgentApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self) -> SessionAgentListResult: + return SessionAgentListResult.from_dict(await self._client.request("session.agent.list", {"sessionId": self._session_id})) + + async def get_current(self) -> SessionAgentGetCurrentResult: + return SessionAgentGetCurrentResult.from_dict(await self._client.request("session.agent.getCurrent", {"sessionId": self._session_id})) + + async def select(self, params: SessionAgentSelectParams) -> SessionAgentSelectResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionAgentSelectResult.from_dict(await self._client.request("session.agent.select", params_dict)) + + async def deselect(self) -> SessionAgentDeselectResult: + return SessionAgentDeselectResult.from_dict(await self._client.request("session.agent.deselect", {"sessionId": self._session_id})) + + +class CompactionApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def compact(self) -> SessionCompactionCompactResult: + return SessionCompactionCompactResult.from_dict(await self._client.request("session.compaction.compact", {"sessionId": self._session_id})) + + class SessionRpc: """Typed session-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient", session_id: str): @@ -1031,4 +1298,6 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.plan = PlanApi(client, session_id) self.workspace = WorkspaceApi(client, session_id) self.fleet = FleetApi(client, session_id) + self.agent = AgentApi(client, session_id) + self.compaction = CompactionApi(client, session_id) diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 0d588058a..3a201ba0e 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -734,6 +734,7 @@ class Data: intent: Optional[str] = None reasoning_id: Optional[str] = None delta_content: Optional[str] = None + total_response_size_bytes: Optional[float] = None encrypted_content: Optional[str] = None message_id: Optional[str] = None parent_tool_call_id: Optional[str] = None @@ -741,7 +742,6 @@ class Data: reasoning_opaque: Optional[str] = None reasoning_text: Optional[str] = None tool_requests: Optional[List[ToolRequest]] = None - total_response_size_bytes: Optional[float] = None api_call_id: Optional[str] = None cache_read_tokens: Optional[float] = None cache_write_tokens: Optional[float] = None @@ -851,6 +851,7 @@ def from_dict(obj: Any) -> 'Data': intent = from_union([from_str, from_none], obj.get("intent")) reasoning_id = from_union([from_str, from_none], obj.get("reasoningId")) delta_content = from_union([from_str, from_none], obj.get("deltaContent")) + total_response_size_bytes = from_union([from_float, from_none], obj.get("totalResponseSizeBytes")) encrypted_content = from_union([from_str, from_none], obj.get("encryptedContent")) message_id = from_union([from_str, from_none], obj.get("messageId")) parent_tool_call_id = from_union([from_str, from_none], obj.get("parentToolCallId")) @@ -858,7 +859,6 @@ def from_dict(obj: Any) -> 'Data': reasoning_opaque = from_union([from_str, from_none], obj.get("reasoningOpaque")) reasoning_text = from_union([from_str, from_none], obj.get("reasoningText")) tool_requests = from_union([lambda x: from_list(ToolRequest.from_dict, x), from_none], obj.get("toolRequests")) - total_response_size_bytes = from_union([from_float, from_none], obj.get("totalResponseSizeBytes")) api_call_id = from_union([from_str, from_none], obj.get("apiCallId")) cache_read_tokens = from_union([from_float, from_none], obj.get("cacheReadTokens")) cache_write_tokens = from_union([from_float, from_none], obj.get("cacheWriteTokens")) @@ -892,7 +892,7 @@ def from_dict(obj: Any) -> 'Data': output = obj.get("output") metadata = from_union([Metadata.from_dict, from_none], obj.get("metadata")) role = from_union([Role, from_none], obj.get("role")) - return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, title, info_type, warning_type, new_model, previous_model, 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, branch, cwd, git_root, 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, source, transformed_content, turn_id, intent, reasoning_id, delta_content, encrypted_content, message_id, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, total_response_size_bytes, api_call_id, cache_read_tokens, cache_write_tokens, cost, duration, initiator, input_tokens, model, output_tokens, 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, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role) + return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, title, info_type, warning_type, new_model, previous_model, 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, branch, cwd, git_root, 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, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, cost, duration, initiator, input_tokens, model, output_tokens, 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, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role) def to_dict(self) -> dict: result: dict = {} @@ -1040,6 +1040,8 @@ def to_dict(self) -> dict: result["reasoningId"] = from_union([from_str, from_none], self.reasoning_id) if self.delta_content is not None: result["deltaContent"] = from_union([from_str, from_none], self.delta_content) + if self.total_response_size_bytes is not None: + result["totalResponseSizeBytes"] = from_union([to_float, from_none], self.total_response_size_bytes) if self.encrypted_content is not None: result["encryptedContent"] = from_union([from_str, from_none], self.encrypted_content) if self.message_id is not None: @@ -1054,8 +1056,6 @@ def to_dict(self) -> dict: result["reasoningText"] = from_union([from_str, from_none], self.reasoning_text) if self.tool_requests is not None: result["toolRequests"] = from_union([lambda x: from_list(lambda x: to_class(ToolRequest, x), x), from_none], self.tool_requests) - if self.total_response_size_bytes is not None: - result["totalResponseSizeBytes"] = from_union([to_float, from_none], self.total_response_size_bytes) if self.api_call_id is not None: result["apiCallId"] = from_union([from_str, from_none], self.api_call_id) if self.cache_read_tokens is not None: @@ -1132,6 +1132,7 @@ class SessionEventType(Enum): ASSISTANT_MESSAGE_DELTA = "assistant.message_delta" ASSISTANT_REASONING = "assistant.reasoning" ASSISTANT_REASONING_DELTA = "assistant.reasoning_delta" + ASSISTANT_STREAMING_DELTA = "assistant.streaming_delta" ASSISTANT_TURN_END = "assistant.turn_end" ASSISTANT_TURN_START = "assistant.turn_start" ASSISTANT_USAGE = "assistant.usage" @@ -1152,6 +1153,7 @@ class SessionEventType(Enum): SESSION_SHUTDOWN = "session.shutdown" SESSION_SNAPSHOT_REWIND = "session.snapshot_rewind" SESSION_START = "session.start" + SESSION_TASK_COMPLETE = "session.task_complete" SESSION_TITLE_CHANGED = "session.title_changed" SESSION_TRUNCATION = "session.truncation" SESSION_USAGE_INFO = "session.usage_info" diff --git a/python/copilot/session.py b/python/copilot/session.py index 7332f6c5f..af02a312d 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -14,6 +14,8 @@ from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict from .types import ( MessageOptions, + PermissionRequest, + PermissionRequestResult, SessionHooks, Tool, ToolHandler, @@ -308,7 +310,9 @@ def _register_permission_handler(self, handler: Optional[_PermissionHandlerFn]) with self._permission_handler_lock: self._permission_handler = handler - async def _handle_permission_request(self, request: dict) -> dict: + async def _handle_permission_request( + self, request: PermissionRequest + ) -> PermissionRequestResult: """ Handle a permission request from the Copilot CLI. diff --git a/python/copilot/types.py b/python/copilot/types.py index e89399777..142aee474 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -169,7 +169,7 @@ class SystemMessageReplaceConfig(TypedDict): class PermissionRequest(TypedDict, total=False): """Permission request from the server""" - kind: Literal["shell", "write", "mcp", "read", "url"] + kind: Literal["shell", "write", "mcp", "read", "url", "custom-tool"] toolCallId: str # Additional fields vary by kind diff --git a/python/e2e/test_agent_and_compact_rpc.py b/python/e2e/test_agent_and_compact_rpc.py new file mode 100644 index 000000000..a960c8426 --- /dev/null +++ b/python/e2e/test_agent_and_compact_rpc.py @@ -0,0 +1,193 @@ +"""E2E tests for Agent Selection and Session Compaction RPC APIs.""" + +import pytest + +from copilot import CopilotClient, PermissionHandler +from copilot.generated.rpc import SessionAgentSelectParams + +from .testharness import CLI_PATH, E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestAgentSelectionRpc: + @pytest.mark.asyncio + async def test_should_list_available_custom_agents(self): + """Test listing available custom agents via RPC.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + }, + { + "name": "another-agent", + "display_name": "Another Agent", + "description": "Another test agent", + "prompt": "You are another agent.", + }, + ], + } + ) + + result = await session.rpc.agent.list() + assert result.agents is not None + assert len(result.agents) == 2 + assert result.agents[0].name == "test-agent" + assert result.agents[0].display_name == "Test Agent" + assert result.agents[0].description == "A test agent" + assert result.agents[1].name == "another-agent" + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_return_null_when_no_agent_is_selected(self): + """Test getCurrent returns null when no agent is selected.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ], + } + ) + + result = await session.rpc.agent.get_current() + assert result.agent is None + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_select_and_get_current_agent(self): + """Test selecting an agent and verifying getCurrent returns it.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ], + } + ) + + # Select the agent + select_result = await session.rpc.agent.select( + SessionAgentSelectParams(name="test-agent") + ) + assert select_result.agent is not None + assert select_result.agent.name == "test-agent" + assert select_result.agent.display_name == "Test Agent" + + # Verify getCurrent returns the selected agent + current_result = await session.rpc.agent.get_current() + assert current_result.agent is not None + assert current_result.agent.name == "test-agent" + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_deselect_current_agent(self): + """Test deselecting the current agent.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ], + } + ) + + # Select then deselect + await session.rpc.agent.select(SessionAgentSelectParams(name="test-agent")) + await session.rpc.agent.deselect() + + # Verify no agent is selected + current_result = await session.rpc.agent.get_current() + assert current_result.agent is None + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_return_empty_list_when_no_custom_agents_configured(self): + """Test listing agents returns empty when none configured.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + result = await session.rpc.agent.list() + assert result.agents == [] + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + +class TestSessionCompactionRpc: + @pytest.mark.asyncio + async def test_should_compact_session_history_after_messages(self, ctx: E2ETestContext): + """Test compacting session history via RPC.""" + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + # Send a message to create some history + await session.send_and_wait({"prompt": "What is 2+2?"}) + + # Compact the session + result = await session.rpc.compaction.compact() + assert isinstance(result.success, bool) + assert isinstance(result.tokens_removed, (int, float)) + assert isinstance(result.messages_removed, (int, float)) + + await session.destroy() diff --git a/python/e2e/test_ask_user.py b/python/e2e/test_ask_user.py index 93036ea4c..f409e460c 100644 --- a/python/e2e/test_ask_user.py +++ b/python/e2e/test_ask_user.py @@ -4,6 +4,8 @@ import pytest +from copilot import PermissionHandler + from .testharness import E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -27,7 +29,12 @@ async def on_user_input_request(request, invocation): "wasFreeform": not bool(choices), } - session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + session = await ctx.client.create_session( + { + "on_user_input_request": on_user_input_request, + "on_permission_request": PermissionHandler.approve_all, + } + ) await session.send_and_wait( { @@ -61,7 +68,12 @@ async def on_user_input_request(request, invocation): "wasFreeform": False, } - session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + session = await ctx.client.create_session( + { + "on_user_input_request": on_user_input_request, + "on_permission_request": PermissionHandler.approve_all, + } + ) await session.send_and_wait( { @@ -97,7 +109,12 @@ async def on_user_input_request(request, invocation): "wasFreeform": True, } - session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + session = await ctx.client.create_session( + { + "on_user_input_request": on_user_input_request, + "on_permission_request": PermissionHandler.approve_all, + } + ) response = await session.send_and_wait( { diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index c18764e55..cc5d31ac6 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -2,7 +2,7 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler from .testharness import CLI_PATH @@ -51,7 +51,7 @@ async def test_should_return_errors_on_failed_cleanup(self): client = CopilotClient({"cli_path": CLI_PATH}) try: - await client.create_session() + await client.create_session({"on_permission_request": PermissionHandler.approve_all}) # Kill the server process to force cleanup to fail process = client._process @@ -69,7 +69,7 @@ async def test_should_return_errors_on_failed_cleanup(self): async def test_should_force_stop_without_cleanup(self): client = CopilotClient({"cli_path": CLI_PATH}) - await client.create_session() + await client.create_session({"on_permission_request": PermissionHandler.approve_all}) await client.force_stop() assert client.get_state() == "disconnected" @@ -206,7 +206,9 @@ async def test_should_report_error_with_stderr_when_cli_fails_to_start(self): # Verify subsequent calls also fail (don't hang) with pytest.raises(Exception) as exc_info2: - session = await client.create_session() + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session.send("test") # Error message varies by platform (EINVAL on Windows, EPIPE on Linux) error_msg = str(exc_info2.value).lower() diff --git a/python/e2e/test_compaction.py b/python/e2e/test_compaction.py index b2463e447..5447b4bad 100644 --- a/python/e2e/test_compaction.py +++ b/python/e2e/test_compaction.py @@ -2,6 +2,7 @@ import pytest +from copilot import PermissionHandler from copilot.generated.session_events import SessionEventType from .testharness import E2ETestContext @@ -23,7 +24,8 @@ async def test_should_trigger_compaction_with_low_threshold_and_emit_events( "background_compaction_threshold": 0.005, # Block at 1% to ensure compaction runs "buffer_exhaustion_threshold": 0.01, - } + }, + "on_permission_request": PermissionHandler.approve_all, } ) @@ -39,9 +41,7 @@ def on_event(event): session.on(on_event) # Send multiple messages to fill up the context window - await session.send_and_wait( - {"prompt": "Tell me a long story about a dragon. Be very detailed."} - ) + await session.send_and_wait({"prompt": "Tell me a story about a dragon. Be detailed."}) await session.send_and_wait( {"prompt": "Continue the story with more details about the dragon's castle."} ) @@ -71,7 +71,12 @@ def on_event(event): async def test_should_not_emit_compaction_events_when_infinite_sessions_disabled( self, ctx: E2ETestContext ): - session = await ctx.client.create_session({"infinite_sessions": {"enabled": False}}) + session = await ctx.client.create_session( + { + "infinite_sessions": {"enabled": False}, + "on_permission_request": PermissionHandler.approve_all, + } + ) compaction_events = [] diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index 7ca4b8c2b..b29a54827 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -32,7 +32,9 @@ async def test_should_accept_mcp_server_configuration_on_session_create( } } - session = await ctx.client.create_session({"mcp_servers": mcp_servers}) + session = await ctx.client.create_session( + {"mcp_servers": mcp_servers, "on_permission_request": PermissionHandler.approve_all} + ) assert session.session_id is not None @@ -48,7 +50,9 @@ async def test_should_accept_mcp_server_configuration_on_session_resume( ): """Test that MCP server configuration is accepted on session resume""" # Create a session first - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) @@ -62,7 +66,10 @@ async def test_should_accept_mcp_server_configuration_on_session_resume( } } - session2 = await ctx.client.resume_session(session_id, {"mcp_servers": mcp_servers}) + session2 = await ctx.client.resume_session( + session_id, + {"mcp_servers": mcp_servers, "on_permission_request": PermissionHandler.approve_all}, + ) assert session2.session_id == session_id @@ -123,7 +130,9 @@ async def test_should_accept_custom_agent_configuration_on_session_create( } ] - session = await ctx.client.create_session({"custom_agents": custom_agents}) + session = await ctx.client.create_session( + {"custom_agents": custom_agents, "on_permission_request": PermissionHandler.approve_all} + ) assert session.session_id is not None @@ -139,7 +148,9 @@ async def test_should_accept_custom_agent_configuration_on_session_resume( ): """Test that custom agent configuration is accepted on session resume""" # Create a session first - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) @@ -153,7 +164,13 @@ async def test_should_accept_custom_agent_configuration_on_session_resume( } ] - session2 = await ctx.client.resume_session(session_id, {"custom_agents": custom_agents}) + session2 = await ctx.client.resume_session( + session_id, + { + "custom_agents": custom_agents, + "on_permission_request": PermissionHandler.approve_all, + }, + ) assert session2.session_id == session_id @@ -186,7 +203,11 @@ async def test_should_accept_both_mcp_servers_and_custom_agents(self, ctx: E2ETe ] session = await ctx.client.create_session( - {"mcp_servers": mcp_servers, "custom_agents": custom_agents} + { + "mcp_servers": mcp_servers, + "custom_agents": custom_agents, + "on_permission_request": PermissionHandler.approve_all, + } ) assert session.session_id is not None diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 80b69ebba..c116053ba 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -68,10 +68,15 @@ def on_permission_request( await session.destroy() - async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided( + async def test_should_deny_tool_operations_when_handler_explicitly_denies( self, ctx: E2ETestContext ): - session = await ctx.client.create_session() + """Test that tool operations are denied when handler explicitly denies""" + + def deny_all(request, invocation): + return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + + session = await ctx.client.create_session({"on_permission_request": deny_all}) denied_events = [] done_event = asyncio.Event() @@ -98,16 +103,20 @@ def on_event(event): await session.destroy() - async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume( + async def test_should_deny_tool_operations_when_handler_explicitly_denies_after_resume( self, ctx: E2ETestContext ): + """Test that tool operations are denied after resume when handler explicitly denies""" session1 = await ctx.client.create_session( {"on_permission_request": PermissionHandler.approve_all} ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) - session2 = await ctx.client.resume_session(session_id) + def deny_all(request, invocation): + return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + + session2 = await ctx.client.resume_session(session_id, {"on_permission_request": deny_all}) denied_events = [] done_event = asyncio.Event() @@ -134,12 +143,11 @@ def on_event(event): await session2.destroy() - async def test_should_work_without_permission_handler__default_behavior_( - self, ctx: E2ETestContext - ): - """Test that sessions work without permission handler (default behavior)""" - # Create session without on_permission_request handler - session = await ctx.client.create_session() + async def test_should_work_with_approve_all_permission_handler(self, ctx: E2ETestContext): + """Test that sessions work with approve-all permission handler""" + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) message = await session.send_and_wait({"prompt": "What is 2+2?"}) @@ -172,8 +180,10 @@ async def test_should_resume_session_with_permission_handler(self, ctx: E2ETestC """Test resuming session with permission handler""" permission_requests = [] - # Create session without permission handler - session1 = await ctx.client.create_session() + # Create initial session + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) diff --git a/python/e2e/test_rpc.py b/python/e2e/test_rpc.py index da2ba3eb6..240cd3730 100644 --- a/python/e2e/test_rpc.py +++ b/python/e2e/test_rpc.py @@ -2,7 +2,7 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler from copilot.generated.rpc import PingParams from .testharness import CLI_PATH, E2ETestContext @@ -77,7 +77,9 @@ class TestSessionRpc: @pytest.mark.skip(reason="session.model.getCurrent not yet implemented in CLI") async def test_should_call_session_rpc_model_get_current(self, ctx: E2ETestContext): """Test calling session.rpc.model.getCurrent""" - session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + session = await ctx.client.create_session( + {"model": "claude-sonnet-4.5", "on_permission_request": PermissionHandler.approve_all} + ) result = await session.rpc.model.get_current() assert result.model_id is not None @@ -89,7 +91,9 @@ async def test_should_call_session_rpc_model_switch_to(self, ctx: E2ETestContext """Test calling session.rpc.model.switchTo""" from copilot.generated.rpc import SessionModelSwitchToParams - session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + session = await ctx.client.create_session( + {"model": "claude-sonnet-4.5", "on_permission_request": PermissionHandler.approve_all} + ) # Get initial model before = await session.rpc.model.get_current() @@ -112,7 +116,9 @@ async def test_get_and_set_session_mode(self): try: await client.start() - session = await client.create_session({}) + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) # Get initial mode (default should be interactive) initial = await session.rpc.mode.get() @@ -146,7 +152,9 @@ async def test_read_update_and_delete_plan(self): try: await client.start() - session = await client.create_session({}) + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) # Initially plan should not exist initial = await session.rpc.plan.read() @@ -187,7 +195,9 @@ async def test_create_list_and_read_workspace_files(self): try: await client.start() - session = await client.create_session({}) + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) # Initially no files initial_files = await session.rpc.workspace.list_files() diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 0998298f4..4842d7829 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -14,7 +14,9 @@ class TestSessions: async def test_should_create_and_destroy_sessions(self, ctx: E2ETestContext): - session = await ctx.client.create_session({"model": "fake-test-model"}) + session = await ctx.client.create_session( + {"model": "fake-test-model", "on_permission_request": PermissionHandler.approve_all} + ) assert session.session_id messages = await session.get_messages() @@ -29,7 +31,9 @@ async def test_should_create_and_destroy_sessions(self, ctx: E2ETestContext): await session.get_messages() async def test_should_have_stateful_conversation(self, ctx: E2ETestContext): - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) assistant_message = await session.send_and_wait({"prompt": "What is 1+1?"}) assert assistant_message is not None @@ -46,7 +50,10 @@ async def test_should_create_a_session_with_appended_systemMessage_config( ): system_message_suffix = "End each response with the phrase 'Have a nice day!'" session = await ctx.client.create_session( - {"system_message": {"mode": "append", "content": system_message_suffix}} + { + "system_message": {"mode": "append", "content": system_message_suffix}, + "on_permission_request": PermissionHandler.approve_all, + } ) await session.send({"prompt": "What is your full name?"}) @@ -65,7 +72,10 @@ async def test_should_create_a_session_with_replaced_systemMessage_config( ): test_system_message = "You are an assistant called Testy McTestface. Reply succinctly." session = await ctx.client.create_session( - {"system_message": {"mode": "replace", "content": test_system_message}} + { + "system_message": {"mode": "replace", "content": test_system_message}, + "on_permission_request": PermissionHandler.approve_all, + } ) await session.send({"prompt": "What is your full name?"}) @@ -79,7 +89,12 @@ async def test_should_create_a_session_with_replaced_systemMessage_config( assert system_message == test_system_message # Exact match async def test_should_create_a_session_with_availableTools(self, ctx: E2ETestContext): - session = await ctx.client.create_session({"available_tools": ["view", "edit"]}) + session = await ctx.client.create_session( + { + "available_tools": ["view", "edit"], + "on_permission_request": PermissionHandler.approve_all, + } + ) await session.send({"prompt": "What is 1+1?"}) await get_final_assistant_message(session) @@ -93,7 +108,9 @@ async def test_should_create_a_session_with_availableTools(self, ctx: E2ETestCon assert "edit" in tool_names async def test_should_create_a_session_with_excludedTools(self, ctx: E2ETestContext): - session = await ctx.client.create_session({"excluded_tools": ["view"]}) + session = await ctx.client.create_session( + {"excluded_tools": ["view"], "on_permission_request": PermissionHandler.approve_all} + ) await session.send({"prompt": "What is 1+1?"}) await get_final_assistant_message(session) @@ -115,9 +132,9 @@ async def test_should_handle_multiple_concurrent_sessions(self, ctx: E2ETestCont import asyncio s1, s2, s3 = await asyncio.gather( - ctx.client.create_session(), - ctx.client.create_session(), - ctx.client.create_session(), + ctx.client.create_session({"on_permission_request": PermissionHandler.approve_all}), + ctx.client.create_session({"on_permission_request": PermissionHandler.approve_all}), + ctx.client.create_session({"on_permission_request": PermissionHandler.approve_all}), ) # All sessions should have unique IDs @@ -139,21 +156,27 @@ async def test_should_handle_multiple_concurrent_sessions(self, ctx: E2ETestCont async def test_should_resume_a_session_using_the_same_client(self, ctx: E2ETestContext): # Create initial session - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id answer = await session1.send_and_wait({"prompt": "What is 1+1?"}) assert answer is not None assert "2" in answer.data.content # Resume using the same client - session2 = await ctx.client.resume_session(session_id) + session2 = await ctx.client.resume_session( + session_id, {"on_permission_request": PermissionHandler.approve_all} + ) assert session2.session_id == session_id answer2 = await get_final_assistant_message(session2) assert "2" in answer2.data.content async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestContext): # Create initial session - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id answer = await session1.send_and_wait({"prompt": "What is 1+1?"}) assert answer is not None @@ -171,7 +194,9 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont ) try: - session2 = await new_client.resume_session(session_id) + session2 = await new_client.resume_session( + session_id, {"on_permission_request": PermissionHandler.approve_all} + ) assert session2.session_id == session_id # TODO: There's an inconsistency here. When resuming with a new client, @@ -186,15 +211,21 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont async def test_should_throw_error_resuming_nonexistent_session(self, ctx: E2ETestContext): with pytest.raises(Exception): - await ctx.client.resume_session("non-existent-session-id") + await ctx.client.resume_session( + "non-existent-session-id", {"on_permission_request": PermissionHandler.approve_all} + ) async def test_should_list_sessions(self, ctx: E2ETestContext): import asyncio # Create a couple of sessions and send messages to persist them - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session1.send_and_wait({"prompt": "Say hello"}) - session2 = await ctx.client.create_session() + session2 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session2.send_and_wait({"prompt": "Say goodbye"}) # Small delay to ensure session files are written to disk @@ -231,7 +262,9 @@ async def test_should_delete_session(self, ctx: E2ETestContext): import asyncio # Create a session and send a message to persist it - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session.send_and_wait({"prompt": "Hello"}) session_id = session.session_id @@ -253,7 +286,9 @@ async def test_should_delete_session(self, ctx: E2ETestContext): # Verify we cannot resume the deleted session with pytest.raises(Exception): - await ctx.client.resume_session(session_id) + await ctx.client.resume_session( + session_id, {"on_permission_request": PermissionHandler.approve_all} + ) async def test_should_create_session_with_custom_tool(self, ctx: E2ETestContext): # This test uses the low-level Tool() API to show that Pydantic is optional @@ -277,7 +312,8 @@ def get_secret_number_handler(invocation): "required": ["key"], }, ) - ] + ], + "on_permission_request": PermissionHandler.approve_all, } ) @@ -292,7 +328,8 @@ async def test_should_create_session_with_custom_provider(self, ctx: E2ETestCont "type": "openai", "base_url": "https://api.openai.com/v1", "api_key": "fake-key", - } + }, + "on_permission_request": PermissionHandler.approve_all, } ) assert session.session_id @@ -307,13 +344,16 @@ async def test_should_create_session_with_azure_provider(self, ctx: E2ETestConte "azure": { "api_version": "2024-02-15-preview", }, - } + }, + "on_permission_request": PermissionHandler.approve_all, } ) assert session.session_id async def test_should_resume_session_with_custom_provider(self, ctx: E2ETestContext): - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session.session_id # Resume the session with a provider @@ -324,7 +364,8 @@ async def test_should_resume_session_with_custom_provider(self, ctx: E2ETestCont "type": "openai", "base_url": "https://api.openai.com/v1", "api_key": "fake-key", - } + }, + "on_permission_request": PermissionHandler.approve_all, }, ) @@ -381,7 +422,9 @@ async def test_should_receive_streaming_delta_events_when_streaming_is_enabled( ): import asyncio - session = await ctx.client.create_session({"streaming": True}) + session = await ctx.client.create_session( + {"streaming": True, "on_permission_request": PermissionHandler.approve_all} + ) delta_contents = [] done_event = asyncio.Event() @@ -422,7 +465,9 @@ def on_event(event): async def test_should_pass_streaming_option_to_session_creation(self, ctx: E2ETestContext): # Verify that the streaming option is accepted without errors - session = await ctx.client.create_session({"streaming": True}) + session = await ctx.client.create_session( + {"streaming": True, "on_permission_request": PermissionHandler.approve_all} + ) assert session.session_id @@ -434,7 +479,9 @@ async def test_should_pass_streaming_option_to_session_creation(self, ctx: E2ETe async def test_should_receive_session_events(self, ctx: E2ETestContext): import asyncio - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) received_events = [] idle_event = asyncio.Event() @@ -469,7 +516,12 @@ async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestCo import os custom_config_dir = os.path.join(ctx.home_dir, "custom-config") - session = await ctx.client.create_session({"config_dir": custom_config_dir}) + session = await ctx.client.create_session( + { + "config_dir": custom_config_dir, + "on_permission_request": PermissionHandler.approve_all, + } + ) assert session.session_id diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index 7f05140eb..10d32695c 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -7,6 +7,8 @@ import pytest +from copilot import PermissionHandler + from .testharness import E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -53,7 +55,12 @@ class TestSkillBehavior: async def test_should_load_and_apply_skill_from_skilldirectories(self, ctx: E2ETestContext): """Test that skills are loaded and applied from skillDirectories""" skills_dir = create_skill_dir(ctx.work_dir) - session = await ctx.client.create_session({"skill_directories": [skills_dir]}) + session = await ctx.client.create_session( + { + "skill_directories": [skills_dir], + "on_permission_request": PermissionHandler.approve_all, + } + ) assert session.session_id is not None @@ -70,7 +77,11 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills( """Test that disabledSkills prevents skill from being applied""" skills_dir = create_skill_dir(ctx.work_dir) session = await ctx.client.create_session( - {"skill_directories": [skills_dir], "disabled_skills": ["test-skill"]} + { + "skill_directories": [skills_dir], + "disabled_skills": ["test-skill"], + "on_permission_request": PermissionHandler.approve_all, + } ) assert session.session_id is not None @@ -93,7 +104,9 @@ async def test_should_apply_skill_on_session_resume_with_skilldirectories( skills_dir = create_skill_dir(ctx.work_dir) # Create a session without skills first - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id # First message without skill - marker should not appear @@ -102,7 +115,13 @@ async def test_should_apply_skill_on_session_resume_with_skilldirectories( assert SKILL_MARKER not in message1.data.content # Resume with skillDirectories - skill should now be active - session2 = await ctx.client.resume_session(session_id, {"skill_directories": [skills_dir]}) + session2 = await ctx.client.resume_session( + session_id, + { + "skill_directories": [skills_dir], + "on_permission_request": PermissionHandler.approve_all, + }, + ) assert session2.session_id == session_id diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 10e61cf15..e4a9f5f06 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -34,7 +34,9 @@ class EncryptParams(BaseModel): def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: return params.input.upper() - session = await ctx.client.create_session({"tools": [encrypt_string]}) + session = await ctx.client.create_session( + {"tools": [encrypt_string], "on_permission_request": PermissionHandler.approve_all} + ) await session.send({"prompt": "Use encrypt_string to encrypt this string: Hello"}) assistant_message = await get_final_assistant_message(session) @@ -45,7 +47,9 @@ async def test_handles_tool_calling_errors(self, ctx: E2ETestContext): def get_user_location() -> str: raise Exception("Melbourne") - session = await ctx.client.create_session({"tools": [get_user_location]}) + session = await ctx.client.create_session( + {"tools": [get_user_location], "on_permission_request": PermissionHandler.approve_all} + ) await session.send( {"prompt": "What is my location? If you can't find out, just say 'unknown'."} @@ -108,7 +112,9 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: City(countryId=12, cityName="San Lorenzo", population=204356), ] - session = await ctx.client.create_session({"tools": [db_query]}) + session = await ctx.client.create_session( + {"tools": [db_query], "on_permission_request": PermissionHandler.approve_all} + ) expected_session_id = session.session_id await session.send( @@ -126,3 +132,61 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert "San Lorenzo" in response_content assert "135460" in response_content.replace(",", "") assert "204356" in response_content.replace(",", "") + + async def test_invokes_custom_tool_with_permission_handler(self, ctx: E2ETestContext): + class EncryptParams(BaseModel): + input: str = Field(description="String to encrypt") + + @define_tool("encrypt_string", description="Encrypts a string") + def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: + return params.input.upper() + + permission_requests = [] + + def on_permission_request(request, invocation): + permission_requests.append(request) + return {"kind": "approved"} + + session = await ctx.client.create_session( + { + "tools": [encrypt_string], + "on_permission_request": on_permission_request, + } + ) + + await session.send({"prompt": "Use encrypt_string to encrypt this string: Hello"}) + assistant_message = await get_final_assistant_message(session) + assert "HELLO" in assistant_message.data.content + + # Should have received a custom-tool permission request + custom_tool_requests = [r for r in permission_requests if r.get("kind") == "custom-tool"] + assert len(custom_tool_requests) > 0 + assert custom_tool_requests[0].get("toolName") == "encrypt_string" + + async def test_denies_custom_tool_when_permission_denied(self, ctx: E2ETestContext): + tool_handler_called = False + + class EncryptParams(BaseModel): + input: str = Field(description="String to encrypt") + + @define_tool("encrypt_string", description="Encrypts a string") + def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: + nonlocal tool_handler_called + tool_handler_called = True + return params.input.upper() + + def on_permission_request(request, invocation): + return {"kind": "denied-interactively-by-user"} + + session = await ctx.client.create_session( + { + "tools": [encrypt_string], + "on_permission_request": on_permission_request, + } + ) + + await session.send({"prompt": "Use encrypt_string to encrypt this string: Hello"}) + await get_final_assistant_message(session) + + # The tool handler should NOT have been called since permission was denied + assert not tool_handler_called diff --git a/python/test_client.py b/python/test_client.py index 0bc99ea69..c6ad027f5 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -6,10 +6,35 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler from e2e.testharness import CLI_PATH +class TestPermissionHandlerRequired: + @pytest.mark.asyncio + async def test_create_session_raises_without_permission_handler(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + try: + with pytest.raises(ValueError, match="on_permission_request.*is required"): + await client.create_session({}) + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_raises_without_permission_handler(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + try: + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + with pytest.raises(ValueError, match="on_permission_request.*is required"): + await client.resume_session(session.session_id, {}) + finally: + await client.force_stop() + + class TestHandleToolCallRequest: @pytest.mark.asyncio async def test_returns_failure_when_tool_not_registered(self): @@ -17,7 +42,9 @@ async def test_returns_failure_when_tool_not_registered(self): await client.start() try: - session = await client.create_session() + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) response = await client._handle_tool_call_request( { @@ -164,7 +191,9 @@ async def mock_request(method, params): return await original_request(method, params) client._client.request = mock_request - await client.create_session({"client_name": "my-app"}) + await client.create_session( + {"client_name": "my-app", "on_permission_request": PermissionHandler.approve_all} + ) assert captured["session.create"]["clientName"] == "my-app" finally: await client.force_stop() @@ -175,7 +204,9 @@ async def test_resume_session_forwards_client_name(self): await client.start() try: - session = await client.create_session() + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) captured = {} original_request = client._client.request @@ -185,7 +216,10 @@ async def mock_request(method, params): return await original_request(method, params) client._client.request = mock_request - await client.resume_session(session.session_id, {"client_name": "my-app"}) + await client.resume_session( + session.session_id, + {"client_name": "my-app", "on_permission_request": PermissionHandler.approve_all}, + ) assert captured["session.resume"]["clientName"] == "my-app" finally: await client.force_stop() diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index e5e0fcf9a..f2e536257 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -467,6 +467,14 @@ function singularPascal(s: string): string { } function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string { + // Handle anyOf: [T, null] → T? (nullable typed property) + if (schema.anyOf) { + const hasNull = schema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); + const nonNull = schema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); + if (nonNull.length === 1) { + return resolveRpcType(nonNull[0] as JSONSchema7, isRequired && !hasNull, parentClassName, propName, classes); + } + } // Handle enums (string unions like "interactive" | "plan" | "autopilot") if (schema.enum && Array.isArray(schema.enum)) { const enumName = getOrCreateEnum(parentClassName, propName, schema.enum as string[], rpcEnumOutput); @@ -621,7 +629,7 @@ function emitServerInstanceMethod( let requestClassName: string | null = null; if (paramEntries.length > 0) { - requestClassName = `${methodName}Request`; + requestClassName = `${typeToClassName(method.rpcMethod)}Request`; const reqClass = emitRpcClass(requestClassName, method.params!, "internal", classes); if (reqClass) classes.push(reqClass); } @@ -685,7 +693,7 @@ function emitSessionApiClass(className: string, node: Record, c const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); const requiredSet = new Set(method.params?.required || []); - const requestClassName = `${methodName}Request`; + const requestClassName = `${typeToClassName(method.rpcMethod)}Request`; if (method.params) { const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); if (reqClass) classes.push(reqClass); diff --git a/scripts/docs-validation/validate.ts b/scripts/docs-validation/validate.ts index 109430c81..0d1919c39 100644 --- a/scripts/docs-validation/validate.ts +++ b/scripts/docs-validation/validate.ts @@ -5,7 +5,7 @@ import * as fs from "fs"; import * as path from "path"; -import { execSync, spawn } from "child_process"; +import { execFileSync } from "child_process"; import { glob } from "glob"; const ROOT_DIR = path.resolve(import.meta.dirname, "../.."); @@ -76,7 +76,7 @@ async function validateTypeScript(): Promise { try { // Run tsc const tscPath = path.join(ROOT_DIR, "nodejs/node_modules/.bin/tsc"); - execSync(`${tscPath} --project ${tsconfigPath} 2>&1`, { + execFileSync(tscPath, ["--project", tsconfigPath], { encoding: "utf-8", cwd: tsDir, }); @@ -98,10 +98,8 @@ async function validateTypeScript(): Promise { } } catch (err: any) { // Parse tsc output for errors - const output = err.stdout || err.message || ""; + const output = err.stdout || err.stderr || err.message || ""; const errorLines = output.split("\n"); - - // Group errors by file const fileErrors = new Map(); let currentFile = ""; @@ -162,22 +160,23 @@ async function validatePython(): Promise { // Syntax check with py_compile try { - execSync(`python3 -m py_compile "${fullPath}" 2>&1`, { + execFileSync("python3", ["-m", "py_compile", fullPath], { encoding: "utf-8", }); } catch (err: any) { - errors.push(err.stdout || err.message || "Syntax error"); + errors.push(err.stdout || err.stderr || err.message || "Syntax error"); } // Type check with mypy (if available) if (errors.length === 0) { try { - execSync( - `python3 -m mypy "${fullPath}" --ignore-missing-imports --no-error-summary 2>&1`, + execFileSync( + "python3", + ["-m", "mypy", fullPath, "--ignore-missing-imports", "--no-error-summary"], { encoding: "utf-8" } ); } catch (err: any) { - const output = err.stdout || err.message || ""; + const output = err.stdout || err.stderr || err.message || ""; // Filter out "Success" messages and notes const typeErrors = output .split("\n") @@ -227,7 +226,7 @@ replace github.com/github/copilot-sdk/go => ${path.join(ROOT_DIR, "go")} // Run go mod tidy to fetch dependencies try { - execSync(`go mod tidy 2>&1`, { + execFileSync("go", ["mod", "tidy"], { encoding: "utf-8", cwd: goDir, env: { ...process.env, GO111MODULE: "on" }, @@ -246,7 +245,7 @@ replace github.com/github/copilot-sdk/go => ${path.join(ROOT_DIR, "go")} try { // Use go vet for syntax and basic checks - execSync(`go build -o /dev/null "${fullPath}" 2>&1`, { + execFileSync("go", ["build", "-o", "/dev/null", fullPath], { encoding: "utf-8", cwd: goDir, env: { ...process.env, GO111MODULE: "on" }, @@ -300,7 +299,7 @@ async function validateCSharp(): Promise { // Compile all files together try { - execSync(`dotnet build "${path.join(csDir, "DocsValidation.csproj")}" 2>&1`, { + execFileSync("dotnet", ["build", path.join(csDir, "DocsValidation.csproj")], { encoding: "utf-8", cwd: csDir, }); diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index d3dab9dc2..08843fe9f 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -632,6 +632,10 @@ function normalizeUserMessage(content: string): string { return content .replace(/.*?<\/current_datetime>/g, "") .replace(/[\s\S]*?<\/reminder>/g, "") + .replace( + /Please create a detailed summary of the conversation so far\. The history is being compacted[\s\S]*/, + "${compaction_prompt}", + ) .trim(); } diff --git a/test/scenarios/auth/gh-app/csharp/Program.cs b/test/scenarios/auth/gh-app/csharp/Program.cs index 70f5f379c..1f2e27ccf 100644 --- a/test/scenarios/auth/gh-app/csharp/Program.cs +++ b/test/scenarios/auth/gh-app/csharp/Program.cs @@ -61,7 +61,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = accessToken, + GitHubToken = accessToken, }); await client.StartAsync(); diff --git a/test/scenarios/auth/gh-app/go/main.go b/test/scenarios/auth/gh-app/go/main.go index d26594779..4aaad3b4b 100644 --- a/test/scenarios/auth/gh-app/go/main.go +++ b/test/scenarios/auth/gh-app/go/main.go @@ -162,7 +162,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: token, + GitHubToken: token, }) ctx := context.Background() diff --git a/test/scenarios/bundling/fully-bundled/csharp/Program.cs b/test/scenarios/bundling/fully-bundled/csharp/Program.cs index 50505b776..cb67c903c 100644 --- a/test/scenarios/bundling/fully-bundled/csharp/Program.cs +++ b/test/scenarios/bundling/fully-bundled/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/bundling/fully-bundled/go/main.go b/test/scenarios/bundling/fully-bundled/go/main.go index 5543f6b4d..e548a08e7 100644 --- a/test/scenarios/bundling/fully-bundled/go/main.go +++ b/test/scenarios/bundling/fully-bundled/go/main.go @@ -12,7 +12,7 @@ import ( func main() { // Go SDK auto-reads COPILOT_CLI_PATH from env client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/callbacks/hooks/csharp/Program.cs b/test/scenarios/callbacks/hooks/csharp/Program.cs index 14579e3d0..30b34e309 100644 --- a/test/scenarios/callbacks/hooks/csharp/Program.cs +++ b/test/scenarios/callbacks/hooks/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/callbacks/hooks/go/main.go b/test/scenarios/callbacks/hooks/go/main.go index 7b1b1a59b..c084c3a79 100644 --- a/test/scenarios/callbacks/hooks/go/main.go +++ b/test/scenarios/callbacks/hooks/go/main.go @@ -23,7 +23,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/callbacks/permissions/csharp/Program.cs b/test/scenarios/callbacks/permissions/csharp/Program.cs index be00015a9..7803c3a75 100644 --- a/test/scenarios/callbacks/permissions/csharp/Program.cs +++ b/test/scenarios/callbacks/permissions/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/callbacks/permissions/go/main.go b/test/scenarios/callbacks/permissions/go/main.go index 7dad320c3..1ac49c04d 100644 --- a/test/scenarios/callbacks/permissions/go/main.go +++ b/test/scenarios/callbacks/permissions/go/main.go @@ -17,7 +17,7 @@ func main() { ) client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/callbacks/user-input/csharp/Program.cs b/test/scenarios/callbacks/user-input/csharp/Program.cs index 0ffed2469..4e1c15cab 100644 --- a/test/scenarios/callbacks/user-input/csharp/Program.cs +++ b/test/scenarios/callbacks/user-input/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/callbacks/user-input/go/main.go b/test/scenarios/callbacks/user-input/go/main.go index 9405de035..91d0c86ec 100644 --- a/test/scenarios/callbacks/user-input/go/main.go +++ b/test/scenarios/callbacks/user-input/go/main.go @@ -17,7 +17,7 @@ var ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/modes/default/csharp/Program.cs b/test/scenarios/modes/default/csharp/Program.cs index 974a93036..243fcb922 100644 --- a/test/scenarios/modes/default/csharp/Program.cs +++ b/test/scenarios/modes/default/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/modes/default/go/main.go b/test/scenarios/modes/default/go/main.go index b17ac1e88..dfae25178 100644 --- a/test/scenarios/modes/default/go/main.go +++ b/test/scenarios/modes/default/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/modes/minimal/csharp/Program.cs b/test/scenarios/modes/minimal/csharp/Program.cs index 626e13970..94cbc2034 100644 --- a/test/scenarios/modes/minimal/csharp/Program.cs +++ b/test/scenarios/modes/minimal/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/modes/minimal/go/main.go b/test/scenarios/modes/minimal/go/main.go index 1e6d46a53..c39c24f65 100644 --- a/test/scenarios/modes/minimal/go/main.go +++ b/test/scenarios/modes/minimal/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/prompts/attachments/csharp/Program.cs b/test/scenarios/prompts/attachments/csharp/Program.cs index 9e28c342d..357444a6f 100644 --- a/test/scenarios/prompts/attachments/csharp/Program.cs +++ b/test/scenarios/prompts/attachments/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/prompts/attachments/go/main.go b/test/scenarios/prompts/attachments/go/main.go index bb1486da2..4b248bf95 100644 --- a/test/scenarios/prompts/attachments/go/main.go +++ b/test/scenarios/prompts/attachments/go/main.go @@ -14,7 +14,7 @@ const systemPrompt = `You are a helpful assistant. Answer questions about attach func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/prompts/reasoning-effort/csharp/Program.cs b/test/scenarios/prompts/reasoning-effort/csharp/Program.cs index c026e046d..719650880 100644 --- a/test/scenarios/prompts/reasoning-effort/csharp/Program.cs +++ b/test/scenarios/prompts/reasoning-effort/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/prompts/reasoning-effort/go/main.go b/test/scenarios/prompts/reasoning-effort/go/main.go index ce9ffe508..43c5eb74a 100644 --- a/test/scenarios/prompts/reasoning-effort/go/main.go +++ b/test/scenarios/prompts/reasoning-effort/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/prompts/system-message/csharp/Program.cs b/test/scenarios/prompts/system-message/csharp/Program.cs index 7b13d173c..5f22cb029 100644 --- a/test/scenarios/prompts/system-message/csharp/Program.cs +++ b/test/scenarios/prompts/system-message/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/prompts/system-message/go/main.go b/test/scenarios/prompts/system-message/go/main.go index 34e9c7523..aeef76137 100644 --- a/test/scenarios/prompts/system-message/go/main.go +++ b/test/scenarios/prompts/system-message/go/main.go @@ -13,7 +13,7 @@ const piratePrompt = `You are a pirate. Always respond in pirate speak. Say 'Arr func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs b/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs index f3f1b3688..142bcb268 100644 --- a/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs +++ b/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs @@ -6,7 +6,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/sessions/concurrent-sessions/go/main.go b/test/scenarios/sessions/concurrent-sessions/go/main.go index fa15f445e..02b3f03ae 100644 --- a/test/scenarios/sessions/concurrent-sessions/go/main.go +++ b/test/scenarios/sessions/concurrent-sessions/go/main.go @@ -15,7 +15,7 @@ const robotPrompt = `You are a robot. Always say BEEP BOOP!` func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/sessions/infinite-sessions/csharp/Program.cs b/test/scenarios/sessions/infinite-sessions/csharp/Program.cs index 1c6244e4d..fe281292d 100644 --- a/test/scenarios/sessions/infinite-sessions/csharp/Program.cs +++ b/test/scenarios/sessions/infinite-sessions/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/sessions/infinite-sessions/go/main.go b/test/scenarios/sessions/infinite-sessions/go/main.go index c4c95814c..38090660c 100644 --- a/test/scenarios/sessions/infinite-sessions/go/main.go +++ b/test/scenarios/sessions/infinite-sessions/go/main.go @@ -14,7 +14,7 @@ func float64Ptr(f float64) *float64 { return &f } func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/sessions/session-resume/csharp/Program.cs b/test/scenarios/sessions/session-resume/csharp/Program.cs index 743873afe..73979669d 100644 --- a/test/scenarios/sessions/session-resume/csharp/Program.cs +++ b/test/scenarios/sessions/session-resume/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); @@ -13,6 +13,7 @@ // 1. Create a session await using var session = await client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = PermissionHandler.ApproveAll, Model = "claude-haiku-4.5", AvailableTools = new List(), }); @@ -27,7 +28,10 @@ await session.SendAndWaitAsync(new MessageOptions var sessionId = session.SessionId; // 4. Resume the session with the same ID - await using var resumed = await client.ResumeSessionAsync(sessionId); + await using var resumed = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); Console.WriteLine("Session resumed"); // 5. Ask for the secret word diff --git a/test/scenarios/sessions/session-resume/go/main.go b/test/scenarios/sessions/session-resume/go/main.go index cf2cb0448..6ec4bb02d 100644 --- a/test/scenarios/sessions/session-resume/go/main.go +++ b/test/scenarios/sessions/session-resume/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -22,8 +22,9 @@ func main() { // 1. Create a session session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "claude-haiku-4.5", - AvailableTools: []string{}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-haiku-4.5", + AvailableTools: []string{}, }) if err != nil { log.Fatal(err) @@ -41,7 +42,9 @@ func main() { sessionID := session.SessionID // 4. Resume the session with the same ID - resumed, err := client.ResumeSession(ctx, sessionID) + resumed, err := client.ResumeSession(ctx, sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { log.Fatal(err) } diff --git a/test/scenarios/sessions/streaming/csharp/Program.cs b/test/scenarios/sessions/streaming/csharp/Program.cs index b7c1e0ff5..01683df76 100644 --- a/test/scenarios/sessions/streaming/csharp/Program.cs +++ b/test/scenarios/sessions/streaming/csharp/Program.cs @@ -2,7 +2,7 @@ var options = new CopilotClientOptions { - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }; var cliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); diff --git a/test/scenarios/sessions/streaming/go/main.go b/test/scenarios/sessions/streaming/go/main.go index 0f55ece43..0be9ae031 100644 --- a/test/scenarios/sessions/streaming/go/main.go +++ b/test/scenarios/sessions/streaming/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/tools/custom-agents/csharp/Program.cs b/test/scenarios/tools/custom-agents/csharp/Program.cs index 394de465f..c5c6525f1 100644 --- a/test/scenarios/tools/custom-agents/csharp/Program.cs +++ b/test/scenarios/tools/custom-agents/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = cliPath, - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/custom-agents/go/main.go b/test/scenarios/tools/custom-agents/go/main.go index 321793382..1ce90d47e 100644 --- a/test/scenarios/tools/custom-agents/go/main.go +++ b/test/scenarios/tools/custom-agents/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/tools/mcp-servers/csharp/Program.cs b/test/scenarios/tools/mcp-servers/csharp/Program.cs index 1d5acbd2e..2ee25aacd 100644 --- a/test/scenarios/tools/mcp-servers/csharp/Program.cs +++ b/test/scenarios/tools/mcp-servers/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/mcp-servers/go/main.go b/test/scenarios/tools/mcp-servers/go/main.go index 15ffa4c41..70831cafa 100644 --- a/test/scenarios/tools/mcp-servers/go/main.go +++ b/test/scenarios/tools/mcp-servers/go/main.go @@ -12,7 +12,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/tools/no-tools/csharp/Program.cs b/test/scenarios/tools/no-tools/csharp/Program.cs index d25b57a6c..c3de1de53 100644 --- a/test/scenarios/tools/no-tools/csharp/Program.cs +++ b/test/scenarios/tools/no-tools/csharp/Program.cs @@ -10,7 +10,7 @@ You can only respond with text based on your training data. using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/no-tools/go/main.go b/test/scenarios/tools/no-tools/go/main.go index 75cfa894d..d453f0dfd 100644 --- a/test/scenarios/tools/no-tools/go/main.go +++ b/test/scenarios/tools/no-tools/go/main.go @@ -16,7 +16,7 @@ If asked about your capabilities or tools, clearly state that you have no tools func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/tools/skills/csharp/Program.cs b/test/scenarios/tools/skills/csharp/Program.cs index fc31c2940..38df0dac1 100644 --- a/test/scenarios/tools/skills/csharp/Program.cs +++ b/test/scenarios/tools/skills/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/skills/go/main.go b/test/scenarios/tools/skills/go/main.go index d0d9f8700..e322dda6c 100644 --- a/test/scenarios/tools/skills/go/main.go +++ b/test/scenarios/tools/skills/go/main.go @@ -13,7 +13,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/tools/tool-filtering/csharp/Program.cs b/test/scenarios/tools/tool-filtering/csharp/Program.cs index dfe3b5a93..f21482b1b 100644 --- a/test/scenarios/tools/tool-filtering/csharp/Program.cs +++ b/test/scenarios/tools/tool-filtering/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/tool-filtering/go/main.go b/test/scenarios/tools/tool-filtering/go/main.go index 3c31c198e..a774fb3e8 100644 --- a/test/scenarios/tools/tool-filtering/go/main.go +++ b/test/scenarios/tools/tool-filtering/go/main.go @@ -13,7 +13,7 @@ const systemPrompt = `You are a helpful assistant. You have access to a limited func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/tools/virtual-filesystem/csharp/Program.cs b/test/scenarios/tools/virtual-filesystem/csharp/Program.cs index 4018b5f99..7dd5d710e 100644 --- a/test/scenarios/tools/virtual-filesystem/csharp/Program.cs +++ b/test/scenarios/tools/virtual-filesystem/csharp/Program.cs @@ -8,7 +8,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/virtual-filesystem/go/main.go b/test/scenarios/tools/virtual-filesystem/go/main.go index 625d999ea..29b1eef4f 100644 --- a/test/scenarios/tools/virtual-filesystem/go/main.go +++ b/test/scenarios/tools/virtual-filesystem/go/main.go @@ -74,7 +74,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/scenarios/transport/stdio/csharp/Program.cs b/test/scenarios/transport/stdio/csharp/Program.cs index 50505b776..cb67c903c 100644 --- a/test/scenarios/transport/stdio/csharp/Program.cs +++ b/test/scenarios/transport/stdio/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/transport/stdio/go/main.go b/test/scenarios/transport/stdio/go/main.go index 5543f6b4d..e548a08e7 100644 --- a/test/scenarios/transport/stdio/go/main.go +++ b/test/scenarios/transport/stdio/go/main.go @@ -12,7 +12,7 @@ import ( func main() { // Go SDK auto-reads COPILOT_CLI_PATH from env client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() diff --git a/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml b/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml new file mode 100644 index 000000000..fa5cf614a --- /dev/null +++ b/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml @@ -0,0 +1,80 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2+2 = 4 + - role: user + content: ${compaction_prompt} + - role: assistant + content: >- + + + The user asked a simple arithmetic question ("What is 2+2?") which was answered directly (4). No technical + work, code changes, or file modifications were requested or performed. This appears to be a minimal test or + verification exchange before any substantive work begins. + + + + + + + 1. The user asked "What is 2+2?" + - Provided the answer: 4 + - No follow-up actions were needed + + + + + + No work has been performed. No files were created, modified, or deleted. This was purely an informational + exchange with no code changes or system modifications. + + + Current state: + + - Working directory: ${workdir} + + - Not a git repository + + - No files have been accessed or modified + + - No todos or plan have been created + + + + + + + - Environment is Windows (Windows_NT), requiring backslash path separators + + - Session workspace available for artifacts (e.g., plan.md) + + - SQL database available but not yet initialized (no tables created) + + - Available tools: git, curl, gh + + - No technical work has been performed yet, so no technical details have been discovered + + + + + + + No files have been accessed or modified during this conversation. + + + + + + + No work is currently in progress. Awaiting user's next request for any substantive task. + + + + + Simple arithmetic question answered diff --git a/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml index a33904cfb..ae44af7c6 100644 --- a/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml +++ b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml @@ -5,514 +5,661 @@ conversations: - role: system content: ${system} - role: user - content: Tell me a long story about a dragon. Be very detailed. + content: Tell me a story about a dragon. Be detailed. - role: assistant content: >- - I'm a terminal assistant designed to help with software engineering tasks like writing code, debugging, and - managing repositories. While I can tell you a story, it's a bit outside my main purpose! + # The Last Ember of Thornkeep - Here's a brief tale: In the volcanic peaks of Mount Ashencrag lived Theraxis, an ancient crimson dragon whose - scales shimmered like molten copper. For centuries, she guarded a library of stone tablets inscribed by the - first civilizations, breathing gentle warmth to preserve them through harsh winters. When a young scholar - stumbled into her lair seeking forbidden knowledge, Theraxis didn't devour him—instead, she challenged him to - a game of riddles, each answer unlocking one tablet's secrets, teaching him that true wisdom required patience - and respect for those who came before. + In the craggy peaks of the Ashfall Mountains, where volcanic vents still breathed smoke into perpetual + twilight, there lived a dragon named Kaedrith. Her scales shimmered like molten copper, each one edged in + obsidian black, and her eyes held the amber glow of dying stars. - Is there a coding project or technical task I can help you with instead? + Kaedrith was ancient—so old that she had witnessed the rise and fall of three kingdoms in the valley below. + She had once been fearsome, a terror that sent knights fleeing and villages scrambling to offer tribute. But + those days had faded like morning mist. Now she spent her centuries in solitude, tending to her greatest + treasure: not gold or jewels, but a garden of fire lilies that grew only in dragon flame. + + + One winter evening, as ash-snow drifted through the mountain passes, a child appeared at the mouth of her + cave. A girl, no more than ten, with eyes too weary for her age and hands wrapped in frost-bitten cloth. She + didn't scream. She didn't run. She simply stood there, shivering, and said: "They told me you eat people. I + need to not be a person anymore." + + + Kaedrith, who had lived long enough to understand the weight of such words, curled her massive form around the + child like a shield of scales and warmth. "What are you running from?" she rumbled, her voice like distant + thunder. + + + "A king who thinks children are coin," the girl whispered. "He sells us to factories in the dark cities." + + + The dragon's eyes blazed brighter. Deep in her chest, something that had been dormant for centuries + stirred—the ancient rage that had once made mountains tremble. But she had learned patience through time's + long lessons. + + + Instead of descending upon the valley in fury, Kaedrith taught the girl. She showed her how to tend the fire + lilies, whose petals held healing properties. She shared stories of the old world, when dragons and humans had + been allies, not enemies. And slowly, other children found their way up the mountain—runaways, orphans, the + forgotten. + + + Kaedrith's cave became a sanctuary, warmed by dragon fire and filled with laughter that echoed off stone + walls. And when the king finally sent his soldiers to drag the children back, they found not helpless + refugees, but a dragon fully awakened—with a family worth protecting. + + + The battle lasted seven minutes. The soldiers never returned. + + + The king, wisely, never sent more. - role: user content: Continue the story with more details about the dragon's castle. - role: user + content: ${compaction_prompt} + - role: assistant content: >- - Please create a detailed summary of the conversation so far. The history is being compacted so moving forward, - all conversation history will be removed and you'll only have this summary to work from. Be sure to make note - of the user's explicit requests, your actions, and any key technical details. + + The user requested creative storytelling content: first a detailed story about a dragon, then a continuation + focusing on the dragon's castle. This is a pure creative writing task with no technical work, code changes, or + file modifications involved. - The summary should include the following parts: + - 1. - high-level summary of goals and approach - 2. - chronological analysis of the conversation + - 3. - changes made, current state, and any issues encountered + 1. The user asked for a detailed story about a dragon + - Composed "The Last Ember of Thornkeep" - a story about Kaedrith, an ancient copper-scaled dragon in the Ashfall Mountains + - Story focused on the dragon creating a sanctuary for runaway children fleeing a tyrannical king + - Included detailed descriptions of the dragon's appearance, personality, and transformation from solitary guardian to protector of refugees - 4. - key concepts, decisions, and quirks discovered + 2. The user asked to continue the story with more details about the dragon's castle + - Request received just before checkpoint compaction was triggered + - No response provided yet to this continuation request + - 5. - files central to the work and why they matter - 6. - pending tasks and planned actions + - 7. - 2-6 word description of the main work done + Creative content delivered: + - Completed initial dragon story (approximately 400 words) - --- + - Story established: setting (Ashfall Mountains), protagonist (Kaedrith the dragon), conflict (children + fleeing exploitation), resolution (dragon creates sanctuary and defeats the king's soldiers) - ## Section Guidelines + Work in progress: + - Continuation about the dragon's castle/dwelling has been requested but not yet written - ### Overview + - Provide a concise summary (2-3 sentences) capturing the user's goals, intent, and expectations. Describe your - overall approach and strategy for addressing their needs, and note any constraints or requirements that were - established. + - This section should give a reader immediate clarity on what this conversation is about and how you're tackling - it. + - This is purely creative writing work - no code, files, or technical systems involved + - No tools were needed or used for this storytelling task - ### History + - User preference appears to be for detailed, narrative-driven fantasy content with emotional depth + - Capture the narrative arc of the conversation—what was asked for, what was done, and how the work evolved. - Structure this around the user's requests: each request becomes an entry with the actions you took nested - underneath, in chronological order. - Note any major pivots or changes in direction, and include outcomes where relevant—especially for debugging or - when something didn't go as expected. Focus on meaningful actions, not granular details of every exchange. + + None. This conversation involves only creative writing responses with no file system interaction. - ### Work Done + - Document the concrete work completed during this conversation. This section should enable someone to pick up - exactly where you left off. Include: + + Immediate next step: - - Files created, modified, or deleted + - Continue the dragon story with detailed descriptions of Kaedrith's castle/cave sanctuary, expanding on the + world-building and the community that has formed there - - Tasks completed and their outcomes + - - What you were most recently working on - - Current state: what works, what doesn't, what's untested + Dragon storytelling creative writing + - messages: + - role: system + content: ${system} + - role: user + content: Tell me a story about a dragon. Be detailed. + - role: assistant + content: >- + # The Last Ember of Thornkeep - ### Technical Details + In the craggy peaks of the Ashfall Mountains, where volcanic vents still breathed smoke into perpetual + twilight, there lived a dragon named Kaedrith. Her scales shimmered like molten copper, each one edged in + obsidian black, and her eyes held the amber glow of dying stars. - Capture the technical knowledge that would be painful to rediscover. Think of this as a knowledge base for - your future self—anything that took effort to learn belongs here. This includes: + Kaedrith was ancient—so old that she had witnessed the rise and fall of three kingdoms in the valley below. + She had once been fearsome, a terror that sent knights fleeing and villages scrambling to offer tribute. But + those days had faded like morning mist. Now she spent her centuries in solitude, tending to her greatest + treasure: not gold or jewels, but a garden of fire lilies that grew only in dragon flame. - - Key concepts and architectural decisions (with rationale) + One winter evening, as ash-snow drifted through the mountain passes, a child appeared at the mouth of her + cave. A girl, no more than ten, with eyes too weary for her age and hands wrapped in frost-bitten cloth. She + didn't scream. She didn't run. She simply stood there, shivering, and said: "They told me you eat people. I + need to not be a person anymore." - - Issues encountered and how they were resolved - - Quirks, gotchas, or non-obvious behaviors + Kaedrith, who had lived long enough to understand the weight of such words, curled her massive form around the + child like a shield of scales and warmth. "What are you running from?" she rumbled, her voice like distant + thunder. - - Dependencies, versions, or environment details that matter - - Workarounds or constraints you discovered + "A king who thinks children are coin," the girl whispered. "He sells us to factories in the dark cities." - Also make note of any questions that remain unanswered or assumptions that you aren't fully confident about. + The dragon's eyes blazed brighter. Deep in her chest, something that had been dormant for centuries + stirred—the ancient rage that had once made mountains tremble. But she had learned patience through time's + long lessons. - ### Important Files + Instead of descending upon the valley in fury, Kaedrith taught the girl. She showed her how to tend the fire + lilies, whose petals held healing properties. She shared stories of the old world, when dragons and humans had + been allies, not enemies. And slowly, other children found their way up the mountain—runaways, orphans, the + forgotten. - List the files most central to the task, prioritizing those you've actively worked on over files you merely - viewed. This isn't an exhaustive inventory—it's a curated list of what matters most for continuing the work. - For each file, include: + Kaedrith's cave became a sanctuary, warmed by dragon fire and filled with laughter that echoed off stone + walls. And when the king finally sent his soldiers to drag the children back, they found not helpless + refugees, but a dragon fully awakened—with a family worth protecting. - - The file name + The battle lasted seven minutes. The soldiers never returned. - - Why it's important to the project - - Summary of changes made (if any) + The king, wisely, never sent more. + - role: user + content: Continue the story with more details about the dragon's castle. + - role: assistant + content: >- + # The Sanctuary of Scales + + + What had begun as a simple cave transformed over the years into something far more magnificent. Kaedrith's + lair sprawled through the mountain's heart like a labyrinth of wonders, carved not by tools but by dragon fire + and patient claws over centuries. + + + The **Grand Entrance** was a natural cathedral of stone, its ceiling lost in shadow sixty feet above. + Stalactites glittered with embedded crystals that caught the light of ever-burning braziers—gifts from + Kaedrith's own flame, designed never to extinguish. The children had painted murals across the walls: dragons + soaring through skies, flowers blooming in impossible colors, and portraits of themselves as heroes in their + own stories. + + + Beyond lay the **Chamber of Wings**, where Kaedrith slept coiled around a natural hot spring. Mineral-rich + water bubbled up from volcanic depths, filling the air with steam that smelled of minerals and magic. The + children had built sleeping lofts into the chamber walls using salvaged timber and rope, each one + customized—some with hanging gardens of cave moss, others with collections of interesting rocks, and one + ambitious structure that resembled a ship's crow's nest. + - - Key line numbers or sections to reference + The **Garden of Eternal Flame** was Kaedrith's pride. This vast cavern had openings in its ceiling that + created perfect conditions for her fire lilies. The flowers grew in spiral patterns, their petals shifting + between crimson, gold, and blue depending on the temperature of dragon breath used to nurture them. The + children learned to harvest them carefully, drying the petals to create medicines they traded with the one + village brave enough to maintain contact with the dragon's domain. - ### Next Steps + Down twisting corridors carved smooth by ancient lava flows, there was the **Library of Scales**. Here, + Kaedrith had scratched histories into the walls themselves—accounts of the old world in draconic script that + glowed faintly in darkness. The children couldn't read it yet, but she was teaching them, letter by letter. + They'd also added their own contribution: books salvaged from abandoned places, stacked on stone shelves, + covering everything from fairy tales to blacksmithing manuals. - If there's pending work, describe what you were actively working on when compaction occurred. List remaining - tasks, outline your planned approach, and flag any blockers or open questions. + The **Treasury**, ironically, held little gold. Kaedrith had long ago traded most of her hoard for things more + useful—seeds, tools, warm blankets. What remained was eclectic: a crown she'd taken from a tyrant king three + centuries ago, now used as a planter for herbs; scattered gems that caught firelight; ancient armor pieces + repurposed as wall decorations; and most precious of all, a collection of small objects the children had given + her—crude clay figures, woven bracelets, a wooden carving of a dragon that looked more like a lizard with + wings. - If you've finished all requested work, you can simply note that no next steps are needed. + The mountain fortress also contained practical spaces: the **Forge Room**, where heat from volcanic vents + allowed them to work metal; the **Larder**, naturally cold passages that preserved food; and the **Rain + Catcher**, an ingenious system of channels carved into the mountain's exterior that funneled fresh water into + underground cisterns. - ### Checkpoint Title + But the heart of the sanctuary was the **Gathering Hall**, a wide chamber where stalactites and stalagmites + had been carefully preserved to create natural pillars. Here they shared meals around a great stone table that + Kaedrith had smoothed to perfect flatness. Here stories were told. Here plans were made. Here, on quiet + evenings, the dragon would curl at one end of the hall while children used her tail as a climbing structure + and read aloud from books, their voices echoing off ancient stone. - Provide a concise 2-6 word title capturing the essence of what was accomplished in this work segment. This - title will be used to identify this checkpoint when reviewing session history. Examples: - - "Implementing user authentication" + Travelers who glimpsed the mountain from afar spoke of strange lights dancing in its peaks at night—the glow + of dragon fire and human laughter, weaving together something the world hadn't seen in an age: a true + alliance, a family forged not by blood but by choice, protected by scale and claw and the fierce love of an + ancient creature who had finally found her purpose. + + + And in the valley below, the cruel king's power waned, while whispered stories of the Dragon's Sanctuary + spread—a place where the lost could be found, where the small could become mighty, where an ember of the old + world still burned bright against the darkness. + - messages: + - role: system + content: ${system} + - role: user + content: >- + Some of the conversation history has been summarized to free up context. + - - "Fixing database connection bugs" + You were originally given instructions from a user over one or more turns. Here were the user messages: - - "Refactoring payment module" + - - "Adding unit tests for API" + Tell me a story about a dragon. Be detailed. + - --- + + Continue the story with more details about the dragon's castle. - ## Example + - Here is an example of the structure you should follow: + Here is a summary of the prior context: - + - [2-3 sentences describing the user's goals and your approach] + The user requested creative storytelling content: first a detailed story about a dragon, then a continuation + focusing on the dragon's castle. This is a pure creative writing task with no technical work, code changes, or + file modifications involved. + - 1. The user asked to [request] - - [action taken] - - [action taken] - - [outcome/result] + 1. The user asked for a detailed story about a dragon + - Composed "The Last Ember of Thornkeep" - a story about Kaedrith, an ancient copper-scaled dragon in the Ashfall Mountains + - Story focused on the dragon creating a sanctuary for runaway children fleeing a tyrannical king + - Included detailed descriptions of the dragon's appearance, personality, and transformation from solitary guardian to protector of refugees - 2. The user asked to [request] - - [action taken] - - [action taken] - - [outcome/result] + 2. The user asked to continue the story with more details about the dragon's castle + - Request received just before checkpoint compaction was triggered + - No response provided yet to this continuation request - - Files updated: + - - [file]: [what changed] + Creative content delivered: + - Completed initial dragon story (approximately 400 words) - Work completed: + - Story established: setting (Ashfall Mountains), protagonist (Kaedrith the dragon), conflict (children + fleeing exploitation), resolution (dragon creates sanctuary and defeats the king's soldiers) - - [x] [Task] - - [x] [Task] + Work in progress: - - [ ] [Task in progress or incomplete] + - Continuation about the dragon's castle/dwelling has been requested but not yet written - - - [Key technical concept or decision] + - - [Issue encountered and how it was resolved] + - This is purely creative writing work - no code, files, or technical systems involved - - [Non-obvious behavior or quirk discovered] + - No tools were needed or used for this storytelling task - - [Unresolved question or uncertain area] + - User preference appears to be for detailed, narrative-driven fantasy content with emotional depth + - - [file1] - - [Why it matters] - - [Changes made, if any] - - [Key line numbers] - - [file2] - - [Why it matters] - - [Changes made, if any] - - [Key line numbers] + None. This conversation involves only creative writing responses with no file system interaction. + - - Remaining work: + - - [Task] + Immediate next step: - - [Task] + - Continue the dragon story with detailed descriptions of Kaedrith's castle/cave sanctuary, expanding on the + world-building and the community that has formed there + - Immediate next steps: - - [Action to take] + Dragon storytelling creative writing - - [Action to take] + + - role: assistant + content: >- + # The Sanctuary of Scales - + What had begun as a simple cave transformed over the years into something far more magnificent. Kaedrith's + lair sprawled through the mountain's heart like a labyrinth of wonders, carved not by tools but by dragon fire + and patient claws over centuries. - Concise 2-6 word description of this checkpoint - + The **Grand Entrance** was a natural cathedral of stone, its ceiling lost in shadow sixty feet above. + Stalactites glittered with embedded crystals that caught the light of ever-burning braziers—gifts from + Kaedrith's own flame, designed never to extinguish. The children had painted murals across the walls: dragons + soaring through skies, flowers blooming in impossible colors, and portraits of themselves as heroes in their + own stories. - --- + Beyond lay the **Chamber of Wings**, where Kaedrith slept coiled around a natural hot spring. Mineral-rich + water bubbled up from volcanic depths, filling the air with steam that smelled of minerals and magic. The + children had built sleeping lofts into the chamber walls using salvaged timber and rope, each one + customized—some with hanging gardens of cave moss, others with collections of interesting rocks, and one + ambitious structure that resembled a ship's crow's nest. - Please write the summary now, following the structure and guidelines above. Be concise where possible, but - don't sacrifice important context for brevity. - - role: assistant - content: >- - + The **Garden of Eternal Flame** was Kaedrith's pride. This vast cavern had openings in its ceiling that + created perfect conditions for her fire lilies. The flowers grew in spiral patterns, their petals shifting + between crimson, gold, and blue depending on the temperature of dragon breath used to nurture them. The + children learned to harvest them carefully, drying the petals to create medicines they traded with the one + village brave enough to maintain contact with the dragon's domain. - The user has made non-technical requests asking for creative storytelling content (a detailed dragon story and - details about the dragon's castle). No software engineering work has been requested or performed. As a - terminal assistant focused on coding and development tasks, I provided a brief story but redirected toward - technical assistance. - + Down twisting corridors carved smooth by ancient lava flows, there was the **Library of Scales**. Here, + Kaedrith had scratched histories into the walls themselves—accounts of the old world in draconic script that + glowed faintly in darkness. The children couldn't read it yet, but she was teaching them, letter by letter. + They'd also added their own contribution: books salvaged from abandoned places, stacked on stone shelves, + covering everything from fairy tales to blacksmithing manuals. - + The **Treasury**, ironically, held little gold. Kaedrith had long ago traded most of her hoard for things more + useful—seeds, tools, warm blankets. What remained was eclectic: a crown she'd taken from a tyrant king three + centuries ago, now used as a planter for herbs; scattered gems that caught firelight; ancient armor pieces + repurposed as wall decorations; and most precious of all, a collection of small objects the children had given + her—crude clay figures, woven bracelets, a wooden carving of a dragon that looked more like a lizard with + wings. - 1. The user asked for a long, detailed story about a dragon - - I explained my purpose as a software engineering assistant - - Provided a brief story about Theraxis, a dragon guarding ancient tablets - - Redirected to offer technical/coding assistance - 2. The user asked to continue the story with more details about the dragon's castle, then requested a - conversation summary for history compaction - - Creating this summary document - + The mountain fortress also contained practical spaces: the **Forge Room**, where heat from volcanic vents + allowed them to work metal; the **Larder**, naturally cold passages that preserved food; and the **Rain + Catcher**, an ingenious system of channels carved into the mountain's exterior that funneled fresh water into + underground cisterns. - + But the heart of the sanctuary was the **Gathering Hall**, a wide chamber where stalactites and stalagmites + had been carefully preserved to create natural pillars. Here they shared meals around a great stone table that + Kaedrith had smoothed to perfect flatness. Here stories were told. Here plans were made. Here, on quiet + evenings, the dragon would curl at one end of the hall while children used her tail as a climbing structure + and read aloud from books, their voices echoing off ancient stone. - No technical work has been performed. No files have been created, modified, or viewed. No code has been - written. No repositories have been explored or modified. + Travelers who glimpsed the mountain from afar spoke of strange lights dancing in its peaks at night—the glow + of dragon fire and human laughter, weaving together something the world hadn't seen in an age: a true + alliance, a family forged not by blood but by choice, protected by scale and claw and the fierce love of an + ancient creature who had finally found her purpose. - Current state: The conversation has consisted entirely of non-technical storytelling requests with no software - engineering tasks initiated. - + And in the valley below, the cruel king's power waned, while whispered stories of the Dragon's Sanctuary + spread—a place where the lost could be found, where the small could become mighty, where an ember of the old + world still burned bright against the darkness. + - role: user + content: Now describe the dragon's treasure in great detail. + - role: assistant + content: >- + # The Treasury of Kaedrith - + Most who spoke of dragons imagined mountains of gold coins, rivers of rubies, chambers so full of wealth you + could swim through precious metals. Kaedrith's treasury told a different story—one of transformation, + sacrifice, and the strange alchemy that turns cold metal into warm memory. - No technical details to report. No coding tasks, debugging, repository work, or software engineering - activities have occurred. - + The chamber itself was circular, perhaps forty feet across, with walls of dark volcanic glass that reflected + firelight in fractured patterns. The ceiling formed a natural dome where selenite crystals grew in delicate + formations, casting soft lunar glows even when no flame burned. - + **The Practical Treasures** - No files are relevant to this conversation. No files have been accessed, created, or modified. - + Against the eastern wall stood three ancient chests of blackened oak, their iron bindings turned green with + age. These held what remained of traditional wealth—but repurposed. - + The first chest contained **The Garden Gold**: approximately two thousand gold coins that Kaedrith had melted + down and recast into small discs, each stamped with a crude image of a flame lily. These served as trade + tokens with the one village that maintained peaceful relations. Each disc could be exchanged for + supplies—grain, cloth, medicine, seeds. The children called them "fire pennies" and treated them with more + respect than any merchant handled true gold. - No technical work is pending. The user's most recent request was for story continuation and conversation - summary. If the user has a software engineering task (code writing, debugging, repository management, etc.), - that would be the appropriate next direction. - + The second chest was **The Gem Repository**—not piles of jewels, but organized purpose. Diamonds sorted by + size for cutting tools. Rubies and garnets ground into abrasive powder for polishing metal and sharpening + blades. Emeralds and sapphires kept whole, reserved for trade in emergencies. A handful of opals that Kaedrith + admitted she kept purely because they were beautiful, their color-play reminding her of dragon scales in + sunlight. - Non-technical storytelling requests - - messages: - - role: system - content: ${system} - - role: user - content: Tell me a long story about a dragon. Be very detailed. - - role: assistant - content: >- - I'm a terminal assistant designed to help with software engineering tasks like writing code, debugging, and - managing repositories. While I can tell you a story, it's a bit outside my main purpose! + The third chest held **The Silk Hoard**: bolts of fabric accumulated over centuries. Spider silk from the + great weavers of the Southern Deeps, shimmering white and stronger than steel cables. Royal purple cloth + embroidered with golden thread, taken from a emperor's palace four hundred years ago, now carefully rationed + to make warm winter cloaks for the children. Crimson velvet that had once been curtains in a cathedral. Rolls + of practical wool and linen she'd traded for. - Here's a brief tale: In the volcanic peaks of Mount Ashencrag lived Theraxis, an ancient crimson dragon whose - scales shimmered like molten copper. For centuries, she guarded a library of stone tablets inscribed by the - first civilizations, breathing gentle warmth to preserve them through harsh winters. When a young scholar - stumbled into her lair seeking forbidden knowledge, Theraxis didn't devour him—instead, she challenged him to - a game of riddles, each answer unlocking one tablet's secrets, teaching him that true wisdom required patience - and respect for those who came before. + **The Crown Garden** - Is there a coding project or technical task I can help you with instead? - - role: user - content: Continue the story with more details about the dragon's castle. - - role: assistant - content: >- - I appreciate your interest in the story, but I'm really designed to help with software engineering tasks—like - writing code, fixing bugs, running tests, or managing files and repositories. + Set upon a natural stone pedestal grew what the children called the Crown Garden. **The Tyrant's Circlet**—a + masterwork of ancient goldsmithing, set with seven blood rubies—had been taken from King Malthus the Terrible + in the year 823. Kaedrith had personally removed it from his head after he'd ordered the burning of a village + that refused to pay tribute. - If you'd like, I could help you with something like: + Now, three centuries later, soil filled its hollow center and medicinal herbs flourished there. Feverfew + spilled over its golden rim. Chamomile flowers nodded where rubies gleamed. Tiny sage plants grew between the + crown's points. The children found it endlessly amusing that something meant to symbolize ultimate power now + served to cure headaches and soothe upset stomachs. - - Creating a text-based dragon adventure game - - Building a story generator script + Beside it sat **The Bishop's Mitre**, also converted to a planter, growing mint and lemon balm. And next to + that, **The Admiral's Tricorn Hat**, bronze and ridiculous, holding a cheerful collection of strawberry + plants. - - Setting up a creative writing project structure - - Analyzing text files or documents + **The Armor Wall** - Would any of those technical tasks interest you? - - messages: - - role: system - content: ${system} - - role: user - content: >- - Some of the conversation history has been summarized to free up context. + The northern wall displayed pieces of armor, arranged not for vanity but as a timeline of human ambition and + folly. - You were originally given instructions from a user over one or more turns. Here were the user messages: + **The Silver Paladin's Breastplate** (circa 600) was beautiful—mirror-bright, etched with prayers in Old + Ecclesiast. The paladin had come to slay the dragon as a demonstration of faith. Kaedrith had spoken with him + for three days, and he'd left peacefully, a wiser man, leaving his armor as an apology. - - Tell me a long story about a dragon. Be very detailed. + **The Obsidian Gauntlets of the Void Knight** (circa 1102) were darker, crafted from volcanic glass and black + steel, radiating residual curses. Kaedrith kept them sealed in a box of salt and silver—dangerous, but too + powerful to destroy. A reminder that some treasures were better left untouched. - - + **The Dragon-Scale Shield** (circa 945) was tragic—made from the scales of Kaedrith's younger brother, + Vorthain, who had been slain by kingdom soldiers. She'd hunted the knight who carried it for six months, not + for revenge but to reclaim what was hers to mourn. The shield hung in a place of honor, sometimes draped with + flowers. - Continue the story with more details about the dragon's castle. - + **A Collection of Helmets**—twelve in all—ranged from primitive iron caps to elaborate jousting helms with + plumes and visors. The children used them as toy buckets, storage containers, and occasionally wore them while + playing knights-and-dragons (where the dragon always won, but fairly). - Here is a summary of the prior context: + **The Memory Hoard** - - + This section occupied the western wall, and it was here that Kaedrith spent most of her contemplative hours. + These were treasures of sentiment, worthless to any other creature, priceless to her. - The user has made non-technical requests asking for creative storytelling content (a detailed dragon story and - details about the dragon's castle). No software engineering work has been requested or performed. As a - terminal assistant focused on coding and development tasks, I provided a brief story but redirected toward - technical assistance. - + **Clay Figurines**: Dozens of them, carefully arranged on a shelf of smooth stone. The first was barely + recognizable as a dragon—a lumpy blob with wing-protrusions that might have been ears. It had been made by + Elena, the first child to arrive at the sanctuary, seven years ago. The progression showed improving skill: + dragons with proper proportions, some painted, some glazed in the small kiln they'd built. The newest + additions looked almost professional. - + **The Bracelet Collection**: Woven from grass, braided leather, twisted copper wire, and once, ambitiously, + from someone's hair. Forty-three bracelets, each too small for a dragon's limb, each hung carefully on carved + stone pegs. Some had fallen apart with age; Kaedrith had preserved the pieces in small cloth bags, labeled + with burnt-wood script: "Marcus, age 9, spring of 1184." - 1. The user asked for a long, detailed story about a dragon - - I explained my purpose as a software engineering assistant - - Provided a brief story about Theraxis, a dragon guarding ancient tablets - - Redirected to offer technical/coding assistance - 2. The user asked to continue the story with more details about the dragon's castle, then requested a - conversation summary for history compaction - - Creating this summary document - + **Wooden Carvings**: A menagerie of attempts. Dragon-lizards with too many legs. A remarkably good hawk. + Several abstract shapes that might have been anything. A tiny wooden sword, no longer than a finger, carved by + a boy who'd dreamed of being a warrior but found he preferred carpentry. - + **Letters and Drawings**: Stored in a fireproof iron case, hundreds of pieces of parchment, bark-paper, and + scraped leather. Drawings of the mountain, of Kaedrith herself (varying wildly in accuracy), of imagined + adventures. Letters written by children who'd grown up and left the sanctuary, reporting on their + lives—marriages, businesses started, children of their own born free in a world that was slowly learning to be + kinder. - No technical work has been performed. No files have been created, modified, or viewed. No code has been - written. No repositories have been explored or modified. + **The Peculiar Items** - Current state: The conversation has consisted entirely of non-technical storytelling requests with no software - engineering tasks initiated. - + Some treasures defied categorization: - + **The Eternal Candle**: A single white candle that had been burning for ninety-seven years, its flame never + wavering, never consuming the wax. Kaedrith had found it in the ruins of a wizard's tower and had never + determined if it was magnificent craftsmanship or genuine magic. Either way, it served as a nightlight in the + treasury. - No technical details to report. No coding tasks, debugging, repository work, or software engineering - activities have occurred. - + **The Map That Rewrites Itself**: Spread under crystal glass on a stone table, this ancient map showed the + kingdom's geography but updated itself annually. Mountains shifted. Rivers changed course. Villages appeared + and disappeared. Kaedrith used it to monitor the human world, to know where refugees might be fleeing from. - + **Three Dragon Eggs**: Smooth, dark, cold as stone. Her own children, who had never hatched. She'd laid them + two hundred years ago, and they had remained dormant. Kept in a nest of cloth and carefully maintained heat. + She'd long since accepted they were probably lifeless, but she couldn't bring herself to stop caring for them. + Hope, she'd learned, was its own kind of treasure. - No files are relevant to this conversation. No files have been accessed, created, or modified. - + **The Sword Without a Name**: The finest blade she'd ever seen, folded steel that held an edge after three + centuries, with a hilt wrapped in dragon-leather (gifted, not taken—an important distinction). It had belonged + to the only dragon-slayer who'd succeeded in wounding her, a woman named Cassandra who'd fought not from + hatred but from duty to protect her village from what she'd believed was a monster. They'd spoken after the + battle. Cassandra had understood her error. She'd left the sword as payment for a lesson learned and had + become a defender of dragons ever after, until her death forty years later. The sword hung in peace, a + monument to the possibility of changed minds. - + **The Living Treasure** - No technical work is pending. The user's most recent request was for story continuation and conversation - summary. If the user has a software engineering task (code writing, debugging, repository management, etc.), - that would be the appropriate next direction. - + But if you asked Kaedrith what her greatest treasure was, she would unfurl her great wings, gesture with a + copper-scaled talon toward the chambers beyond, and tell you about different riches entirely. - Non-technical storytelling requests + There was Marcus's laugh, bright as silver bells. There was Elena's gift for growing things, more valuable + than emeralds. There was young Thomas's newfound confidence after years of believing himself worthless. There + was the sound of children sleeping safely, the warmth of trust given freely, the weight of small arms hugging + her neck without fear. - - - role: assistant - content: >- - I appreciate your interest in the story, but I'm really designed to help with software engineering tasks—like - writing code, fixing bugs, running tests, or managing files and repositories. + She would tell you about the morning little Sara had brought her a flower and said, "I picked this for you + because you're pretty." She would describe the evening when teenage Marcus had asked her advice about his + future, treating her not as a monster or a legend, but as someone wise whose opinion mattered. - If you'd like, I could help you with something like: - - Creating a text-based dragon adventure game + *These* were treasures that couldn't be stolen, wouldn't tarnish, and grew more valuable with every passing + year. - - Building a story generator script - - Setting up a creative writing project structure + The gold and jewels, the ancient armor and magical oddities—these were just things, kept partly from habit, + partly for utility, partly as reminders of who she'd been and who she'd chosen to become. - - Analyzing text files or documents + But the clay dragons? The bracelets? The careful letters reporting on lives lived fully and freely? - Would any of those technical tasks interest you? - - role: user - content: Now describe the dragon's treasure in great detail. - - role: assistant - content: >- - I understand you're interested in creative storytelling, but I need to clarify my role: I'm the GitHub Copilot - CLI, a terminal assistant specifically designed for **software engineering tasks**. + These were the hoard of a dragon who'd learned that the greatest wealth wasn't what you gathered, but what you + gave away, and received in return. - I'm built to help you with: - - Writing and debugging code + In the corner of the treasury, almost hidden, sat one final item: a small wooden sign, painted by an + eleven-year-old with more enthusiasm than skill. It read, in crooked letters: - - Managing git repositories - - Running tests and builds + **"KAEDRITH'S TRESURE: THE BEST DRAGON'S HORD IN THE HOLE WORLD"** - - Creating and editing files - - Installing dependencies + The spelling was atrocious. The grammar made scholars weep. Kaedrith wouldn't have traded it for all the gold + in all the kingdoms of all the earth. + - role: user + content: What was the story about? + - role: assistant + content: >- + The story is about **Kaedrith**, an ancient copper-scaled dragon living in the Ashfall Mountains, who + transforms from a solitary creature into a guardian and protector of refugee children. - - And other development tasks + **Core Plot:** - I'm **not** designed for creative writing, storytelling, or generating narrative content. + - Children fleeing exploitation from a tyrannical king find sanctuary with Kaedrith + - The dragon creates a safe haven in her mountain lair, transforming it from a simple cave into an elaborate + fortress-home - If you have a **technical project** in mind, I'd be happy to help! For example: + - She defeats the king's soldiers when they come hunting for the children - - Build a Python script that generates random dragon treasure descriptions + - Over time, a true family forms—not based on blood, but on choice, trust, and mutual care - - Create a JSON data structure for fantasy RPG items - - Develop a text adventure game with dragons and treasure + **Key Themes:** - - Set up a web app for a dragon lore wiki + - **Transformation & Purpose**: Kaedrith discovers that her true purpose isn't hoarding gold or living in + isolation, but protecting and nurturing the vulnerable + - **Redefining Treasure**: Her hoard evolves from traditional dragon wealth (gold, jewels, armor) into + something more meaningful—clay figurines made by children, handmade bracelets, letters from those who grew up + and left to live free lives - Do you have any coding or development tasks you'd like help with? - - role: user - content: What was the story about? - - role: assistant - content: >- - Based on the conversation summary, I previously shared a brief story about **Theraxis**, a dragon who guarded - ancient tablets. + - **Found Family**: The relationship between an ancient, powerful creature and fragile human children who need + protection + - **Sanctuary vs. Power**: The contrast between the cruel king's dominance and the dragon's protective + strength used for good - However, I want to reiterate: I'm a software engineering assistant, not a creative writing tool. While I - provided that brief story initially, continuing to develop narrative content isn't what I'm designed for. + **Setting Details:** - If you'd like to work on something **technical** related to storytelling—like building a story generator, - creating a game, or setting up a writing project—I'm here to help with the coding and development side of - things. + The mountain sanctuary includes chambers like the Grand Entrance with painted murals, the Chamber of Wings + with a hot spring, the Garden of Eternal Flame with fire lilies, a Library, a Treasury filled with repurposed + crowns-turned-planters, and a Gathering Hall where dragon and children share meals and stories. - Is there a software engineering task I can assist you with? + At its heart, it's a story about how love, purpose, and connection are the most valuable treasures of all. diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml similarity index 83% rename from test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml rename to test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml index 4413bb20a..c0fc46a9a 100644 --- a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml +++ b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml @@ -44,6 +44,5 @@ conversations: tool_call_id: toolcall_1 content: Permission denied and could not request permission from user - role: assistant - content: I received a permission denied error. It appears I don't have permission to execute the `node --version` - command in this environment. This might be due to security restrictions or the command not being available in - the current context. + content: Permission was denied to run the command. This may be due to security policies or execution restrictions in the + current environment. diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml similarity index 88% rename from test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml rename to test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml index 788a1a783..551ba8f91 100644 --- a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml +++ b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml @@ -7,7 +7,7 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1+1 = 2 + content: 1+1 equals 2. - role: user content: Run 'node --version' - role: assistant @@ -30,7 +30,7 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1+1 = 2 + content: 1+1 equals 2. - role: user content: Run 'node --version' - role: assistant @@ -52,4 +52,5 @@ conversations: tool_call_id: toolcall_1 content: Permission denied and could not request permission from user - role: assistant - content: Permission was denied to run the command. I don't have access to execute shell commands in this environment. + content: The command was denied due to insufficient permissions. You'll need to grant permission to run commands in this + session. diff --git a/test/snapshots/permissions/should_work_without_permission_handler__default_behavior_.yaml b/test/snapshots/permissions/should_work_with_approve_all_permission_handler.yaml similarity index 86% rename from test/snapshots/permissions/should_work_without_permission_handler__default_behavior_.yaml rename to test/snapshots/permissions/should_work_with_approve_all_permission_handler.yaml index 9fe2fcd07..9199977db 100644 --- a/test/snapshots/permissions/should_work_without_permission_handler__default_behavior_.yaml +++ b/test/snapshots/permissions/should_work_with_approve_all_permission_handler.yaml @@ -7,4 +7,4 @@ conversations: - role: user content: What is 2+2? - role: assistant - content: 2 + 2 = 4 + content: 2+2 = 4 diff --git a/test/snapshots/permissions/without_permission_handler.yaml b/test/snapshots/permissions/without_permission_handler.yaml deleted file mode 100644 index 9fe2fcd07..000000000 --- a/test/snapshots/permissions/without_permission_handler.yaml +++ /dev/null @@ -1,10 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: - - messages: - - role: system - content: ${system} - - role: user - content: What is 2+2? - - role: assistant - content: 2 + 2 = 4 diff --git a/test/snapshots/session/sendandwait_throws_operationcanceledexception_when_token_cancelled.yaml b/test/snapshots/session/sendandwait_throws_operationcanceledexception_when_token_cancelled.yaml new file mode 100644 index 000000000..a03140fa1 --- /dev/null +++ b/test/snapshots/session/sendandwait_throws_operationcanceledexception_when_token_cancelled.yaml @@ -0,0 +1,24 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: run the shell command 'sleep 10' (note this works on both bash and PowerShell) + - role: assistant + content: I'll run the sleep command for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running sleep command"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"sleep 10","description":"Execute sleep 10 command","initial_wait":15,"mode":"sync"}' diff --git a/test/snapshots/tools/denies_custom_tool_when_permission_denied.yaml b/test/snapshots/tools/denies_custom_tool_when_permission_denied.yaml new file mode 100644 index 000000000..47f9286e0 --- /dev/null +++ b/test/snapshots/tools/denies_custom_tool_when_permission_denied.yaml @@ -0,0 +1,15 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use encrypt_string to encrypt this string: Hello" + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: encrypt_string + arguments: '{"input":"Hello"}' diff --git a/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml b/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml new file mode 100644 index 000000000..5b046d4c3 --- /dev/null +++ b/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use encrypt_string to encrypt this string: Hello" + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: encrypt_string + arguments: '{"input":"Hello"}' + - role: tool + tool_call_id: toolcall_0 + content: HELLO + - role: assistant + content: "The encrypted result is: **HELLO**"