From dea514d971f40aa930492e34b690002364481a41 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 23 Feb 2026 02:59:06 -0800 Subject: [PATCH 1/8] Fix timeout issue when downloading the exe file (#533) --- dotnet/src/build/GitHub.Copilot.SDK.targets | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 @@ From eaf06cdbb16126fa86bb662a08fe16decfd8831a Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Mon, 23 Feb 2026 12:07:55 +0100 Subject: [PATCH 2/8] fix: replace execSync with execFileSync in docs validation (#505) * fix: replace execSync with execFileSync in docs validation Replace all execSync calls with execFileSync to avoid shell interpretation of interpolated paths. This resolves a CodeQL alert about shell command injection via uncontrolled absolute paths (scripts/docs-validation/validate.ts:79). execFileSync passes arguments as an array, bypassing the shell entirely, which eliminates the risk of path characters being misinterpreted. Also updated catch blocks to check err.stderr in addition to err.stdout, since 2>&1 shell redirection is no longer used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove unused spawn import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/docs-validation/validate.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) 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, }); From 0e6561df1a0716bd0dcd2f1b37a5de9e3ea0483c Mon Sep 17 00:00:00 2001 From: Lee Stott Date: Mon, 23 Feb 2026 11:37:02 +0000 Subject: [PATCH 3/8] Document Microsoft Foundry Local setup and usage (#461) * Document Microsoft Foundry Local setup and usage Added section for Microsoft Foundry Local with installation and usage instructions. * Update docs/auth/byok.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/auth/byok.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) 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 From 99c7c52cebdfe3385d7d10a9c404363ae9dd51b9 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Feb 2026 12:22:11 +0000 Subject: [PATCH 4/8] fix: correct 'GithubToken' casing to 'GitHubToken' in .NET and Go (#541) --- dotnet/README.md | 4 ++-- dotnet/src/Client.cs | 14 ++++++------ dotnet/src/Types.cs | 18 +++++++++++---- dotnet/test/ClientTests.cs | 14 ++++++------ dotnet/test/CloneTests.cs | 4 ++-- dotnet/test/Harness/E2ETestContext.cs | 2 +- go/README.md | 4 ++-- go/client.go | 18 +++++++-------- go/client_test.go | 22 +++++++++---------- go/internal/e2e/testharness/context.go | 2 +- go/types.go | 8 +++---- python/copilot/session.py | 6 ++++- test/scenarios/auth/gh-app/csharp/Program.cs | 2 +- test/scenarios/auth/gh-app/go/main.go | 2 +- .../bundling/fully-bundled/csharp/Program.cs | 2 +- .../bundling/fully-bundled/go/main.go | 2 +- .../callbacks/hooks/csharp/Program.cs | 2 +- test/scenarios/callbacks/hooks/go/main.go | 2 +- .../callbacks/permissions/csharp/Program.cs | 2 +- .../callbacks/permissions/go/main.go | 2 +- .../callbacks/user-input/csharp/Program.cs | 2 +- .../scenarios/callbacks/user-input/go/main.go | 2 +- .../scenarios/modes/default/csharp/Program.cs | 2 +- test/scenarios/modes/default/go/main.go | 2 +- .../scenarios/modes/minimal/csharp/Program.cs | 2 +- test/scenarios/modes/minimal/go/main.go | 2 +- .../prompts/attachments/csharp/Program.cs | 2 +- test/scenarios/prompts/attachments/go/main.go | 2 +- .../reasoning-effort/csharp/Program.cs | 2 +- .../prompts/reasoning-effort/go/main.go | 2 +- .../prompts/system-message/csharp/Program.cs | 2 +- .../prompts/system-message/go/main.go | 2 +- .../concurrent-sessions/csharp/Program.cs | 2 +- .../sessions/concurrent-sessions/go/main.go | 2 +- .../infinite-sessions/csharp/Program.cs | 2 +- .../sessions/infinite-sessions/go/main.go | 2 +- .../sessions/session-resume/csharp/Program.cs | 2 +- .../sessions/session-resume/go/main.go | 2 +- .../sessions/streaming/csharp/Program.cs | 2 +- test/scenarios/sessions/streaming/go/main.go | 2 +- .../tools/custom-agents/csharp/Program.cs | 2 +- test/scenarios/tools/custom-agents/go/main.go | 2 +- .../tools/mcp-servers/csharp/Program.cs | 2 +- test/scenarios/tools/mcp-servers/go/main.go | 2 +- .../tools/no-tools/csharp/Program.cs | 2 +- test/scenarios/tools/no-tools/go/main.go | 2 +- test/scenarios/tools/skills/csharp/Program.cs | 2 +- test/scenarios/tools/skills/go/main.go | 2 +- .../tools/tool-filtering/csharp/Program.cs | 2 +- .../scenarios/tools/tool-filtering/go/main.go | 2 +- .../virtual-filesystem/csharp/Program.cs | 2 +- .../tools/virtual-filesystem/go/main.go | 2 +- .../transport/stdio/csharp/Program.cs | 2 +- test/scenarios/transport/stdio/go/main.go | 2 +- 54 files changed, 107 insertions(+), 93 deletions(-) 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..cf29dd116 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -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; @@ -944,13 +944,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 +982,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/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/test/ClientTests.cs b/dotnet/test/ClientTests.cs index ee5b73bc7..bbb3e8544 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -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" }); }); } 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/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/go/README.md b/go/README.md index 37cb7ce07..c528e945c 100644 --- a/go/README.md +++ b/go/README.md @@ -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..82c095274 100644 --- a/go/client.go +++ b/go/client.go @@ -134,8 +134,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 +177,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 @@ -1040,14 +1040,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 +1074,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..d21cc0185 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -255,17 +255,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 +283,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 +294,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 +308,7 @@ func TestClient_AuthOptions(t *testing.T) { NewClient(&ClientOptions{ CLIUrl: "localhost:8080", - GithubToken: "gho_test_token", + GitHubToken: "gho_test_token", }) }) @@ -317,7 +317,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) } 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/types.go b/go/types.go index 6abbf4a12..f3f299ed5 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 } 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/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..adb7b1f12 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(); diff --git a/test/scenarios/sessions/session-resume/go/main.go b/test/scenarios/sessions/session-resume/go/main.go index cf2cb0448..796694ec4 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() 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() From 5a9b47560f2babcc4f3b722d06d842b50fea7373 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Mon, 23 Feb 2026 14:09:22 +0000 Subject: [PATCH 5/8] chore(nodejs): bump @github/copilot from ^0.0.411 to ^0.0.414 (#542) Update the Copilot CLI dependency to the latest version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/package-lock.json | 56 ++++++++++++++++++++-------------------- nodejs/package.json | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3cba7c816..91fc00d14 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.414", "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.414", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.414.tgz", + "integrity": "sha512-jseJ2S02CLWrFks5QK22zzq7as2ErY5m1wMCFBOE6sro1uACq1kvqqM1LwM4qy58YSZFrM1ZAn1s7UOVd9zhIA==", "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.414", + "@github/copilot-darwin-x64": "0.0.414", + "@github/copilot-linux-arm64": "0.0.414", + "@github/copilot-linux-x64": "0.0.414", + "@github/copilot-win32-arm64": "0.0.414", + "@github/copilot-win32-x64": "0.0.414" } }, "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.414", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.414.tgz", + "integrity": "sha512-PW4v89v41i4Mg/NYl4+gEhwnDaVz+olNL+RbqtiQI3IV89gZdS+RZQbUEJfOwMaFcT2GfiUK1OuB+YDv5GrkBg==", "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.414", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.414.tgz", + "integrity": "sha512-NyPYm0NovQTwtuI42WJIi4cjYd2z0wBHEvWlUSczRsSuYEyImAClmZmBPegUU63e5JdZd1PdQkQ7FqrrfL2fZQ==", "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.414", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.414.tgz", + "integrity": "sha512-VgdRsvA1FiZ1lcU/AscSvyJWEUWZzoXv2tSZ6WV3NE0uUTuO1Qoq4xuqbKZ/+vKJmn1b8afe7sxAAOtCoWPBHQ==", "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.414", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.414.tgz", + "integrity": "sha512-3HyZsbZqYTF5jcT7/e+nDIYBCQXo8UCVWjBI3raOE4lzAw9b2ucL290IhtA23s1+EiquMxJ4m3FnjwFmwlQ12A==", "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.414", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.414.tgz", + "integrity": "sha512-8gdaoF4MPpeV0h8UnCZ8TKI5l274EP0fvAaV9BGjsdyEydDcEb+DHqQiXgitWVKKiHAAaPi12aH8P5OsEDUneQ==", "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.414", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.414.tgz", + "integrity": "sha512-E1Oq1jXHaL1oWNsmmiCd4G30/CfgVdswg/T5oDFUxx3Ri+6uBekciIzdyCDelsP1kn2+fC1EYz2AerQ6F+huzg==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index a0c85478b..34ca78f2a 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.414", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, From f0909a78ce6c242f1f07e91b72ab7a7d8c910531 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:06:53 +0000 Subject: [PATCH 6/8] [dotnet] Fix SendAndWaitAsync to throw OperationCanceledException on external cancellation (#543) --- dotnet/src/Session.cs | 8 ++++++- dotnet/test/SessionTests.cs | 24 +++++++++++++++++++ ...anceledexception_when_token_cancelled.yaml | 24 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/snapshots/session/sendandwait_throws_operationcanceledexception_when_token_cancelled.yaml diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 4feeb9f95..45f093b10 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -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; } diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index c9a152ce9..7b7dcafd7 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -403,6 +403,30 @@ 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 Client.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() { 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"}' From 279f6c4dc51473322c070bf09562ad53b4da1dfe Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 24 Feb 2026 14:41:30 +0000 Subject: [PATCH 7/8] Require permission handler on session creation (#554) * Improve discoverability of permission handler being required * Update TypeScript tests * Formatting * Test updates * Formatting * More doc updates * Fix E2E tests: add permission handler to all session calls across Python, Go, C# - Add on_permission_request/OnPermissionRequest to all Python and Go E2E test create_session/resume_session calls - Fix pre-existing deny tests: restore 'denied-interactively-by-user' kind (was accidentally changed by blanket replace) - Fix session-resume scenario builds for Go and C# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix docs validation: add permission handler to getting-started.md examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix remaining E2E tests missing permission handler - Go: client_test.go CreateSession calls need OnPermissionRequest - Python: test_client.py create_session call needs config arg Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/getting-started.md | 17 +- dotnet/src/Client.cs | 138 ++++++------ dotnet/src/Session.cs | 6 +- dotnet/test/AskUserTests.cs | 6 +- dotnet/test/ClientTests.cs | 34 ++- dotnet/test/CompactionTests.cs | 4 +- dotnet/test/Harness/E2ETestBase.cs | 22 ++ dotnet/test/HooksTests.cs | 8 +- dotnet/test/McpAndAgentsTests.cs | 24 +- dotnet/test/PermissionTests.cs | 37 ++-- dotnet/test/RpcTests.cs | 10 +- dotnet/test/SessionTests.cs | 44 ++-- dotnet/test/SkillsTests.cs | 8 +- dotnet/test/ToolsTests.cs | 10 +- go/README.md | 2 +- go/client.go | 206 +++++++++--------- go/client_test.go | 44 +++- go/internal/e2e/ask_user_test.go | 3 + go/internal/e2e/client_test.go | 8 +- go/internal/e2e/compaction_test.go | 2 + go/internal/e2e/mcp_and_agents_test.go | 33 ++- go/internal/e2e/permissions_test.go | 22 +- go/internal/e2e/rpc_test.go | 12 +- go/internal/e2e/session_test.go | 53 +++-- go/internal/e2e/skills_test.go | 13 +- go/internal/e2e/tools_test.go | 3 + nodejs/samples/package-lock.json | 2 +- nodejs/src/client.ts | 39 ++-- nodejs/src/types.ts | 2 +- nodejs/test/client.test.ts | 34 ++- nodejs/test/e2e/ask_user.test.ts | 4 + nodejs/test/e2e/client.test.ts | 10 +- nodejs/test/e2e/compaction.test.ts | 4 +- nodejs/test/e2e/hooks.test.ts | 1 + nodejs/test/e2e/mcp_and_agents.test.ts | 13 +- nodejs/test/e2e/permissions.test.ts | 25 ++- nodejs/test/e2e/rpc.test.ts | 18 +- nodejs/test/e2e/session.test.ts | 48 ++-- nodejs/test/e2e/skills.test.ts | 6 +- nodejs/test/e2e/tools.test.ts | 3 + python/copilot/client.py | 43 ++-- python/e2e/test_ask_user.py | 23 +- python/e2e/test_client.py | 10 +- python/e2e/test_compaction.py | 11 +- python/e2e/test_mcp_and_agents.py | 35 ++- python/e2e/test_permissions.py | 34 ++- python/e2e/test_rpc.py | 22 +- python/e2e/test_session.py | 106 ++++++--- python/e2e/test_skills.py | 27 ++- python/e2e/test_tools.py | 12 +- python/test_client.py | 44 +++- .../sessions/session-resume/csharp/Program.cs | 6 +- .../sessions/session-resume/go/main.go | 9 +- ...tions_when_handler_explicitly_denies.yaml} | 5 +- ...ndler_explicitly_denies_after_resume.yaml} | 7 +- ..._with_approve_all_permission_handler.yaml} | 2 +- .../without_permission_handler.yaml | 10 - 57 files changed, 914 insertions(+), 470 deletions(-) rename test/snapshots/permissions/{should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml => should_deny_tool_operations_when_handler_explicitly_denies.yaml} (83%) rename test/snapshots/permissions/{should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml => should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml} (88%) rename test/snapshots/permissions/{should_work_without_permission_handler__default_behavior_.yaml => should_work_with_approve_all_permission_handler.yaml} (86%) delete mode 100644 test/snapshots/permissions/without_permission_handler.yaml 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/dotnet/src/Client.cs b/dotnet/src/Client.cs index cf29dd116..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 => @@ -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 }); /// } /// /// diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 45f093b10..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 => @@ -557,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/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 bbb3e8544..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); @@ -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/CompactionTests.cs b/dotnet/test/CompactionTests.cs index af76508c7..91551e550 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 { @@ -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/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/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 7b7dcafd7..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(() => @@ -406,7 +406,7 @@ public async Task SendAndWait_Throws_On_Timeout() [Fact] public async Task SendAndWait_Throws_OperationCanceledException_When_Token_Cancelled() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Set up wait for tool execution to start BEFORE sending var toolStartTask = TestHelper.GetNextEventOfTypeAsync(session); @@ -431,7 +431,7 @@ public async Task SendAndWait_Throws_OperationCanceledException_When_Token_Cance 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..942a09a09 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -21,7 +21,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,7 +39,7 @@ 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")], }); @@ -64,7 +64,7 @@ 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] }); @@ -105,7 +105,7 @@ 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)], }); @@ -151,7 +151,7 @@ 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")], }); diff --git a/go/README.md b/go/README.md index c528e945c..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 diff --git a/go/client.go b/go/client.go index 82c095274..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 { @@ -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 diff --git a/go/client_test.go b/go/client_test.go index d21cc0185..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) } @@ -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/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..239e1e128 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, @@ -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/tools_test.go b/go/internal/e2e/tools_test.go index d54bdcb14..b38e41a60 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -55,6 +55,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 +88,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 +191,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) { 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 7df64e507..6d841c7cc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -91,7 +91,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) => { @@ -494,10 +494,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", @@ -508,7 +509,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(); @@ -551,9 +558,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); } @@ -580,18 +585,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(); @@ -635,9 +644,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); } @@ -657,7 +664,7 @@ export class CopilotClient { * @example * ```typescript * if (client.getState() === "connected") { - * const session = await client.createSession(); + * const session = await client.createSession({ onPermissionRequest: approveAll }); * } * ``` */ @@ -802,7 +809,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/types.ts b/nodejs/src/types.ts index 79692b782..c016edff2 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -675,7 +675,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 5d1ed8ac3..6fa33e9ec 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/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..e9ea287d3 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) @@ -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..a6ad0c049 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -26,6 +26,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", @@ -45,6 +46,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", @@ -85,6 +87,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", 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/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..dc95b6855 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, } ) @@ -71,7 +73,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..485998e00 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( 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/test/scenarios/sessions/session-resume/csharp/Program.cs b/test/scenarios/sessions/session-resume/csharp/Program.cs index adb7b1f12..73979669d 100644 --- a/test/scenarios/sessions/session-resume/csharp/Program.cs +++ b/test/scenarios/sessions/session-resume/csharp/Program.cs @@ -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 796694ec4..6ec4bb02d 100644 --- a/test/scenarios/sessions/session-resume/go/main.go +++ b/test/scenarios/sessions/session-resume/go/main.go @@ -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/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 From bce4de0aff74ffdf165dc2a6c997fd7d0025b779 Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Tue, 24 Feb 2026 09:34:39 -0800 Subject: [PATCH 8/8] docs: fix inaccuracies in skills.md (#556) Replace fabricated skill.json references with actual SKILL.md format used by the SDK. The previous doc described a skill.json manifest, prompts/ directory, and tools/ directory that don't exist in the implementation. Changes: - Replace skill.json with SKILL.md format (YAML frontmatter + markdown) - Fix directory structure to match actual layout (skill-name/SKILL.md) - Add onPermissionRequest to all code examples (required by SDK) - Remove fabricated prompts/ and tools/ directory concepts - Fix troubleshooting and best practices to reference SKILL.md - Remove speculative skill conflict precedence claims Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/guides/skills.md | 75 ++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 40 deletions(-) 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