diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 406a961a2..3c1035e20 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -219,6 +219,30 @@ public class AccountGetQuotaResult public Dictionary QuotaSnapshots { get => field ??= []; set; } } +/// RPC data type for SessionFsSetProvider operations. +public class SessionFsSetProviderResult +{ + /// Whether the provider was set successfully. + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +/// RPC data type for SessionFsSetProvider operations. +internal class SessionFsSetProviderRequest +{ + /// Initial working directory for sessions. + [JsonPropertyName("initialCwd")] + public string InitialCwd { get; set; } = string.Empty; + + /// Path within each session's SessionFs where the runtime stores files for that session. + [JsonPropertyName("sessionStatePath")] + public string SessionStatePath { get; set; } = string.Empty; + + /// Path conventions used by this filesystem. + [JsonPropertyName("conventions")] + public SessionFsSetProviderRequestConventions Conventions { get; set; } +} + /// RPC data type for SessionLog operations. public class SessionLogResult { @@ -705,7 +729,7 @@ public class Server [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; - /// Connection status: connected, failed, pending, disabled, or not_configured. + /// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonPropertyName("status")] public ServerStatus Status { get; set; } @@ -1156,6 +1180,19 @@ internal class SessionShellKillRequest public SessionShellKillRequestSignal? Signal { get; set; } } +/// Path conventions used by this filesystem. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionFsSetProviderRequestConventions +{ + /// The windows variant. + [JsonStringEnumMemberName("windows")] + Windows, + /// The posix variant. + [JsonStringEnumMemberName("posix")] + Posix, +} + + /// Log severity level. Determines how the message is displayed in the timeline. Defaults to "info". [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionLogRequestLevel @@ -1188,7 +1225,7 @@ public enum SessionModeGetResultMode } -/// Connection status: connected, failed, pending, disabled, or not_configured. +/// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonConverter(typeof(JsonStringEnumConverter))] public enum ServerStatus { @@ -1198,6 +1235,9 @@ public enum ServerStatus /// The failed variant. [JsonStringEnumMemberName("failed")] Failed, + /// The needs-auth variant. + [JsonStringEnumMemberName("needs-auth")] + NeedsAuth, /// The pending variant. [JsonStringEnumMemberName("pending")] Pending, @@ -1285,6 +1325,8 @@ internal ServerRpc(JsonRpc rpc) Models = new ServerModelsApi(rpc); Tools = new ServerToolsApi(rpc); Account = new ServerAccountApi(rpc); + Mcp = new ServerMcpApi(rpc); + SessionFs = new ServerSessionFsApi(rpc); } /// Calls "ping". @@ -1302,6 +1344,12 @@ public async Task PingAsync(string? message = null, CancellationToke /// Account APIs. public ServerAccountApi Account { get; } + + /// Mcp APIs. + public ServerMcpApi Mcp { get; } + + /// SessionFs APIs. + public ServerSessionFsApi SessionFs { get; } } /// Provides server-scoped Models APIs. @@ -1356,6 +1404,35 @@ public async Task GetQuotaAsync(CancellationToken cancell } } +/// Provides server-scoped Mcp APIs. +public class ServerMcpApi +{ + private readonly JsonRpc _rpc; + + internal ServerMcpApi(JsonRpc rpc) + { + _rpc = rpc; + } +} + +/// Provides server-scoped SessionFs APIs. +public class ServerSessionFsApi +{ + private readonly JsonRpc _rpc; + + internal ServerSessionFsApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "sessionFs.setProvider". + public async Task SetProviderAsync(string initialCwd, string sessionStatePath, SessionFsSetProviderRequestConventions conventions, CancellationToken cancellationToken = default) + { + var request = new SessionFsSetProviderRequest { InitialCwd = initialCwd, SessionStatePath = sessionStatePath, Conventions = conventions }; + return await CopilotClient.InvokeRpcAsync(_rpc, "sessionFs.setProvider", [request], cancellationToken); + } +} + /// Provides typed session-scoped RPC methods. public class SessionRpc { @@ -1959,6 +2036,8 @@ public async Task KillAsync(string processId, SessionShe [JsonSerializable(typeof(SessionExtensionsReloadResult))] [JsonSerializable(typeof(SessionFleetStartRequest))] [JsonSerializable(typeof(SessionFleetStartResult))] +[JsonSerializable(typeof(SessionFsSetProviderRequest))] +[JsonSerializable(typeof(SessionFsSetProviderResult))] [JsonSerializable(typeof(SessionLogRequest))] [JsonSerializable(typeof(SessionLogResult))] [JsonSerializable(typeof(SessionMcpDisableRequest))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 6da3de682..c01d1ddcd 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -1216,6 +1216,11 @@ public partial class SessionIdleData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("backgroundTasks")] public SessionIdleDataBackgroundTasks? BackgroundTasks { get; set; } + + /// True when the preceding agentic loop was cancelled via abort signal. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("aborted")] + public bool? Aborted { get; set; } } /// Session title change payload containing the new display title. @@ -2593,7 +2598,7 @@ public partial class SessionMcpServerStatusChangedData [JsonPropertyName("serverName")] public required string ServerName { get; set; } - /// New connection status: connected, failed, pending, disabled, or not_configured. + /// New connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonPropertyName("status")] public required SessionMcpServersLoadedDataServersItemStatus Status { get; set; } } @@ -3786,7 +3791,7 @@ public partial class SessionMcpServersLoadedDataServersItem [JsonPropertyName("name")] public required string Name { get; set; } - /// Connection status: connected, failed, pending, disabled, or not_configured. + /// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonPropertyName("status")] public required SessionMcpServersLoadedDataServersItemStatus Status { get; set; } @@ -3998,7 +4003,7 @@ public enum ElicitationRequestedDataMode Url, } -/// Connection status: connected, failed, pending, disabled, or not_configured. +/// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured. [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionMcpServersLoadedDataServersItemStatus { @@ -4008,6 +4013,9 @@ public enum SessionMcpServersLoadedDataServersItemStatus /// The failed variant. [JsonStringEnumMemberName("failed")] Failed, + /// The needs-auth variant. + [JsonStringEnumMemberName("needs-auth")] + NeedsAuth, /// The pending variant. [JsonStringEnumMemberName("pending")] Pending, diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index a37ef3c15..21479a376 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -46,7 +46,8 @@ await session.SendAsync(new MessageOptions Assert.Contains(preToolUseInputs, i => !string.IsNullOrEmpty(i.ToolName)); } - [Fact] + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + [Fact(Skip = "Runtime postToolUse hooks broken")] public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() { var postToolUseInputs = new List(); @@ -83,7 +84,8 @@ await session.SendAsync(new MessageOptions Assert.Contains(postToolUseInputs, i => i.ToolResult != null); } - [Fact] + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + [Fact(Skip = "Runtime postToolUse hooks broken")] public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single_Tool_Call() { var preToolUseInputs = new List(); diff --git a/go/generated_session_events.go b/go/generated_session_events.go index 8eafb13d0..4799aca91 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -385,6 +385,8 @@ type Data struct { // // URL to open in the user's browser (url mode only) URL *string `json:"url,omitempty"` + // True when the preceding agentic loop was cancelled via abort signal + Aborted *bool `json:"aborted,omitempty"` // Background tasks still running when the agent became idle BackgroundTasks *BackgroundTasks `json:"backgroundTasks,omitempty"` // The new display title for the session @@ -856,7 +858,7 @@ type Data struct { Warnings []string `json:"warnings,omitempty"` // Array of MCP server status summaries Servers []Server `json:"servers,omitempty"` - // New connection status: connected, failed, pending, disabled, or not_configured + // New connection status: connected, failed, needs-auth, pending, disabled, or not_configured Status *ServerStatus `json:"status,omitempty"` // Array of discovered extensions and their status Extensions []Extension `json:"extensions,omitempty"` @@ -1368,7 +1370,7 @@ type Server struct { Name string `json:"name"` // Configuration source: user, workspace, plugin, or builtin Source *string `json:"source,omitempty"` - // Connection status: connected, failed, pending, disabled, or not_configured + // Connection status: connected, failed, needs-auth, pending, disabled, or not_configured Status ServerStatus `json:"status"` } @@ -1567,14 +1569,15 @@ const ( RoleSystem Role = "system" ) -// Connection status: connected, failed, pending, disabled, or not_configured +// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured // -// New connection status: connected, failed, pending, disabled, or not_configured +// New connection status: connected, failed, needs-auth, pending, disabled, or not_configured type ServerStatus string const ( ServerStatusConnected ServerStatus = "connected" ServerStatusDisabled ServerStatus = "disabled" + ServerStatusNeedsAuth ServerStatus = "needs-auth" ServerStatusNotConfigured ServerStatus = "not_configured" ServerStatusPending ServerStatus = "pending" ServerStatusFailed ServerStatus = "failed" diff --git a/go/internal/e2e/hooks_test.go b/go/internal/e2e/hooks_test.go index 70aa6ec71..2b8a63921 100644 --- a/go/internal/e2e/hooks_test.go +++ b/go/internal/e2e/hooks_test.go @@ -74,7 +74,9 @@ func TestHooks(t *testing.T) { } }) + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) t.Run("should invoke postToolUse hook after model runs a tool", func(t *testing.T) { + t.Skip("Runtime postToolUse hooks broken") ctx.ConfigureForTest(t) var postToolUseInputs []copilot.PostToolUseHookInput @@ -139,7 +141,9 @@ func TestHooks(t *testing.T) { } }) + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) t.Run("should invoke both preToolUse and postToolUse hooks for a single tool call", func(t *testing.T) { + t.Skip("Runtime postToolUse hooks broken") ctx.ConfigureForTest(t) var preToolUseInputs []copilot.PreToolUseHookInput diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index e9042e964..f6011d900 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -131,6 +131,98 @@ type QuotaSnapshot struct { UsedRequests float64 `json:"usedRequests"` } +type MCPConfigListResult struct { + // All MCP servers from user config, keyed by name + Servers map[string]ServerValue `json:"servers"` +} + +// MCP server configuration (local/stdio or remote/http) +type ServerValue struct { + Args []string `json:"args,omitempty"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping *FilterMappingUnion `json:"filterMapping"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools if not specified. + Tools []string `json:"tools,omitempty"` + Type *ServerType `json:"type,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + URL *string `json:"url,omitempty"` +} + +type MCPConfigAddParams struct { + // MCP server configuration (local/stdio or remote/http) + Config MCPConfigAddParamsConfig `json:"config"` + // Unique name for the MCP server + Name string `json:"name"` +} + +// MCP server configuration (local/stdio or remote/http) +type MCPConfigAddParamsConfig struct { + Args []string `json:"args,omitempty"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping *FilterMappingUnion `json:"filterMapping"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools if not specified. + Tools []string `json:"tools,omitempty"` + Type *ServerType `json:"type,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + URL *string `json:"url,omitempty"` +} + +type MCPConfigUpdateParams struct { + // MCP server configuration (local/stdio or remote/http) + Config MCPConfigUpdateParamsConfig `json:"config"` + // Name of the MCP server to update + Name string `json:"name"` +} + +// MCP server configuration (local/stdio or remote/http) +type MCPConfigUpdateParamsConfig struct { + Args []string `json:"args,omitempty"` + Command *string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + FilterMapping *FilterMappingUnion `json:"filterMapping"` + IsDefaultServer *bool `json:"isDefaultServer,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` + // Tools to include. Defaults to all tools if not specified. + Tools []string `json:"tools,omitempty"` + Type *ServerType `json:"type,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + OauthClientID *string `json:"oauthClientId,omitempty"` + OauthPublicClient *bool `json:"oauthPublicClient,omitempty"` + URL *string `json:"url,omitempty"` +} + +type MCPConfigRemoveParams struct { + // Name of the MCP server to remove + Name string `json:"name"` +} + +type SessionFSSetProviderResult struct { + // Whether the provider was set successfully + Success bool `json:"success"` +} + +type SessionFSSetProviderParams struct { + // Path conventions used by this filesystem + Conventions Conventions `json:"conventions"` + // Initial working directory for sessions + InitialCwd string `json:"initialCwd"` + // Path within each session's SessionFs where the runtime stores files for that session + SessionStatePath string `json:"sessionStatePath"` +} + type SessionModelGetCurrentResult struct { // Currently active model identifier ModelID *string `json:"modelId,omitempty"` @@ -338,17 +430,17 @@ type SessionSkillsReloadResult struct { type SessionMCPListResult struct { // Configured MCP servers - Servers []Server `json:"servers"` + Servers []ServerElement `json:"servers"` } -type Server struct { +type ServerElement struct { // Error message if the server failed to connect Error *string `json:"error,omitempty"` // Server name (config key) Name string `json:"name"` // Configuration source: user, workspace, plugin, or builtin Source *string `json:"source,omitempty"` - // Connection status: connected, failed, pending, disabled, or not_configured + // Connection status: connected, failed, needs-auth, pending, disabled, or not_configured Status ServerStatus `json:"status"` } @@ -610,6 +702,31 @@ type SessionShellKillParams struct { Signal *Signal `json:"signal,omitempty"` } +type FilterMappingEnum string + +const ( + FilterMappingEnumHiddenCharacters FilterMappingEnum = "hidden_characters" + FilterMappingEnumMarkdown FilterMappingEnum = "markdown" + FilterMappingEnumNone FilterMappingEnum = "none" +) + +type ServerType string + +const ( + ServerTypeHTTP ServerType = "http" + ServerTypeLocal ServerType = "local" + ServerTypeSse ServerType = "sse" + ServerTypeStdio ServerType = "stdio" +) + +// Path conventions used by this filesystem +type Conventions string + +const ( + ConventionsPosix Conventions = "posix" + ConventionsWindows Conventions = "windows" +) + // The current agent mode. // // The agent mode after switching. @@ -623,11 +740,12 @@ const ( ModePlan Mode = "plan" ) -// Connection status: connected, failed, pending, disabled, or not_configured +// Connection status: connected, failed, needs-auth, pending, disabled, or not_configured type ServerStatus string const ( ServerStatusConnected ServerStatus = "connected" + ServerStatusNeedsAuth ServerStatus = "needs-auth" ServerStatusNotConfigured ServerStatus = "not_configured" ServerStatusPending ServerStatus = "pending" ServerStatusDisabled ServerStatus = "disabled" @@ -721,6 +839,11 @@ const ( SignalSIGTERM Signal = "SIGTERM" ) +type FilterMappingUnion struct { + Enum *FilterMappingEnum + EnumMap map[string]FilterMappingEnum +} + type ResultUnion struct { ResultResult *ResultResult String *string @@ -779,13 +902,31 @@ func (a *ServerAccountApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult return &result, nil } +type ServerMcpApi serverApi + +type ServerSessionFsApi serverApi + +func (a *ServerSessionFsApi) SetProvider(ctx context.Context, params *SessionFSSetProviderParams) (*SessionFSSetProviderResult, error) { + raw, err := a.client.Request("sessionFs.setProvider", params) + if err != nil { + return nil, err + } + var result SessionFSSetProviderResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // ServerRpc provides typed server-scoped RPC methods. type ServerRpc struct { common serverApi // Reuse a single struct instead of allocating one for each service on the heap. - Models *ServerModelsApi - Tools *ServerToolsApi - Account *ServerAccountApi + Models *ServerModelsApi + Tools *ServerToolsApi + Account *ServerAccountApi + Mcp *ServerMcpApi + SessionFs *ServerSessionFsApi } func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, error) { @@ -806,6 +947,8 @@ func NewServerRpc(client *jsonrpc2.Client) *ServerRpc { r.Models = (*ServerModelsApi)(&r.common) r.Tools = (*ServerToolsApi)(&r.common) r.Account = (*ServerAccountApi)(&r.common) + r.Mcp = (*ServerMcpApi)(&r.common) + r.SessionFs = (*ServerSessionFsApi)(&r.common) return r } diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index a3a94ac5e..1f472943d 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,11 +9,12 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.14-0", + "@github/copilot": "^1.0.15-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, "devDependencies": { + "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", @@ -662,26 +663,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.14-0.tgz", - "integrity": "sha512-9eA5sFbvx69OtQnVoeik/8boFqHgGAhylLeUjEACc3kB70aaH1E/cHgxNzSMyYgZDjpXov0/IBXjtx2otpfHBw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.15-1.tgz", + "integrity": "sha512-H5I7CXJpOj+nUD1+0VQzawhV86X9Nb2m4fU0h70KDk+LDWRGhWvOlhK/bfFTVj6TPQbjBaOU4n2QJ+zKv48fGw==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.14-0", - "@github/copilot-darwin-x64": "1.0.14-0", - "@github/copilot-linux-arm64": "1.0.14-0", - "@github/copilot-linux-x64": "1.0.14-0", - "@github/copilot-win32-arm64": "1.0.14-0", - "@github/copilot-win32-x64": "1.0.14-0" + "@github/copilot-darwin-arm64": "1.0.15-1", + "@github/copilot-darwin-x64": "1.0.15-1", + "@github/copilot-linux-arm64": "1.0.15-1", + "@github/copilot-linux-x64": "1.0.15-1", + "@github/copilot-win32-arm64": "1.0.15-1", + "@github/copilot-win32-x64": "1.0.15-1" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.14-0.tgz", - "integrity": "sha512-w11Eqmfnu0ihrvgLysTd5Tkq8LuQa9eW63CNTQ/k5copnG1AMCdvd3K/78MxE2DdFJPq2L95KGS5cs9jH1dlIw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.15-1.tgz", + "integrity": "sha512-xo3yBGtzEliSnKZ+5RLBS94PxXpDkeNEf/dqi9/EtMjWTA8Zr6Zc318XDMG+7R/PwwiGdDNHa2+41/ffQ5ek4A==", "cpu": [ "arm64" ], @@ -695,9 +696,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.14-0.tgz", - "integrity": "sha512-4X/dMSPxCE/rvL6N1tgnwFxBg2uXnPrN63GGgS/FqK/fNi3TtcuojDVv8K1yjmEYpF8PXdkQttDlp6bKc+Nonw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.15-1.tgz", + "integrity": "sha512-gJ4uVuETqHSdvz+XD65F7MJqojU8Nthoi4+10549jPNhn29rAk6huZSJHg7DzK9K/bSlKEXKDziOE+p799EF8g==", "cpu": [ "x64" ], @@ -711,9 +712,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.14-0.tgz", - "integrity": "sha512-A4thcLUoErEvfBO3Hsl/hJASibn44qwZm1ZSeVBPCa1FkpowBwo8fT1eV9EwN/ftKsyks3QkndNFvHkVzjUfxA==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.15-1.tgz", + "integrity": "sha512-j0a+rAopJxV1NaA4VJElHMsA7x7ICD3+vkhb/1tOW1mfRQSg9OMegajidA0UvnMBAgQrOODUm8CAXc2ko1QMNw==", "cpu": [ "arm64" ], @@ -727,9 +728,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.14-0.tgz", - "integrity": "sha512-Kwn+Qn8/BqWRKa2DewZipH7rPIO8nDRWzpVy/ZLcRWBAvnIU+6BLWfhnYEU44DsqkD2VeWhKVfQlNmDX23xKKg==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.15-1.tgz", + "integrity": "sha512-K0UAkXKHlKU2wPgafO6mNl6xF5EoJ8xRBbXgJZOQZZtuJVHxGrVmmQWMdvz7bixrL+F1eB35jMYexupXS3C4Vw==", "cpu": [ "x64" ], @@ -743,9 +744,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.14-0.tgz", - "integrity": "sha512-8P5kxcb8YVWSS+Ihs+ykyy8jov1WwQ8GKV4d7mJN268Jpd8y5VI8Peb7uE2VO0lRLgq5c2VcXuZDsLG/1Wgnlw==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.15-1.tgz", + "integrity": "sha512-BKMqmsZ/EKSJZZ3M2HHcVLOxFvqcwO4ZtpEQPsXqPpbjyRRZCfbVr0fwb9ltZmiNP8rKMtEAO8yxYStiYHXjgw==", "cpu": [ "arm64" ], @@ -759,9 +760,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.14-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.14-0.tgz", - "integrity": "sha512-JWxp08j5o/PUkRZtZVagNYJLjH+KCURCyZRb7BfnC0A3vLeqcJQ70JC5qlYEAlcRnb4uCUJnmnpbWLLOJ+ObrA==", + "version": "1.0.15-1", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.15-1.tgz", + "integrity": "sha512-qdOefGZzDq9V9BxRDCx45FtWBy2epmPYtAG4icGzjqJQnl5+D//SjMbfpcYPYopBgAywgH7tEVxvWcvJINA23w==", "cpu": [ "x64" ], @@ -847,6 +848,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@platformatic/vfs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@platformatic/vfs/-/vfs-0.3.0.tgz", + "integrity": "sha512-BGXVOAz59HYPZCgI9v/MtiTF/ng8YAWtkooxVwOPR3TatNgGy0WZ/t15ScqytiZi5NdSRqWNRfuAbXKeAlKDdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 1787721a8..4b92ad8ac 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,11 +56,12 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.14-0", + "@github/copilot": "^1.0.15-1", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, "devDependencies": { + "@platformatic/vfs": "^0.3.0", "@types/node": "^25.2.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 50715c0eb..23aac99a3 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -24,7 +24,7 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; -import { createServerRpc } from "./generated/rpc.js"; +import { createServerRpc, registerClientSessionApiHandlers } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { getTraceContext } from "./telemetry.js"; @@ -40,6 +40,7 @@ import type { SessionConfig, SessionContext, SessionEvent, + SessionFsConfig, SessionLifecycleEvent, SessionLifecycleEventType, SessionLifecycleHandler, @@ -216,6 +217,7 @@ export class CopilotClient { | "onListModels" | "telemetry" | "onGetTraceContext" + | "sessionFs" > > & { cliPath?: string; @@ -238,6 +240,8 @@ export class CopilotClient { private _rpc: ReturnType | null = null; private processExitPromise: Promise | null = null; // Rejects when CLI process exits private negotiatedProtocolVersion: number | null = null; + /** Connection-level session filesystem config, set via constructor option. */ + private sessionFsConfig: SessionFsConfig | null = null; /** * Typed server-scoped RPC methods. @@ -307,6 +311,7 @@ export class CopilotClient { this.onListModels = options.onListModels; this.onGetTraceContext = options.onGetTraceContext; + this.sessionFsConfig = options.sessionFs ?? null; const effectiveEnv = options.env ?? process.env; this.options = { @@ -399,6 +404,15 @@ export class CopilotClient { // Verify protocol version compatibility await this.verifyProtocolVersion(); + // If a session filesystem provider was configured, register it + if (this.sessionFsConfig) { + await this.connection!.sendRequest("sessionFs.setProvider", { + initialCwd: this.sessionFsConfig.initialCwd, + sessionStatePath: this.sessionFsConfig.sessionStatePath, + conventions: this.sessionFsConfig.conventions, + }); + } + this.state = "connected"; } catch (error) { this.state = "error"; @@ -666,6 +680,15 @@ export class CopilotClient { session.on(config.onEvent); } this.sessions.set(sessionId, session); + if (this.sessionFsConfig) { + if (config.createSessionFsHandler) { + session.clientSessionApis.sessionFs = config.createSessionFsHandler(session); + } else { + throw new Error( + "createSessionFsHandler is required in session config when sessionFs is enabled in client options." + ); + } + } try { const response = await this.connection!.sendRequest("session.create", { @@ -792,6 +815,15 @@ export class CopilotClient { session.on(config.onEvent); } this.sessions.set(sessionId, session); + if (this.sessionFsConfig) { + if (config.createSessionFsHandler) { + session.clientSessionApis.sessionFs = config.createSessionFsHandler(session); + } else { + throw new Error( + "createSessionFsHandler is required in session config when sessionFs is enabled in client options." + ); + } + } try { const response = await this.connection!.sendRequest("session.resume", { @@ -1077,7 +1109,9 @@ export class CopilotClient { throw new Error("Client not connected"); } - const response = await this.connection.sendRequest("session.list", { filter }); + const response = await this.connection.sendRequest("session.list", { + filter, + }); const { sessions } = response as { sessions: Array<{ sessionId: string; @@ -1623,6 +1657,14 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); + // Register client session API handlers. + const sessions = this.sessions; + registerClientSessionApiHandlers(this.connection, (sessionId) => { + const session = sessions.get(sessionId); + if (!session) throw new Error(`No session found for sessionId: ${sessionId}`); + return session.clientSessionApis; + }); + this.connection.onClose(() => { this.state = "disconnected"; }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 1db497ae6..845d49129 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -179,6 +179,189 @@ export interface AccountGetQuotaResult { }; } +export interface McpConfigListResult { + /** + * All MCP servers from user config, keyed by name + */ + servers: { + /** + * MCP server configuration (local/stdio or remote/http) + */ + [k: string]: + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type?: "local" | "stdio"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + command: string; + args: string[]; + cwd?: string; + env?: { + [k: string]: string; + }; + } + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type: "http" | "sse"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + url: string; + headers?: { + [k: string]: string; + }; + oauthClientId?: string; + oauthPublicClient?: boolean; + }; + }; +} + +export interface McpConfigAddParams { + /** + * Unique name for the MCP server + */ + name: string; + /** + * MCP server configuration (local/stdio or remote/http) + */ + config: + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type?: "local" | "stdio"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + command: string; + args: string[]; + cwd?: string; + env?: { + [k: string]: string; + }; + } + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type: "http" | "sse"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + url: string; + headers?: { + [k: string]: string; + }; + oauthClientId?: string; + oauthPublicClient?: boolean; + }; +} + +export interface McpConfigUpdateParams { + /** + * Name of the MCP server to update + */ + name: string; + /** + * MCP server configuration (local/stdio or remote/http) + */ + config: + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type?: "local" | "stdio"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + command: string; + args: string[]; + cwd?: string; + env?: { + [k: string]: string; + }; + } + | { + /** + * Tools to include. Defaults to all tools if not specified. + */ + tools?: string[]; + type: "http" | "sse"; + isDefaultServer?: boolean; + filterMapping?: + | { + [k: string]: "none" | "markdown" | "hidden_characters"; + } + | ("none" | "markdown" | "hidden_characters"); + timeout?: number; + url: string; + headers?: { + [k: string]: string; + }; + oauthClientId?: string; + oauthPublicClient?: boolean; + }; +} + +export interface McpConfigRemoveParams { + /** + * Name of the MCP server to remove + */ + name: string; +} + +export interface SessionFsSetProviderResult { + /** + * Whether the provider was set successfully + */ + success: boolean; +} + +export interface SessionFsSetProviderParams { + /** + * Initial working directory for sessions + */ + initialCwd: string; + /** + * Path within each session's SessionFs where the runtime stores files for that session + */ + sessionStatePath: string; + /** + * Path conventions used by this filesystem + */ + conventions: "windows" | "posix"; +} + export interface SessionModelGetCurrentResult { /** * Currently active model identifier @@ -584,9 +767,9 @@ export interface SessionMcpListResult { */ name: string; /** - * Connection status: connected, failed, pending, disabled, or not_configured + * Connection status: connected, failed, needs-auth, pending, disabled, or not_configured */ - status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + status: "connected" | "failed" | "needs-auth" | "pending" | "disabled" | "not_configured"; /** * Configuration source: user, workspace, plugin, or builtin */ @@ -1083,6 +1266,212 @@ export interface SessionShellKillParams { signal?: "SIGTERM" | "SIGKILL" | "SIGINT"; } +export interface SessionFsReadFileResult { + /** + * File content as UTF-8 string + */ + content: string; +} + +export interface SessionFsReadFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsWriteFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; + /** + * Content to write + */ + content: string; + /** + * Optional POSIX-style mode for newly created files + */ + mode?: number; +} + +export interface SessionFsAppendFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; + /** + * Content to append + */ + content: string; + /** + * Optional POSIX-style mode for newly created files + */ + mode?: number; +} + +export interface SessionFsExistsResult { + /** + * Whether the path exists + */ + exists: boolean; +} + +export interface SessionFsExistsParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsStatResult { + /** + * Whether the path is a file + */ + isFile: boolean; + /** + * Whether the path is a directory + */ + isDirectory: boolean; + /** + * File size in bytes + */ + size: number; + /** + * ISO 8601 timestamp of last modification + */ + mtime: string; + /** + * ISO 8601 timestamp of creation + */ + birthtime: string; +} + +export interface SessionFsStatParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsMkdirParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; + /** + * Create parent directories as needed + */ + recursive?: boolean; + /** + * Optional POSIX-style mode for newly created directories + */ + mode?: number; +} + +export interface SessionFsReaddirResult { + /** + * Entry names in the directory + */ + entries: string[]; +} + +export interface SessionFsReaddirParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsReaddirWithTypesResult { + /** + * Directory entries with type information + */ + entries: { + /** + * Entry name + */ + name: string; + /** + * Entry type + */ + type: "file" | "directory"; + }[]; +} + +export interface SessionFsReaddirWithTypesParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; +} + +export interface SessionFsRmParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Path using SessionFs conventions + */ + path: string; + /** + * Remove directories and their contents recursively + */ + recursive?: boolean; + /** + * Ignore errors if the path does not exist + */ + force?: boolean; +} + +export interface SessionFsRenameParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Source path using SessionFs conventions + */ + src: string; + /** + * Destination path using SessionFs conventions + */ + dest: string; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -1100,6 +1489,22 @@ export function createServerRpc(connection: MessageConnection) { getQuota: async (): Promise => connection.sendRequest("account.getQuota", {}), }, + mcp: { + config: { + list: async (): Promise => + connection.sendRequest("mcp.config.list", {}), + add: async (params: McpConfigAddParams): Promise => + connection.sendRequest("mcp.config.add", params), + update: async (params: McpConfigUpdateParams): Promise => + connection.sendRequest("mcp.config.update", params), + remove: async (params: McpConfigRemoveParams): Promise => + connection.sendRequest("mcp.config.remove", params), + }, + }, + sessionFs: { + setProvider: async (params: SessionFsSetProviderParams): Promise => + connection.sendRequest("sessionFs.setProvider", params), + }, }; } @@ -1223,3 +1628,84 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin }, }; } + +/** Handler for `sessionFs` client session API methods. */ +export interface SessionFsHandler { + readFile(params: SessionFsReadFileParams): Promise; + writeFile(params: SessionFsWriteFileParams): Promise; + appendFile(params: SessionFsAppendFileParams): Promise; + exists(params: SessionFsExistsParams): Promise; + stat(params: SessionFsStatParams): Promise; + mkdir(params: SessionFsMkdirParams): Promise; + readdir(params: SessionFsReaddirParams): Promise; + readdirWithTypes(params: SessionFsReaddirWithTypesParams): Promise; + rm(params: SessionFsRmParams): Promise; + rename(params: SessionFsRenameParams): Promise; +} + +/** All client session API handler groups. */ +export interface ClientSessionApiHandlers { + sessionFs?: SessionFsHandler; +} + +/** + * Register client session API handlers on a JSON-RPC connection. + * The server calls these methods to delegate work to the client. + * Each incoming call includes a `sessionId` in the params; the registration + * function uses `getHandlers` to resolve the session's handlers. + */ +export function registerClientSessionApiHandlers( + connection: MessageConnection, + getHandlers: (sessionId: string) => ClientSessionApiHandlers, +): void { + connection.onRequest("sessionFs.readFile", async (params: SessionFsReadFileParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readFile(params); + }); + connection.onRequest("sessionFs.writeFile", async (params: SessionFsWriteFileParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.writeFile(params); + }); + connection.onRequest("sessionFs.appendFile", async (params: SessionFsAppendFileParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.appendFile(params); + }); + connection.onRequest("sessionFs.exists", async (params: SessionFsExistsParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.exists(params); + }); + connection.onRequest("sessionFs.stat", async (params: SessionFsStatParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.stat(params); + }); + connection.onRequest("sessionFs.mkdir", async (params: SessionFsMkdirParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.mkdir(params); + }); + connection.onRequest("sessionFs.readdir", async (params: SessionFsReaddirParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readdir(params); + }); + connection.onRequest("sessionFs.readdirWithTypes", async (params: SessionFsReaddirWithTypesParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.readdirWithTypes(params); + }); + connection.onRequest("sessionFs.rm", async (params: SessionFsRmParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.rm(params); + }); + connection.onRequest("sessionFs.rename", async (params: SessionFsRenameParams) => { + const handler = getHandlers(params.sessionId).sessionFs; + if (!handler) throw new Error(`No sessionFs handler registered for session: ${params.sessionId}`); + return handler.rename(params); + }); +} diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 5d8e12830..137c474f2 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -308,6 +308,10 @@ export type SessionEvent = description?: string; }[]; }; + /** + * True when the preceding agentic loop was cancelled via abort signal + */ + aborted?: boolean; }; } | { @@ -3657,9 +3661,9 @@ export type SessionEvent = */ name: string; /** - * Connection status: connected, failed, pending, disabled, or not_configured + * Connection status: connected, failed, needs-auth, pending, disabled, or not_configured */ - status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + status: "connected" | "failed" | "needs-auth" | "pending" | "disabled" | "not_configured"; /** * Configuration source: user, workspace, plugin, or builtin */ @@ -3692,9 +3696,9 @@ export type SessionEvent = */ serverName: string; /** - * New connection status: connected, failed, pending, disabled, or not_configured + * New connection status: connected, failed, needs-auth, pending, disabled, or not_configured */ - status: "connected" | "failed" | "pending" | "disabled" | "not_configured"; + status: "connected" | "failed" | "needs-auth" | "pending" | "disabled" | "not_configured"; }; } | { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 4fc1b75fb..4c41d2dfe 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -58,6 +58,8 @@ export type { SessionListFilter, SessionMetadata, SessionUiApi, + SessionFsConfig, + SessionFsHandler, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index cb2cf826b..4cb636e1a 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -10,6 +10,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; +import type { ClientSessionApiHandlers } from "./generated/rpc.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, @@ -86,6 +87,9 @@ export class CopilotSession { private traceContextProvider?: TraceContextProvider; private _capabilities: SessionCapabilities = {}; + /** @internal Client session API handlers, populated by CopilotClient during create/resume. */ + clientSessionApis: ClientSessionApiHandlers = {}; + /** * Creates a new CopilotSession instance. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index b4b9e563c..10bbf68db 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -7,8 +7,11 @@ */ // Import and re-export generated session event types +import type { SessionFsHandler } from "./generated/rpc.js"; import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; +import type { CopilotSession } from "./session.js"; export type SessionEvent = GeneratedSessionEvent; +export type { SessionFsHandler } from "./generated/rpc.js"; /** * Options for creating a CopilotClient @@ -171,6 +174,14 @@ export interface CopilotClientOptions { * ``` */ onGetTraceContext?: TraceContextProvider; + + /** + * Custom session filesystem provider. + * When provided, the client registers as the session filesystem provider + * on connection, routing all session-scoped file I/O through these callbacks + * instead of the server's default local filesystem storage. + */ + sessionFs?: SessionFsConfig; } /** @@ -1181,6 +1192,12 @@ export interface SessionConfig { * but executes earlier in the lifecycle so no events are missed. */ onEvent?: SessionEventHandler; + + /** + * Supplies a handler for session filesystem operations. This takes effect + * only if {@link CopilotClientOptions.sessionFs} is configured. + */ + createSessionFsHandler?: (session: CopilotSession) => SessionFsHandler; } /** @@ -1211,6 +1228,7 @@ export type ResumeSessionConfig = Pick< | "disabledSkills" | "infiniteSessions" | "onEvent" + | "createSessionFsHandler" > & { /** * When true, skips emitting the session.resume event. @@ -1352,6 +1370,27 @@ export interface SessionContext { branch?: string; } +/** + * Configuration for a custom session filesystem provider. + */ +export interface SessionFsConfig { + /** + * Initial working directory for sessions (user's project directory). + */ + initialCwd: string; + + /** + * Path within each session's SessionFs where the runtime stores + * session-scoped files (events, workspace, checkpoints, etc.). + */ + sessionStatePath: string; + + /** + * Path conventions used by this filesystem provider. + */ + conventions: "windows" | "posix"; +} + /** * Filter options for listing sessions */ diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index ed505a0cb..c6d413936 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -9,7 +9,7 @@ import { basename, dirname, join, resolve } from "path"; import { rimraf } from "rimraf"; import { fileURLToPath } from "url"; import { afterAll, afterEach, beforeEach, onTestFailed, TestContext } from "vitest"; -import { CopilotClient } from "../../../src"; +import { CopilotClient, CopilotClientOptions } from "../../../src"; import { CapiProxy } from "./CapiProxy"; import { retry } from "./sdkTestHelper"; @@ -22,10 +22,12 @@ const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); export async function createSdkTestContext({ logLevel, useStdio, + copilotClientOptions, }: { logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all"; cliPath?: string; useStdio?: boolean; + copilotClientOptions?: CopilotClientOptions; } = {}) { const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-"))); const workDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-work-"))); @@ -51,6 +53,7 @@ export async function createSdkTestContext({ // Use fake token in CI to allow cached responses without real auth githubToken: isCI ? "fake-token-for-e2e-tests" : undefined, useStdio: useStdio, + ...copilotClientOptions, }); const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 9743d91f3..c510d7154 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -48,7 +48,8 @@ describe("Session hooks", async () => { await session.disconnect(); }); - it("should invoke postToolUse hook after model runs a tool", async () => { + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + it.skip("should invoke postToolUse hook after model runs a tool", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ @@ -79,7 +80,8 @@ describe("Session hooks", async () => { await session.disconnect(); }); - it("should invoke both preToolUse and postToolUse hooks for a single tool call", async () => { + // TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + it.skip("should invoke both preToolUse and postToolUse hooks for a single tool call", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const postToolUseInputs: PostToolUseHookInput[] = []; diff --git a/nodejs/test/e2e/session_fs.test.ts b/nodejs/test/e2e/session_fs.test.ts new file mode 100644 index 000000000..2f67f2ca0 --- /dev/null +++ b/nodejs/test/e2e/session_fs.test.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { SessionCompactionCompleteEvent } from "@github/copilot/sdk"; +import { MemoryProvider, VirtualProvider } from "@platformatic/vfs"; +import { describe, expect, it, onTestFinished } from "vitest"; +import { CopilotClient } from "../../src/client.js"; +import { SessionFsHandler } from "../../src/generated/rpc.js"; +import { + approveAll, + CopilotSession, + defineTool, + SessionEvent, + type SessionFsConfig, +} from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Session Fs", async () => { + // Single provider for the describe block — session IDs are unique per test, + // so no cross-contamination between tests. + const provider = new MemoryProvider(); + const createSessionFsHandler = (session: CopilotSession) => + createTestSessionFsHandler(session, provider); + + // Helpers to build session-namespaced paths for direct provider assertions + const p = (sessionId: string, path: string) => + `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; + + const { copilotClient: client, env } = await createSdkTestContext({ + copilotClientOptions: { sessionFs: sessionFsConfig }, + }); + + it("should route file operations through the session fs provider", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); + + const msg = await session.sendAndWait({ prompt: "What is 100 + 200?" }); + expect(msg?.data.content).toContain("300"); + await session.disconnect(); + + const buf = await provider.readFile(p(session.sessionId, "/session-state/events.jsonl")); + const content = buf.toString("utf8"); + expect(content).toContain("300"); + }); + + it("should load session data from fs provider on resume", async () => { + const session1 = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); + const sessionId = session1.sessionId; + + const msg = await session1.sendAndWait({ prompt: "What is 50 + 50?" }); + expect(msg?.data.content).toContain("100"); + await session1.disconnect(); + + // The events file should exist before resume + expect(await provider.exists(p(sessionId, "/session-state/events.jsonl"))).toBe(true); + + const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, + createSessionFsHandler, + }); + + // Send another message to verify the session is functional after resume + const msg2 = await session2.sendAndWait({ prompt: "What is that times 3?" }); + await session2.disconnect(); + expect(msg2?.data.content).toContain("300"); + }); + + it("should reject setProvider when sessions already exist", async () => { + const client = new CopilotClient({ + useStdio: false, // Use TCP so we can connect from a second client + env, + }); + await client.createSession({ onPermissionRequest: approveAll, createSessionFsHandler }); + + // Get the port the first client's runtime is listening on + const port = (client as unknown as { actualPort: number }).actualPort; + + // Second client tries to connect with a session fs — should fail + // because sessions already exist on the runtime. + const client2 = new CopilotClient({ + env, + logLevel: "error", + cliUrl: `localhost:${port}`, + sessionFs: sessionFsConfig, + }); + onTestFinished(() => client2.forceStop()); + + await expect(client2.start()).rejects.toThrow(); + }); + + it("should map large output handling into sessionFs", async () => { + const suppliedFileContent = "x".repeat(100_000); + const session = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + tools: [ + defineTool("get_big_string", { + description: "Returns a large string", + handler: async () => suppliedFileContent, + }), + ], + }); + + await session.sendAndWait({ + prompt: "Call the get_big_string tool and reply with the word DONE only.", + }); + + // The tool result should reference a temp file under the session state path + const messages = await session.getMessages(); + const toolResult = findToolCallResult(messages, "get_big_string"); + expect(toolResult).toContain("/session-state/temp/"); + const filename = toolResult?.match(/(\/session-state\/temp\/[^\s]+)/)?.[1]; + expect(filename).toBeDefined(); + + // Verify the file was written with the correct content via the provider + const fileContent = await provider.readFile(p(session.sessionId, filename!), "utf8"); + expect(fileContent).toBe(suppliedFileContent); + }); + + it("should succeed with compaction while using sessionFs", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + createSessionFsHandler, + }); + + let compactionEvent: SessionCompactionCompleteEvent | undefined; + session.on("session.compaction_complete", (evt) => (compactionEvent = evt)); + + await session.sendAndWait({ prompt: "What is 2+2?" }); + + const eventsPath = p(session.sessionId, "/session-state/events.jsonl"); + await expect.poll(() => provider.exists(eventsPath)).toBe(true); + const contentBefore = await provider.readFile(eventsPath, "utf8"); + expect(contentBefore).not.toContain("checkpointNumber"); + + await session.rpc.compaction.compact(); + await expect.poll(() => compactionEvent).toBeDefined(); + expect(compactionEvent!.data.success).toBe(true); + + // Verify the events file was rewritten with a checkpoint via sessionFs + await expect + .poll(() => provider.readFile(eventsPath, "utf8")) + .toContain("checkpointNumber"); + }); +}); + +function findToolCallResult(messages: SessionEvent[], toolName: string): string | undefined { + for (const m of messages) { + if (m.type === "tool.execution_complete") { + if (findToolName(messages, m.data.toolCallId) === toolName) { + return m.data.result?.content; + } + } + } +} + +function findToolName(messages: SessionEvent[], toolCallId: string): string | undefined { + for (const m of messages) { + if (m.type === "tool.execution_start" && m.data.toolCallId === toolCallId) { + return m.data.toolName; + } + } +} + +const sessionFsConfig: SessionFsConfig = { + initialCwd: "/", + sessionStatePath: "/session-state", + conventions: "posix", +}; + +function createTestSessionFsHandler( + session: CopilotSession, + provider: VirtualProvider +): SessionFsHandler { + const sp = (sessionId: string, path: string) => + `/${sessionId}${path.startsWith("/") ? path : "/" + path}`; + + return { + readFile: async ({ path }) => { + const content = await provider.readFile(sp(session.sessionId, path), "utf8"); + return { content: content as string }; + }, + writeFile: async ({ path, content }) => { + await provider.writeFile(sp(session.sessionId, path), content); + }, + appendFile: async ({ path, content }) => { + await provider.appendFile(sp(session.sessionId, path), content); + }, + exists: async ({ path }) => { + return { exists: await provider.exists(sp(session.sessionId, path)) }; + }, + stat: async ({ path }) => { + const st = await provider.stat(sp(session.sessionId, path)); + return { + isFile: st.isFile(), + isDirectory: st.isDirectory(), + size: st.size, + mtime: new Date(st.mtimeMs).toISOString(), + birthtime: new Date(st.birthtimeMs).toISOString(), + }; + }, + mkdir: async ({ path, recursive, mode }) => { + await provider.mkdir(sp(session.sessionId, path), { + recursive: recursive ?? false, + mode, + }); + }, + readdir: async ({ path }) => { + const entries = await provider.readdir(sp(session.sessionId, path)); + return { entries: entries as string[] }; + }, + readdirWithTypes: async ({ path }) => { + const names = (await provider.readdir(sp(session.sessionId, path))) as string[]; + const entries = await Promise.all( + names.map(async (name) => { + const st = await provider.stat(sp(session.sessionId, `${path}/${name}`)); + return { + name, + type: st.isDirectory() ? ("directory" as const) : ("file" as const), + }; + }) + ); + return { entries }; + }, + rm: async ({ path }) => { + await provider.unlink(sp(session.sessionId, path)); + }, + rename: async ({ src, dest }) => { + await provider.rename(sp(session.sessionId, src), sp(session.sessionId, dest)); + }, + }; +} diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index f7ea6dbad..39e20a05d 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -454,6 +454,355 @@ def to_dict(self) -> dict: return result +class FilterMappingEnum(Enum): + HIDDEN_CHARACTERS = "hidden_characters" + MARKDOWN = "markdown" + NONE = "none" + + +class ServerType(Enum): + HTTP = "http" + LOCAL = "local" + SSE = "sse" + STDIO = "stdio" + + +@dataclass +class ServerValue: + """MCP server configuration (local/stdio or remote/http)""" + + args: list[str] | None = None + command: str | None = None + cwd: str | None = None + env: dict[str, str] | None = None + filter_mapping: dict[str | FilterMappingEnum] | FilterMappingEnum | None = None + is_default_server: bool | None = None + timeout: float | None = None + tools: list[str] | None = None + """Tools to include. Defaults to all tools if not specified.""" + + type: ServerType | None = None + headers: dict[str, str] | None = None + oauth_client_id: str | None = None + oauth_public_client: bool | None = None + url: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'ServerValue': + assert isinstance(obj, dict) + args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + command = from_union([from_str, from_none], obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) + filter_mapping = from_union([lambda x: from_dict(FilterMappingEnum, x), FilterMappingEnum, from_none], obj.get("filterMapping")) + is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + timeout = from_union([from_float, from_none], obj.get("timeout")) + tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) + type = from_union([ServerType, from_none], obj.get("type")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) + oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) + url = from_union([from_str, from_none], obj.get("url")) + return ServerValue(args, command, cwd, env, filter_mapping, is_default_server, timeout, tools, type, headers, oauth_client_id, oauth_public_client, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.args is not None: + result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.env is not None: + result["env"] = from_union([lambda x: from_dict(from_str, x), from_none], self.env) + if self.filter_mapping is not None: + result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(FilterMappingEnum, x), x), lambda x: to_enum(FilterMappingEnum, x), from_none], self.filter_mapping) + if self.is_default_server is not None: + result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.timeout is not None: + result["timeout"] = from_union([to_float, from_none], self.timeout) + if self.tools is not None: + result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) + if self.type is not None: + result["type"] = from_union([lambda x: to_enum(ServerType, x), from_none], self.type) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.oauth_client_id is not None: + result["oauthClientId"] = from_union([from_str, from_none], self.oauth_client_id) + if self.oauth_public_client is not None: + result["oauthPublicClient"] = from_union([from_bool, from_none], self.oauth_public_client) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class MCPConfigListResult: + servers: dict[str, ServerValue] + """All MCP servers from user config, keyed by name""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigListResult': + assert isinstance(obj, dict) + servers = from_dict(ServerValue.from_dict, obj.get("servers")) + return MCPConfigListResult(servers) + + def to_dict(self) -> dict: + result: dict = {} + result["servers"] = from_dict(lambda x: to_class(ServerValue, x), self.servers) + return result + + +@dataclass +class MCPConfigAddParamsConfig: + """MCP server configuration (local/stdio or remote/http)""" + + args: list[str] | None = None + command: str | None = None + cwd: str | None = None + env: dict[str, str] | None = None + filter_mapping: dict[str | FilterMappingEnum] | FilterMappingEnum | None = None + is_default_server: bool | None = None + timeout: float | None = None + tools: list[str] | None = None + """Tools to include. Defaults to all tools if not specified.""" + + type: ServerType | None = None + headers: dict[str, str] | None = None + oauth_client_id: str | None = None + oauth_public_client: bool | None = None + url: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigAddParamsConfig': + assert isinstance(obj, dict) + args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + command = from_union([from_str, from_none], obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) + filter_mapping = from_union([lambda x: from_dict(FilterMappingEnum, x), FilterMappingEnum, from_none], obj.get("filterMapping")) + is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + timeout = from_union([from_float, from_none], obj.get("timeout")) + tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) + type = from_union([ServerType, from_none], obj.get("type")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) + oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) + url = from_union([from_str, from_none], obj.get("url")) + return MCPConfigAddParamsConfig(args, command, cwd, env, filter_mapping, is_default_server, timeout, tools, type, headers, oauth_client_id, oauth_public_client, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.args is not None: + result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.env is not None: + result["env"] = from_union([lambda x: from_dict(from_str, x), from_none], self.env) + if self.filter_mapping is not None: + result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(FilterMappingEnum, x), x), lambda x: to_enum(FilterMappingEnum, x), from_none], self.filter_mapping) + if self.is_default_server is not None: + result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.timeout is not None: + result["timeout"] = from_union([to_float, from_none], self.timeout) + if self.tools is not None: + result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) + if self.type is not None: + result["type"] = from_union([lambda x: to_enum(ServerType, x), from_none], self.type) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.oauth_client_id is not None: + result["oauthClientId"] = from_union([from_str, from_none], self.oauth_client_id) + if self.oauth_public_client is not None: + result["oauthPublicClient"] = from_union([from_bool, from_none], self.oauth_public_client) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class MCPConfigAddParams: + config: MCPConfigAddParamsConfig + """MCP server configuration (local/stdio or remote/http)""" + + name: str + """Unique name for the MCP server""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigAddParams': + assert isinstance(obj, dict) + config = MCPConfigAddParamsConfig.from_dict(obj.get("config")) + name = from_str(obj.get("name")) + return MCPConfigAddParams(config, name) + + def to_dict(self) -> dict: + result: dict = {} + result["config"] = to_class(MCPConfigAddParamsConfig, self.config) + result["name"] = from_str(self.name) + return result + + +@dataclass +class MCPConfigUpdateParamsConfig: + """MCP server configuration (local/stdio or remote/http)""" + + args: list[str] | None = None + command: str | None = None + cwd: str | None = None + env: dict[str, str] | None = None + filter_mapping: dict[str | FilterMappingEnum] | FilterMappingEnum | None = None + is_default_server: bool | None = None + timeout: float | None = None + tools: list[str] | None = None + """Tools to include. Defaults to all tools if not specified.""" + + type: ServerType | None = None + headers: dict[str, str] | None = None + oauth_client_id: str | None = None + oauth_public_client: bool | None = None + url: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigUpdateParamsConfig': + assert isinstance(obj, dict) + args = from_union([lambda x: from_list(from_str, x), from_none], obj.get("args")) + command = from_union([from_str, from_none], obj.get("command")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + env = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("env")) + filter_mapping = from_union([lambda x: from_dict(FilterMappingEnum, x), FilterMappingEnum, from_none], obj.get("filterMapping")) + is_default_server = from_union([from_bool, from_none], obj.get("isDefaultServer")) + timeout = from_union([from_float, from_none], obj.get("timeout")) + tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("tools")) + type = from_union([ServerType, from_none], obj.get("type")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + oauth_client_id = from_union([from_str, from_none], obj.get("oauthClientId")) + oauth_public_client = from_union([from_bool, from_none], obj.get("oauthPublicClient")) + url = from_union([from_str, from_none], obj.get("url")) + return MCPConfigUpdateParamsConfig(args, command, cwd, env, filter_mapping, is_default_server, timeout, tools, type, headers, oauth_client_id, oauth_public_client, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.args is not None: + result["args"] = from_union([lambda x: from_list(from_str, x), from_none], self.args) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.env is not None: + result["env"] = from_union([lambda x: from_dict(from_str, x), from_none], self.env) + if self.filter_mapping is not None: + result["filterMapping"] = from_union([lambda x: from_dict(lambda x: to_enum(FilterMappingEnum, x), x), lambda x: to_enum(FilterMappingEnum, x), from_none], self.filter_mapping) + if self.is_default_server is not None: + result["isDefaultServer"] = from_union([from_bool, from_none], self.is_default_server) + if self.timeout is not None: + result["timeout"] = from_union([to_float, from_none], self.timeout) + if self.tools is not None: + result["tools"] = from_union([lambda x: from_list(from_str, x), from_none], self.tools) + if self.type is not None: + result["type"] = from_union([lambda x: to_enum(ServerType, x), from_none], self.type) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.oauth_client_id is not None: + result["oauthClientId"] = from_union([from_str, from_none], self.oauth_client_id) + if self.oauth_public_client is not None: + result["oauthPublicClient"] = from_union([from_bool, from_none], self.oauth_public_client) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class MCPConfigUpdateParams: + config: MCPConfigUpdateParamsConfig + """MCP server configuration (local/stdio or remote/http)""" + + name: str + """Name of the MCP server to update""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigUpdateParams': + assert isinstance(obj, dict) + config = MCPConfigUpdateParamsConfig.from_dict(obj.get("config")) + name = from_str(obj.get("name")) + return MCPConfigUpdateParams(config, name) + + def to_dict(self) -> dict: + result: dict = {} + result["config"] = to_class(MCPConfigUpdateParamsConfig, self.config) + result["name"] = from_str(self.name) + return result + + +@dataclass +class MCPConfigRemoveParams: + name: str + """Name of the MCP server to remove""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPConfigRemoveParams': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + return MCPConfigRemoveParams(name) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionFSSetProviderResult: + success: bool + """Whether the provider was set successfully""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionFSSetProviderResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionFSSetProviderResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +class Conventions(Enum): + """Path conventions used by this filesystem""" + + POSIX = "posix" + WINDOWS = "windows" + + +@dataclass +class SessionFSSetProviderParams: + conventions: Conventions + """Path conventions used by this filesystem""" + + initial_cwd: str + """Initial working directory for sessions""" + + session_state_path: str + """Path within each session's SessionFs where the runtime stores files for that session""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionFSSetProviderParams': + assert isinstance(obj, dict) + conventions = Conventions(obj.get("conventions")) + initial_cwd = from_str(obj.get("initialCwd")) + session_state_path = from_str(obj.get("sessionStatePath")) + return SessionFSSetProviderParams(conventions, initial_cwd, session_state_path) + + def to_dict(self) -> dict: + result: dict = {} + result["conventions"] = to_enum(Conventions, self.conventions) + result["initialCwd"] = from_str(self.initial_cwd) + result["sessionStatePath"] = from_str(self.session_state_path) + return result + + @dataclass class SessionModelGetCurrentResult: model_id: str | None = None @@ -1116,22 +1465,23 @@ def to_dict(self) -> dict: class ServerStatus(Enum): - """Connection status: connected, failed, pending, disabled, or not_configured""" + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" CONNECTED = "connected" DISABLED = "disabled" FAILED = "failed" + NEEDS_AUTH = "needs-auth" NOT_CONFIGURED = "not_configured" PENDING = "pending" @dataclass -class Server: +class ServerElement: name: str """Server name (config key)""" status: ServerStatus - """Connection status: connected, failed, pending, disabled, or not_configured""" + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" error: str | None = None """Error message if the server failed to connect""" @@ -1140,13 +1490,13 @@ class Server: """Configuration source: user, workspace, plugin, or builtin""" @staticmethod - def from_dict(obj: Any) -> 'Server': + def from_dict(obj: Any) -> 'ServerElement': assert isinstance(obj, dict) name = from_str(obj.get("name")) status = ServerStatus(obj.get("status")) error = from_union([from_str, from_none], obj.get("error")) source = from_union([from_str, from_none], obj.get("source")) - return Server(name, status, error, source) + return ServerElement(name, status, error, source) def to_dict(self) -> dict: result: dict = {} @@ -1161,18 +1511,18 @@ def to_dict(self) -> dict: @dataclass class SessionMCPListResult: - servers: list[Server] + servers: list[ServerElement] """Configured MCP servers""" @staticmethod def from_dict(obj: Any) -> 'SessionMCPListResult': assert isinstance(obj, dict) - servers = from_list(Server.from_dict, obj.get("servers")) + servers = from_list(ServerElement.from_dict, obj.get("servers")) return SessionMCPListResult(servers) def to_dict(self) -> dict: result: dict = {} - result["servers"] = from_list(lambda x: to_class(Server, x), self.servers) + result["servers"] = from_list(lambda x: to_class(ServerElement, x), self.servers) return result @@ -2167,6 +2517,54 @@ def account_get_quota_result_to_dict(x: AccountGetQuotaResult) -> Any: return to_class(AccountGetQuotaResult, x) +def mcp_config_list_result_from_dict(s: Any) -> MCPConfigListResult: + return MCPConfigListResult.from_dict(s) + + +def mcp_config_list_result_to_dict(x: MCPConfigListResult) -> Any: + return to_class(MCPConfigListResult, x) + + +def mcp_config_add_params_from_dict(s: Any) -> MCPConfigAddParams: + return MCPConfigAddParams.from_dict(s) + + +def mcp_config_add_params_to_dict(x: MCPConfigAddParams) -> Any: + return to_class(MCPConfigAddParams, x) + + +def mcp_config_update_params_from_dict(s: Any) -> MCPConfigUpdateParams: + return MCPConfigUpdateParams.from_dict(s) + + +def mcp_config_update_params_to_dict(x: MCPConfigUpdateParams) -> Any: + return to_class(MCPConfigUpdateParams, x) + + +def mcp_config_remove_params_from_dict(s: Any) -> MCPConfigRemoveParams: + return MCPConfigRemoveParams.from_dict(s) + + +def mcp_config_remove_params_to_dict(x: MCPConfigRemoveParams) -> Any: + return to_class(MCPConfigRemoveParams, x) + + +def session_fs_set_provider_result_from_dict(s: Any) -> SessionFSSetProviderResult: + return SessionFSSetProviderResult.from_dict(s) + + +def session_fs_set_provider_result_to_dict(x: SessionFSSetProviderResult) -> Any: + return to_class(SessionFSSetProviderResult, x) + + +def session_fs_set_provider_params_from_dict(s: Any) -> SessionFSSetProviderParams: + return SessionFSSetProviderParams.from_dict(s) + + +def session_fs_set_provider_params_to_dict(x: SessionFSSetProviderParams) -> Any: + return to_class(SessionFSSetProviderParams, x) + + def session_model_get_current_result_from_dict(s: Any) -> SessionModelGetCurrentResult: return SessionModelGetCurrentResult.from_dict(s) @@ -2671,6 +3069,20 @@ async def get_quota(self, *, timeout: float | None = None) -> AccountGetQuotaRes return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {}, **_timeout_kwargs(timeout))) +class ServerMcpApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + +class ServerSessionFsApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def set_provider(self, params: SessionFSSetProviderParams, *, timeout: float | None = None) -> SessionFSSetProviderResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return SessionFSSetProviderResult.from_dict(await self._client.request("sessionFs.setProvider", params_dict, **_timeout_kwargs(timeout))) + + class ServerRpc: """Typed server-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient"): @@ -2678,6 +3090,8 @@ def __init__(self, client: "JsonRpcClient"): self.models = ServerModelsApi(client) self.tools = ServerToolsApi(client) self.account = ServerAccountApi(client) + self.mcp = ServerMcpApi(client) + self.session_fs = ServerSessionFsApi(client) async def ping(self, params: PingParams, *, timeout: float | None = None) -> PingResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index c3123102b..2c3acba81 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -1556,13 +1556,14 @@ class Role(Enum): class ServerStatus(Enum): - """Connection status: connected, failed, pending, disabled, or not_configured + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured - New connection status: connected, failed, pending, disabled, or not_configured + New connection status: connected, failed, needs-auth, pending, disabled, or not_configured """ CONNECTED = "connected" DISABLED = "disabled" FAILED = "failed" + NEEDS_AUTH = "needs-auth" NOT_CONFIGURED = "not_configured" PENDING = "pending" @@ -1573,7 +1574,7 @@ class Server: """Server name (config key)""" status: ServerStatus - """Connection status: connected, failed, pending, disabled, or not_configured""" + """Connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" error: str | None = None """Error message if the server failed to connect""" @@ -1988,6 +1989,9 @@ class Data: URL to open in the user's browser (url mode only) """ + aborted: bool | None = None + """True when the preceding agentic loop was cancelled via abort signal""" + background_tasks: BackgroundTasks | None = None """Background tasks still running when the agent became idle""" @@ -2606,7 +2610,7 @@ class Data: """Array of MCP server status summaries""" status: ServerStatus | None = None - """New connection status: connected, failed, pending, disabled, or not_configured""" + """New connection status: connected, failed, needs-auth, pending, disabled, or not_configured""" extensions: list[Extension] | None = None """Array of discovered extensions and their status""" @@ -2632,6 +2636,7 @@ def from_dict(obj: Any) -> 'Data': stack = from_union([from_str, from_none], obj.get("stack")) status_code = from_union([from_int, from_none], obj.get("statusCode")) url = from_union([from_str, from_none], obj.get("url")) + aborted = from_union([from_bool, from_none], obj.get("aborted")) background_tasks = from_union([BackgroundTasks.from_dict, from_none], obj.get("backgroundTasks")) title = from_union([from_str, from_none], obj.get("title")) info_type = from_union([from_str, from_none], obj.get("infoType")) @@ -2780,7 +2785,7 @@ def from_dict(obj: Any) -> 'Data': servers = from_union([lambda x: from_list(Server.from_dict, x), from_none], obj.get("servers")) status = from_union([ServerStatus, from_none], obj.get("status")) extensions = from_union([lambda x: from_list(Extension.from_dict, x), from_none], obj.get("extensions")) - return Data(already_in_use, context, copilot_version, producer, reasoning_effort, remote_steerable, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, url, background_tasks, title, info_type, warning_type, new_model, previous_model, previous_reasoning_effort, new_mode, previous_mode, operation, path, handoff_time, host, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, conversation_tokens, current_model, current_tokens, error_reason, model_metrics, session_start_time, shutdown_type, system_tokens, tool_definitions_tokens, total_api_duration_ms, total_premium_requests, base_commit, branch, cwd, git_root, head_commit, host_type, is_initial, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, inter_token_latency_ms, model, quota_snapshots, ttft_ms, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, description, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, duration_ms, total_tokens, total_tool_calls, tools, hook_invocation_id, hook_type, input, output, metadata, role, kind, permission_request, allow_freeform, choices, question, elicitation_source, mode, requested_schema, mcp_request_id, server_name, server_url, static_client_config, traceparent, tracestate, command, args, command_name, commands, ui, actions, plan_content, recommended_action, skills, agents, errors, warnings, servers, status, extensions) + return Data(already_in_use, context, copilot_version, producer, reasoning_effort, remote_steerable, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, url, aborted, background_tasks, title, info_type, warning_type, new_model, previous_model, previous_reasoning_effort, new_mode, previous_mode, operation, path, handoff_time, host, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, conversation_tokens, current_model, current_tokens, error_reason, model_metrics, session_start_time, shutdown_type, system_tokens, tool_definitions_tokens, total_api_duration_ms, total_premium_requests, base_commit, branch, cwd, git_root, head_commit, host_type, is_initial, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, inter_token_latency_ms, model, quota_snapshots, ttft_ms, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, description, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, duration_ms, total_tokens, total_tool_calls, tools, hook_invocation_id, hook_type, input, output, metadata, role, kind, permission_request, allow_freeform, choices, question, elicitation_source, mode, requested_schema, mcp_request_id, server_name, server_url, static_client_config, traceparent, tracestate, command, args, command_name, commands, ui, actions, plan_content, recommended_action, skills, agents, errors, warnings, servers, status, extensions) def to_dict(self) -> dict: result: dict = {} @@ -2820,6 +2825,8 @@ def to_dict(self) -> dict: result["statusCode"] = from_union([from_int, from_none], self.status_code) if self.url is not None: result["url"] = from_union([from_str, from_none], self.url) + if self.aborted is not None: + result["aborted"] = from_union([from_bool, from_none], self.aborted) if self.background_tasks is not None: result["backgroundTasks"] = from_union([lambda x: to_class(BackgroundTasks, x), from_none], self.background_tasks) if self.title is not None: diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index e355f3a80..2ecdc6b07 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -41,6 +41,8 @@ async def on_pre_tool_use(input_data, invocation): await session.disconnect() + # TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + @pytest.mark.skip(reason="Runtime postToolUse hooks broken") async def test_should_invoke_posttooluse_hook_after_model_runs_a_tool( self, ctx: E2ETestContext ): @@ -71,6 +73,8 @@ async def on_post_tool_use(input_data, invocation): await session.disconnect() + # TODO: Re-enable once runtime postToolUse hooks are fixed (https://github.com/github/copilot-sdk/issues/972) + @pytest.mark.skip(reason="Runtime postToolUse hooks broken") async def test_should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call( self, ctx: E2ETestContext ): diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index a48ed47b6..304324421 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -806,7 +806,16 @@ function emitServerInstanceMethod( for (const [pName, pSchema] of paramEntries) { if (typeof pSchema !== "object") continue; const isReq = requiredSet.has(pName); - const csType = schemaTypeToCSharp(pSchema as JSONSchema7, isReq, rpcKnownTypes); + const jsonSchema = pSchema as JSONSchema7; + let csType: string; + // If the property has an enum, resolve to the generated enum type + if (jsonSchema.enum && Array.isArray(jsonSchema.enum) && requestClassName) { + const valuesKey = [...jsonSchema.enum].sort().join("|"); + const match = [...generatedEnums.values()].find((e) => [...e.values].sort().join("|") === valuesKey); + csType = match ? (isReq ? match.enumName : `${match.enumName}?`) : schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); + } else { + csType = schemaTypeToCSharp(jsonSchema, isReq, rpcKnownTypes); + } sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); } diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 8d23b428f..e5e82bdc6 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -86,17 +86,20 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; `); const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); - for (const method of allMethods) { - const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { - bannerComment: "", - additionalProperties: false, - }); - if (method.stability === "experimental") { - lines.push("/** @experimental */"); + for (const method of [...allMethods, ...clientSessionMethods]) { + if (method.result) { + const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { + bannerComment: "", + additionalProperties: false, + }); + if (method.stability === "experimental") { + lines.push("/** @experimental */"); + } + lines.push(compiled.trim()); + lines.push(""); } - lines.push(compiled.trim()); - lines.push(""); if (method.params?.properties && Object.keys(method.params.properties).length > 0) { const paramsCompiled = await compile(method.params, paramsTypeName(method.rpcMethod), { @@ -132,6 +135,11 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; lines.push(""); } + // Generate client session API handler interfaces and registration function + if (schema.clientSession) { + lines.push(...emitClientSessionApiRegistration(schema.clientSession)); + } + const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", lines.join("\n")); console.log(` ✓ ${outPath}`); } @@ -141,7 +149,7 @@ function emitGroup(node: Record, indent: string, isSession: boo for (const [key, value] of Object.entries(node)) { if (isRpcMethod(value)) { const { rpcMethod, params } = value; - const resultType = resultTypeName(rpcMethod); + const resultType = value.result ? resultTypeName(rpcMethod) : "void"; const paramsType = paramsTypeName(rpcMethod); const paramEntries = params?.properties ? Object.entries(params.properties).filter(([k]) => k !== "sessionId") : []; @@ -185,6 +193,112 @@ function emitGroup(node: Record, indent: string, isSession: boo return lines; } +// ── Client Session API Handler Generation ─────────────────────────────────── + +/** + * Collect client API methods grouped by their top-level namespace. + * Returns a map like: { sessionFs: [{ rpcMethod, params, result }, ...] } + */ +function collectClientGroups(node: Record): Map { + const groups = new Map(); + for (const [groupName, groupNode] of Object.entries(node)) { + if (typeof groupNode === "object" && groupNode !== null) { + groups.set(groupName, collectRpcMethods(groupNode as Record)); + } + } + return groups; +} + +/** + * Derive the handler method name from the full RPC method name. + * e.g., "sessionFs.readFile" → "readFile" + */ +function handlerMethodName(rpcMethod: string): string { + const parts = rpcMethod.split("."); + return parts[parts.length - 1]; +} + +/** + * Generate handler interfaces and a registration function for client session API groups. + * + * Client session API methods have `sessionId` on the wire (injected by the + * runtime's proxy layer). The generated registration function accepts a + * `getHandler` callback that resolves a sessionId to a handler object. + * Param types include sessionId — handler code can simply ignore it. + */ +function emitClientSessionApiRegistration(clientSchema: Record): string[] { + const lines: string[] = []; + const groups = collectClientGroups(clientSchema); + + // Emit a handler interface per group + for (const [groupName, methods] of groups) { + const interfaceName = toPascalCase(groupName) + "Handler"; + lines.push(`/** Handler for \`${groupName}\` client session API methods. */`); + lines.push(`export interface ${interfaceName} {`); + for (const method of methods) { + const name = handlerMethodName(method.rpcMethod); + const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; + const pType = hasParams ? paramsTypeName(method.rpcMethod) : ""; + const rType = method.result ? resultTypeName(method.rpcMethod) : "void"; + + if (hasParams) { + lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); + } else { + lines.push(` ${name}(): Promise<${rType}>;`); + } + } + lines.push(`}`); + lines.push(""); + } + + // Emit combined ClientSessionApiHandlers type + lines.push(`/** All client session API handler groups. */`); + lines.push(`export interface ClientSessionApiHandlers {`); + for (const [groupName] of groups) { + const interfaceName = toPascalCase(groupName) + "Handler"; + lines.push(` ${groupName}?: ${interfaceName};`); + } + lines.push(`}`); + lines.push(""); + + // Emit registration function + lines.push(`/**`); + lines.push(` * Register client session API handlers on a JSON-RPC connection.`); + lines.push(` * The server calls these methods to delegate work to the client.`); + lines.push(` * Each incoming call includes a \`sessionId\` in the params; the registration`); + lines.push(` * function uses \`getHandlers\` to resolve the session's handlers.`); + lines.push(` */`); + lines.push(`export function registerClientSessionApiHandlers(`); + lines.push(` connection: MessageConnection,`); + lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers,`); + lines.push(`): void {`); + + for (const [groupName, methods] of groups) { + for (const method of methods) { + const name = handlerMethodName(method.rpcMethod); + const pType = paramsTypeName(method.rpcMethod); + const hasParams = method.params?.properties && Object.keys(method.params.properties).length > 0; + + if (hasParams) { + lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); + lines.push(` const handler = getHandlers(params.sessionId).${groupName};`); + lines.push(` if (!handler) throw new Error(\`No ${groupName} handler registered for session: \${params.sessionId}\`);`); + lines.push(` return handler.${name}(params);`); + lines.push(` });`); + } else { + lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); + lines.push(` throw new Error("No params provided for ${method.rpcMethod}");`); + lines.push(` });`); + } + } + } + + lines.push(`}`); + lines.push(""); + + return lines; +} + // ── Main ──────────────────────────────────────────────────────────────────── async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts index 2c13b1d96..1e95b4dd4 100644 --- a/scripts/codegen/utils.ts +++ b/scripts/codegen/utils.ts @@ -125,13 +125,14 @@ export async function writeGeneratedFile(relativePath: string, content: string): export interface RpcMethod { rpcMethod: string; params: JSONSchema7 | null; - result: JSONSchema7; + result: JSONSchema7 | null; stability?: string; } export interface ApiSchema { server?: Record; session?: Record; + clientSession?: Record; } export function isRpcMethod(node: unknown): node is RpcMethod { diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index a41b93d78..53d8c2b07 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -52,6 +52,9 @@ const defaultModel = "claude-sonnet-4.5"; export class ReplayingCapiProxy extends CapturingHttpProxy { private state: ReplayingCapiProxyState | null = null; private startPromise: Promise | null = null; + private defaultToolResultNormalizers: ToolResultNormalizer[] = [ + { toolName: "*", normalizer: normalizeLargeOutputFilepaths }, + ]; /** * If true, cached responses are played back slowly (~ 2KiB/sec). Otherwise streaming responses are sent as fast as possible. @@ -70,7 +73,12 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { // skip the need to do a /config POST before other requests. This only makes // sense if the config will be static for the lifetime of the proxy. if (filePath && workDir) { - this.state = { filePath, workDir, testInfo, toolResultNormalizers: [] }; + this.state = { + filePath, + workDir, + testInfo, + toolResultNormalizers: [...this.defaultToolResultNormalizers], + }; } } @@ -96,7 +104,7 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { filePath: config.filePath, workDir: config.workDir, testInfo: config.testInfo, - toolResultNormalizers: [], + toolResultNormalizers: [...this.defaultToolResultNormalizers], }; this.clearExchanges(); @@ -592,7 +600,10 @@ function normalizeToolCalls( .find((tc) => tc.id === msg.tool_call_id); if (precedingToolCall) { for (const normalizer of resultNormalizers) { - if (precedingToolCall.function?.name === normalizer.toolName) { + if ( + precedingToolCall.function?.name === normalizer.toolName || + normalizer.toolName === "*" + ) { msg.content = normalizer.normalizer(msg.content); } } @@ -724,6 +735,14 @@ function normalizeUserMessage(content: string): string { .trim(); } +function normalizeLargeOutputFilepaths(result: string): string { + // Replaces filenames like 1774637043987-copilot-tool-output-tk7puw.txt with PLACEHOLDER-copilot-tool-output-PLACEHOLDER + return result.replace( + /\d+-copilot-tool-output-[a-z0-9.]+/g, + "PLACEHOLDER-copilot-tool-output-PLACEHOLDER", + ); +} + // Transforms a single OpenAI-style inbound response message into normalized form function transformOpenAIResponseChoice( choices: ChatCompletion.Choice[], diff --git a/test/snapshots/session_fs/should_load_session_data_from_fs_provider_on_resume.yaml b/test/snapshots/session_fs/should_load_session_data_from_fs_provider_on_resume.yaml new file mode 100644 index 000000000..4744667cd --- /dev/null +++ b/test/snapshots/session_fs/should_load_session_data_from_fs_provider_on_resume.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 50 + 50? + - role: assistant + content: 50 + 50 = 100 + - role: user + content: What is that times 3? + - role: assistant + content: 100 × 3 = 300 diff --git a/test/snapshots/session_fs/should_map_large_output_handling_into_sessionfs.yaml b/test/snapshots/session_fs/should_map_large_output_handling_into_sessionfs.yaml new file mode 100644 index 000000000..e80ce51e6 --- /dev/null +++ b/test/snapshots/session_fs/should_map_large_output_handling_into_sessionfs.yaml @@ -0,0 +1,25 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Call the get_big_string tool and reply with the word DONE only. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: get_big_string + arguments: "{}" + - role: tool + tool_call_id: toolcall_0 + content: |- + Output too large to read at once (97.7 KB). Saved to: /session-state/temp/PLACEHOLDER-copilot-tool-output-PLACEHOLDER + Consider using tools like grep (for searching), head/tail (for viewing start/end), view with view_range (for specific sections), or jq (for JSON) to examine portions of the output. + + Preview (first 500 chars): + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + - role: assistant + content: DONE diff --git a/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml b/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml new file mode 100644 index 000000000..269a80f11 --- /dev/null +++ b/test/snapshots/session_fs/should_reject_setprovider_when_sessions_already_exist.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Hello + - role: assistant + content: Hello! How can I help you today? diff --git a/test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.yaml b/test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.yaml new file mode 100644 index 000000000..455652bfd --- /dev/null +++ b/test/snapshots/session_fs/should_route_file_operations_through_the_session_fs_provider.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 100 + 200? + - role: assistant + content: 100 + 200 = 300 diff --git a/test/snapshots/session_fs/should_succeed_with_compaction_while_using_sessionfs.yaml b/test/snapshots/session_fs/should_succeed_with_compaction_while_using_sessionfs.yaml new file mode 100644 index 000000000..2b984d74c --- /dev/null +++ b/test/snapshots/session_fs/should_succeed_with_compaction_while_using_sessionfs.yaml @@ -0,0 +1,75 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2 + 2 = 4 + - role: user + content: ${compaction_prompt} + - role: assistant + content: >- + + + The user asked a simple arithmetic question ("What is 2+2?"). This was a minimal interaction with no technical + work, coding tasks, or file modifications requested or performed. The conversation consisted solely of + providing a basic mathematical answer. + + + + + + + 1. The user asked "What is 2+2?" + - Provided the answer: 4 + - No further requests or actions were needed + + 2. The user requested a checkpoint summary + - Creating this summary to preserve conversation context before history compaction + + + + + + No files were created, modified, or deleted. No technical work was performed. The conversation consisted only + of answering a simple arithmetic question. + + + Current state: + + - No active tasks + + - No code changes + + - No systems or processes started + + + + + + + No technical work was performed during this conversation. No technical decisions, issues, or discoveries were + made. + + + + + + + No files are relevant to this conversation, as no technical work was performed. + + + + + + + No pending work or next steps. The user's request (answering "2+2") has been completed. Awaiting further + instructions from the user. + + + + + Simple arithmetic question answered diff --git a/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml b/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml index cf55fcc17..fdb7ebca0 100644 --- a/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml +++ b/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml @@ -5,13 +5,13 @@ conversations: - role: system content: ${system} - role: user - content: What is 3+3? Reply with just the number. + content: What is 1+1? Reply with just the number. - role: assistant - content: "6" + content: "2" - messages: - role: system content: ${system} - role: user - content: What is 1+1? Reply with just the number. + content: What is 3+3? Reply with just the number. - role: assistant - content: "2" + content: "6"