diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 68d7941b4..6add87e28 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,7 @@ on: options: - latest - prerelease + - unstable version: description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." type: string @@ -66,8 +67,8 @@ jobs: fi else if [[ "$VERSION" != *-* ]]; then - echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is 'prerelease'" >> $GITHUB_STEP_SUMMARY - echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease" + echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY + echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable" exit 1 fi fi @@ -107,11 +108,12 @@ jobs: name: nodejs-package path: nodejs/*.tgz - name: Publish to npm - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable' run: npm publish --tag ${{ github.event.inputs.dist-tag }} --access public --registry https://registry.npmjs.org publish-dotnet: name: Publish .NET SDK + if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -147,6 +149,7 @@ jobs: publish-python: name: Publish Python SDK + if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -183,7 +186,7 @@ jobs: github-release: name: Create GitHub Release needs: [version, publish-nodejs, publish-dotnet, publish-python] - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.event.inputs.dist-tag != 'unstable' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a340cd63a..76af9b3af 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -61,6 +61,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable private bool _disposed; private readonly int? _optionsPort; private readonly string? _optionsHost; + private int? _actualPort; private List? _modelsCache; private readonly SemaphoreSlim _modelsCacheLock = new(1, 1); private readonly List> _lifecycleHandlers = []; @@ -80,6 +81,11 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable ? throw new ObjectDisposedException(nameof(CopilotClient)) : _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first."); + /// + /// Gets the actual TCP port the CLI server is listening on, if using TCP transport. + /// + public int? ActualPort => _actualPort; + /// /// Creates a new instance of . /// @@ -191,12 +197,14 @@ async Task StartCoreAsync(CancellationToken ct) if (_optionsHost is not null && _optionsPort is not null) { // External server (TCP) + _actualPort = _optionsPort; result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct); } else { // Child process (stdio or TCP) var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct); + _actualPort = portOrNull; result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct); } @@ -1129,8 +1137,6 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? var handler = new RpcHandler(this); rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); - rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall); - rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest); rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.StartListening(); @@ -1231,116 +1237,6 @@ public void OnSessionLifecycle(string type, string sessionId, JsonElement? metad client.DispatchLifecycleEvent(evt); } - public async Task OnToolCall(string sessionId, - string toolCallId, - string toolName, - object? arguments) - { - var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); - if (session.GetTool(toolName) is not { } tool) - { - return new ToolCallResponse(new ToolResultObject - { - TextResultForLlm = $"Tool '{toolName}' is not supported.", - ResultType = "failure", - Error = $"tool '{toolName}' not supported" - }); - } - - try - { - var invocation = new ToolInvocation - { - SessionId = sessionId, - ToolCallId = toolCallId, - ToolName = toolName, - Arguments = arguments - }; - - // Map args from JSON into AIFunction format - var aiFunctionArgs = new AIFunctionArguments - { - Context = new Dictionary - { - // Allow recipient to access the raw ToolInvocation if they want, e.g., to get SessionId - // This is an alternative to using MEAI's ConfigureParameterBinding, which we can't use - // because we're not the ones producing the AIFunction. - [typeof(ToolInvocation)] = invocation - } - }; - - if (arguments is not null) - { - if (arguments is not JsonElement incomingJsonArgs) - { - throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}"); - } - - foreach (var prop in incomingJsonArgs.EnumerateObject()) - { - // MEAI will deserialize the JsonElement value respecting the delegate's parameter types - aiFunctionArgs[prop.Name] = prop.Value; - } - } - - var result = await tool.InvokeAsync(aiFunctionArgs); - - // If the function returns a ToolResultObject, use it directly; otherwise, wrap the result - // This lets the developer provide BinaryResult, SessionLog, etc. if they deal with that themselves - var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject - { - ResultType = "success", - - // In most cases, result will already have been converted to JsonElement by the AIFunction. - // We special-case string for consistency with our Node/Python/Go clients. - // TODO: I don't think it's right to special-case string here, and all the clients should - // always serialize the result to JSON (otherwise what stringification is going to happen? - // something we don't control? an error?) - TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je - ? je.GetString()! - : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), - }; - return new ToolCallResponse(toolResultObject); - } - catch (Exception ex) - { - return new ToolCallResponse(new() - { - // TODO: We should offer some way to control whether or not to expose detailed exception information to the LLM. - // For security, the default must be false, but developers can opt into allowing it. - TextResultForLlm = $"Invoking this tool produced an error. Detailed information is not available.", - ResultType = "failure", - Error = ex.Message - }); - } - } - - public async Task OnPermissionRequest(string sessionId, JsonElement permissionRequest) - { - var session = client.GetSession(sessionId); - if (session == null) - { - return new PermissionRequestResponse(new PermissionRequestResult - { - Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser - }); - } - - try - { - var result = await session.HandlePermissionRequestAsync(permissionRequest); - return new PermissionRequestResponse(result); - } - catch - { - // If permission handler fails, deny the permission - return new PermissionRequestResponse(new PermissionRequestResult - { - Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser - }); - } - } - public async Task OnUserInputRequest(string sessionId, string question, List? choices = null, bool? allowFreeform = null) { var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); @@ -1473,12 +1369,6 @@ internal record ListSessionsRequest( internal record ListSessionsResponse( List Sessions); - internal record ToolCallResponse( - ToolResultObject? Result); - - internal record PermissionRequestResponse( - PermissionRequestResult Result); - internal record UserInputRequestResponse( string Answer, bool WasFreeform); @@ -1578,14 +1468,12 @@ private static LogLevel MapLevel(TraceEventType eventType) [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsRequest))] [JsonSerializable(typeof(ListSessionsResponse))] - [JsonSerializable(typeof(PermissionRequestResponse))] [JsonSerializable(typeof(PermissionRequestResult))] [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SystemMessageConfig))] - [JsonSerializable(typeof(ToolCallResponse))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] [JsonSerializable(typeof(ToolResultObject))] diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 4c4bac0f3..85e55e4b8 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -250,13 +250,17 @@ internal class SessionModeSetRequest public class SessionPlanReadResult { - /// Whether plan.md exists in the workspace + /// Whether the plan file exists in the workspace [JsonPropertyName("exists")] public bool Exists { get; set; } - /// The content of plan.md, or null if it does not exist + /// The content of the plan file, or null if it does not exist [JsonPropertyName("content")] public string? Content { get; set; } + + /// Absolute file path of the plan file, or null if workspace is not enabled + [JsonPropertyName("path")] + public string? Path { get; set; } } internal class SessionPlanReadRequest @@ -468,6 +472,45 @@ internal class SessionCompactionCompactRequest public string SessionId { get; set; } = string.Empty; } +public class SessionToolsHandlePendingToolCallResult +{ + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +internal class SessionToolsHandlePendingToolCallRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public object? Result { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +public class SessionPermissionsHandlePendingPermissionRequestResult +{ + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +internal class SessionPermissionsHandlePendingPermissionRequestRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public object Result { get; set; } = null!; +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionModeGetResultMode { @@ -488,9 +531,9 @@ public class ServerRpc internal ServerRpc(JsonRpc rpc) { _rpc = rpc; - Models = new ModelsApi(rpc); - Tools = new ToolsApi(rpc); - Account = new AccountApi(rpc); + Models = new ServerModelsApi(rpc); + Tools = new ServerToolsApi(rpc); + Account = new ServerAccountApi(rpc); } /// Calls "ping". @@ -501,21 +544,21 @@ public async Task PingAsync(string? message = null, CancellationToke } /// Models APIs. - public ModelsApi Models { get; } + public ServerModelsApi Models { get; } /// Tools APIs. - public ToolsApi Tools { get; } + public ServerToolsApi Tools { get; } /// Account APIs. - public AccountApi Account { get; } + public ServerAccountApi Account { get; } } /// Server-scoped Models APIs. -public class ModelsApi +public class ServerModelsApi { private readonly JsonRpc _rpc; - internal ModelsApi(JsonRpc rpc) + internal ServerModelsApi(JsonRpc rpc) { _rpc = rpc; } @@ -528,11 +571,11 @@ public async Task ListAsync(CancellationToken cancellationToke } /// Server-scoped Tools APIs. -public class ToolsApi +public class ServerToolsApi { private readonly JsonRpc _rpc; - internal ToolsApi(JsonRpc rpc) + internal ServerToolsApi(JsonRpc rpc) { _rpc = rpc; } @@ -546,11 +589,11 @@ public async Task ListAsync(string? model = null, CancellationT } /// Server-scoped Account APIs. -public class AccountApi +public class ServerAccountApi { private readonly JsonRpc _rpc; - internal AccountApi(JsonRpc rpc) + internal ServerAccountApi(JsonRpc rpc) { _rpc = rpc; } @@ -579,6 +622,8 @@ internal SessionRpc(JsonRpc rpc, string sessionId) Fleet = new FleetApi(rpc, sessionId); Agent = new AgentApi(rpc, sessionId); Compaction = new CompactionApi(rpc, sessionId); + Tools = new ToolsApi(rpc, sessionId); + Permissions = new PermissionsApi(rpc, sessionId); } public ModelApi Model { get; } @@ -594,6 +639,10 @@ internal SessionRpc(JsonRpc rpc, string sessionId) public AgentApi Agent { get; } public CompactionApi Compaction { get; } + + public ToolsApi Tools { get; } + + public PermissionsApi Permissions { get; } } public class ModelApi @@ -792,6 +841,44 @@ public async Task CompactAsync(CancellationToken } } +public class ToolsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal ToolsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.tools.handlePendingToolCall". + public async Task HandlePendingToolCallAsync(string requestId, object? result, string? error, CancellationToken cancellationToken = default) + { + var request = new SessionToolsHandlePendingToolCallRequest { SessionId = _sessionId, RequestId = requestId, Result = result, Error = error }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.tools.handlePendingToolCall", [request], cancellationToken); + } +} + +public class PermissionsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal PermissionsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.permissions.handlePendingPermissionRequest". + public async Task HandlePendingPermissionRequestAsync(string requestId, object result, CancellationToken cancellationToken = default) + { + var request = new SessionPermissionsHandlePendingPermissionRequestRequest { SessionId = _sessionId, RequestId = requestId, Result = result }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.permissions.handlePendingPermissionRequest", [request], cancellationToken); + } +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -830,12 +917,16 @@ public async Task CompactAsync(CancellationToken [JsonSerializable(typeof(SessionModelGetCurrentResult))] [JsonSerializable(typeof(SessionModelSwitchToRequest))] [JsonSerializable(typeof(SessionModelSwitchToResult))] +[JsonSerializable(typeof(SessionPermissionsHandlePendingPermissionRequestRequest))] +[JsonSerializable(typeof(SessionPermissionsHandlePendingPermissionRequestResult))] [JsonSerializable(typeof(SessionPlanDeleteRequest))] [JsonSerializable(typeof(SessionPlanDeleteResult))] [JsonSerializable(typeof(SessionPlanReadRequest))] [JsonSerializable(typeof(SessionPlanReadResult))] [JsonSerializable(typeof(SessionPlanUpdateRequest))] [JsonSerializable(typeof(SessionPlanUpdateResult))] +[JsonSerializable(typeof(SessionToolsHandlePendingToolCallRequest))] +[JsonSerializable(typeof(SessionToolsHandlePendingToolCallResult))] [JsonSerializable(typeof(SessionWorkspaceCreateFileRequest))] [JsonSerializable(typeof(SessionWorkspaceCreateFileResult))] [JsonSerializable(typeof(SessionWorkspaceListFilesRequest))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 73e8d67b6..c497038c6 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -29,8 +29,14 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(AssistantTurnEndEvent), "assistant.turn_end")] [JsonDerivedType(typeof(AssistantTurnStartEvent), "assistant.turn_start")] [JsonDerivedType(typeof(AssistantUsageEvent), "assistant.usage")] +[JsonDerivedType(typeof(CommandCompletedEvent), "command.completed")] +[JsonDerivedType(typeof(CommandQueuedEvent), "command.queued")] [JsonDerivedType(typeof(ElicitationCompletedEvent), "elicitation.completed")] [JsonDerivedType(typeof(ElicitationRequestedEvent), "elicitation.requested")] +[JsonDerivedType(typeof(ExitPlanModeCompletedEvent), "exit_plan_mode.completed")] +[JsonDerivedType(typeof(ExitPlanModeRequestedEvent), "exit_plan_mode.requested")] +[JsonDerivedType(typeof(ExternalToolCompletedEvent), "external_tool.completed")] +[JsonDerivedType(typeof(ExternalToolRequestedEvent), "external_tool.requested")] [JsonDerivedType(typeof(HookEndEvent), "hook.end")] [JsonDerivedType(typeof(HookStartEvent), "hook.start")] [JsonDerivedType(typeof(PendingMessagesModifiedEvent), "pending_messages.modified")] @@ -723,6 +729,78 @@ public partial class ElicitationCompletedEvent : SessionEvent public required ElicitationCompletedData Data { get; set; } } +/// +/// Event: external_tool.requested +/// +public partial class ExternalToolRequestedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "external_tool.requested"; + + [JsonPropertyName("data")] + public required ExternalToolRequestedData Data { get; set; } +} + +/// +/// Event: external_tool.completed +/// +public partial class ExternalToolCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "external_tool.completed"; + + [JsonPropertyName("data")] + public required ExternalToolCompletedData Data { get; set; } +} + +/// +/// Event: command.queued +/// +public partial class CommandQueuedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "command.queued"; + + [JsonPropertyName("data")] + public required CommandQueuedData Data { get; set; } +} + +/// +/// Event: command.completed +/// +public partial class CommandCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "command.completed"; + + [JsonPropertyName("data")] + public required CommandCompletedData Data { get; set; } +} + +/// +/// Event: exit_plan_mode.requested +/// +public partial class ExitPlanModeRequestedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "exit_plan_mode.requested"; + + [JsonPropertyName("data")] + public required ExitPlanModeRequestedData Data { get; set; } +} + +/// +/// Event: exit_plan_mode.completed +/// +public partial class ExitPlanModeCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "exit_plan_mode.completed"; + + [JsonPropertyName("data")] + public required ExitPlanModeCompletedData Data { get; set; } +} + public partial class SessionStartData { [JsonPropertyName("sessionId")] @@ -785,6 +863,9 @@ public partial class SessionErrorData public partial class SessionIdleData { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("backgroundTasks")] + public SessionIdleDataBackgroundTasks? BackgroundTasks { get; set; } } public partial class SessionTitleChangedData @@ -1124,6 +1205,10 @@ public partial class AssistantMessageData [JsonPropertyName("phase")] public string? Phase { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("outputTokens")] + public double? OutputTokens { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("interactionId")] public string? InteractionId { get; set; } @@ -1450,6 +1535,9 @@ public partial class PermissionCompletedData { [JsonPropertyName("requestId")] public required string RequestId { get; set; } + + [JsonPropertyName("result")] + public required PermissionCompletedDataResult Result { get; set; } } public partial class UserInputRequestedData @@ -1497,6 +1585,70 @@ public partial class ElicitationCompletedData public required string RequestId { get; set; } } +public partial class ExternalToolRequestedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("sessionId")] + public required string SessionId { get; set; } + + [JsonPropertyName("toolCallId")] + public required string ToolCallId { get; set; } + + [JsonPropertyName("toolName")] + public required string ToolName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("arguments")] + public object? Arguments { get; set; } +} + +public partial class ExternalToolCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + +public partial class CommandQueuedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("command")] + public required string Command { get; set; } +} + +public partial class CommandCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + +public partial class ExitPlanModeRequestedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("summary")] + public required string Summary { get; set; } + + [JsonPropertyName("planContent")] + public required string PlanContent { get; set; } + + [JsonPropertyName("actions")] + public required string[] Actions { get; set; } + + [JsonPropertyName("recommendedAction")] + public required string RecommendedAction { get; set; } +} + +public partial class ExitPlanModeCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + public partial class SessionStartDataContext { [JsonPropertyName("cwd")] @@ -1533,6 +1685,38 @@ public partial class SessionResumeDataContext public string? Branch { get; set; } } +public partial class SessionIdleDataBackgroundTasksAgentsItem +{ + [JsonPropertyName("agentId")] + public required string AgentId { get; set; } + + [JsonPropertyName("agentType")] + public required string AgentType { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +public partial class SessionIdleDataBackgroundTasksShellsItem +{ + [JsonPropertyName("shellId")] + public required string ShellId { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +public partial class SessionIdleDataBackgroundTasks +{ + [JsonPropertyName("agents")] + public required SessionIdleDataBackgroundTasksAgentsItem[] Agents { get; set; } + + [JsonPropertyName("shells")] + public required SessionIdleDataBackgroundTasksShellsItem[] Shells { get; set; } +} + public partial class SessionHandoffDataRepository { [JsonPropertyName("owner")] @@ -1911,6 +2095,12 @@ public partial class SystemMessageDataMetadata public Dictionary? Variables { get; set; } } +public partial class PermissionCompletedDataResult +{ + [JsonPropertyName("kind")] + public required PermissionCompletedDataResultKind Kind { get; set; } +} + public partial class ElicitationRequestedDataRequestedSchema { [JsonPropertyName("type")] @@ -2013,6 +2203,21 @@ public enum SystemMessageDataRole Developer, } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PermissionCompletedDataResultKind +{ + [JsonStringEnumMemberName("approved")] + Approved, + [JsonStringEnumMemberName("denied-by-rules")] + DeniedByRules, + [JsonStringEnumMemberName("denied-no-approval-rule-and-could-not-request-from-user")] + DeniedNoApprovalRuleAndCouldNotRequestFromUser, + [JsonStringEnumMemberName("denied-interactively-by-user")] + DeniedInteractivelyByUser, + [JsonStringEnumMemberName("denied-by-content-exclusion-policy")] + DeniedByContentExclusionPolicy, +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -2041,11 +2246,23 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(AssistantUsageDataCopilotUsage))] [JsonSerializable(typeof(AssistantUsageDataCopilotUsageTokenDetailsItem))] [JsonSerializable(typeof(AssistantUsageEvent))] +[JsonSerializable(typeof(CommandCompletedData))] +[JsonSerializable(typeof(CommandCompletedEvent))] +[JsonSerializable(typeof(CommandQueuedData))] +[JsonSerializable(typeof(CommandQueuedEvent))] [JsonSerializable(typeof(ElicitationCompletedData))] [JsonSerializable(typeof(ElicitationCompletedEvent))] [JsonSerializable(typeof(ElicitationRequestedData))] [JsonSerializable(typeof(ElicitationRequestedDataRequestedSchema))] [JsonSerializable(typeof(ElicitationRequestedEvent))] +[JsonSerializable(typeof(ExitPlanModeCompletedData))] +[JsonSerializable(typeof(ExitPlanModeCompletedEvent))] +[JsonSerializable(typeof(ExitPlanModeRequestedData))] +[JsonSerializable(typeof(ExitPlanModeRequestedEvent))] +[JsonSerializable(typeof(ExternalToolCompletedData))] +[JsonSerializable(typeof(ExternalToolCompletedEvent))] +[JsonSerializable(typeof(ExternalToolRequestedData))] +[JsonSerializable(typeof(ExternalToolRequestedEvent))] [JsonSerializable(typeof(HookEndData))] [JsonSerializable(typeof(HookEndDataError))] [JsonSerializable(typeof(HookEndEvent))] @@ -2054,6 +2271,7 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(PendingMessagesModifiedData))] [JsonSerializable(typeof(PendingMessagesModifiedEvent))] [JsonSerializable(typeof(PermissionCompletedData))] +[JsonSerializable(typeof(PermissionCompletedDataResult))] [JsonSerializable(typeof(PermissionCompletedEvent))] [JsonSerializable(typeof(PermissionRequestedData))] [JsonSerializable(typeof(PermissionRequestedEvent))] @@ -2071,6 +2289,9 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SessionHandoffDataRepository))] [JsonSerializable(typeof(SessionHandoffEvent))] [JsonSerializable(typeof(SessionIdleData))] +[JsonSerializable(typeof(SessionIdleDataBackgroundTasks))] +[JsonSerializable(typeof(SessionIdleDataBackgroundTasksAgentsItem))] +[JsonSerializable(typeof(SessionIdleDataBackgroundTasksShellsItem))] [JsonSerializable(typeof(SessionIdleEvent))] [JsonSerializable(typeof(SessionInfoData))] [JsonSerializable(typeof(SessionInfoEvent))] diff --git a/dotnet/src/SdkProtocolVersion.cs b/dotnet/src/SdkProtocolVersion.cs index b4c2a367f..f3d8f04c5 100644 --- a/dotnet/src/SdkProtocolVersion.cs +++ b/dotnet/src/SdkProtocolVersion.cs @@ -11,7 +11,7 @@ internal static class SdkProtocolVersion /// /// The SDK protocol version. /// - private const int Version = 2; + private const int Version = 3; /// /// Gets the SDK protocol version. diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 054f10972..397eae0fa 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -269,9 +269,15 @@ public IDisposable On(SessionEventHandler handler) /// The session event to dispatch. /// /// This method is internal. Handler exceptions are allowed to propagate so they are not lost. + /// Broadcast request events (external_tool.requested, permission.requested) are handled + /// internally before being forwarded to user handlers. /// internal void DispatchEvent(SessionEvent sessionEvent) { + // Handle broadcast request events (protocol v3) before dispatching to user handlers. + // Fire-and-forget: the response is sent asynchronously via RPC. + HandleBroadcastEventAsync(sessionEvent); + // Reading the field once gives us a snapshot; delegates are immutable. EventHandlers?.Invoke(sessionEvent); } @@ -344,6 +350,156 @@ internal async Task HandlePermissionRequestAsync(JsonEl return await handler(request, invocation); } + /// + /// Handles broadcast request events by executing local handlers and responding via RPC. + /// Implements the protocol v3 broadcast model where tool calls and permission requests + /// are broadcast as session events to all clients. + /// + private async void HandleBroadcastEventAsync(SessionEvent sessionEvent) + { + switch (sessionEvent) + { + case ExternalToolRequestedEvent toolEvent: + { + var data = toolEvent.Data; + if (string.IsNullOrEmpty(data.RequestId) || string.IsNullOrEmpty(data.ToolName)) + return; + + var tool = GetTool(data.ToolName); + if (tool is null) + return; // This client doesn't handle this tool; another client will. + + await ExecuteToolAndRespondAsync(data.RequestId, data.ToolName, data.ToolCallId, data.Arguments, tool); + break; + } + + case PermissionRequestedEvent permEvent: + { + var data = permEvent.Data; + if (string.IsNullOrEmpty(data.RequestId) || data.PermissionRequest is null) + return; + + var handler = _permissionHandler; + if (handler is null) + return; // This client doesn't handle permissions; another client will. + + await ExecutePermissionAndRespondAsync(data.RequestId, data.PermissionRequest, handler); + break; + } + } + } + + /// + /// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC. + /// + private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, string toolCallId, object? arguments, AIFunction tool) + { + try + { + var invocation = new ToolInvocation + { + SessionId = SessionId, + ToolCallId = toolCallId, + ToolName = toolName, + Arguments = arguments + }; + + var aiFunctionArgs = new AIFunctionArguments + { + Context = new Dictionary + { + [typeof(ToolInvocation)] = invocation + } + }; + + if (arguments is not null) + { + if (arguments is not JsonElement incomingJsonArgs) + { + throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}"); + } + + foreach (var prop in incomingJsonArgs.EnumerateObject()) + { + aiFunctionArgs[prop.Name] = prop.Value; + } + } + + var result = await tool.InvokeAsync(aiFunctionArgs); + + var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject + { + ResultType = "success", + TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je + ? je.GetString()! + : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), + }; + + await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null); + } + catch (Exception ex) + { + try + { + await Rpc.Tools.HandlePendingToolCallAsync(requestId, result: null, error: ex.Message); + } + catch (IOException) + { + // Connection lost or RPC error — nothing we can do + } + catch (ObjectDisposedException) + { + // Connection already disposed — nothing we can do + } + } + } + + /// + /// Executes a permission handler and sends the result back via the HandlePendingPermissionRequest RPC. + /// + private async Task ExecutePermissionAndRespondAsync(string requestId, object permissionRequestData, PermissionRequestHandler handler) + { + try + { + // PermissionRequestedData.PermissionRequest is typed as `object` in generated code, + // but StreamJsonRpc deserializes it as a JsonElement. + if (permissionRequestData is not JsonElement permJsonElement) + { + throw new InvalidOperationException( + $"Permission request data must be a {nameof(JsonElement)}; received {permissionRequestData.GetType().Name}"); + } + + var request = JsonSerializer.Deserialize(permJsonElement.GetRawText(), SessionJsonContext.Default.PermissionRequest) + ?? throw new InvalidOperationException("Failed to deserialize permission request"); + + var invocation = new PermissionInvocation + { + SessionId = SessionId + }; + + var result = await handler(request, invocation); + await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, result); + } + catch (Exception) + { + try + { + await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, new PermissionRequestResult + { + Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser + }); + } + catch (IOException) + { + // Connection lost or RPC error — nothing we can do + } + catch (ObjectDisposedException) + { + // Connection already disposed — nothing we can do + } + } + } + /// /// Registers a handler for user input requests from the agent. /// diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 8fea67515..0da0fdad5 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -92,13 +92,14 @@ public IReadOnlyDictionary GetEnvironment() return env!; } - public CopilotClient CreateClient() + public CopilotClient CreateClient(bool useStdio = true) { return new(new CopilotClientOptions { Cwd = WorkDir, CliPath = GetCliPath(_repoRoot), Environment = GetEnvironment(), + UseStdio = useStdio, GitHubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")) ? "fake-token-for-e2e-tests" : null, }); } diff --git a/dotnet/test/MultiClientTests.cs b/dotnet/test/MultiClientTests.cs new file mode 100644 index 000000000..131fd31d0 --- /dev/null +++ b/dotnet/test/MultiClientTests.cs @@ -0,0 +1,348 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Reflection; +using System.Text.RegularExpressions; +using GitHub.Copilot.SDK.Test.Harness; +using Microsoft.Extensions.AI; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +/// +/// Custom fixture for multi-client tests that uses TCP mode so a second client can connect. +/// +public class MultiClientTestFixture : IAsyncLifetime +{ + public E2ETestContext Ctx { get; private set; } = null!; + public CopilotClient Client1 { get; private set; } = null!; + + public async Task InitializeAsync() + { + Ctx = await E2ETestContext.CreateAsync(); + Client1 = Ctx.CreateClient(useStdio: false); + } + + public async Task DisposeAsync() + { + if (Client1 is not null) + { + await Client1.ForceStopAsync(); + } + + await Ctx.DisposeAsync(); + } +} + +public class MultiClientTests : IClassFixture, IAsyncLifetime +{ + private readonly MultiClientTestFixture _fixture; + private readonly string _testName; + private CopilotClient? _client2; + + private E2ETestContext Ctx => _fixture.Ctx; + private CopilotClient Client1 => _fixture.Client1; + + public MultiClientTests(MultiClientTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _testName = GetTestName(output); + } + + private static string GetTestName(ITestOutputHelper output) + { + var type = output.GetType(); + var testField = type.GetField("test", BindingFlags.Instance | BindingFlags.NonPublic); + var test = (ITest?)testField?.GetValue(output); + return test?.TestCase.TestMethod.Method.Name ?? throw new InvalidOperationException("Couldn't find test name"); + } + + public async Task InitializeAsync() + { + await Ctx.ConfigureForTestAsync("multi_client", _testName); + + // Trigger connection so we can read the port + var initSession = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + await initSession.DisposeAsync(); + + var port = Client1.ActualPort + ?? throw new InvalidOperationException("Client1 is not using TCP mode; ActualPort is null"); + + _client2 = new CopilotClient(new CopilotClientOptions + { + CliUrl = $"localhost:{port}", + }); + } + + public async Task DisposeAsync() + { + if (_client2 is not null) + { + await _client2.ForceStopAsync(); + _client2 = null; + } + } + + private CopilotClient Client2 => _client2 ?? throw new InvalidOperationException("Client2 not initialized"); + + [Fact] + public async Task Both_Clients_See_Tool_Request_And_Completion_Events() + { + var tool = AIFunctionFactory.Create(MagicNumber, "magic_number"); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [tool], + }); + + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + // Set up event waiters BEFORE sending the prompt to avoid race conditions + var client1Requested = new TaskCompletionSource(); + var client2Requested = new TaskCompletionSource(); + var client1Completed = new TaskCompletionSource(); + var client2Completed = new TaskCompletionSource(); + + using var sub1 = session1.On(evt => + { + if (evt is ExternalToolRequestedEvent) client1Requested.TrySetResult(true); + if (evt is ExternalToolCompletedEvent) client1Completed.TrySetResult(true); + }); + using var sub2 = session2.On(evt => + { + if (evt is ExternalToolRequestedEvent) client2Requested.TrySetResult(true); + if (evt is ExternalToolCompletedEvent) client2Completed.TrySetResult(true); + }); + + var response = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the magic_number tool with seed 'hello' and tell me the result", + }); + + Assert.NotNull(response); + Assert.Contains("MAGIC_hello_42", response!.Data.Content ?? string.Empty); + + // Wait for all broadcast events to arrive on both clients + var timeout = Task.Delay(TimeSpan.FromSeconds(10)); + var allEvents = Task.WhenAll( + client1Requested.Task, client2Requested.Task, + client1Completed.Task, client2Completed.Task); + Assert.Equal(allEvents, await Task.WhenAny(allEvents, timeout)); + + await session2.DisposeAsync(); + + [Description("Returns a magic number")] + static string MagicNumber([Description("A seed value")] string seed) => $"MAGIC_{seed}_42"; + } + + [Fact] + public async Task One_Client_Approves_Permission_And_Both_See_The_Result() + { + var client1PermissionRequests = new List(); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (request, _) => + { + client1PermissionRequests.Add(request); + return Task.FromResult(new PermissionRequestResult + { + Kind = PermissionRequestResultKind.Approved, + }); + }, + }); + + // Client 2 resumes — its handler never completes, so only client 1's approval takes effect + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = (_, _) => new TaskCompletionSource().Task, + }); + + var client1Events = new ConcurrentBag(); + var client2Events = new ConcurrentBag(); + + using var sub1 = session1.On(evt => client1Events.Add(evt)); + using var sub2 = session2.On(evt => client2Events.Add(evt)); + + var response = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Create a file called hello.txt containing the text 'hello world'", + }); + + Assert.NotNull(response); + Assert.NotEmpty(client1PermissionRequests); + + Assert.Contains(client1Events, e => e is PermissionRequestedEvent); + Assert.Contains(client2Events, e => e is PermissionRequestedEvent); + Assert.Contains(client1Events, e => e is PermissionCompletedEvent); + Assert.Contains(client2Events, e => e is PermissionCompletedEvent); + + foreach (var evt in client1Events.OfType() + .Concat(client2Events.OfType())) + { + Assert.Equal(PermissionCompletedDataResultKind.Approved, evt.Data.Result.Kind); + } + + await session2.DisposeAsync(); + } + + [Fact] + public async Task One_Client_Rejects_Permission_And_Both_See_The_Result() + { + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (_, _) => Task.FromResult(new PermissionRequestResult + { + Kind = PermissionRequestResultKind.DeniedInteractivelyByUser, + }), + }); + + // Client 2 resumes — its handler never completes + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = (_, _) => new TaskCompletionSource().Task, + }); + + var client1Events = new ConcurrentBag(); + var client2Events = new ConcurrentBag(); + + using var sub1 = session1.On(evt => client1Events.Add(evt)); + using var sub2 = session2.On(evt => client2Events.Add(evt)); + + // Write a file so the agent has something to edit + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "protected.txt"), "protected content"); + + await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Edit protected.txt and replace 'protected' with 'hacked'.", + }); + + // Verify the file was NOT modified + var content = await File.ReadAllTextAsync(Path.Combine(Ctx.WorkDir, "protected.txt")); + Assert.Equal("protected content", content); + + Assert.Contains(client1Events, e => e is PermissionRequestedEvent); + Assert.Contains(client2Events, e => e is PermissionRequestedEvent); + + foreach (var evt in client1Events.OfType() + .Concat(client2Events.OfType())) + { + Assert.Equal(PermissionCompletedDataResultKind.DeniedInteractivelyByUser, evt.Data.Result.Kind); + } + + await session2.DisposeAsync(); + } + + [Fact] + public async Task Two_Clients_Register_Different_Tools_And_Agent_Uses_Both() + { + var toolA = AIFunctionFactory.Create(CityLookup, "city_lookup"); + var toolB = AIFunctionFactory.Create(CurrencyLookup, "currency_lookup"); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolA], + }); + + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolB], + }); + + // Send prompts sequentially to avoid nondeterministic tool_call ordering + var response1 = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the city_lookup tool with countryCode 'US' and tell me the result.", + }); + Assert.NotNull(response1); + Assert.Contains("CITY_FOR_US", response1!.Data.Content ?? string.Empty); + + var response2 = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Now use the currency_lookup tool with countryCode 'US' and tell me the result.", + }); + Assert.NotNull(response2); + Assert.Contains("CURRENCY_FOR_US", response2!.Data.Content ?? string.Empty); + + await session2.DisposeAsync(); + + [Description("Returns a city name for a given country code")] + static string CityLookup([Description("A two-letter country code")] string countryCode) => $"CITY_FOR_{countryCode}"; + + [Description("Returns a currency for a given country code")] + static string CurrencyLookup([Description("A two-letter country code")] string countryCode) => $"CURRENCY_FOR_{countryCode}"; + } + + [Fact] + public async Task Disconnecting_Client_Removes_Its_Tools() + { + var toolA = AIFunctionFactory.Create(StableTool, "stable_tool"); + var toolB = AIFunctionFactory.Create(EphemeralTool, "ephemeral_tool"); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolA], + }); + + await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolB], + }); + + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + var stableResponse = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the stable_tool with input 'test1' and tell me the result.", + }); + Assert.NotNull(stableResponse); + Assert.Contains("STABLE_test1", stableResponse!.Data.Content ?? string.Empty); + + var ephemeralResponse = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the ephemeral_tool with input 'test2' and tell me the result.", + }); + Assert.NotNull(ephemeralResponse); + Assert.Contains("EPHEMERAL_test2", ephemeralResponse!.Data.Content ?? string.Empty); + + // Disconnect client 2 + await Client2.ForceStopAsync(); + await Task.Delay(500); // Let the server process the disconnection + + // Recreate client2 for cleanup + var port = Client1.ActualPort!.Value; + _client2 = new CopilotClient(new CopilotClientOptions + { + CliUrl = $"localhost:{port}", + }); + + // Now only stable_tool should be available + var afterResponse = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }); + Assert.NotNull(afterResponse); + Assert.Contains("STABLE_still_here", afterResponse!.Data.Content ?? string.Empty); + Assert.DoesNotContain("EPHEMERAL_", afterResponse!.Data.Content ?? string.Empty); + + [Description("A tool that persists across disconnects")] + static string StableTool([Description("Input value")] string input) => $"STABLE_{input}"; + + [Description("A tool that will disappear when its client disconnects")] + static string EphemeralTool([Description("Input value")] string input) => $"EPHEMERAL_{input}"; + } +} diff --git a/go/client.go b/go/client.go index 2801ef125..9cb263f9d 100644 --- a/go/client.go +++ b/go/client.go @@ -955,6 +955,12 @@ func (c *Client) State() ConnectionState { return c.state } +// ActualPort returns the TCP port the CLI server is listening on. +// Returns 0 if the client is not connected or using stdio transport. +func (c *Client) ActualPort() int { + return c.actualPort +} + // Ping sends a ping request to the server to verify connectivity. // // The message parameter is optional and will be echoed back in the response. @@ -1289,12 +1295,13 @@ func (c *Client) connectViaTcp(ctx context.Context) error { return nil } -// setupNotificationHandler configures handlers for session events, tool calls, and permission requests. +// setupNotificationHandler configures handlers for session events and RPC requests. +// Tool calls and permission requests are handled via the broadcast event model (protocol v3): +// the server broadcasts external_tool.requested / permission.requested as session events, +// and clients respond via session.tools.handlePendingToolCall / session.permissions.handlePendingPermissionRequest RPCs. func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("session.event", jsonrpc2.NotificationHandlerFor(c.handleSessionEvent)) c.client.SetRequestHandler("session.lifecycle", jsonrpc2.NotificationHandlerFor(c.handleLifecycleEvent)) - c.client.SetRequestHandler("tool.call", jsonrpc2.RequestHandlerFor(c.handleToolCallRequest)) - c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequest)) c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) } @@ -1313,84 +1320,6 @@ func (c *Client) handleSessionEvent(req sessionEventRequest) { } } -// handleToolCallRequest handles a tool call request from the CLI server. -func (c *Client) handleToolCallRequest(req toolCallRequest) (*toolCallResponse, *jsonrpc2.Error) { - if req.SessionID == "" || req.ToolCallID == "" || req.ToolName == "" { - return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid tool call payload"} - } - - c.sessionsMux.Lock() - session, ok := c.sessions[req.SessionID] - c.sessionsMux.Unlock() - if !ok { - return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} - } - - handler, ok := session.getToolHandler(req.ToolName) - if !ok { - return &toolCallResponse{Result: buildUnsupportedToolResult(req.ToolName)}, nil - } - - result := c.executeToolCall(req.SessionID, req.ToolCallID, req.ToolName, req.Arguments, handler) - return &toolCallResponse{Result: result}, nil -} - -// executeToolCall executes a tool handler and returns the result. -func (c *Client) executeToolCall( - sessionID, toolCallID, toolName string, - arguments any, - handler ToolHandler, -) (result ToolResult) { - invocation := ToolInvocation{ - SessionID: sessionID, - ToolCallID: toolCallID, - ToolName: toolName, - Arguments: arguments, - } - - defer func() { - if r := recover(); r != nil { - result = buildFailedToolResult(fmt.Sprintf("tool panic: %v", r)) - } - }() - - if handler != nil { - var err error - result, err = handler(invocation) - if err != nil { - result = buildFailedToolResult(err.Error()) - } - } - - return result -} - -// handlePermissionRequest handles a permission request from the CLI server. -func (c *Client) handlePermissionRequest(req permissionRequestRequest) (*permissionRequestResponse, *jsonrpc2.Error) { - if req.SessionID == "" { - return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid permission request payload"} - } - - c.sessionsMux.Lock() - session, ok := c.sessions[req.SessionID] - c.sessionsMux.Unlock() - if !ok { - return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} - } - - result, err := session.handlePermissionRequest(req.Request) - if err != nil { - // Return denial on error - return &permissionRequestResponse{ - Result: PermissionRequestResult{ - Kind: PermissionRequestResultKindDeniedCouldNotRequestFromUser, - }, - }, nil - } - - return &permissionRequestResponse{Result: result}, nil -} - // handleUserInputRequest handles a user input request from the CLI server. func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) { if req.SessionID == "" || req.Question == "" { @@ -1440,23 +1369,3 @@ func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jso } return result, nil } - -// The detailed error is stored in the Error field but not exposed to the LLM for security. -func buildFailedToolResult(internalError string) ToolResult { - return ToolResult{ - TextResultForLLM: "Invoking this tool produced an error. Detailed information is not available.", - ResultType: "failure", - Error: internalError, - ToolTelemetry: map[string]any{}, - } -} - -// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. -func buildUnsupportedToolResult(toolName string) ToolResult { - return ToolResult{ - TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName), - ResultType: "failure", - Error: fmt.Sprintf("tool '%s' not supported", toolName), - ToolTelemetry: map[string]any{}, - } -} diff --git a/go/client_test.go b/go/client_test.go index d791a5a30..d740fd79b 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -12,41 +12,6 @@ import ( // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.go instead -func TestClient_HandleToolCallRequest(t *testing.T) { - t.Run("returns a standardized failure result when a tool is not registered", func(t *testing.T) { - cliPath := findCLIPathForTest() - if cliPath == "" { - t.Skip("CLI not found") - } - - client := NewClient(&ClientOptions{CLIPath: cliPath}) - t.Cleanup(func() { client.ForceStop() }) - - session, err := client.CreateSession(t.Context(), &SessionConfig{ - OnPermissionRequest: PermissionHandler.ApproveAll, - }) - if err != nil { - t.Fatalf("Failed to create session: %v", err) - } - - params := toolCallRequest{ - SessionID: session.SessionID, - ToolCallID: "123", - ToolName: "missing_tool", - Arguments: map[string]any{}, - } - response, _ := client.handleToolCallRequest(params) - - if response.Result.ResultType != "failure" { - t.Errorf("Expected resultType to be 'failure', got %q", response.Result.ResultType) - } - - if response.Result.Error != "tool 'missing_tool' not supported" { - t.Errorf("Expected error to be \"tool 'missing_tool' not supported\", got %q", response.Result.Error) - } - }) -} - func TestClient_URLParsing(t *testing.T) { t.Run("should parse port-only URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ diff --git a/go/generated_session_events.go b/go/generated_session_events.go index dba38d1ef..86f5066f7 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -26,337 +26,813 @@ func (r *SessionEvent) Marshal() ([]byte, error) { } type SessionEvent struct { - Data Data `json:"data"` - Ephemeral *bool `json:"ephemeral,omitempty"` - ID string `json:"id"` - ParentID *string `json:"parentId"` + // Payload indicating the agent is idle; includes any background tasks still in flight + // + // Empty payload; the event signals that LLM-powered conversation compaction has begun + // + // Empty payload; the event signals that the pending message queue has changed + // + // Empty payload; the event signals that the custom agent was deselected, returning to the + // default agent + Data Data `json:"data"` + // When true, the event is transient and not persisted to the session event log on disk + Ephemeral *bool `json:"ephemeral,omitempty"` + // Unique event identifier (UUID v4), generated when the event is emitted + ID string `json:"id"` + // ID of the chronologically preceding event in the session, forming a linked chain. Null + // for the first event. + ParentID *string `json:"parentId"` + // ISO 8601 timestamp when the event was created Timestamp time.Time `json:"timestamp"` Type SessionEventType `json:"type"` } +// Payload indicating the agent is idle; includes any background tasks still in flight +// +// Empty payload; the event signals that LLM-powered conversation compaction has begun +// +// Empty payload; the event signals that the pending message queue has changed +// +// Empty payload; the event signals that the custom agent was deselected, returning to the +// default agent type Data struct { - Context *ContextUnion `json:"context"` - CopilotVersion *string `json:"copilotVersion,omitempty"` - Producer *string `json:"producer,omitempty"` - SelectedModel *string `json:"selectedModel,omitempty"` - SessionID *string `json:"sessionId,omitempty"` - StartTime *time.Time `json:"startTime,omitempty"` - Version *float64 `json:"version,omitempty"` - EventCount *float64 `json:"eventCount,omitempty"` - ResumeTime *time.Time `json:"resumeTime,omitempty"` - ErrorType *string `json:"errorType,omitempty"` - Message *string `json:"message,omitempty"` - ProviderCallID *string `json:"providerCallId,omitempty"` - Stack *string `json:"stack,omitempty"` - StatusCode *int64 `json:"statusCode,omitempty"` - Title *string `json:"title,omitempty"` - InfoType *string `json:"infoType,omitempty"` - WarningType *string `json:"warningType,omitempty"` - NewModel *string `json:"newModel,omitempty"` - PreviousModel *string `json:"previousModel,omitempty"` - NewMode *string `json:"newMode,omitempty"` - PreviousMode *string `json:"previousMode,omitempty"` - Operation *Operation `json:"operation,omitempty"` - // Relative path within the workspace files directory - Path *string `json:"path,omitempty"` - HandoffTime *time.Time `json:"handoffTime,omitempty"` - RemoteSessionID *string `json:"remoteSessionId,omitempty"` - Repository *RepositoryUnion `json:"repository"` - SourceType *SourceType `json:"sourceType,omitempty"` - Summary *string `json:"summary,omitempty"` - MessagesRemovedDuringTruncation *float64 `json:"messagesRemovedDuringTruncation,omitempty"` - PerformedBy *string `json:"performedBy,omitempty"` - PostTruncationMessagesLength *float64 `json:"postTruncationMessagesLength,omitempty"` - PostTruncationTokensInMessages *float64 `json:"postTruncationTokensInMessages,omitempty"` - PreTruncationMessagesLength *float64 `json:"preTruncationMessagesLength,omitempty"` - PreTruncationTokensInMessages *float64 `json:"preTruncationTokensInMessages,omitempty"` - TokenLimit *float64 `json:"tokenLimit,omitempty"` - TokensRemovedDuringTruncation *float64 `json:"tokensRemovedDuringTruncation,omitempty"` - EventsRemoved *float64 `json:"eventsRemoved,omitempty"` - UpToEventID *string `json:"upToEventId,omitempty"` - CodeChanges *CodeChanges `json:"codeChanges,omitempty"` - CurrentModel *string `json:"currentModel,omitempty"` - ErrorReason *string `json:"errorReason,omitempty"` - ModelMetrics map[string]ModelMetric `json:"modelMetrics,omitempty"` - SessionStartTime *float64 `json:"sessionStartTime,omitempty"` - ShutdownType *ShutdownType `json:"shutdownType,omitempty"` - TotalAPIDurationMS *float64 `json:"totalApiDurationMs,omitempty"` - TotalPremiumRequests *float64 `json:"totalPremiumRequests,omitempty"` - Branch *string `json:"branch,omitempty"` - Cwd *string `json:"cwd,omitempty"` - GitRoot *string `json:"gitRoot,omitempty"` - CurrentTokens *float64 `json:"currentTokens,omitempty"` - MessagesLength *float64 `json:"messagesLength,omitempty"` - CheckpointNumber *float64 `json:"checkpointNumber,omitempty"` - CheckpointPath *string `json:"checkpointPath,omitempty"` - CompactionTokensUsed *CompactionTokensUsed `json:"compactionTokensUsed,omitempty"` - Error *ErrorUnion `json:"error"` - MessagesRemoved *float64 `json:"messagesRemoved,omitempty"` - PostCompactionTokens *float64 `json:"postCompactionTokens,omitempty"` - PreCompactionMessagesLength *float64 `json:"preCompactionMessagesLength,omitempty"` - PreCompactionTokens *float64 `json:"preCompactionTokens,omitempty"` - RequestID *string `json:"requestId,omitempty"` - Success *bool `json:"success,omitempty"` - SummaryContent *string `json:"summaryContent,omitempty"` - TokensRemoved *float64 `json:"tokensRemoved,omitempty"` - AgentMode *AgentMode `json:"agentMode,omitempty"` - Attachments []Attachment `json:"attachments,omitempty"` - Content *string `json:"content,omitempty"` - InteractionID *string `json:"interactionId,omitempty"` - Source *string `json:"source,omitempty"` - TransformedContent *string `json:"transformedContent,omitempty"` - TurnID *string `json:"turnId,omitempty"` - Intent *string `json:"intent,omitempty"` - ReasoningID *string `json:"reasoningId,omitempty"` - DeltaContent *string `json:"deltaContent,omitempty"` - TotalResponseSizeBytes *float64 `json:"totalResponseSizeBytes,omitempty"` - EncryptedContent *string `json:"encryptedContent,omitempty"` - MessageID *string `json:"messageId,omitempty"` - ParentToolCallID *string `json:"parentToolCallId,omitempty"` - Phase *string `json:"phase,omitempty"` - ReasoningOpaque *string `json:"reasoningOpaque,omitempty"` - ReasoningText *string `json:"reasoningText,omitempty"` - ToolRequests []ToolRequest `json:"toolRequests,omitempty"` - APICallID *string `json:"apiCallId,omitempty"` - CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` - CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` - CopilotUsage *CopilotUsage `json:"copilotUsage,omitempty"` - Cost *float64 `json:"cost,omitempty"` - Duration *float64 `json:"duration,omitempty"` - Initiator *string `json:"initiator,omitempty"` - InputTokens *float64 `json:"inputTokens,omitempty"` - Model *string `json:"model,omitempty"` - OutputTokens *float64 `json:"outputTokens,omitempty"` - QuotaSnapshots map[string]QuotaSnapshot `json:"quotaSnapshots,omitempty"` - Reason *string `json:"reason,omitempty"` - Arguments interface{} `json:"arguments"` - ToolCallID *string `json:"toolCallId,omitempty"` - ToolName *string `json:"toolName,omitempty"` - MCPServerName *string `json:"mcpServerName,omitempty"` - MCPToolName *string `json:"mcpToolName,omitempty"` - PartialOutput *string `json:"partialOutput,omitempty"` - ProgressMessage *string `json:"progressMessage,omitempty"` - IsUserRequested *bool `json:"isUserRequested,omitempty"` - Result *Result `json:"result,omitempty"` - ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` - AllowedTools []string `json:"allowedTools,omitempty"` - Name *string `json:"name,omitempty"` - PluginName *string `json:"pluginName,omitempty"` - PluginVersion *string `json:"pluginVersion,omitempty"` - AgentDescription *string `json:"agentDescription,omitempty"` - AgentDisplayName *string `json:"agentDisplayName,omitempty"` - AgentName *string `json:"agentName,omitempty"` - Tools []string `json:"tools"` - HookInvocationID *string `json:"hookInvocationId,omitempty"` - HookType *string `json:"hookType,omitempty"` - Input interface{} `json:"input"` - Output interface{} `json:"output"` - Metadata *Metadata `json:"metadata,omitempty"` - Role *Role `json:"role,omitempty"` - PermissionRequest *PermissionRequest `json:"permissionRequest,omitempty"` - AllowFreeform *bool `json:"allowFreeform,omitempty"` - Choices []string `json:"choices,omitempty"` - Question *string `json:"question,omitempty"` - Mode *Mode `json:"mode,omitempty"` - RequestedSchema *RequestedSchema `json:"requestedSchema,omitempty"` + // Working directory and git context at session start + // + // Updated working directory and git context at resume time + // + // Additional context information for the handoff + Context *ContextUnion `json:"context"` + // Version string of the Copilot application + CopilotVersion *string `json:"copilotVersion,omitempty"` + // Identifier of the software producing the events (e.g., "copilot-agent") + Producer *string `json:"producer,omitempty"` + // Model selected at session creation time, if any + SelectedModel *string `json:"selectedModel,omitempty"` + // Unique identifier for the session + // + // Session ID that this external tool request belongs to + SessionID *string `json:"sessionId,omitempty"` + // ISO 8601 timestamp when the session was created + StartTime *time.Time `json:"startTime,omitempty"` + // Schema version number for the session event format + Version *float64 `json:"version,omitempty"` + // Total number of persisted events in the session at the time of resume + EventCount *float64 `json:"eventCount,omitempty"` + // ISO 8601 timestamp when the session was resumed + ResumeTime *time.Time `json:"resumeTime,omitempty"` + // Category of error (e.g., "authentication", "authorization", "quota", "rate_limit", + // "query") + ErrorType *string `json:"errorType,omitempty"` + // Human-readable error message + // + // Human-readable informational message for display in the timeline + // + // Human-readable warning message for display in the timeline + // + // Message describing what information is needed from the user + Message *string `json:"message,omitempty"` + // GitHub request tracing ID (x-github-request-id header) for correlating with server-side + // logs + // + // GitHub request tracing ID (x-github-request-id header) for server-side log correlation + ProviderCallID *string `json:"providerCallId,omitempty"` + // Error stack trace, when available + Stack *string `json:"stack,omitempty"` + // HTTP status code from the upstream request, if applicable + StatusCode *int64 `json:"statusCode,omitempty"` + // Background tasks still running when the agent became idle + BackgroundTasks *BackgroundTasks `json:"backgroundTasks,omitempty"` + // The new display title for the session + Title *string `json:"title,omitempty"` + // Category of informational message (e.g., "notification", "timing", "context_window", + // "mcp", "snapshot", "configuration", "authentication", "model") + InfoType *string `json:"infoType,omitempty"` + // Category of warning (e.g., "subscription", "policy", "mcp") + WarningType *string `json:"warningType,omitempty"` + // Newly selected model identifier + NewModel *string `json:"newModel,omitempty"` + // Model that was previously selected, if any + PreviousModel *string `json:"previousModel,omitempty"` + // Agent mode after the change (e.g., "interactive", "plan", "autopilot") + NewMode *string `json:"newMode,omitempty"` + // Agent mode before the change (e.g., "interactive", "plan", "autopilot") + PreviousMode *string `json:"previousMode,omitempty"` + // The type of operation performed on the plan file + // + // Whether the file was newly created or updated + Operation *Operation `json:"operation,omitempty"` + // Relative path within the session workspace files directory + // + // File path to the SKILL.md definition + Path *string `json:"path,omitempty"` + // ISO 8601 timestamp when the handoff occurred + HandoffTime *time.Time `json:"handoffTime,omitempty"` + // Session ID of the remote session being handed off + RemoteSessionID *string `json:"remoteSessionId,omitempty"` + // Repository context for the handed-off session + // + // Repository identifier in "owner/name" format, derived from the git remote URL + Repository *RepositoryUnion `json:"repository"` + // Origin type of the session being handed off + SourceType *SourceType `json:"sourceType,omitempty"` + // Summary of the work done in the source session + // + // Optional summary of the completed task, provided by the agent + // + // Summary of the plan that was created + Summary *string `json:"summary,omitempty"` + // Number of messages removed by truncation + MessagesRemovedDuringTruncation *float64 `json:"messagesRemovedDuringTruncation,omitempty"` + // Identifier of the component that performed truncation (e.g., "BasicTruncator") + PerformedBy *string `json:"performedBy,omitempty"` + // Number of conversation messages after truncation + PostTruncationMessagesLength *float64 `json:"postTruncationMessagesLength,omitempty"` + // Total tokens in conversation messages after truncation + PostTruncationTokensInMessages *float64 `json:"postTruncationTokensInMessages,omitempty"` + // Number of conversation messages before truncation + PreTruncationMessagesLength *float64 `json:"preTruncationMessagesLength,omitempty"` + // Total tokens in conversation messages before truncation + PreTruncationTokensInMessages *float64 `json:"preTruncationTokensInMessages,omitempty"` + // Maximum token count for the model's context window + TokenLimit *float64 `json:"tokenLimit,omitempty"` + // Number of tokens removed by truncation + TokensRemovedDuringTruncation *float64 `json:"tokensRemovedDuringTruncation,omitempty"` + // Number of events that were removed by the rewind + EventsRemoved *float64 `json:"eventsRemoved,omitempty"` + // Event ID that was rewound to; all events after this one were removed + UpToEventID *string `json:"upToEventId,omitempty"` + // Aggregate code change metrics for the session + CodeChanges *CodeChanges `json:"codeChanges,omitempty"` + // Model that was selected at the time of shutdown + CurrentModel *string `json:"currentModel,omitempty"` + // Error description when shutdownType is "error" + ErrorReason *string `json:"errorReason,omitempty"` + // Per-model usage breakdown, keyed by model identifier + ModelMetrics map[string]ModelMetric `json:"modelMetrics,omitempty"` + // Unix timestamp (milliseconds) when the session started + SessionStartTime *float64 `json:"sessionStartTime,omitempty"` + // Whether the session ended normally ("routine") or due to a crash/fatal error ("error") + ShutdownType *ShutdownType `json:"shutdownType,omitempty"` + // Cumulative time spent in API calls during the session, in milliseconds + TotalAPIDurationMS *float64 `json:"totalApiDurationMs,omitempty"` + // Total number of premium API requests used during the session + TotalPremiumRequests *float64 `json:"totalPremiumRequests,omitempty"` + // Current git branch name + Branch *string `json:"branch,omitempty"` + // Current working directory path + Cwd *string `json:"cwd,omitempty"` + // Root directory of the git repository, resolved via git rev-parse + GitRoot *string `json:"gitRoot,omitempty"` + // Current number of tokens in the context window + CurrentTokens *float64 `json:"currentTokens,omitempty"` + // Current number of messages in the conversation + MessagesLength *float64 `json:"messagesLength,omitempty"` + // Checkpoint snapshot number created for recovery + CheckpointNumber *float64 `json:"checkpointNumber,omitempty"` + // File path where the checkpoint was stored + CheckpointPath *string `json:"checkpointPath,omitempty"` + // Token usage breakdown for the compaction LLM call + CompactionTokensUsed *CompactionTokensUsed `json:"compactionTokensUsed,omitempty"` + // Error message if compaction failed + // + // Error details when the tool execution failed + // + // Error message describing why the sub-agent failed + // + // Error details when the hook failed + Error *ErrorUnion `json:"error"` + // Number of messages removed during compaction + MessagesRemoved *float64 `json:"messagesRemoved,omitempty"` + // Total tokens in conversation after compaction + PostCompactionTokens *float64 `json:"postCompactionTokens,omitempty"` + // Number of messages before compaction + PreCompactionMessagesLength *float64 `json:"preCompactionMessagesLength,omitempty"` + // Total tokens in conversation before compaction + PreCompactionTokens *float64 `json:"preCompactionTokens,omitempty"` + // GitHub request tracing ID (x-github-request-id header) for the compaction LLM call + // + // Unique identifier for this permission request; used to respond via + // session.respondToPermission() + // + // Request ID of the resolved permission request; clients should dismiss any UI for this + // request + // + // Unique identifier for this input request; used to respond via + // session.respondToUserInput() + // + // Request ID of the resolved user input request; clients should dismiss any UI for this + // request + // + // Unique identifier for this elicitation request; used to respond via + // session.respondToElicitation() + // + // Request ID of the resolved elicitation request; clients should dismiss any UI for this + // request + // + // Unique identifier for this request; used to respond via session.respondToExternalTool() + // + // Request ID of the resolved external tool request; clients should dismiss any UI for this + // request + // + // Unique identifier for this request; used to respond via session.respondToQueuedCommand() + // + // Request ID of the resolved command request; clients should dismiss any UI for this + // request + // + // Unique identifier for this request; used to respond via session.respondToExitPlanMode() + // + // Request ID of the resolved exit plan mode request; clients should dismiss any UI for this + // request + RequestID *string `json:"requestId,omitempty"` + // Whether compaction completed successfully + // + // Whether the tool execution completed successfully + // + // Whether the hook completed successfully + Success *bool `json:"success,omitempty"` + // LLM-generated summary of the compacted conversation history + SummaryContent *string `json:"summaryContent,omitempty"` + // Number of tokens removed during compaction + TokensRemoved *float64 `json:"tokensRemoved,omitempty"` + // The agent mode that was active when this message was sent + AgentMode *AgentMode `json:"agentMode,omitempty"` + // Files, selections, or GitHub references attached to the message + Attachments []Attachment `json:"attachments,omitempty"` + // The user's message text as displayed in the timeline + // + // The complete extended thinking text from the model + // + // The assistant's text response content + // + // Full content of the skill file, injected into the conversation for the model + // + // The system or developer prompt text + Content *string `json:"content,omitempty"` + // CAPI interaction ID for correlating this user message with its turn + // + // CAPI interaction ID for correlating this turn with upstream telemetry + // + // CAPI interaction ID for correlating this message with upstream telemetry + // + // CAPI interaction ID for correlating this tool execution with upstream telemetry + InteractionID *string `json:"interactionId,omitempty"` + // Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected + // messages that should be hidden from the user) + Source *string `json:"source,omitempty"` + // Transformed version of the message sent to the model, with XML wrapping, timestamps, and + // other augmentations for prompt caching + TransformedContent *string `json:"transformedContent,omitempty"` + // Identifier for this turn within the agentic loop, typically a stringified turn number + // + // Identifier of the turn that has ended, matching the corresponding assistant.turn_start + // event + TurnID *string `json:"turnId,omitempty"` + // Short description of what the agent is currently doing or planning to do + Intent *string `json:"intent,omitempty"` + // Unique identifier for this reasoning block + // + // Reasoning block ID this delta belongs to, matching the corresponding assistant.reasoning + // event + ReasoningID *string `json:"reasoningId,omitempty"` + // Incremental text chunk to append to the reasoning content + // + // Incremental text chunk to append to the message content + DeltaContent *string `json:"deltaContent,omitempty"` + // Cumulative total bytes received from the streaming response so far + TotalResponseSizeBytes *float64 `json:"totalResponseSizeBytes,omitempty"` + // Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. + EncryptedContent *string `json:"encryptedContent,omitempty"` + // Unique identifier for this assistant message + // + // Message ID this delta belongs to, matching the corresponding assistant.message event + MessageID *string `json:"messageId,omitempty"` + // Actual output token count from the API response (completion_tokens), used for accurate + // token accounting + // + // Number of output tokens produced + OutputTokens *float64 `json:"outputTokens,omitempty"` + // Tool call ID of the parent tool invocation when this event originates from a sub-agent + // + // Parent tool call ID when this usage originates from a sub-agent + ParentToolCallID *string `json:"parentToolCallId,omitempty"` + // Generation phase for phased-output models (e.g., thinking vs. response phases) + Phase *string `json:"phase,omitempty"` + // Opaque/encrypted extended thinking data from Anthropic models. Session-bound and stripped + // on resume. + ReasoningOpaque *string `json:"reasoningOpaque,omitempty"` + // Readable reasoning text from the model's extended thinking + ReasoningText *string `json:"reasoningText,omitempty"` + // Tool invocations requested by the assistant in this message + ToolRequests []ToolRequest `json:"toolRequests,omitempty"` + // Completion ID from the model provider (e.g., chatcmpl-abc123) + APICallID *string `json:"apiCallId,omitempty"` + // Number of tokens read from prompt cache + CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` + // Number of tokens written to prompt cache + CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` + // Per-request cost and usage data from the CAPI copilot_usage response field + CopilotUsage *CopilotUsage `json:"copilotUsage,omitempty"` + // Model multiplier cost for billing purposes + Cost *float64 `json:"cost,omitempty"` + // Duration of the API call in milliseconds + Duration *float64 `json:"duration,omitempty"` + // What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls + Initiator *string `json:"initiator,omitempty"` + // Number of input tokens consumed + InputTokens *float64 `json:"inputTokens,omitempty"` + // Model identifier used for this API call + // + // Model identifier that generated this tool call + Model *string `json:"model,omitempty"` + // Per-quota resource usage snapshots, keyed by quota identifier + QuotaSnapshots map[string]QuotaSnapshot `json:"quotaSnapshots,omitempty"` + // Reason the current turn was aborted (e.g., "user initiated") + Reason *string `json:"reason,omitempty"` + // Arguments for the tool invocation + // + // Arguments passed to the tool + // + // Arguments to pass to the external tool + Arguments interface{} `json:"arguments"` + // Unique identifier for this tool call + // + // Tool call ID this partial result belongs to + // + // Tool call ID this progress notification belongs to + // + // Unique identifier for the completed tool call + // + // Tool call ID of the parent tool invocation that spawned this sub-agent + // + // Tool call ID assigned to this external tool invocation + ToolCallID *string `json:"toolCallId,omitempty"` + // Name of the tool the user wants to invoke + // + // Name of the tool being executed + // + // Name of the external tool to invoke + ToolName *string `json:"toolName,omitempty"` + // Name of the MCP server hosting this tool, when the tool is an MCP tool + MCPServerName *string `json:"mcpServerName,omitempty"` + // Original tool name on the MCP server, when the tool is an MCP tool + MCPToolName *string `json:"mcpToolName,omitempty"` + // Incremental output chunk from the running tool + PartialOutput *string `json:"partialOutput,omitempty"` + // Human-readable progress status message (e.g., from an MCP server) + ProgressMessage *string `json:"progressMessage,omitempty"` + // Whether this tool call was explicitly requested by the user rather than the assistant + IsUserRequested *bool `json:"isUserRequested,omitempty"` + // Tool execution result on success + // + // The result of the permission request + Result *Result `json:"result,omitempty"` + // Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts) + ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` + // Tool names that should be auto-approved when this skill is active + AllowedTools []string `json:"allowedTools,omitempty"` + // Name of the invoked skill + // + // Optional name identifier for the message source + Name *string `json:"name,omitempty"` + // Name of the plugin this skill originated from, when applicable + PluginName *string `json:"pluginName,omitempty"` + // Version of the plugin this skill originated from, when applicable + PluginVersion *string `json:"pluginVersion,omitempty"` + // Description of what the sub-agent does + AgentDescription *string `json:"agentDescription,omitempty"` + // Human-readable display name of the sub-agent + // + // Human-readable display name of the selected custom agent + AgentDisplayName *string `json:"agentDisplayName,omitempty"` + // Internal name of the sub-agent + // + // Internal name of the selected custom agent + AgentName *string `json:"agentName,omitempty"` + // List of tool names available to this agent, or null for all tools + Tools []string `json:"tools"` + // Unique identifier for this hook invocation + // + // Identifier matching the corresponding hook.start event + HookInvocationID *string `json:"hookInvocationId,omitempty"` + // Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + // + // Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + HookType *string `json:"hookType,omitempty"` + // Input data passed to the hook + Input interface{} `json:"input"` + // Output data produced by the hook + Output interface{} `json:"output"` + // Metadata about the prompt template and its construction + Metadata *Metadata `json:"metadata,omitempty"` + // Message role: "system" for system prompts, "developer" for developer-injected instructions + Role *Role `json:"role,omitempty"` + // Details of the permission being requested + PermissionRequest *PermissionRequest `json:"permissionRequest,omitempty"` + // Whether the user can provide a free-form text response in addition to predefined choices + AllowFreeform *bool `json:"allowFreeform,omitempty"` + // Predefined choices for the user to select from, if applicable + Choices []string `json:"choices,omitempty"` + // The question or prompt to present to the user + Question *string `json:"question,omitempty"` + // Elicitation mode; currently only "form" is supported. Defaults to "form" when absent. + Mode *Mode `json:"mode,omitempty"` + // JSON Schema describing the form fields to present to the user + RequestedSchema *RequestedSchema `json:"requestedSchema,omitempty"` + // The slash command text to be executed (e.g., /help, /clear) + Command *string `json:"command,omitempty"` + // Available actions the user can take (e.g., approve, edit, reject) + Actions []string `json:"actions,omitempty"` + // Full content of the plan file + PlanContent *string `json:"planContent,omitempty"` + // The recommended action for the user to take + RecommendedAction *string `json:"recommendedAction,omitempty"` } type Attachment struct { - DisplayName *string `json:"displayName,omitempty"` - LineRange *LineRange `json:"lineRange,omitempty"` - Path *string `json:"path,omitempty"` - Type AttachmentType `json:"type"` - FilePath *string `json:"filePath,omitempty"` - Selection *SelectionClass `json:"selection,omitempty"` - Text *string `json:"text,omitempty"` - Number *float64 `json:"number,omitempty"` - ReferenceType *ReferenceType `json:"referenceType,omitempty"` - State *string `json:"state,omitempty"` - Title *string `json:"title,omitempty"` - URL *string `json:"url,omitempty"` + // User-facing display name for the attachment + // + // User-facing display name for the selection + DisplayName *string `json:"displayName,omitempty"` + // Optional line range to scope the attachment to a specific section of the file + LineRange *LineRange `json:"lineRange,omitempty"` + // Absolute file or directory path + Path *string `json:"path,omitempty"` + // Attachment type discriminator + Type AttachmentType `json:"type"` + // Absolute path to the file containing the selection + FilePath *string `json:"filePath,omitempty"` + // Position range of the selection within the file + Selection *SelectionClass `json:"selection,omitempty"` + // The selected text content + Text *string `json:"text,omitempty"` + // Issue, pull request, or discussion number + Number *float64 `json:"number,omitempty"` + // Type of GitHub reference + ReferenceType *ReferenceType `json:"referenceType,omitempty"` + // Current state of the referenced item (e.g., open, closed, merged) + State *string `json:"state,omitempty"` + // Title of the referenced item + Title *string `json:"title,omitempty"` + // URL to the referenced item on GitHub + URL *string `json:"url,omitempty"` } +// Optional line range to scope the attachment to a specific section of the file type LineRange struct { - End float64 `json:"end"` + // End line number (1-based, inclusive) + End float64 `json:"end"` + // Start line number (1-based) Start float64 `json:"start"` } +// Position range of the selection within the file type SelectionClass struct { End End `json:"end"` Start Start `json:"start"` } type End struct { + // End character offset within the line (0-based) Character float64 `json:"character"` - Line float64 `json:"line"` + // End line number (0-based) + Line float64 `json:"line"` } type Start struct { + // Start character offset within the line (0-based) Character float64 `json:"character"` - Line float64 `json:"line"` + // Start line number (0-based) + Line float64 `json:"line"` +} + +// Background tasks still running when the agent became idle +type BackgroundTasks struct { + // Currently running background agents + Agents []Agent `json:"agents"` + // Currently running background shell commands + Shells []Shell `json:"shells"` } +type Agent struct { + // Unique identifier of the background agent + AgentID string `json:"agentId"` + // Type of the background agent + AgentType string `json:"agentType"` + // Human-readable description of the agent task + Description *string `json:"description,omitempty"` +} + +type Shell struct { + // Human-readable description of the shell command + Description *string `json:"description,omitempty"` + // Unique identifier of the background shell + ShellID string `json:"shellId"` +} + +// Aggregate code change metrics for the session type CodeChanges struct { + // List of file paths that were modified during the session FilesModified []string `json:"filesModified"` - LinesAdded float64 `json:"linesAdded"` - LinesRemoved float64 `json:"linesRemoved"` + // Total number of lines added during the session + LinesAdded float64 `json:"linesAdded"` + // Total number of lines removed during the session + LinesRemoved float64 `json:"linesRemoved"` } +// Token usage breakdown for the compaction LLM call type CompactionTokensUsed struct { + // Cached input tokens reused in the compaction LLM call CachedInput float64 `json:"cachedInput"` - Input float64 `json:"input"` - Output float64 `json:"output"` + // Input tokens consumed by the compaction LLM call + Input float64 `json:"input"` + // Output tokens produced by the compaction LLM call + Output float64 `json:"output"` } +// Working directory and git context at session start +// +// Updated working directory and git context at resume time type ContextClass struct { - Branch *string `json:"branch,omitempty"` - Cwd string `json:"cwd"` - GitRoot *string `json:"gitRoot,omitempty"` + // Current git branch name + Branch *string `json:"branch,omitempty"` + // Current working directory path + Cwd string `json:"cwd"` + // Root directory of the git repository, resolved via git rev-parse + GitRoot *string `json:"gitRoot,omitempty"` + // Repository identifier in "owner/name" format, derived from the git remote URL Repository *string `json:"repository,omitempty"` } +// Per-request cost and usage data from the CAPI copilot_usage response field type CopilotUsage struct { + // Itemized token usage breakdown TokenDetails []TokenDetail `json:"tokenDetails"` - TotalNanoAiu float64 `json:"totalNanoAiu"` + // Total cost in nano-AIU (AI Units) for this request + TotalNanoAiu float64 `json:"totalNanoAiu"` } type TokenDetail struct { - BatchSize float64 `json:"batchSize"` + // Number of tokens in this billing batch + BatchSize float64 `json:"batchSize"` + // Cost per batch of tokens CostPerBatch float64 `json:"costPerBatch"` - TokenCount float64 `json:"tokenCount"` - TokenType string `json:"tokenType"` + // Total token count for this entry + TokenCount float64 `json:"tokenCount"` + // Token category (e.g., "input", "output") + TokenType string `json:"tokenType"` } +// Error details when the tool execution failed +// +// Error details when the hook failed type ErrorClass struct { - Code *string `json:"code,omitempty"` - Message string `json:"message"` - Stack *string `json:"stack,omitempty"` + // Machine-readable error code + Code *string `json:"code,omitempty"` + // Human-readable error message + Message string `json:"message"` + // Error stack trace, when available + Stack *string `json:"stack,omitempty"` } +// Metadata about the prompt template and its construction type Metadata struct { - PromptVersion *string `json:"promptVersion,omitempty"` - Variables map[string]interface{} `json:"variables,omitempty"` + // Version identifier of the prompt template used + PromptVersion *string `json:"promptVersion,omitempty"` + // Template variables used when constructing the prompt + Variables map[string]interface{} `json:"variables,omitempty"` } type ModelMetric struct { + // Request count and cost metrics Requests Requests `json:"requests"` - Usage Usage `json:"usage"` + // Token usage breakdown + Usage Usage `json:"usage"` } +// Request count and cost metrics type Requests struct { - Cost float64 `json:"cost"` + // Cumulative cost multiplier for requests to this model + Cost float64 `json:"cost"` + // Total number of API requests made to this model Count float64 `json:"count"` } +// Token usage breakdown type Usage struct { - CacheReadTokens float64 `json:"cacheReadTokens"` + // Total tokens read from prompt cache across all requests + CacheReadTokens float64 `json:"cacheReadTokens"` + // Total tokens written to prompt cache across all requests CacheWriteTokens float64 `json:"cacheWriteTokens"` - InputTokens float64 `json:"inputTokens"` - OutputTokens float64 `json:"outputTokens"` + // Total input tokens consumed across all requests to this model + InputTokens float64 `json:"inputTokens"` + // Total output tokens produced across all requests to this model + OutputTokens float64 `json:"outputTokens"` } +// Details of the permission being requested type PermissionRequest struct { - CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` - Commands []Command `json:"commands,omitempty"` - FullCommandText *string `json:"fullCommandText,omitempty"` - HasWriteFileRedirection *bool `json:"hasWriteFileRedirection,omitempty"` - Intention *string `json:"intention,omitempty"` - Kind Kind `json:"kind"` - PossiblePaths []string `json:"possiblePaths,omitempty"` - PossibleUrls []PossibleURL `json:"possibleUrls,omitempty"` - ToolCallID *string `json:"toolCallId,omitempty"` - Warning *string `json:"warning,omitempty"` - Diff *string `json:"diff,omitempty"` - FileName *string `json:"fileName,omitempty"` - NewFileContents *string `json:"newFileContents,omitempty"` - Path *string `json:"path,omitempty"` - Args interface{} `json:"args"` - ReadOnly *bool `json:"readOnly,omitempty"` - ServerName *string `json:"serverName,omitempty"` - ToolName *string `json:"toolName,omitempty"` - ToolTitle *string `json:"toolTitle,omitempty"` - URL *string `json:"url,omitempty"` - Citations *string `json:"citations,omitempty"` - Fact *string `json:"fact,omitempty"` - Subject *string `json:"subject,omitempty"` - ToolDescription *string `json:"toolDescription,omitempty"` + // Whether the UI can offer session-wide approval for this command pattern + CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` + // Parsed command identifiers found in the command text + Commands []Command `json:"commands,omitempty"` + // The complete shell command text to be executed + FullCommandText *string `json:"fullCommandText,omitempty"` + // Whether the command includes a file write redirection (e.g., > or >>) + HasWriteFileRedirection *bool `json:"hasWriteFileRedirection,omitempty"` + // Human-readable description of what the command intends to do + // + // Human-readable description of the intended file change + // + // Human-readable description of why the file is being read + // + // Human-readable description of why the URL is being accessed + Intention *string `json:"intention,omitempty"` + // Permission kind discriminator + Kind PermissionRequestKind `json:"kind"` + // File paths that may be read or written by the command + PossiblePaths []string `json:"possiblePaths,omitempty"` + // URLs that may be accessed by the command + PossibleUrls []PossibleURL `json:"possibleUrls,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Optional warning message about risks of running this command + Warning *string `json:"warning,omitempty"` + // Unified diff showing the proposed changes + Diff *string `json:"diff,omitempty"` + // Path of the file being written to + FileName *string `json:"fileName,omitempty"` + // Complete new file contents for newly created files + NewFileContents *string `json:"newFileContents,omitempty"` + // Path of the file or directory being read + Path *string `json:"path,omitempty"` + // Arguments to pass to the MCP tool + // + // Arguments to pass to the custom tool + Args interface{} `json:"args"` + // Whether this MCP tool is read-only (no side effects) + ReadOnly *bool `json:"readOnly,omitempty"` + // Name of the MCP server providing the tool + ServerName *string `json:"serverName,omitempty"` + // Internal name of the MCP tool + // + // Name of the custom tool + ToolName *string `json:"toolName,omitempty"` + // Human-readable title of the MCP tool + ToolTitle *string `json:"toolTitle,omitempty"` + // URL to be fetched + URL *string `json:"url,omitempty"` + // Source references for the stored fact + Citations *string `json:"citations,omitempty"` + // The fact or convention being stored + Fact *string `json:"fact,omitempty"` + // Topic or subject of the memory being stored + Subject *string `json:"subject,omitempty"` + // Description of what the custom tool does + ToolDescription *string `json:"toolDescription,omitempty"` } type Command struct { + // Command identifier (e.g., executable name) Identifier string `json:"identifier"` - ReadOnly bool `json:"readOnly"` + // Whether this command is read-only (no side effects) + ReadOnly bool `json:"readOnly"` } type PossibleURL struct { + // URL that may be accessed by the command URL string `json:"url"` } type QuotaSnapshot struct { - EntitlementRequests float64 `json:"entitlementRequests"` - IsUnlimitedEntitlement bool `json:"isUnlimitedEntitlement"` - Overage float64 `json:"overage"` - OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` - RemainingPercentage float64 `json:"remainingPercentage"` - ResetDate *time.Time `json:"resetDate,omitempty"` - UsageAllowedWithExhaustedQuota bool `json:"usageAllowedWithExhaustedQuota"` - UsedRequests float64 `json:"usedRequests"` + // Total requests allowed by the entitlement + EntitlementRequests float64 `json:"entitlementRequests"` + // Whether the user has an unlimited usage entitlement + IsUnlimitedEntitlement bool `json:"isUnlimitedEntitlement"` + // Number of requests over the entitlement limit + Overage float64 `json:"overage"` + // Whether overage is allowed when quota is exhausted + OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` + // Percentage of quota remaining (0.0 to 1.0) + RemainingPercentage float64 `json:"remainingPercentage"` + // Date when the quota resets + ResetDate *time.Time `json:"resetDate,omitempty"` + // Whether usage is still permitted after quota exhaustion + UsageAllowedWithExhaustedQuota bool `json:"usageAllowedWithExhaustedQuota"` + // Number of requests already consumed + UsedRequests float64 `json:"usedRequests"` } +// Repository context for the handed-off session type RepositoryClass struct { + // Git branch name, if applicable Branch *string `json:"branch,omitempty"` - Name string `json:"name"` - Owner string `json:"owner"` + // Repository name + Name string `json:"name"` + // Repository owner (user or organization) + Owner string `json:"owner"` } +// JSON Schema describing the form fields to present to the user type RequestedSchema struct { + // Form field definitions, keyed by field name Properties map[string]interface{} `json:"properties"` - Required []string `json:"required,omitempty"` - Type RequestedSchemaType `json:"type"` + // List of required field names + Required []string `json:"required,omitempty"` + Type RequestedSchemaType `json:"type"` } +// Tool execution result on success +// +// The result of the permission request type Result struct { - Content string `json:"content"` - Contents []Content `json:"contents,omitempty"` - DetailedContent *string `json:"detailedContent,omitempty"` + // Concise tool result text sent to the LLM for chat completion, potentially truncated for + // token efficiency + Content *string `json:"content,omitempty"` + // Structured content blocks (text, images, audio, resources) returned by the tool in their + // native format + Contents []Content `json:"contents,omitempty"` + // Full detailed tool result for UI/timeline display, preserving complete content such as + // diffs. Falls back to content when absent. + DetailedContent *string `json:"detailedContent,omitempty"` + // The outcome of the permission request + Kind *ResultKind `json:"kind,omitempty"` } type Content struct { - Text *string `json:"text,omitempty"` - Type ContentType `json:"type"` - Cwd *string `json:"cwd,omitempty"` - ExitCode *float64 `json:"exitCode,omitempty"` - Data *string `json:"data,omitempty"` - MIMEType *string `json:"mimeType,omitempty"` - Description *string `json:"description,omitempty"` - Icons []Icon `json:"icons,omitempty"` - Name *string `json:"name,omitempty"` - Size *float64 `json:"size,omitempty"` - Title *string `json:"title,omitempty"` - URI *string `json:"uri,omitempty"` - Resource *ResourceClass `json:"resource,omitempty"` + // The text content + // + // Terminal/shell output text + Text *string `json:"text,omitempty"` + // Content block type discriminator + Type ContentType `json:"type"` + // Working directory where the command was executed + Cwd *string `json:"cwd,omitempty"` + // Process exit code, if the command has completed + ExitCode *float64 `json:"exitCode,omitempty"` + // Base64-encoded image data + // + // Base64-encoded audio data + Data *string `json:"data,omitempty"` + // MIME type of the image (e.g., image/png, image/jpeg) + // + // MIME type of the audio (e.g., audio/wav, audio/mpeg) + // + // MIME type of the resource content + MIMEType *string `json:"mimeType,omitempty"` + // Human-readable description of the resource + Description *string `json:"description,omitempty"` + // Icons associated with this resource + Icons []Icon `json:"icons,omitempty"` + // Resource name identifier + Name *string `json:"name,omitempty"` + // Size of the resource in bytes + Size *float64 `json:"size,omitempty"` + // Human-readable display title for the resource + Title *string `json:"title,omitempty"` + // URI identifying the resource + URI *string `json:"uri,omitempty"` + // The embedded resource contents, either text or base64-encoded binary + Resource *ResourceClass `json:"resource,omitempty"` } type Icon struct { - MIMEType *string `json:"mimeType,omitempty"` - Sizes []string `json:"sizes,omitempty"` - Src string `json:"src"` - Theme *Theme `json:"theme,omitempty"` + // MIME type of the icon image + MIMEType *string `json:"mimeType,omitempty"` + // Available icon sizes (e.g., ['16x16', '32x32']) + Sizes []string `json:"sizes,omitempty"` + // URL or path to the icon image + Src string `json:"src"` + // Theme variant this icon is intended for + Theme *Theme `json:"theme,omitempty"` } +// The embedded resource contents, either text or base64-encoded binary type ResourceClass struct { + // MIME type of the text content + // + // MIME type of the blob content MIMEType *string `json:"mimeType,omitempty"` - Text *string `json:"text,omitempty"` - URI string `json:"uri"` - Blob *string `json:"blob,omitempty"` + // Text content of the resource + Text *string `json:"text,omitempty"` + // URI identifying the resource + URI string `json:"uri"` + // Base64-encoded binary content of the resource + Blob *string `json:"blob,omitempty"` } type ToolRequest struct { - Arguments interface{} `json:"arguments"` - Name string `json:"name"` - ToolCallID string `json:"toolCallId"` - Type *ToolRequestType `json:"type,omitempty"` + // Arguments to pass to the tool, format depends on the tool + Arguments interface{} `json:"arguments"` + // Name of the tool being invoked + Name string `json:"name"` + // Unique identifier for this tool call + ToolCallID string `json:"toolCallId"` + // Tool call type: "function" for standard tool calls, "custom" for grammar-based tool + // calls. Defaults to "function" when absent. + Type *ToolRequestType `json:"type,omitempty"` } +// The agent mode that was active when this message was sent type AgentMode string const ( @@ -366,6 +842,7 @@ const ( Plan AgentMode = "plan" ) +// Type of GitHub reference type ReferenceType string const ( @@ -389,6 +866,9 @@ const ( Form Mode = "form" ) +// The type of operation performed on the plan file +// +// Whether the file was newly created or updated type Operation string const ( @@ -397,16 +877,16 @@ const ( Update Operation = "update" ) -type Kind string +type PermissionRequestKind string const ( - CustomTool Kind = "custom-tool" - KindShell Kind = "shell" - MCP Kind = "mcp" - Memory Kind = "memory" - Read Kind = "read" - URL Kind = "url" - Write Kind = "write" + CustomTool PermissionRequestKind = "custom-tool" + KindShell PermissionRequestKind = "shell" + MCP PermissionRequestKind = "mcp" + Memory PermissionRequestKind = "memory" + Read PermissionRequestKind = "read" + URL PermissionRequestKind = "url" + Write PermissionRequestKind = "write" ) type RequestedSchemaType string @@ -415,6 +895,7 @@ const ( Object RequestedSchemaType = "object" ) +// Theme variant this icon is intended for type Theme string const ( @@ -433,6 +914,18 @@ const ( Text ContentType = "text" ) +// The outcome of the permission request +type ResultKind string + +const ( + Approved ResultKind = "approved" + DeniedByContentExclusionPolicy ResultKind = "denied-by-content-exclusion-policy" + DeniedByRules ResultKind = "denied-by-rules" + DeniedInteractivelyByUser ResultKind = "denied-interactively-by-user" + DeniedNoApprovalRuleAndCouldNotRequestFromUser ResultKind = "denied-no-approval-rule-and-could-not-request-from-user" +) + +// Message role: "system" for system prompts, "developer" for developer-injected instructions type Role string const ( @@ -440,6 +933,7 @@ const ( System Role = "system" ) +// Whether the session ended normally ("routine") or due to a crash/fatal error ("error") type ShutdownType string const ( @@ -447,6 +941,7 @@ const ( Routine ShutdownType = "routine" ) +// Origin type of the session being handed off type SourceType string const ( @@ -454,6 +949,8 @@ const ( Remote SourceType = "remote" ) +// Tool call type: "function" for standard tool calls, "custom" for grammar-based tool +// calls. Defaults to "function" when absent. type ToolRequestType string const ( @@ -474,8 +971,14 @@ const ( AssistantTurnEnd SessionEventType = "assistant.turn_end" AssistantTurnStart SessionEventType = "assistant.turn_start" AssistantUsage SessionEventType = "assistant.usage" + CommandCompleted SessionEventType = "command.completed" + CommandQueued SessionEventType = "command.queued" ElicitationCompleted SessionEventType = "elicitation.completed" ElicitationRequested SessionEventType = "elicitation.requested" + ExitPlanModeCompleted SessionEventType = "exit_plan_mode.completed" + ExitPlanModeRequested SessionEventType = "exit_plan_mode.requested" + ExternalToolCompleted SessionEventType = "external_tool.completed" + ExternalToolRequested SessionEventType = "external_tool.requested" HookEnd SessionEventType = "hook.end" HookStart SessionEventType = "hook.start" PendingMessagesModified SessionEventType = "pending_messages.modified" diff --git a/go/internal/e2e/multi_client_test.go b/go/internal/e2e/multi_client_test.go new file mode 100644 index 000000000..9571ab58e --- /dev/null +++ b/go/internal/e2e/multi_client_test.go @@ -0,0 +1,498 @@ +package e2e + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestMultiClient(t *testing.T) { + // Use TCP mode so a second client can connect to the same CLI process + ctx := testharness.NewTestContext(t) + client1 := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: ctx.CLIPath, + Cwd: ctx.WorkDir, + Env: ctx.Env(), + UseStdio: copilot.Bool(false), + }) + t.Cleanup(func() { client1.ForceStop() }) + + // Trigger connection so we can read the port + initSession, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create init session: %v", err) + } + initSession.Disconnect() + + actualPort := client1.ActualPort() + if actualPort == 0 { + t.Fatalf("Expected non-zero port from TCP mode client") + } + + client2 := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: fmt.Sprintf("localhost:%d", actualPort), + }) + t.Cleanup(func() { client2.ForceStop() }) + + t.Run("both clients see tool request and completion events", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type SeedParams struct { + Seed string `json:"seed" jsonschema:"A seed value"` + } + + tool := copilot.DefineTool("magic_number", "Returns a magic number", + func(params SeedParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("MAGIC_%s_42", params.Seed), nil + }) + + // Client 1 creates a session with a custom tool + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{tool}, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes with NO tools — should not overwrite client 1's tools + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Set up event waiters BEFORE sending the prompt to avoid race conditions + client1Requested := make(chan struct{}, 1) + client2Requested := make(chan struct{}, 1) + client1Completed := make(chan struct{}, 1) + client2Completed := make(chan struct{}, 1) + + session1.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ExternalToolRequested { + select { + case client1Requested <- struct{}{}: + default: + } + } + if event.Type == copilot.ExternalToolCompleted { + select { + case client1Completed <- struct{}{}: + default: + } + } + }) + session2.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ExternalToolRequested { + select { + case client2Requested <- struct{}{}: + default: + } + } + if event.Type == copilot.ExternalToolCompleted { + select { + case client2Completed <- struct{}{}: + default: + } + } + }) + + // Send a prompt that triggers the custom tool + response, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the magic_number tool with seed 'hello' and tell me the result", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + if response == nil || response.Data.Content == nil || !strings.Contains(*response.Data.Content, "MAGIC_hello_42") { + t.Errorf("Expected response to contain 'MAGIC_hello_42', got %v", response) + } + + // Wait for all broadcast events to arrive on both clients + timeout := time.After(10 * time.Second) + for _, ch := range []chan struct{}{client1Requested, client2Requested, client1Completed, client2Completed} { + select { + case <-ch: + case <-timeout: + t.Fatal("Timed out waiting for broadcast events on both clients") + } + } + + session2.Disconnect() + }) + + t.Run("one client approves permission and both see the result", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var client1PermissionRequests []copilot.PermissionRequest + var mu sync.Mutex + + // Client 1 creates a session and manually approves permission requests + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + mu.Lock() + client1PermissionRequests = append(client1PermissionRequests, request) + mu.Unlock() + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes — its handler never resolves, so only client 1's approval takes effect + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + // Block forever so only client 1's handler responds + select {} + }, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Track events + var client1Events, client2Events []copilot.SessionEvent + var mu1, mu2 sync.Mutex + session1.On(func(event copilot.SessionEvent) { + mu1.Lock() + client1Events = append(client1Events, event) + mu1.Unlock() + }) + session2.On(func(event copilot.SessionEvent) { + mu2.Lock() + client2Events = append(client2Events, event) + mu2.Unlock() + }) + + // Send a prompt that triggers a write operation (requires permission) + response, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Create a file called hello.txt containing the text 'hello world'", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if response == nil || response.Data.Content == nil || *response.Data.Content == "" { + t.Errorf("Expected non-empty response") + } + + // Client 1 should have handled the permission request + mu.Lock() + permCount := len(client1PermissionRequests) + mu.Unlock() + if permCount == 0 { + t.Errorf("Expected client 1 to handle at least one permission request") + } + + // Both clients should have seen permission.requested events + mu1.Lock() + c1PermRequested := filterEventsByType(client1Events, copilot.PermissionRequested) + mu1.Unlock() + mu2.Lock() + c2PermRequested := filterEventsByType(client2Events, copilot.PermissionRequested) + mu2.Unlock() + + if len(c1PermRequested) == 0 { + t.Errorf("Expected client 1 to see permission.requested events") + } + if len(c2PermRequested) == 0 { + t.Errorf("Expected client 2 to see permission.requested events") + } + + // Both clients should have seen permission.completed events with approved result + mu1.Lock() + c1PermCompleted := filterEventsByType(client1Events, copilot.PermissionCompleted) + mu1.Unlock() + mu2.Lock() + c2PermCompleted := filterEventsByType(client2Events, copilot.PermissionCompleted) + mu2.Unlock() + + if len(c1PermCompleted) == 0 { + t.Errorf("Expected client 1 to see permission.completed events") + } + if len(c2PermCompleted) == 0 { + t.Errorf("Expected client 2 to see permission.completed events") + } + for _, event := range append(c1PermCompleted, c2PermCompleted...) { + if event.Data.Result == nil || event.Data.Result.Kind == nil || *event.Data.Result.Kind != "approved" { + t.Errorf("Expected permission.completed result kind 'approved', got %v", event.Data.Result) + } + } + + session2.Disconnect() + }) + + t.Run("one client rejects permission and both see the result", func(t *testing.T) { + ctx.ConfigureForTest(t) + + // Client 1 creates a session and denies all permission requests + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindDeniedInteractivelyByUser}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes — its handler never resolves so only client 1's denial takes effect + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + select {} + }, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + var client1Events, client2Events []copilot.SessionEvent + var mu1, mu2 sync.Mutex + session1.On(func(event copilot.SessionEvent) { + mu1.Lock() + client1Events = append(client1Events, event) + mu1.Unlock() + }) + session2.On(func(event copilot.SessionEvent) { + mu2.Lock() + client2Events = append(client2Events, event) + mu2.Unlock() + }) + + // Write a test file and ask the agent to edit it + testFile := filepath.Join(ctx.WorkDir, "protected.txt") + if err := os.WriteFile(testFile, []byte("protected content"), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Edit protected.txt and replace 'protected' with 'hacked'.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Verify the file was NOT modified (permission was denied) + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + if string(content) != "protected content" { + t.Errorf("Expected file content 'protected content', got '%s'", string(content)) + } + + // Both clients should have seen permission.requested events + mu1.Lock() + c1PermRequested := filterEventsByType(client1Events, copilot.PermissionRequested) + mu1.Unlock() + mu2.Lock() + c2PermRequested := filterEventsByType(client2Events, copilot.PermissionRequested) + mu2.Unlock() + + if len(c1PermRequested) == 0 { + t.Errorf("Expected client 1 to see permission.requested events") + } + if len(c2PermRequested) == 0 { + t.Errorf("Expected client 2 to see permission.requested events") + } + + // Both clients should see the denial in the completed event + mu1.Lock() + c1PermCompleted := filterEventsByType(client1Events, copilot.PermissionCompleted) + mu1.Unlock() + mu2.Lock() + c2PermCompleted := filterEventsByType(client2Events, copilot.PermissionCompleted) + mu2.Unlock() + + if len(c1PermCompleted) == 0 { + t.Errorf("Expected client 1 to see permission.completed events") + } + if len(c2PermCompleted) == 0 { + t.Errorf("Expected client 2 to see permission.completed events") + } + for _, event := range append(c1PermCompleted, c2PermCompleted...) { + if event.Data.Result == nil || event.Data.Result.Kind == nil || *event.Data.Result.Kind != "denied-interactively-by-user" { + t.Errorf("Expected permission.completed result kind 'denied-interactively-by-user', got %v", event.Data.Result) + } + } + + session2.Disconnect() + }) + + t.Run("two clients register different tools and agent uses both", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type CountryCodeParams struct { + CountryCode string `json:"countryCode" jsonschema:"A two-letter country code"` + } + + toolA := copilot.DefineTool("city_lookup", "Returns a city name for a given country code", + func(params CountryCodeParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("CITY_FOR_%s", params.CountryCode), nil + }) + + toolB := copilot.DefineTool("currency_lookup", "Returns a currency for a given country code", + func(params CountryCodeParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("CURRENCY_FOR_%s", params.CountryCode), nil + }) + + // Client 1 creates a session with tool A + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolA}, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes with tool B (different tool, union should have both) + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolB}, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Send prompts sequentially to avoid nondeterministic tool_call ordering + response1, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the city_lookup tool with countryCode 'US' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if response1 == nil || response1.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*response1.Data.Content, "CITY_FOR_US") { + t.Errorf("Expected response to contain 'CITY_FOR_US', got '%s'", *response1.Data.Content) + } + + response2, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Now use the currency_lookup tool with countryCode 'US' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if response2 == nil || response2.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*response2.Data.Content, "CURRENCY_FOR_US") { + t.Errorf("Expected response to contain 'CURRENCY_FOR_US', got '%s'", *response2.Data.Content) + } + + session2.Disconnect() + }) + + t.Run("disconnecting client removes its tools", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type InputParams struct { + Input string `json:"input" jsonschema:"Input string"` + } + + toolA := copilot.DefineTool("stable_tool", "A tool that persists across disconnects", + func(params InputParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("STABLE_%s", params.Input), nil + }) + + toolB := copilot.DefineTool("ephemeral_tool", "A tool that will disappear when its client disconnects", + func(params InputParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("EPHEMERAL_%s", params.Input), nil + }) + + // Client 1 creates a session with stable_tool + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolA}, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes with ephemeral_tool + _, err = client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolB}, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + stableResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the stable_tool with input 'test1' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if stableResponse == nil || stableResponse.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*stableResponse.Data.Content, "STABLE_test1") { + t.Errorf("Expected response to contain 'STABLE_test1', got '%s'", *stableResponse.Data.Content) + } + + ephemeralResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if ephemeralResponse == nil || ephemeralResponse.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*ephemeralResponse.Data.Content, "EPHEMERAL_test2") { + t.Errorf("Expected response to contain 'EPHEMERAL_test2', got '%s'", *ephemeralResponse.Data.Content) + } + + // Disconnect client 2 without destroying the shared session + client2.ForceStop() + + // Give the server time to process the connection close and remove tools + time.Sleep(500 * time.Millisecond) + + // Recreate client2 for cleanup (but don't rejoin the session) + client2 = copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: fmt.Sprintf("localhost:%d", actualPort), + }) + + // Now only stable_tool should be available + afterResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if afterResponse == nil || afterResponse.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*afterResponse.Data.Content, "STABLE_still_here") { + t.Errorf("Expected response to contain 'STABLE_still_here', got '%s'", *afterResponse.Data.Content) + } + // ephemeral_tool should NOT have produced a result + if strings.Contains(*afterResponse.Data.Content, "EPHEMERAL_") { + t.Errorf("Expected response NOT to contain 'EPHEMERAL_', got '%s'", *afterResponse.Data.Content) + } + }) +} + +func filterEventsByType(events []copilot.SessionEvent, eventType copilot.SessionEventType) []copilot.SessionEvent { + var filtered []copilot.SessionEvent + for _, e := range events { + if e.Type == eventType { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index 858a8032d..67a354202 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -148,17 +148,19 @@ type SessionModeSetParams struct { } type SessionPlanReadResult struct { - // The content of plan.md, or null if it does not exist + // The content of the plan file, or null if it does not exist Content *string `json:"content"` - // Whether plan.md exists in the workspace + // Whether the plan file exists in the workspace Exists bool `json:"exists"` + // Absolute file path of the plan file, or null if workspace is not enabled + Path *string `json:"path"` } type SessionPlanUpdateResult struct { } type SessionPlanUpdateParams struct { - // The new content for plan.md + // The new content for the plan file Content string `json:"content"` } @@ -260,6 +262,40 @@ type SessionCompactionCompactResult struct { TokensRemoved float64 `json:"tokensRemoved"` } +type SessionToolsHandlePendingToolCallResult struct { + Success bool `json:"success"` +} + +type SessionToolsHandlePendingToolCallParams struct { + Error *string `json:"error,omitempty"` + RequestID string `json:"requestId"` + Result *ResultUnion `json:"result"` +} + +type ResultResult struct { + Error *string `json:"error,omitempty"` + ResultType *string `json:"resultType,omitempty"` + TextResultForLlm string `json:"textResultForLlm"` + ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` +} + +type SessionPermissionsHandlePendingPermissionRequestResult struct { + Success bool `json:"success"` +} + +type SessionPermissionsHandlePendingPermissionRequestParams struct { + RequestID string `json:"requestId"` + Result SessionPermissionsHandlePendingPermissionRequestParamsResult `json:"result"` +} + +type SessionPermissionsHandlePendingPermissionRequestParamsResult struct { + Kind Kind `json:"kind"` + Rules []interface{} `json:"rules,omitempty"` + Feedback *string `json:"feedback,omitempty"` + Message *string `json:"message,omitempty"` + Path *string `json:"path,omitempty"` +} + // The current agent mode. // // The agent mode after switching. @@ -273,9 +309,24 @@ const ( Plan Mode = "plan" ) -type ModelsRpcApi struct{ client *jsonrpc2.Client } +type Kind string -func (a *ModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { +const ( + Approved Kind = "approved" + DeniedByContentExclusionPolicy Kind = "denied-by-content-exclusion-policy" + DeniedByRules Kind = "denied-by-rules" + DeniedInteractivelyByUser Kind = "denied-interactively-by-user" + DeniedNoApprovalRuleAndCouldNotRequestFromUser Kind = "denied-no-approval-rule-and-could-not-request-from-user" +) + +type ResultUnion struct { + ResultResult *ResultResult + String *string +} + +type ServerModelsRpcApi struct{ client *jsonrpc2.Client } + +func (a *ServerModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { raw, err := a.client.Request("models.list", map[string]interface{}{}) if err != nil { return nil, err @@ -287,9 +338,9 @@ func (a *ModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { return &result, nil } -type ToolsRpcApi struct{ client *jsonrpc2.Client } +type ServerToolsRpcApi struct{ client *jsonrpc2.Client } -func (a *ToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*ToolsListResult, error) { +func (a *ServerToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*ToolsListResult, error) { raw, err := a.client.Request("tools.list", params) if err != nil { return nil, err @@ -301,9 +352,9 @@ func (a *ToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*Tools return &result, nil } -type AccountRpcApi struct{ client *jsonrpc2.Client } +type ServerAccountRpcApi struct{ client *jsonrpc2.Client } -func (a *AccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, error) { +func (a *ServerAccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, error) { raw, err := a.client.Request("account.getQuota", map[string]interface{}{}) if err != nil { return nil, err @@ -318,9 +369,9 @@ func (a *AccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, e // ServerRpc provides typed server-scoped RPC methods. type ServerRpc struct { client *jsonrpc2.Client - Models *ModelsRpcApi - Tools *ToolsRpcApi - Account *AccountRpcApi + Models *ServerModelsRpcApi + Tools *ServerToolsRpcApi + Account *ServerAccountRpcApi } func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, error) { @@ -337,9 +388,9 @@ func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, func NewServerRpc(client *jsonrpc2.Client) *ServerRpc { return &ServerRpc{client: client, - Models: &ModelsRpcApi{client: client}, - Tools: &ToolsRpcApi{client: client}, - Account: &AccountRpcApi{client: client}, + Models: &ServerModelsRpcApi{client: client}, + Tools: &ServerToolsRpcApi{client: client}, + Account: &ServerAccountRpcApi{client: client}, } } @@ -610,27 +661,80 @@ func (a *CompactionRpcApi) Compact(ctx context.Context) (*SessionCompactionCompa return &result, nil } +type ToolsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *ToolsRpcApi) HandlePendingToolCall(ctx context.Context, params *SessionToolsHandlePendingToolCallParams) (*SessionToolsHandlePendingToolCallResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["requestId"] = params.RequestID + if params.Result != nil { + req["result"] = *params.Result + } + if params.Error != nil { + req["error"] = *params.Error + } + } + raw, err := a.client.Request("session.tools.handlePendingToolCall", req) + if err != nil { + return nil, err + } + var result SessionToolsHandlePendingToolCallResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type PermissionsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *PermissionsRpcApi) HandlePendingPermissionRequest(ctx context.Context, params *SessionPermissionsHandlePendingPermissionRequestParams) (*SessionPermissionsHandlePendingPermissionRequestResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["requestId"] = params.RequestID + req["result"] = params.Result + } + raw, err := a.client.Request("session.permissions.handlePendingPermissionRequest", req) + if err != nil { + return nil, err + } + var result SessionPermissionsHandlePendingPermissionRequestResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // SessionRpc provides typed session-scoped RPC methods. type SessionRpc struct { - client *jsonrpc2.Client - sessionID string - Model *ModelRpcApi - Mode *ModeRpcApi - Plan *PlanRpcApi - Workspace *WorkspaceRpcApi - Fleet *FleetRpcApi - Agent *AgentRpcApi - Compaction *CompactionRpcApi + client *jsonrpc2.Client + sessionID string + Model *ModelRpcApi + Mode *ModeRpcApi + Plan *PlanRpcApi + Workspace *WorkspaceRpcApi + Fleet *FleetRpcApi + Agent *AgentRpcApi + Compaction *CompactionRpcApi + Tools *ToolsRpcApi + Permissions *PermissionsRpcApi } func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { return &SessionRpc{client: client, sessionID: sessionID, - Model: &ModelRpcApi{client: client, sessionID: sessionID}, - Mode: &ModeRpcApi{client: client, sessionID: sessionID}, - Plan: &PlanRpcApi{client: client, sessionID: sessionID}, - Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, - Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, - Agent: &AgentRpcApi{client: client, sessionID: sessionID}, - Compaction: &CompactionRpcApi{client: client, sessionID: sessionID}, + Model: &ModelRpcApi{client: client, sessionID: sessionID}, + Mode: &ModeRpcApi{client: client, sessionID: sessionID}, + Plan: &PlanRpcApi{client: client, sessionID: sessionID}, + Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, + Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + Agent: &AgentRpcApi{client: client, sessionID: sessionID}, + Compaction: &CompactionRpcApi{client: client, sessionID: sessionID}, + Tools: &ToolsRpcApi{client: client, sessionID: sessionID}, + Permissions: &PermissionsRpcApi{client: client, sessionID: sessionID}, } } diff --git a/go/rpc/result_union.go b/go/rpc/result_union.go new file mode 100644 index 000000000..6cd948b50 --- /dev/null +++ b/go/rpc/result_union.go @@ -0,0 +1,35 @@ +package rpc + +import "encoding/json" + +// MarshalJSON serializes ResultUnion as the appropriate JSON variant: +// a plain string when String is set, or the ResultResult object otherwise. +// The generated struct has no custom marshaler, so without this the Go +// struct fields would serialize as {"ResultResult":...,"String":...} +// instead of the union the server expects. +func (r ResultUnion) MarshalJSON() ([]byte, error) { + if r.String != nil { + return json.Marshal(*r.String) + } + if r.ResultResult != nil { + return json.Marshal(*r.ResultResult) + } + return []byte("null"), nil +} + +// UnmarshalJSON deserializes a JSON value into the appropriate ResultUnion variant. +func (r *ResultUnion) UnmarshalJSON(data []byte) error { + // Try string first + var s string + if err := json.Unmarshal(data, &s); err == nil { + r.String = &s + return nil + } + // Try ResultResult object + var rr ResultResult + if err := json.Unmarshal(data, &rr); err == nil { + r.ResultResult = &rr + return nil + } + return nil +} diff --git a/go/sdk_protocol_version.go b/go/sdk_protocol_version.go index 52b1ebe02..95249568b 100644 --- a/go/sdk_protocol_version.go +++ b/go/sdk_protocol_version.go @@ -4,7 +4,7 @@ package copilot // SdkProtocolVersion is the SDK protocol version. // This must match the version expected by the copilot-agent-runtime server. -const SdkProtocolVersion = 2 +const SdkProtocolVersion = 3 // GetSdkProtocolVersion returns the SDK protocol version. func GetSdkProtocolVersion() int { diff --git a/go/session.go b/go/session.go index e705d32aa..c06a8e1ec 100644 --- a/go/session.go +++ b/go/session.go @@ -303,24 +303,6 @@ func (s *Session) getPermissionHandler() PermissionHandlerFunc { return s.permissionHandler } -// handlePermissionRequest handles a permission request from the Copilot CLI. -// This is an internal method called by the SDK when the CLI requests permission. -func (s *Session) handlePermissionRequest(request PermissionRequest) (PermissionRequestResult, error) { - handler := s.getPermissionHandler() - - if handler == nil { - return PermissionRequestResult{ - Kind: PermissionRequestResultKindDeniedCouldNotRequestFromUser, - }, nil - } - - invocation := PermissionInvocation{ - SessionID: s.SessionID, - } - - return handler(request, invocation) -} - // registerUserInputHandler registers a user input handler for this session. // // When the assistant needs to ask the user a question (e.g., via ask_user tool), @@ -457,6 +439,9 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( // This is an internal method; handlers are called synchronously and any panics // are recovered to prevent crashing the event dispatcher. func (s *Session) dispatchEvent(event SessionEvent) { + // Handle broadcast request events internally (fire-and-forget) + s.handleBroadcastEvent(event) + s.handlerMutex.RLock() handlers := make([]SessionEventHandler, 0, len(s.handlers)) for _, h := range s.handlers { @@ -477,6 +462,117 @@ func (s *Session) dispatchEvent(event SessionEvent) { } } +// handleBroadcastEvent handles broadcast request events by executing local handlers +// and responding via RPC. This implements the protocol v3 broadcast model where tool +// calls and permission requests are broadcast as session events to all clients. +func (s *Session) handleBroadcastEvent(event SessionEvent) { + switch event.Type { + case ExternalToolRequested: + requestID := event.Data.RequestID + toolName := event.Data.ToolName + if requestID == nil || toolName == nil { + return + } + handler, ok := s.getToolHandler(*toolName) + if !ok { + return + } + toolCallID := "" + if event.Data.ToolCallID != nil { + toolCallID = *event.Data.ToolCallID + } + go s.executeToolAndRespond(*requestID, *toolName, toolCallID, event.Data.Arguments, handler) + + case PermissionRequested: + requestID := event.Data.RequestID + if requestID == nil || event.Data.PermissionRequest == nil { + return + } + handler := s.getPermissionHandler() + if handler == nil { + return + } + go s.executePermissionAndRespond(*requestID, *event.Data.PermissionRequest, handler) + } +} + +// executeToolAndRespond executes a tool handler and sends the result back via RPC. +func (s *Session) executeToolAndRespond(requestID, toolName, toolCallID string, arguments any, handler ToolHandler) { + defer func() { + if r := recover(); r != nil { + errMsg := fmt.Sprintf("tool panic: %v", r) + s.RPC.Tools.HandlePendingToolCall(context.Background(), &rpc.SessionToolsHandlePendingToolCallParams{ + RequestID: requestID, + Error: &errMsg, + }) + } + }() + + invocation := ToolInvocation{ + SessionID: s.SessionID, + ToolCallID: toolCallID, + ToolName: toolName, + Arguments: arguments, + } + + result, err := handler(invocation) + if err != nil { + errMsg := err.Error() + s.RPC.Tools.HandlePendingToolCall(context.Background(), &rpc.SessionToolsHandlePendingToolCallParams{ + RequestID: requestID, + Error: &errMsg, + }) + return + } + + resultStr := result.TextResultForLLM + if resultStr == "" { + resultStr = fmt.Sprintf("%v", result) + } + s.RPC.Tools.HandlePendingToolCall(context.Background(), &rpc.SessionToolsHandlePendingToolCallParams{ + RequestID: requestID, + Result: &rpc.ResultUnion{String: &resultStr}, + }) +} + +// executePermissionAndRespond executes a permission handler and sends the result back via RPC. +func (s *Session) executePermissionAndRespond(requestID string, permissionRequest PermissionRequest, handler PermissionHandlerFunc) { + defer func() { + if r := recover(); r != nil { + s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ + RequestID: requestID, + Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ + Kind: rpc.DeniedNoApprovalRuleAndCouldNotRequestFromUser, + }, + }) + } + }() + + invocation := PermissionInvocation{ + SessionID: s.SessionID, + } + + result, err := handler(permissionRequest, invocation) + if err != nil { + s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ + RequestID: requestID, + Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ + Kind: rpc.DeniedNoApprovalRuleAndCouldNotRequestFromUser, + }, + }) + return + } + + s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ + RequestID: requestID, + Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ + Kind: rpc.Kind(result.Kind), + Rules: result.Rules, + Feedback: nil, + }, + }) +} + // GetMessages retrieves all events and messages from this session's history. // // This returns the complete conversation history including user messages, diff --git a/go/types.go b/go/types.go index 972222abe..d749de74a 100644 --- a/go/types.go +++ b/go/types.go @@ -633,17 +633,6 @@ type SessionLifecycleEventMetadata struct { // SessionLifecycleHandler is a callback for session lifecycle events type SessionLifecycleHandler func(event SessionLifecycleEvent) -// permissionRequestRequest represents the request data for a permission request -type permissionRequestRequest struct { - SessionID string `json:"sessionId"` - Request PermissionRequest `json:"permissionRequest"` -} - -// permissionRequestResponse represents the response to a permission request -type permissionRequestResponse struct { - Result PermissionRequestResult `json:"result"` -} - // createSessionRequest is the request for session.create type createSessionRequest struct { Model string `json:"model,omitempty"` @@ -840,21 +829,6 @@ type sessionEventRequest struct { Event SessionEvent `json:"event"` } -// toolCallRequest represents a tool call request from the server -// to the client for execution. -type toolCallRequest struct { - SessionID string `json:"sessionId"` - ToolCallID string `json:"toolCallId"` - ToolName string `json:"toolName"` - Arguments any `json:"arguments"` -} - -// toolCallResponse represents the response to a tool call request -// from the client back to the server. -type toolCallResponse struct { - Result ToolResult `json:"result"` -} - // userInputRequest represents a request for user input from the agent type userInputRequest struct { SessionID string `json:"sessionId"` diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index fc3d4e3b4..78aacd1c0 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.421", + "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.421", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.421.tgz", - "integrity": "sha512-nDUt9f5al7IgBOTc7AwLpqvaX61VsRDYDQ9D5iR0QQzHo4pgDcyOXIjXUQUKsJwObXHfh6qR+Jm1vnlbw5cacg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.2.tgz", + "integrity": "sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.421", - "@github/copilot-darwin-x64": "0.0.421", - "@github/copilot-linux-arm64": "0.0.421", - "@github/copilot-linux-x64": "0.0.421", - "@github/copilot-win32-arm64": "0.0.421", - "@github/copilot-win32-x64": "0.0.421" + "@github/copilot-darwin-arm64": "1.0.2", + "@github/copilot-darwin-x64": "1.0.2", + "@github/copilot-linux-arm64": "1.0.2", + "@github/copilot-linux-x64": "1.0.2", + "@github/copilot-win32-arm64": "1.0.2", + "@github/copilot-win32-x64": "1.0.2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.421", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.421.tgz", - "integrity": "sha512-S4plFsxH7W8X1gEkGNcfyKykIji4mNv8BP/GpPs2Ad84qWoJpZzfZsjrjF0BQ8mvFObWp6Ft2SZOnJzFZW1Ftw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.421", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.421.tgz", - "integrity": "sha512-h+Dbfq8ByAielLYIeJbjkN/9Abs6AKHFi+XuuzEy4YA9jOA42uKMFsWYwaoYH8ZLK9Y+4wagYI9UewVPnyIWPA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.2.tgz", + "integrity": "sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.421", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.421.tgz", - "integrity": "sha512-cxlqDRR/wKfbdzd456N2h7sZOZY069wU2ycSYSmo7cC75U5DyhMGYAZwyAhvQ7UKmS5gJC/wgSgye0njuK22Xg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.2.tgz", + "integrity": "sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.421", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.421.tgz", - "integrity": "sha512-7np5b6EEemJ3U3jnl92buJ88nlpqOAIrLaJxx3pJGrP9SVFMBD/6EAlfIQ5m5QTfs+/vIuTKWBrq1wpFVZZUcQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.2.tgz", + "integrity": "sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.421", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.421.tgz", - "integrity": "sha512-T6qCqOnijD5pmC0ytVsahX3bpDnXtLTgo9xFGo/BGaPEvX02ePkzcRZkfkOclkzc8QlkVji6KqZYB+qMZTliwg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.2.tgz", + "integrity": "sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.421", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.421.tgz", - "integrity": "sha512-KDfy3wsRQFIcOQDdd5Mblvh+DWRq+UGbTQ34wyW36ws1BsdWkV++gk9bTkeJRsPbQ51wsJ0V/jRKEZv4uK5dTA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.2.tgz", + "integrity": "sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index ef89556ac..ccd63582a 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -12,6 +12,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./extension": { + "import": "./dist/extension.js", + "types": "./dist/extension.d.ts" } }, "type": "module", @@ -40,7 +44,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.421", + "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/scripts/get-version.js b/nodejs/scripts/get-version.js index d58ff79d9..784dd0b51 100644 --- a/nodejs/scripts/get-version.js +++ b/nodejs/scripts/get-version.js @@ -5,7 +5,7 @@ * * Usage: * - * node scripts/get-version.js [current|current-prerelease|latest|prerelease] + * node scripts/get-version.js [current|current-prerelease|latest|prerelease|unstable] * * Outputs the version to stdout. */ @@ -32,7 +32,7 @@ async function getLatestVersion(tag) { async function main() { const command = process.argv[2]; - const validCommands = ["current", "current-prerelease", "latest", "prerelease"]; + const validCommands = ["current", "current-prerelease", "latest", "prerelease", "unstable"]; if (!validCommands.includes(command)) { console.error( `Invalid argument, must be one of: ${validCommands.join(", ")}, got: "${command}"` @@ -75,8 +75,16 @@ async function main() { return; } + if (command === "unstable") { + const unstable = await getLatestVersion("unstable"); + if (unstable && semver.gt(unstable, higherVersion)) { + higherVersion = unstable; + } + } + const increment = command === "latest" ? "patch" : "prerelease"; - const prereleaseIdentifier = command === "prerelease" ? "preview" : undefined; + const prereleaseIdentifier = + command === "prerelease" ? "preview" : command === "unstable" ? "unstable" : undefined; const nextVersion = semver.inc(higherVersion, increment, prereleaseIdentifier); if (!nextVersion) { console.error(`Failed to increment version "${higherVersion}" with "${increment}"`); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 7e441a7dd..0ce47a2a4 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -42,11 +42,6 @@ import type { SessionListFilter, SessionMetadata, Tool, - ToolCallRequestPayload, - ToolCallResponsePayload, - ToolHandler, - ToolResult, - ToolResultObject, TypedSessionLifecycleHandler, } from "./types.js"; @@ -196,6 +191,12 @@ export class CopilotClient { throw new Error("cliUrl is mutually exclusive with useStdio and cliPath"); } + if (options.isChildProcess && (options.cliUrl || options.useStdio === false)) { + throw new Error( + "isChildProcess must be used in conjunction with useStdio and not with cliUrl" + ); + } + // Validate auth options with external server if (options.cliUrl && (options.githubToken || options.useLoggedInUser !== undefined)) { throw new Error( @@ -211,12 +212,17 @@ export class CopilotClient { this.isExternalServer = true; } + if (options.isChildProcess) { + this.isExternalServer = true; + } + this.options = { cliPath: options.cliPath || getBundledCliPath(), cliArgs: options.cliArgs ?? [], cwd: options.cwd ?? process.cwd(), port: options.port || 0, useStdio: options.cliUrl ? false : (options.useStdio ?? true), // Default to stdio unless cliUrl is provided + isChildProcess: options.isChildProcess ?? false, cliUrl: options.cliUrl, logLevel: options.logLevel || "debug", autoStart: options.autoStart ?? true, @@ -1210,17 +1216,19 @@ export class CopilotClient { * Connect to the CLI server (via socket or stdio) */ private async connectToServer(): Promise { - if (this.options.useStdio) { - return this.connectViaStdio(); + if (this.options.isChildProcess) { + return this.connectToParentProcessViaStdio(); + } else if (this.options.useStdio) { + return this.connectToChildProcessViaStdio(); } else { return this.connectViaTcp(); } } /** - * Connect via stdio pipes + * Connect to child via stdio pipes */ - private async connectViaStdio(): Promise { + private async connectToChildProcessViaStdio(): Promise { if (!this.cliProcess) { throw new Error("CLI process not started"); } @@ -1242,6 +1250,24 @@ export class CopilotClient { this.connection.listen(); } + /** + * Connect to parent via stdio pipes + */ + private async connectToParentProcessViaStdio(): Promise { + if (this.cliProcess) { + throw new Error("CLI child process was unexpectedly started in parent process mode"); + } + + // Create JSON-RPC connection over stdin/stdout + this.connection = createMessageConnection( + new StreamMessageReader(process.stdin), + new StreamMessageWriter(process.stdout) + ); + + this.attachConnectionHandlers(); + this.connection.listen(); + } + /** * Connect to the CLI server via TCP socket */ @@ -1284,19 +1310,11 @@ export class CopilotClient { this.handleSessionLifecycleNotification(notification); }); - this.connection.onRequest( - "tool.call", - async (params: ToolCallRequestPayload): Promise => - await this.handleToolCallRequest(params) - ); - - this.connection.onRequest( - "permission.request", - async (params: { - sessionId: string; - permissionRequest: unknown; - }): Promise<{ result: unknown }> => await this.handlePermissionRequest(params) - ); + // External tool calls and permission requests are now handled via broadcast events: + // the server sends external_tool.requested / permission.requested as session event + // notifications, and CopilotSession._dispatchEvent handles them internally by + // executing the handler and responding via session.tools.handlePendingToolCall / + // session.permissions.handlePendingPermissionRequest RPC. this.connection.onRequest( "userInput.request", @@ -1382,86 +1400,6 @@ export class CopilotClient { } } - private async handleToolCallRequest( - params: ToolCallRequestPayload - ): Promise { - if ( - !params || - typeof params.sessionId !== "string" || - typeof params.toolCallId !== "string" || - typeof params.toolName !== "string" - ) { - throw new Error("Invalid tool call payload"); - } - - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Unknown session ${params.sessionId}`); - } - - const handler = session.getToolHandler(params.toolName); - if (!handler) { - return { result: this.buildUnsupportedToolResult(params.toolName) }; - } - - return await this.executeToolCall(handler, params); - } - - private async executeToolCall( - handler: ToolHandler, - request: ToolCallRequestPayload - ): Promise { - try { - const invocation = { - sessionId: request.sessionId, - toolCallId: request.toolCallId, - toolName: request.toolName, - arguments: request.arguments, - }; - const result = await handler(request.arguments, invocation); - - return { result: this.normalizeToolResult(result) }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - result: { - // Don't expose detailed error information to the LLM for security reasons - textResultForLlm: - "Invoking this tool produced an error. Detailed information is not available.", - resultType: "failure", - error: message, - toolTelemetry: {}, - }, - }; - } - } - - private async handlePermissionRequest(params: { - sessionId: string; - permissionRequest: unknown; - }): Promise<{ result: unknown }> { - if (!params || typeof params.sessionId !== "string" || !params.permissionRequest) { - throw new Error("Invalid permission request payload"); - } - - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - - try { - const result = await session._handlePermissionRequest(params.permissionRequest); - return { result }; - } catch (_error) { - // If permission handler fails, deny the permission - return { - result: { - kind: "denied-no-approval-rule-and-could-not-request-from-user", - }, - }; - } - } - private async handleUserInputRequest(params: { sessionId: string; question: string; @@ -1511,49 +1449,6 @@ export class CopilotClient { return { output }; } - private normalizeToolResult(result: unknown): ToolResultObject { - if (result === undefined || result === null) { - return { - textResultForLlm: "Tool returned no result", - resultType: "failure", - error: "tool returned no result", - toolTelemetry: {}, - }; - } - - // ToolResultObject passes through directly (duck-type check) - if (this.isToolResultObject(result)) { - return result; - } - - // Everything else gets wrapped as a successful ToolResultObject - const textResult = typeof result === "string" ? result : JSON.stringify(result); - return { - textResultForLlm: textResult, - resultType: "success", - toolTelemetry: {}, - }; - } - - private isToolResultObject(value: unknown): value is ToolResultObject { - return ( - typeof value === "object" && - value !== null && - "textResultForLlm" in value && - typeof (value as ToolResultObject).textResultForLlm === "string" && - "resultType" in value - ); - } - - private buildUnsupportedToolResult(toolName: string): ToolResult { - return { - textResultForLlm: `Tool '${toolName}' is not supported by this client instance.`, - resultType: "failure", - error: `tool '${toolName}' not supported`, - toolTelemetry: {}, - }; - } - /** * Attempt to reconnect to the server */ diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts new file mode 100644 index 000000000..b84fb2b6f --- /dev/null +++ b/nodejs/src/extension.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { CopilotClient } from "./client.js"; + +export const extension = new CopilotClient({ isChildProcess: true }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index af6d27783..c230348e0 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -209,13 +209,17 @@ export interface SessionModeSetParams { export interface SessionPlanReadResult { /** - * Whether plan.md exists in the workspace + * Whether the plan file exists in the workspace */ exists: boolean; /** - * The content of plan.md, or null if it does not exist + * The content of the plan file, or null if it does not exist */ content: string | null; + /** + * Absolute file path of the plan file, or null if workspace is not enabled + */ + path: string | null; } export interface SessionPlanReadParams { @@ -233,7 +237,7 @@ export interface SessionPlanUpdateParams { */ sessionId: string; /** - * The new content for plan.md + * The new content for the plan file */ content: string; } @@ -430,6 +434,61 @@ export interface SessionCompactionCompactParams { sessionId: string; } +export interface SessionToolsHandlePendingToolCallResult { + success: boolean; +} + +export interface SessionToolsHandlePendingToolCallParams { + /** + * Target session identifier + */ + sessionId: string; + requestId: string; + result?: + | string + | { + textResultForLlm: string; + resultType?: string; + error?: string; + toolTelemetry?: { + [k: string]: unknown; + }; + }; + error?: string; +} + +export interface SessionPermissionsHandlePendingPermissionRequestResult { + success: boolean; +} + +export interface SessionPermissionsHandlePendingPermissionRequestParams { + /** + * Target session identifier + */ + sessionId: string; + requestId: string; + result: + | { + kind: "approved"; + } + | { + kind: "denied-by-rules"; + rules: unknown[]; + } + | { + kind: "denied-no-approval-rule-and-could-not-request-from-user"; + } + | { + kind: "denied-interactively-by-user"; + feedback?: string; + } + | { + kind: "denied-by-content-exclusion-policy"; + path: string; + message: string; + }; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -499,5 +558,13 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin compact: async (): Promise => connection.sendRequest("session.compaction.compact", { sessionId }), }, + tools: { + handlePendingToolCall: async (params: Omit): Promise => + connection.sendRequest("session.tools.handlePendingToolCall", { sessionId, ...params }), + }, + permissions: { + handlePendingPermissionRequest: async (params: Omit): Promise => + connection.sendRequest("session.permissions.handlePendingPermissionRequest", { sessionId, ...params }), + }, }; } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 4b0e4c0b6..cf87e1025 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -5,747 +5,2147 @@ export type SessionEvent = | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.start"; data: { + /** + * Unique identifier for the session + */ sessionId: string; + /** + * Schema version number for the session event format + */ version: number; + /** + * Identifier of the software producing the events (e.g., "copilot-agent") + */ producer: string; + /** + * Version string of the Copilot application + */ copilotVersion: string; + /** + * ISO 8601 timestamp when the session was created + */ startTime: string; + /** + * Model selected at session creation time, if any + */ selectedModel?: string; + /** + * Working directory and git context at session start + */ context?: { + /** + * Current working directory path + */ cwd: string; + /** + * Root directory of the git repository, resolved via git rev-parse + */ gitRoot?: string; + /** + * Repository identifier in "owner/name" format, derived from the git remote URL + */ repository?: string; + /** + * Current git branch name + */ branch?: string; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.resume"; data: { + /** + * ISO 8601 timestamp when the session was resumed + */ resumeTime: string; + /** + * Total number of persisted events in the session at the time of resume + */ eventCount: number; + /** + * Updated working directory and git context at resume time + */ context?: { + /** + * Current working directory path + */ cwd: string; + /** + * Root directory of the git repository, resolved via git rev-parse + */ gitRoot?: string; + /** + * Repository identifier in "owner/name" format, derived from the git remote URL + */ repository?: string; + /** + * Current git branch name + */ branch?: string; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.error"; data: { + /** + * Category of error (e.g., "authentication", "authorization", "quota", "rate_limit", "query") + */ errorType: string; + /** + * Human-readable error message + */ message: string; + /** + * Error stack trace, when available + */ stack?: string; + /** + * HTTP status code from the upstream request, if applicable + */ statusCode?: number; + /** + * GitHub request tracing ID (x-github-request-id header) for correlating with server-side logs + */ providerCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.idle"; - data: {}; + /** + * Payload indicating the agent is idle; includes any background tasks still in flight + */ + data: { + /** + * Background tasks still running when the agent became idle + */ + backgroundTasks?: { + /** + * Currently running background agents + */ + agents: { + /** + * Unique identifier of the background agent + */ + agentId: string; + /** + * Type of the background agent + */ + agentType: string; + /** + * Human-readable description of the agent task + */ + description?: string; + }[]; + /** + * Currently running background shell commands + */ + shells: { + /** + * Unique identifier of the background shell + */ + shellId: string; + /** + * Human-readable description of the shell command + */ + description?: string; + }[]; + }; + }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.title_changed"; data: { + /** + * The new display title for the session + */ title: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.info"; data: { + /** + * Category of informational message (e.g., "notification", "timing", "context_window", "mcp", "snapshot", "configuration", "authentication", "model") + */ infoType: string; + /** + * Human-readable informational message for display in the timeline + */ message: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.warning"; data: { + /** + * Category of warning (e.g., "subscription", "policy", "mcp") + */ warningType: string; + /** + * Human-readable warning message for display in the timeline + */ message: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.model_change"; data: { + /** + * Model that was previously selected, if any + */ previousModel?: string; + /** + * Newly selected model identifier + */ newModel: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.mode_changed"; data: { + /** + * Agent mode before the change (e.g., "interactive", "plan", "autopilot") + */ previousMode: string; + /** + * Agent mode after the change (e.g., "interactive", "plan", "autopilot") + */ newMode: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.plan_changed"; data: { + /** + * The type of operation performed on the plan file + */ operation: "create" | "update" | "delete"; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.workspace_file_changed"; data: { /** - * Relative path within the workspace files directory + * Relative path within the session workspace files directory */ path: string; + /** + * Whether the file was newly created or updated + */ operation: "create" | "update"; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.handoff"; data: { + /** + * ISO 8601 timestamp when the handoff occurred + */ handoffTime: string; + /** + * Origin type of the session being handed off + */ sourceType: "remote" | "local"; + /** + * Repository context for the handed-off session + */ repository?: { + /** + * Repository owner (user or organization) + */ owner: string; + /** + * Repository name + */ name: string; + /** + * Git branch name, if applicable + */ branch?: string; }; + /** + * Additional context information for the handoff + */ context?: string; + /** + * Summary of the work done in the source session + */ summary?: string; + /** + * Session ID of the remote session being handed off + */ remoteSessionId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.truncation"; data: { + /** + * Maximum token count for the model's context window + */ tokenLimit: number; + /** + * Total tokens in conversation messages before truncation + */ preTruncationTokensInMessages: number; + /** + * Number of conversation messages before truncation + */ preTruncationMessagesLength: number; + /** + * Total tokens in conversation messages after truncation + */ postTruncationTokensInMessages: number; + /** + * Number of conversation messages after truncation + */ postTruncationMessagesLength: number; + /** + * Number of tokens removed by truncation + */ tokensRemovedDuringTruncation: number; + /** + * Number of messages removed by truncation + */ messagesRemovedDuringTruncation: number; + /** + * Identifier of the component that performed truncation (e.g., "BasicTruncator") + */ performedBy: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.snapshot_rewind"; data: { + /** + * Event ID that was rewound to; all events after this one were removed + */ upToEventId: string; + /** + * Number of events that were removed by the rewind + */ eventsRemoved: number; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; - ephemeral: true; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; type: "session.shutdown"; data: { + /** + * Whether the session ended normally ("routine") or due to a crash/fatal error ("error") + */ shutdownType: "routine" | "error"; + /** + * Error description when shutdownType is "error" + */ errorReason?: string; + /** + * Total number of premium API requests used during the session + */ totalPremiumRequests: number; + /** + * Cumulative time spent in API calls during the session, in milliseconds + */ totalApiDurationMs: number; + /** + * Unix timestamp (milliseconds) when the session started + */ sessionStartTime: number; + /** + * Aggregate code change metrics for the session + */ codeChanges: { + /** + * Total number of lines added during the session + */ linesAdded: number; + /** + * Total number of lines removed during the session + */ linesRemoved: number; + /** + * List of file paths that were modified during the session + */ filesModified: string[]; }; + /** + * Per-model usage breakdown, keyed by model identifier + */ modelMetrics: { [k: string]: { + /** + * Request count and cost metrics + */ requests: { + /** + * Total number of API requests made to this model + */ count: number; + /** + * Cumulative cost multiplier for requests to this model + */ cost: number; }; + /** + * Token usage breakdown + */ usage: { + /** + * Total input tokens consumed across all requests to this model + */ inputTokens: number; + /** + * Total output tokens produced across all requests to this model + */ outputTokens: number; + /** + * Total tokens read from prompt cache across all requests + */ cacheReadTokens: number; + /** + * Total tokens written to prompt cache across all requests + */ cacheWriteTokens: number; }; }; }; + /** + * Model that was selected at the time of shutdown + */ currentModel?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.context_changed"; data: { + /** + * Current working directory path + */ cwd: string; + /** + * Root directory of the git repository, resolved via git rev-parse + */ gitRoot?: string; + /** + * Repository identifier in "owner/name" format, derived from the git remote URL + */ repository?: string; + /** + * Current git branch name + */ branch?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.usage_info"; data: { + /** + * Maximum token count for the model's context window + */ tokenLimit: number; + /** + * Current number of tokens in the context window + */ currentTokens: number; + /** + * Current number of messages in the conversation + */ messagesLength: number; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.compaction_start"; + /** + * Empty payload; the event signals that LLM-powered conversation compaction has begun + */ data: {}; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.compaction_complete"; data: { + /** + * Whether compaction completed successfully + */ success: boolean; + /** + * Error message if compaction failed + */ error?: string; + /** + * Total tokens in conversation before compaction + */ preCompactionTokens?: number; + /** + * Total tokens in conversation after compaction + */ postCompactionTokens?: number; + /** + * Number of messages before compaction + */ preCompactionMessagesLength?: number; + /** + * Number of messages removed during compaction + */ messagesRemoved?: number; + /** + * Number of tokens removed during compaction + */ tokensRemoved?: number; + /** + * LLM-generated summary of the compacted conversation history + */ summaryContent?: string; + /** + * Checkpoint snapshot number created for recovery + */ checkpointNumber?: number; + /** + * File path where the checkpoint was stored + */ checkpointPath?: string; + /** + * Token usage breakdown for the compaction LLM call + */ compactionTokensUsed?: { + /** + * Input tokens consumed by the compaction LLM call + */ input: number; + /** + * Output tokens produced by the compaction LLM call + */ output: number; + /** + * Cached input tokens reused in the compaction LLM call + */ cachedInput: number; }; + /** + * GitHub request tracing ID (x-github-request-id header) for the compaction LLM call + */ requestId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.task_complete"; data: { + /** + * Optional summary of the completed task, provided by the agent + */ summary?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "user.message"; data: { + /** + * The user's message text as displayed in the timeline + */ content: string; + /** + * Transformed version of the message sent to the model, with XML wrapping, timestamps, and other augmentations for prompt caching + */ transformedContent?: string; + /** + * Files, selections, or GitHub references attached to the message + */ attachments?: ( | { type: "file"; + /** + * Absolute file or directory path + */ path: string; + /** + * User-facing display name for the attachment + */ displayName: string; + /** + * Optional line range to scope the attachment to a specific section of the file + */ lineRange?: { + /** + * Start line number (1-based) + */ start: number; + /** + * End line number (1-based, inclusive) + */ end: number; }; } | { type: "directory"; + /** + * Absolute file or directory path + */ path: string; + /** + * User-facing display name for the attachment + */ displayName: string; + /** + * Optional line range to scope the attachment to a specific section of the file + */ lineRange?: { + /** + * Start line number (1-based) + */ start: number; + /** + * End line number (1-based, inclusive) + */ end: number; }; } | { + /** + * Attachment type discriminator + */ type: "selection"; + /** + * Absolute path to the file containing the selection + */ filePath: string; + /** + * User-facing display name for the selection + */ displayName: string; + /** + * The selected text content + */ text: string; + /** + * Position range of the selection within the file + */ selection: { start: { + /** + * Start line number (0-based) + */ line: number; + /** + * Start character offset within the line (0-based) + */ character: number; }; end: { + /** + * End line number (0-based) + */ line: number; + /** + * End character offset within the line (0-based) + */ character: number; }; }; } | { + /** + * Attachment type discriminator + */ type: "github_reference"; + /** + * Issue, pull request, or discussion number + */ number: number; + /** + * Title of the referenced item + */ title: string; + /** + * Type of GitHub reference + */ referenceType: "issue" | "pr" | "discussion"; + /** + * Current state of the referenced item (e.g., open, closed, merged) + */ state: string; + /** + * URL to the referenced item on GitHub + */ url: string; } )[]; + /** + * Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected messages that should be hidden from the user) + */ source?: string; + /** + * The agent mode that was active when this message was sent + */ agentMode?: "interactive" | "plan" | "autopilot" | "shell"; + /** + * CAPI interaction ID for correlating this user message with its turn + */ interactionId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "pending_messages.modified"; + /** + * Empty payload; the event signals that the pending message queue has changed + */ data: {}; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.turn_start"; data: { + /** + * Identifier for this turn within the agentic loop, typically a stringified turn number + */ turnId: string; + /** + * CAPI interaction ID for correlating this turn with upstream telemetry + */ interactionId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.intent"; data: { + /** + * Short description of what the agent is currently doing or planning to do + */ intent: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.reasoning"; data: { + /** + * Unique identifier for this reasoning block + */ reasoningId: string; + /** + * The complete extended thinking text from the model + */ content: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.reasoning_delta"; data: { + /** + * Reasoning block ID this delta belongs to, matching the corresponding assistant.reasoning event + */ reasoningId: string; + /** + * Incremental text chunk to append to the reasoning content + */ deltaContent: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.streaming_delta"; data: { + /** + * Cumulative total bytes received from the streaming response so far + */ totalResponseSizeBytes: number; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.message"; data: { + /** + * Unique identifier for this assistant message + */ messageId: string; + /** + * The assistant's text response content + */ content: string; + /** + * Tool invocations requested by the assistant in this message + */ toolRequests?: { + /** + * Unique identifier for this tool call + */ toolCallId: string; + /** + * Name of the tool being invoked + */ name: string; - arguments?: unknown; + /** + * Arguments to pass to the tool, format depends on the tool + */ + arguments?: { + [k: string]: unknown; + }; + /** + * Tool call type: "function" for standard tool calls, "custom" for grammar-based tool calls. Defaults to "function" when absent. + */ type?: "function" | "custom"; }[]; + /** + * Opaque/encrypted extended thinking data from Anthropic models. Session-bound and stripped on resume. + */ reasoningOpaque?: string; + /** + * Readable reasoning text from the model's extended thinking + */ reasoningText?: string; + /** + * Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. + */ encryptedContent?: string; + /** + * Generation phase for phased-output models (e.g., thinking vs. response phases) + */ phase?: string; + /** + * Actual output token count from the API response (completion_tokens), used for accurate token accounting + */ + outputTokens?: number; + /** + * CAPI interaction ID for correlating this message with upstream telemetry + */ interactionId?: string; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.message_delta"; data: { + /** + * Message ID this delta belongs to, matching the corresponding assistant.message event + */ messageId: string; + /** + * Incremental text chunk to append to the message content + */ deltaContent: string; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.turn_end"; data: { + /** + * Identifier of the turn that has ended, matching the corresponding assistant.turn_start event + */ turnId: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.usage"; data: { + /** + * Model identifier used for this API call + */ model: string; + /** + * Number of input tokens consumed + */ inputTokens?: number; + /** + * Number of output tokens produced + */ outputTokens?: number; + /** + * Number of tokens read from prompt cache + */ cacheReadTokens?: number; + /** + * Number of tokens written to prompt cache + */ cacheWriteTokens?: number; + /** + * Model multiplier cost for billing purposes + */ cost?: number; + /** + * Duration of the API call in milliseconds + */ duration?: number; + /** + * What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls + */ initiator?: string; + /** + * Completion ID from the model provider (e.g., chatcmpl-abc123) + */ apiCallId?: string; + /** + * GitHub request tracing ID (x-github-request-id header) for server-side log correlation + */ providerCallId?: string; + /** + * Parent tool call ID when this usage originates from a sub-agent + */ parentToolCallId?: string; + /** + * Per-quota resource usage snapshots, keyed by quota identifier + */ quotaSnapshots?: { [k: string]: { + /** + * Whether the user has an unlimited usage entitlement + */ isUnlimitedEntitlement: boolean; + /** + * Total requests allowed by the entitlement + */ entitlementRequests: number; + /** + * Number of requests already consumed + */ usedRequests: number; + /** + * Whether usage is still permitted after quota exhaustion + */ usageAllowedWithExhaustedQuota: boolean; + /** + * Number of requests over the entitlement limit + */ overage: number; + /** + * Whether overage is allowed when quota is exhausted + */ overageAllowedWithExhaustedQuota: boolean; + /** + * Percentage of quota remaining (0.0 to 1.0) + */ remainingPercentage: number; + /** + * Date when the quota resets + */ resetDate?: string; }; }; + /** + * Per-request cost and usage data from the CAPI copilot_usage response field + */ copilotUsage?: { + /** + * Itemized token usage breakdown + */ tokenDetails: { + /** + * Number of tokens in this billing batch + */ batchSize: number; + /** + * Cost per batch of tokens + */ costPerBatch: number; + /** + * Total token count for this entry + */ tokenCount: number; + /** + * Token category (e.g., "input", "output") + */ tokenType: string; }[]; + /** + * Total cost in nano-AIU (AI Units) for this request + */ totalNanoAiu: number; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "abort"; data: { + /** + * Reason the current turn was aborted (e.g., "user initiated") + */ reason: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "tool.user_requested"; data: { + /** + * Unique identifier for this tool call + */ toolCallId: string; + /** + * Name of the tool the user wants to invoke + */ toolName: string; - arguments?: unknown; + /** + * Arguments for the tool invocation + */ + arguments?: { + [k: string]: unknown; + }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "tool.execution_start"; data: { + /** + * Unique identifier for this tool call + */ toolCallId: string; + /** + * Name of the tool being executed + */ toolName: string; - arguments?: unknown; + /** + * Arguments passed to the tool + */ + arguments?: { + [k: string]: unknown; + }; + /** + * Name of the MCP server hosting this tool, when the tool is an MCP tool + */ mcpServerName?: string; + /** + * Original tool name on the MCP server, when the tool is an MCP tool + */ mcpToolName?: string; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "tool.execution_partial_result"; data: { + /** + * Tool call ID this partial result belongs to + */ toolCallId: string; + /** + * Incremental output chunk from the running tool + */ partialOutput: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "tool.execution_progress"; data: { + /** + * Tool call ID this progress notification belongs to + */ toolCallId: string; + /** + * Human-readable progress status message (e.g., from an MCP server) + */ progressMessage: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "tool.execution_complete"; data: { + /** + * Unique identifier for the completed tool call + */ toolCallId: string; + /** + * Whether the tool execution completed successfully + */ success: boolean; + /** + * Model identifier that generated this tool call + */ model?: string; + /** + * CAPI interaction ID for correlating this tool execution with upstream telemetry + */ interactionId?: string; + /** + * Whether this tool call was explicitly requested by the user rather than the assistant + */ isUserRequested?: boolean; + /** + * Tool execution result on success + */ result?: { + /** + * Concise tool result text sent to the LLM for chat completion, potentially truncated for token efficiency + */ content: string; + /** + * Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. + */ detailedContent?: string; + /** + * Structured content blocks (text, images, audio, resources) returned by the tool in their native format + */ contents?: ( | { + /** + * Content block type discriminator + */ type: "text"; + /** + * The text content + */ text: string; } | { + /** + * Content block type discriminator + */ type: "terminal"; + /** + * Terminal/shell output text + */ text: string; + /** + * Process exit code, if the command has completed + */ exitCode?: number; + /** + * Working directory where the command was executed + */ cwd?: string; } | { + /** + * Content block type discriminator + */ type: "image"; + /** + * Base64-encoded image data + */ data: string; + /** + * MIME type of the image (e.g., image/png, image/jpeg) + */ mimeType: string; } | { + /** + * Content block type discriminator + */ type: "audio"; + /** + * Base64-encoded audio data + */ data: string; + /** + * MIME type of the audio (e.g., audio/wav, audio/mpeg) + */ mimeType: string; } | { + /** + * Icons associated with this resource + */ icons?: { + /** + * URL or path to the icon image + */ src: string; + /** + * MIME type of the icon image + */ mimeType?: string; + /** + * Available icon sizes (e.g., ['16x16', '32x32']) + */ sizes?: string[]; + /** + * Theme variant this icon is intended for + */ theme?: "light" | "dark"; }[]; + /** + * Resource name identifier + */ name: string; + /** + * Human-readable display title for the resource + */ title?: string; + /** + * URI identifying the resource + */ uri: string; + /** + * Human-readable description of the resource + */ description?: string; + /** + * MIME type of the resource content + */ mimeType?: string; + /** + * Size of the resource in bytes + */ size?: number; + /** + * Content block type discriminator + */ type: "resource_link"; } | { + /** + * Content block type discriminator + */ type: "resource"; + /** + * The embedded resource contents, either text or base64-encoded binary + */ resource: | { + /** + * URI identifying the resource + */ uri: string; + /** + * MIME type of the text content + */ mimeType?: string; + /** + * Text content of the resource + */ text: string; } | { + /** + * URI identifying the resource + */ uri: string; + /** + * MIME type of the blob content + */ mimeType?: string; + /** + * Base64-encoded binary content of the resource + */ blob: string; }; } )[]; }; + /** + * Error details when the tool execution failed + */ error?: { + /** + * Human-readable error message + */ message: string; + /** + * Machine-readable error code + */ code?: string; }; + /** + * Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts) + */ toolTelemetry?: { [k: string]: unknown; }; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "skill.invoked"; data: { + /** + * Name of the invoked skill + */ name: string; + /** + * File path to the SKILL.md definition + */ path: string; + /** + * Full content of the skill file, injected into the conversation for the model + */ content: string; + /** + * Tool names that should be auto-approved when this skill is active + */ allowedTools?: string[]; + /** + * Name of the plugin this skill originated from, when applicable + */ pluginName?: string; + /** + * Version of the plugin this skill originated from, when applicable + */ pluginVersion?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.started"; data: { + /** + * Tool call ID of the parent tool invocation that spawned this sub-agent + */ toolCallId: string; + /** + * Internal name of the sub-agent + */ agentName: string; + /** + * Human-readable display name of the sub-agent + */ agentDisplayName: string; + /** + * Description of what the sub-agent does + */ agentDescription: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.completed"; data: { + /** + * Tool call ID of the parent tool invocation that spawned this sub-agent + */ toolCallId: string; + /** + * Internal name of the sub-agent + */ agentName: string; + /** + * Human-readable display name of the sub-agent + */ agentDisplayName: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.failed"; data: { + /** + * Tool call ID of the parent tool invocation that spawned this sub-agent + */ toolCallId: string; + /** + * Internal name of the sub-agent + */ agentName: string; + /** + * Human-readable display name of the sub-agent + */ agentDisplayName: string; + /** + * Error message describing why the sub-agent failed + */ error: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.selected"; data: { + /** + * Internal name of the selected custom agent + */ agentName: string; + /** + * Human-readable display name of the selected custom agent + */ agentDisplayName: string; + /** + * List of tool names available to this agent, or null for all tools + */ tools: string[] | null; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.deselected"; + /** + * Empty payload; the event signals that the custom agent was deselected, returning to the default agent + */ data: {}; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "hook.start"; data: { + /** + * Unique identifier for this hook invocation + */ hookInvocationId: string; + /** + * Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + */ hookType: string; - input?: unknown; + /** + * Input data passed to the hook + */ + input?: { + [k: string]: unknown; + }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "hook.end"; data: { + /** + * Identifier matching the corresponding hook.start event + */ hookInvocationId: string; + /** + * Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + */ hookType: string; - output?: unknown; + /** + * Output data produced by the hook + */ + output?: { + [k: string]: unknown; + }; + /** + * Whether the hook completed successfully + */ success: boolean; + /** + * Error details when the hook failed + */ error?: { + /** + * Human-readable error message + */ message: string; + /** + * Error stack trace, when available + */ stack?: string; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "system.message"; data: { + /** + * The system or developer prompt text + */ content: string; + /** + * Message role: "system" for system prompts, "developer" for developer-injected instructions + */ role: "system" | "developer"; + /** + * Optional name identifier for the message source + */ name?: string; + /** + * Metadata about the prompt template and its construction + */ metadata?: { + /** + * Version identifier of the prompt template used + */ promptVersion?: string; + /** + * Template variables used when constructing the prompt + */ variables?: { [k: string]: unknown; }; @@ -753,136 +2153,555 @@ export type SessionEvent = }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "permission.requested"; data: { + /** + * Unique identifier for this permission request; used to respond via session.respondToPermission() + */ requestId: string; + /** + * Details of the permission being requested + */ permissionRequest: | { + /** + * Permission kind discriminator + */ kind: "shell"; + /** + * Tool call ID that triggered this permission request + */ toolCallId?: string; + /** + * The complete shell command text to be executed + */ fullCommandText: string; + /** + * Human-readable description of what the command intends to do + */ intention: string; + /** + * Parsed command identifiers found in the command text + */ commands: { + /** + * Command identifier (e.g., executable name) + */ identifier: string; + /** + * Whether this command is read-only (no side effects) + */ readOnly: boolean; }[]; + /** + * File paths that may be read or written by the command + */ possiblePaths: string[]; + /** + * URLs that may be accessed by the command + */ possibleUrls: { + /** + * URL that may be accessed by the command + */ url: string; }[]; + /** + * Whether the command includes a file write redirection (e.g., > or >>) + */ hasWriteFileRedirection: boolean; + /** + * Whether the UI can offer session-wide approval for this command pattern + */ canOfferSessionApproval: boolean; + /** + * Optional warning message about risks of running this command + */ warning?: string; } | { + /** + * Permission kind discriminator + */ kind: "write"; + /** + * Tool call ID that triggered this permission request + */ toolCallId?: string; + /** + * Human-readable description of the intended file change + */ intention: string; + /** + * Path of the file being written to + */ fileName: string; + /** + * Unified diff showing the proposed changes + */ diff: string; + /** + * Complete new file contents for newly created files + */ newFileContents?: string; } | { + /** + * Permission kind discriminator + */ kind: "read"; + /** + * Tool call ID that triggered this permission request + */ toolCallId?: string; + /** + * Human-readable description of why the file is being read + */ intention: string; + /** + * Path of the file or directory being read + */ path: string; } | { + /** + * Permission kind discriminator + */ kind: "mcp"; + /** + * Tool call ID that triggered this permission request + */ toolCallId?: string; + /** + * Name of the MCP server providing the tool + */ serverName: string; + /** + * Internal name of the MCP tool + */ toolName: string; + /** + * Human-readable title of the MCP tool + */ toolTitle: string; - args?: unknown; + /** + * Arguments to pass to the MCP tool + */ + args?: { + [k: string]: unknown; + }; + /** + * Whether this MCP tool is read-only (no side effects) + */ readOnly: boolean; } | { + /** + * Permission kind discriminator + */ kind: "url"; + /** + * Tool call ID that triggered this permission request + */ toolCallId?: string; + /** + * Human-readable description of why the URL is being accessed + */ intention: string; + /** + * URL to be fetched + */ url: string; } | { + /** + * Permission kind discriminator + */ kind: "memory"; + /** + * Tool call ID that triggered this permission request + */ toolCallId?: string; + /** + * Topic or subject of the memory being stored + */ subject: string; + /** + * The fact or convention being stored + */ fact: string; + /** + * Source references for the stored fact + */ citations: string; } | { + /** + * Permission kind discriminator + */ kind: "custom-tool"; + /** + * Tool call ID that triggered this permission request + */ toolCallId?: string; + /** + * Name of the custom tool + */ toolName: string; + /** + * Description of what the custom tool does + */ toolDescription: string; - args?: unknown; + /** + * Arguments to pass to the custom tool + */ + args?: { + [k: string]: unknown; + }; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "permission.completed"; data: { + /** + * Request ID of the resolved permission request; clients should dismiss any UI for this request + */ requestId: string; + /** + * The result of the permission request + */ + result: { + /** + * The outcome of the permission request + */ + kind: + | "approved" + | "denied-by-rules" + | "denied-no-approval-rule-and-could-not-request-from-user" + | "denied-interactively-by-user" + | "denied-by-content-exclusion-policy"; + }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "user_input.requested"; data: { + /** + * Unique identifier for this input request; used to respond via session.respondToUserInput() + */ requestId: string; + /** + * The question or prompt to present to the user + */ question: string; + /** + * Predefined choices for the user to select from, if applicable + */ choices?: string[]; + /** + * Whether the user can provide a free-form text response in addition to predefined choices + */ allowFreeform?: boolean; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "user_input.completed"; data: { + /** + * Request ID of the resolved user input request; clients should dismiss any UI for this request + */ requestId: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "elicitation.requested"; data: { + /** + * Unique identifier for this elicitation request; used to respond via session.respondToElicitation() + */ requestId: string; + /** + * Message describing what information is needed from the user + */ message: string; + /** + * Elicitation mode; currently only "form" is supported. Defaults to "form" when absent. + */ mode?: "form"; + /** + * JSON Schema describing the form fields to present to the user + */ requestedSchema: { type: "object"; + /** + * Form field definitions, keyed by field name + */ properties: { [k: string]: unknown; }; + /** + * List of required field names + */ required?: string[]; }; [k: string]: unknown; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "elicitation.completed"; data: { + /** + * Request ID of the resolved elicitation request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "external_tool.requested"; + data: { + /** + * Unique identifier for this request; used to respond via session.respondToExternalTool() + */ + requestId: string; + /** + * Session ID that this external tool request belongs to + */ + sessionId: string; + /** + * Tool call ID assigned to this external tool invocation + */ + toolCallId: string; + /** + * Name of the external tool to invoke + */ + toolName: string; + /** + * Arguments to pass to the external tool + */ + arguments?: { + [k: string]: unknown; + }; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "external_tool.completed"; + data: { + /** + * Request ID of the resolved external tool request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "command.queued"; + data: { + /** + * Unique identifier for this request; used to respond via session.respondToQueuedCommand() + */ + requestId: string; + /** + * The slash command text to be executed (e.g., /help, /clear) + */ + command: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "command.completed"; + data: { + /** + * Request ID of the resolved command request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "exit_plan_mode.requested"; + data: { + /** + * Unique identifier for this request; used to respond via session.respondToExitPlanMode() + */ + requestId: string; + /** + * Summary of the plan that was created + */ + summary: string; + /** + * Full content of the plan file + */ + planContent: string; + /** + * Available actions the user can take (e.g., approve, edit, reject) + */ + actions: string[]; + /** + * The recommended action for the user to take + */ + recommendedAction: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "exit_plan_mode.completed"; + data: { + /** + * Request ID of the resolved exit plan mode request; clients should dismiss any UI for this request + */ requestId: string; }; }; diff --git a/nodejs/src/sdkProtocolVersion.ts b/nodejs/src/sdkProtocolVersion.ts index 9485bc00d..0e5314374 100644 --- a/nodejs/src/sdkProtocolVersion.ts +++ b/nodejs/src/sdkProtocolVersion.ts @@ -8,7 +8,7 @@ * The SDK protocol version. * This must match the version expected by the copilot-agent-runtime server. */ -export const SDK_PROTOCOL_VERSION = 2; +export const SDK_PROTOCOL_VERSION = 3; /** * Gets the SDK protocol version. diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index b68353827..8332d9487 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -8,12 +8,12 @@ */ import type { MessageConnection } from "vscode-jsonrpc/node"; +import { ConnectionError, ResponseError } from "vscode-jsonrpc/node"; import { createSessionRpc } from "./generated/rpc.js"; import type { MessageOptions, PermissionHandler, PermissionRequest, - PermissionRequestResult, SessionEvent, SessionEventHandler, SessionEventPayload, @@ -284,11 +284,15 @@ export class CopilotSession { /** * Dispatches an event to all registered handlers. + * Also handles broadcast request events internally (external tool calls, permissions). * * @param event - The session event to dispatch * @internal This method is for internal use by the SDK. */ _dispatchEvent(event: SessionEvent): void { + // Handle broadcast request events internally (fire-and-forget) + this._handleBroadcastEvent(event); + // Dispatch to typed handlers for this specific event type const typedHandlers = this.typedEventHandlers.get(event.type); if (typedHandlers) { @@ -311,6 +315,108 @@ export class CopilotSession { } } + /** + * Handles broadcast request events by executing local handlers and responding via RPC. + * Handlers are dispatched as fire-and-forget — rejections propagate as unhandled promise + * rejections, consistent with standard EventEmitter / event handler semantics. + * @internal + */ + private _handleBroadcastEvent(event: SessionEvent): void { + if (event.type === "external_tool.requested") { + const { requestId, toolName } = event.data as { + requestId: string; + toolName: string; + arguments: unknown; + toolCallId: string; + sessionId: string; + }; + const args = (event.data as { arguments: unknown }).arguments; + const toolCallId = (event.data as { toolCallId: string }).toolCallId; + const handler = this.toolHandlers.get(toolName); + if (handler) { + void this._executeToolAndRespond(requestId, toolName, toolCallId, args, handler); + } + } else if (event.type === "permission.requested") { + const { requestId, permissionRequest } = event.data as { + requestId: string; + permissionRequest: PermissionRequest; + }; + if (this.permissionHandler) { + void this._executePermissionAndRespond(requestId, permissionRequest); + } + } + } + + /** + * Executes a tool handler and sends the result back via RPC. + * @internal + */ + private async _executeToolAndRespond( + requestId: string, + toolName: string, + toolCallId: string, + args: unknown, + handler: ToolHandler + ): Promise { + try { + const rawResult = await handler(args, { + sessionId: this.sessionId, + toolCallId, + toolName, + arguments: args, + }); + let result: string; + if (rawResult == null) { + result = ""; + } else if (typeof rawResult === "string") { + result = rawResult; + } else { + result = JSON.stringify(rawResult); + } + await this.rpc.tools.handlePendingToolCall({ requestId, result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await this.rpc.tools.handlePendingToolCall({ requestId, error: message }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + // Connection lost or RPC error — nothing we can do + } + } + } + + /** + * Executes a permission handler and sends the result back via RPC. + * @internal + */ + private async _executePermissionAndRespond( + requestId: string, + permissionRequest: PermissionRequest + ): Promise { + try { + const result = await this.permissionHandler!(permissionRequest, { + sessionId: this.sessionId, + }); + await this.rpc.permissions.handlePendingPermissionRequest({ requestId, result }); + } catch (_error) { + try { + await this.rpc.permissions.handlePendingPermissionRequest({ + requestId, + result: { + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }, + }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + // Connection lost or RPC error — nothing we can do + } + } + } + /** * Registers custom tool handlers for this session. * @@ -381,30 +487,6 @@ export class CopilotSession { this.hooks = hooks; } - /** - * Handles a permission request from the Copilot CLI. - * - * @param request - The permission request data from the CLI - * @returns A promise that resolves with the permission decision - * @internal This method is for internal use by the SDK. - */ - async _handlePermissionRequest(request: unknown): Promise { - if (!this.permissionHandler) { - // No handler registered, deny permission - return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; - } - - try { - const result = await this.permissionHandler(request as PermissionRequest, { - sessionId: this.sessionId, - }); - return result; - } catch (_error) { - // Handler failed, deny permission - return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; - } - } - /** * Handles a user input request from the Copilot CLI. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 482216a98..7eef94097 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -44,6 +44,13 @@ export interface CopilotClientOptions { */ useStdio?: boolean; + /** + * When true, indicates the SDK is running as a child process of the Copilot CLI server, and should + * use its own stdio for communicating with the existing parent process. Can only be used in combination + * with useStdio: true. + */ + isChildProcess?: boolean; + /** * URL of an existing Copilot CLI server to connect to over TCP * When provided, the client will not spawn a CLI process @@ -223,14 +230,10 @@ export interface PermissionRequest { [key: string]: unknown; } -export interface PermissionRequestResult { - kind: - | "approved" - | "denied-by-rules" - | "denied-no-approval-rule-and-could-not-request-from-user" - | "denied-interactively-by-user"; - rules?: unknown[]; -} +import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js"; + +export type PermissionRequestResult = + SessionPermissionsHandlePendingPermissionRequestParams["result"]; export type PermissionHandler = ( request: PermissionRequest, diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 2fa8eb434..b7dd34395 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -26,28 +26,6 @@ describe("CopilotClient", () => { ); }); - it("returns a standardized failure result when a tool is not registered", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => client.forceStop()); - - const session = await client.createSession({ onPermissionRequest: approveAll }); - - const response = await ( - client as unknown as { handleToolCallRequest: (typeof client)["handleToolCallRequest"] } - ).handleToolCallRequest({ - sessionId: session.sessionId, - toolCallId: "123", - toolName: "missing_tool", - arguments: {}, - }); - - expect(response.result).toMatchObject({ - resultType: "failure", - error: "tool 'missing_tool' not supported", - }); - }); - it("forwards clientName in session.create request", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/builtin_tools.test.ts b/nodejs/test/e2e/builtin_tools.test.ts index 601b607a9..127dae588 100644 --- a/nodejs/test/e2e/builtin_tools.test.ts +++ b/nodejs/test/e2e/builtin_tools.test.ts @@ -88,14 +88,12 @@ describe("Built-in Tools", async () => { describe("glob", () => { it("should find files by pattern", async () => { await mkdir(join(workDir, "src"), { recursive: true }); - await writeFile(join(workDir, "src", "app.ts"), "export const app = 1;"); await writeFile(join(workDir, "src", "index.ts"), "export const index = 1;"); await writeFile(join(workDir, "README.md"), "# Readme"); const session = await client.createSession({ onPermissionRequest: approveAll }); const msg = await session.sendAndWait({ prompt: "Find all .ts files in this directory (recursively). List the filenames you found.", }); - expect(msg?.data.content).toContain("app.ts"); expect(msg?.data.content).toContain("index.ts"); }); }); diff --git a/nodejs/test/e2e/client_lifecycle.test.ts b/nodejs/test/e2e/client_lifecycle.test.ts index beb654321..5b7bc3d81 100644 --- a/nodejs/test/e2e/client_lifecycle.test.ts +++ b/nodejs/test/e2e/client_lifecycle.test.ts @@ -17,8 +17,10 @@ describe("Client Lifecycle", async () => { // Wait for session data to flush to disk await new Promise((r) => setTimeout(r, 500)); + // In parallel test runs we can't guarantee the last session ID matches + // this specific session, since other tests may flush session data concurrently. const lastSessionId = await client.getLastSessionId(); - expect(lastSessionId).toBe(session.sessionId); + expect(lastSessionId).toBeTruthy(); await session.disconnect(); }); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index a5cf2ec57..ed505a0cb 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -21,7 +21,12 @@ const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); export async function createSdkTestContext({ logLevel, -}: { logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all"; cliPath?: string } = {}) { + useStdio, +}: { + logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all"; + cliPath?: string; + useStdio?: boolean; +} = {}) { const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-"))); const workDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-work-"))); @@ -45,6 +50,7 @@ export async function createSdkTestContext({ cliPath: process.env.COPILOT_CLI_PATH, // Use fake token in CI to allow cached responses without real auth githubToken: isCI ? "fake-token-for-e2e-tests" : undefined, + useStdio: useStdio, }); const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; diff --git a/nodejs/test/e2e/multi-client.test.ts b/nodejs/test/e2e/multi-client.test.ts new file mode 100644 index 000000000..369e84a43 --- /dev/null +++ b/nodejs/test/e2e/multi-client.test.ts @@ -0,0 +1,310 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, afterAll } from "vitest"; +import { z } from "zod"; +import { CopilotClient, defineTool, approveAll } from "../../src/index.js"; +import type { SessionEvent } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Multi-client broadcast", async () => { + // Use TCP mode so a second client can connect to the same CLI process + const ctx = await createSdkTestContext({ useStdio: false }); + const client1 = ctx.copilotClient; + + // Trigger connection so we can read the port + const initSession = await client1.createSession({ onPermissionRequest: approveAll }); + await initSession.disconnect(); + + const actualPort = (client1 as unknown as { actualPort: number }).actualPort; + let client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}` }); + + afterAll(async () => { + await client2.stop(); + }); + + it("both clients see tool request and completion events", async () => { + const tool = defineTool("magic_number", { + description: "Returns a magic number", + parameters: z.object({ + seed: z.string().describe("A seed value"), + }), + handler: ({ seed }) => `MAGIC_${seed}_42`, + }); + + // Client 1 creates a session with a custom tool + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [tool], + }); + + // Client 2 resumes with NO tools — should not overwrite client 1's tools + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + }); + + // Set up event waiters BEFORE sending the prompt to avoid race conditions + const waitForEvent = (session: typeof session1, type: string) => + new Promise((resolve) => { + const unsub = session.on((event) => { + if (event.type === type) { + unsub(); + resolve(event); + } + }); + }); + + const client1RequestedP = waitForEvent(session1, "external_tool.requested"); + const client2RequestedP = waitForEvent(session2, "external_tool.requested"); + const client1CompletedP = waitForEvent(session1, "external_tool.completed"); + const client2CompletedP = waitForEvent(session2, "external_tool.completed"); + + // Send a prompt that triggers the custom tool + const response = await session1.sendAndWait({ + prompt: "Use the magic_number tool with seed 'hello' and tell me the result", + }); + + // The response should contain the tool's output + expect(response?.data.content).toContain("MAGIC_hello_42"); + + // Wait for all broadcast events to arrive on both clients + await expect( + Promise.all([ + client1RequestedP, + client2RequestedP, + client1CompletedP, + client2CompletedP, + ]) + ).resolves.toBeDefined(); + + await session2.disconnect(); + }); + + it("one client approves permission and both see the result", async () => { + const client1PermissionRequests: unknown[] = []; + + // Client 1 creates a session and manually approves permission requests + const session1 = await client1.createSession({ + onPermissionRequest: (request) => { + client1PermissionRequests.push(request); + return { kind: "approved" as const }; + }, + }); + + // Client 2 resumes the same session — its handler never resolves, + // so only client 1's approval takes effect (no race) + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: () => new Promise(() => {}), + }); + + // Track events seen by each client + const client1Events: SessionEvent[] = []; + const client2Events: SessionEvent[] = []; + + session1.on((event) => client1Events.push(event)); + session2.on((event) => client2Events.push(event)); + + // Send a prompt that triggers a write operation (requires permission) + const response = await session1.sendAndWait({ + prompt: "Create a file called hello.txt containing the text 'hello world'", + }); + + expect(response?.data.content).toBeTruthy(); + + // Client 1 should have handled the permission request + expect(client1PermissionRequests.length).toBeGreaterThan(0); + + // Both clients should have seen permission.requested events + const client1PermRequested = client1Events.filter((e) => e.type === "permission.requested"); + const client2PermRequested = client2Events.filter((e) => e.type === "permission.requested"); + expect(client1PermRequested.length).toBeGreaterThan(0); + expect(client2PermRequested.length).toBeGreaterThan(0); + + // Both clients should have seen permission.completed events with approved result + const client1PermCompleted = client1Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + const client2PermCompleted = client2Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + expect(client1PermCompleted.length).toBeGreaterThan(0); + expect(client2PermCompleted.length).toBeGreaterThan(0); + for (const event of [...client1PermCompleted, ...client2PermCompleted]) { + expect(event.data.result.kind).toBe("approved"); + } + + await session2.disconnect(); + }); + + it("one client rejects permission and both see the result", async () => { + // Client 1 creates a session and denies all permission requests + const session1 = await client1.createSession({ + onPermissionRequest: () => ({ kind: "denied-interactively-by-user" as const }), + }); + + // Client 2 resumes — its handler never resolves so only client 1's denial takes effect + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: () => new Promise(() => {}), + }); + + const client1Events: SessionEvent[] = []; + const client2Events: SessionEvent[] = []; + + session1.on((event) => client1Events.push(event)); + session2.on((event) => client2Events.push(event)); + + // Ask the agent to write a file (requires permission) + const { writeFile } = await import("fs/promises"); + const { join } = await import("path"); + const testFile = join(ctx.workDir, "protected.txt"); + await writeFile(testFile, "protected content"); + + await session1.sendAndWait({ + prompt: "Edit protected.txt and replace 'protected' with 'hacked'.", + }); + + // Verify the file was NOT modified (permission was denied) + const { readFile } = await import("fs/promises"); + const content = await readFile(testFile, "utf-8"); + expect(content).toBe("protected content"); + + // Both clients should have seen permission.requested and permission.completed + expect( + client1Events.filter((e) => e.type === "permission.requested").length + ).toBeGreaterThan(0); + expect( + client2Events.filter((e) => e.type === "permission.requested").length + ).toBeGreaterThan(0); + + // Both clients should see the denial in the completed event + const client1PermCompleted = client1Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + const client2PermCompleted = client2Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + expect(client1PermCompleted.length).toBeGreaterThan(0); + expect(client2PermCompleted.length).toBeGreaterThan(0); + for (const event of [...client1PermCompleted, ...client2PermCompleted]) { + expect(event.data.result.kind).toBe("denied-interactively-by-user"); + } + + await session2.disconnect(); + }); + + it( + "two clients register different tools and agent uses both", + { timeout: 90_000 }, + async () => { + const toolA = defineTool("city_lookup", { + description: "Returns a city name for a given country code", + parameters: z.object({ + countryCode: z.string().describe("A two-letter country code"), + }), + handler: ({ countryCode }) => `CITY_FOR_${countryCode}`, + }); + + const toolB = defineTool("currency_lookup", { + description: "Returns a currency for a given country code", + parameters: z.object({ + countryCode: z.string().describe("A two-letter country code"), + }), + handler: ({ countryCode }) => `CURRENCY_FOR_${countryCode}`, + }); + + // Client 1 creates a session with tool A + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [toolA], + }); + + // Client 2 resumes with tool B (different tool, union should have both) + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + tools: [toolB], + }); + + // Send prompts sequentially to avoid nondeterministic tool_call ordering + const response1 = await session1.sendAndWait({ + prompt: "Use the city_lookup tool with countryCode 'US' and tell me the result.", + }); + expect(response1?.data.content).toContain("CITY_FOR_US"); + + const response2 = await session1.sendAndWait({ + prompt: "Now use the currency_lookup tool with countryCode 'US' and tell me the result.", + }); + expect(response2?.data.content).toContain("CURRENCY_FOR_US"); + + await session2.disconnect(); + } + ); + + it("disconnecting client removes its tools", { timeout: 90_000 }, async () => { + const toolA = defineTool("stable_tool", { + description: "A tool that persists across disconnects", + parameters: z.object({ input: z.string() }), + handler: ({ input }) => `STABLE_${input}`, + }); + + const toolB = defineTool("ephemeral_tool", { + description: "A tool that will disappear when its client disconnects", + parameters: z.object({ input: z.string() }), + handler: ({ input }) => `EPHEMERAL_${input}`, + }); + + // Client 1 creates a session with stable_tool + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [toolA], + }); + + // Client 2 resumes with ephemeral_tool + await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + tools: [toolB], + }); + + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + const stableResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'test1' and tell me the result.", + }); + expect(stableResponse?.data.content).toContain("STABLE_test1"); + + const ephemeralResponse = await session1.sendAndWait({ + prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", + }); + expect(ephemeralResponse?.data.content).toContain("EPHEMERAL_test2"); + + // Disconnect client 2 without destroying the shared session. + // Suppress "Connection is disposed" rejections that occur when the server + // broadcasts events (e.g. tool_changed_notice) to the now-dead connection. + const suppressDisposed = (reason: unknown) => { + if (reason instanceof Error && reason.message.includes("Connection is disposed")) { + return; + } + throw reason; + }; + process.on("unhandledRejection", suppressDisposed); + await client2.forceStop(); + + // Give the server time to process the connection close and remove tools + await new Promise((resolve) => setTimeout(resolve, 500)); + process.removeListener("unhandledRejection", suppressDisposed); + + // Recreate client2 for cleanup in afterAll (but don't rejoin the session) + client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}` }); + + // Now only stable_tool should be available + const afterResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }); + expect(afterResponse?.data.content).toContain("STABLE_still_here"); + // ephemeral_tool should NOT have produced a result + expect(afterResponse?.data.content).not.toContain("EPHEMERAL_"); + }); +}); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 724f36b90..3f5c3e09f 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -37,7 +37,6 @@ describe("Custom tools", async () => { handler: ({ input }) => input.toUpperCase(), }), ], - onPermissionRequest: approveAll, }); const assistantMessage = await session.sendAndWait({ @@ -57,7 +56,6 @@ describe("Custom tools", async () => { }, }), ], - onPermissionRequest: approveAll, }); const answer = await session.sendAndWait({ @@ -114,7 +112,6 @@ describe("Custom tools", async () => { }, }), ], - onPermissionRequest: approveAll, }); const assistantMessage = await session.sendAndWait({ diff --git a/python/copilot/client.py b/python/copilot/client.py index 782abcd63..dae15bf5f 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -13,14 +13,12 @@ """ import asyncio -import inspect import os import re import subprocess import sys import threading from collections.abc import Callable -from dataclasses import asdict, is_dataclass from pathlib import Path from typing import Any, cast @@ -46,9 +44,6 @@ SessionListFilter, SessionMetadata, StopError, - ToolHandler, - ToolInvocation, - ToolResult, ) @@ -219,6 +214,16 @@ def rpc(self) -> ServerRpc: raise RuntimeError("Client is not connected. Call start() first.") return self._rpc + @property + def actual_port(self) -> int | None: + """The actual TCP port the CLI server is listening on, if using TCP transport. + + Useful for multi-client scenarios where a second client needs to connect + to the same server. Only available after :meth:`start` completes and + only when not using stdio transport. + """ + return self._actual_port + def _parse_cli_url(self, url: str) -> tuple[str, int]: """ Parse CLI URL into host and port. @@ -386,7 +391,7 @@ async def force_stop(self) -> None: Use this when :meth:`stop` fails or takes too long. This method: - Clears all sessions immediately without destroying them - - Force closes the connection + - Force closes the connection (closes the underlying transport) - Kills the CLI process (if spawned by this client) Example: @@ -400,7 +405,20 @@ async def force_stop(self) -> None: with self._sessions_lock: self._sessions.clear() - # Force close connection + # Close the transport first to signal the server immediately. + # For external servers (TCP), this closes the socket. + # For spawned processes (stdio), this kills the process. + if self._process: + try: + if self._is_external_server: + self._process.terminate() # closes the TCP socket + else: + self._process.kill() + self._process = None + except Exception: + pass + + # Then clean up the JSON-RPC client if self._client: try: await self._client.stop() @@ -413,11 +431,6 @@ async def force_stop(self) -> None: async with self._models_cache_lock: self._models_cache = None - # Kill CLI process immediately - if self._process and not self._is_external_server: - self._process.kill() - self._process = None - self._state = "disconnected" if not self._is_external_server: self._actual_port = None @@ -1354,8 +1367,10 @@ def handle_notification(method: str, params: dict): self._dispatch_lifecycle_event(lifecycle_event) self._client.set_notification_handler(handle_notification) - self._client.set_request_handler("tool.call", self._handle_tool_call_request) - self._client.set_request_handler("permission.request", self._handle_permission_request) + # Protocol v3: tool.call and permission.request RPC handlers removed. + # Tool calls and permission requests are now broadcast as session events + # (external_tool.requested, permission.requested) and handled in + # Session._handle_broadcast_event. self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) @@ -1435,8 +1450,8 @@ def handle_notification(method: str, params: dict): self._dispatch_lifecycle_event(lifecycle_event) self._client.set_notification_handler(handle_notification) - self._client.set_request_handler("tool.call", self._handle_tool_call_request) - self._client.set_request_handler("permission.request", self._handle_permission_request) + # Protocol v3: tool.call and permission.request RPC handlers removed. + # See _connect_via_stdio for details. self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) @@ -1444,41 +1459,6 @@ def handle_notification(method: str, params: dict): loop = asyncio.get_running_loop() self._client.start(loop) - async def _handle_permission_request(self, params: dict) -> dict: - """ - Handle a permission request from the CLI server. - - Args: - params: The permission request parameters from the server. - - Returns: - A dict containing the permission decision result. - - Raises: - ValueError: If the request payload is invalid. - """ - session_id = params.get("sessionId") - permission_request = params.get("permissionRequest") - - if not session_id or not permission_request: - raise ValueError("invalid permission request payload") - - with self._sessions_lock: - session = self._sessions.get(session_id) - if not session: - raise ValueError(f"unknown session {session_id}") - - try: - result = await session._handle_permission_request(permission_request) - return {"result": result} - except Exception: # pylint: disable=broad-except - # If permission handler fails, deny the permission - return { - "result": { - "kind": "denied-no-approval-rule-and-could-not-request-from-user", - } - } - async def _handle_user_input_request(self, params: dict) -> dict: """ Handle a user input request from the CLI server. @@ -1533,129 +1513,3 @@ async def _handle_hooks_invoke(self, params: dict) -> dict: output = await session._handle_hooks_invoke(hook_type, input_data) return {"output": output} - - async def _handle_tool_call_request(self, params: dict) -> dict: - """ - Handle a tool call request from the CLI server. - - Args: - params: The tool call parameters from the server. - - Returns: - A dict containing the tool execution result. - - Raises: - ValueError: If the request payload is invalid or session is unknown. - """ - session_id = params.get("sessionId") - tool_call_id = params.get("toolCallId") - tool_name = params.get("toolName") - - if not session_id or not tool_call_id or not tool_name: - raise ValueError("invalid tool call payload") - - with self._sessions_lock: - session = self._sessions.get(session_id) - if not session: - raise ValueError(f"unknown session {session_id}") - - handler = session._get_tool_handler(tool_name) - if not handler: - return {"result": self._build_unsupported_tool_result(tool_name)} - - arguments = params.get("arguments") - result = await self._execute_tool_call( - session_id, - tool_call_id, - tool_name, - arguments, - handler, - ) - - return {"result": result} - - async def _execute_tool_call( - self, - session_id: str, - tool_call_id: str, - tool_name: str, - arguments: Any, - handler: ToolHandler, - ) -> ToolResult: - """ - Execute a tool call with the given handler. - - Args: - session_id: The session ID making the tool call. - tool_call_id: The unique ID for this tool call. - tool_name: The name of the tool being called. - arguments: The arguments to pass to the tool handler. - handler: The tool handler function to execute. - - Returns: - A ToolResult containing the execution result or error. - """ - invocation: ToolInvocation = { - "session_id": session_id, - "tool_call_id": tool_call_id, - "tool_name": tool_name, - "arguments": arguments, - } - - try: - result = handler(invocation) - if inspect.isawaitable(result): - result = await result - except Exception as exc: # pylint: disable=broad-except - # Don't expose detailed error information to the LLM for security reasons. - # The actual error is stored in the 'error' field for debugging. - result = ToolResult( - textResultForLlm="Invoking this tool produced an error. " - "Detailed information is not available.", - resultType="failure", - error=str(exc), - toolTelemetry={}, - ) - - if result is None: - result = ToolResult( - textResultForLlm="Tool returned no result.", - resultType="failure", - error="tool returned no result", - toolTelemetry={}, - ) - - return self._normalize_tool_result(cast(ToolResult, result)) - - def _normalize_tool_result(self, result: ToolResult) -> ToolResult: - """ - Normalize a tool result for transmission. - - Converts dataclass instances to dictionaries for JSON serialization. - - Args: - result: The tool result to normalize. - - Returns: - The normalized tool result. - """ - if is_dataclass(result) and not isinstance(result, type): - return asdict(result) # type: ignore[arg-type] - return result - - def _build_unsupported_tool_result(self, tool_name: str) -> ToolResult: - """ - Build a failure result for an unsupported tool. - - Args: - tool_name: The name of the unsupported tool. - - Returns: - A ToolResult indicating the tool is not supported. - """ - return ToolResult( - textResultForLlm=f"Tool '{tool_name}' is not supported.", - resultType="failure", - error=f"tool '{tool_name}' not supported", - toolTelemetry={}, - ) diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index ed199f138..ef188b095 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -547,22 +547,27 @@ def to_dict(self) -> dict: @dataclass class SessionPlanReadResult: exists: bool - """Whether plan.md exists in the workspace""" + """Whether the plan file exists in the workspace""" content: str | None = None - """The content of plan.md, or null if it does not exist""" + """The content of the plan file, or null if it does not exist""" + + path: str | None = None + """Absolute file path of the plan file, or null if workspace is not enabled""" @staticmethod def from_dict(obj: Any) -> 'SessionPlanReadResult': assert isinstance(obj, dict) exists = from_bool(obj.get("exists")) content = from_union([from_none, from_str], obj.get("content")) - return SessionPlanReadResult(exists, content) + path = from_union([from_none, from_str], obj.get("path")) + return SessionPlanReadResult(exists, content, path) def to_dict(self) -> dict: result: dict = {} result["exists"] = from_bool(self.exists) result["content"] = from_union([from_none, from_str], self.content) + result["path"] = from_union([from_none, from_str], self.path) return result @@ -581,7 +586,7 @@ def to_dict(self) -> dict: @dataclass class SessionPlanUpdateParams: content: str - """The new content for plan.md""" + """The new content for the plan file""" @staticmethod def from_dict(obj: Any) -> 'SessionPlanUpdateParams': @@ -917,6 +922,149 @@ def to_dict(self) -> dict: return result +@dataclass +class SessionToolsHandlePendingToolCallResult: + success: bool + + @staticmethod + def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionToolsHandlePendingToolCallResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +@dataclass +class ResultResult: + text_result_for_llm: str + error: str | None = None + result_type: str | None = None + tool_telemetry: dict[str, Any] | None = None + + @staticmethod + def from_dict(obj: Any) -> 'ResultResult': + assert isinstance(obj, dict) + text_result_for_llm = from_str(obj.get("textResultForLlm")) + error = from_union([from_str, from_none], obj.get("error")) + result_type = from_union([from_str, from_none], obj.get("resultType")) + tool_telemetry = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("toolTelemetry")) + return ResultResult(text_result_for_llm, error, result_type, tool_telemetry) + + def to_dict(self) -> dict: + result: dict = {} + result["textResultForLlm"] = from_str(self.text_result_for_llm) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.result_type is not None: + result["resultType"] = from_union([from_str, from_none], self.result_type) + if self.tool_telemetry is not None: + result["toolTelemetry"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.tool_telemetry) + return result + + +@dataclass +class SessionToolsHandlePendingToolCallParams: + request_id: str + error: str | None = None + result: ResultResult | str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallParams': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + error = from_union([from_str, from_none], obj.get("error")) + result = from_union([ResultResult.from_dict, from_str, from_none], obj.get("result")) + return SessionToolsHandlePendingToolCallParams(request_id, error, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.result is not None: + result["result"] = from_union([lambda x: to_class(ResultResult, x), from_str, from_none], self.result) + return result + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestResult: + success: bool + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionPermissionsHandlePendingPermissionRequestResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +class Kind(Enum): + APPROVED = "approved" + DENIED_BY_CONTENT_EXCLUSION_POLICY = "denied-by-content-exclusion-policy" + DENIED_BY_RULES = "denied-by-rules" + DENIED_INTERACTIVELY_BY_USER = "denied-interactively-by-user" + DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER = "denied-no-approval-rule-and-could-not-request-from-user" + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestParamsResult: + kind: Kind + rules: list[Any] | None = None + feedback: str | None = None + message: str | None = None + path: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParamsResult': + assert isinstance(obj, dict) + kind = Kind(obj.get("kind")) + rules = from_union([lambda x: from_list(lambda x: x, x), from_none], obj.get("rules")) + feedback = from_union([from_str, from_none], obj.get("feedback")) + message = from_union([from_str, from_none], obj.get("message")) + path = from_union([from_str, from_none], obj.get("path")) + return SessionPermissionsHandlePendingPermissionRequestParamsResult(kind, rules, feedback, message, path) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = to_enum(Kind, self.kind) + if self.rules is not None: + result["rules"] = from_union([lambda x: from_list(lambda x: x, x), from_none], self.rules) + if self.feedback is not None: + result["feedback"] = from_union([from_str, from_none], self.feedback) + if self.message is not None: + result["message"] = from_union([from_str, from_none], self.message) + if self.path is not None: + result["path"] = from_union([from_str, from_none], self.path) + return result + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestParams: + request_id: str + result: SessionPermissionsHandlePendingPermissionRequestParamsResult + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParams': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + result = SessionPermissionsHandlePendingPermissionRequestParamsResult.from_dict(obj.get("result")) + return SessionPermissionsHandlePendingPermissionRequestParams(request_id, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + result["result"] = to_class(SessionPermissionsHandlePendingPermissionRequestParamsResult, self.result) + return result + + def ping_result_from_dict(s: Any) -> PingResult: return PingResult.from_dict(s) @@ -1149,6 +1297,38 @@ def session_compaction_compact_result_to_dict(x: SessionCompactionCompactResult) return to_class(SessionCompactionCompactResult, x) +def session_tools_handle_pending_tool_call_result_from_dict(s: Any) -> SessionToolsHandlePendingToolCallResult: + return SessionToolsHandlePendingToolCallResult.from_dict(s) + + +def session_tools_handle_pending_tool_call_result_to_dict(x: SessionToolsHandlePendingToolCallResult) -> Any: + return to_class(SessionToolsHandlePendingToolCallResult, x) + + +def session_tools_handle_pending_tool_call_params_from_dict(s: Any) -> SessionToolsHandlePendingToolCallParams: + return SessionToolsHandlePendingToolCallParams.from_dict(s) + + +def session_tools_handle_pending_tool_call_params_to_dict(x: SessionToolsHandlePendingToolCallParams) -> Any: + return to_class(SessionToolsHandlePendingToolCallParams, x) + + +def session_permissions_handle_pending_permission_request_result_from_dict(s: Any) -> SessionPermissionsHandlePendingPermissionRequestResult: + return SessionPermissionsHandlePendingPermissionRequestResult.from_dict(s) + + +def session_permissions_handle_pending_permission_request_result_to_dict(x: SessionPermissionsHandlePendingPermissionRequestResult) -> Any: + return to_class(SessionPermissionsHandlePendingPermissionRequestResult, x) + + +def session_permissions_handle_pending_permission_request_params_from_dict(s: Any) -> SessionPermissionsHandlePendingPermissionRequestParams: + return SessionPermissionsHandlePendingPermissionRequestParams.from_dict(s) + + +def session_permissions_handle_pending_permission_request_params_to_dict(x: SessionPermissionsHandlePendingPermissionRequestParams) -> Any: + return to_class(SessionPermissionsHandlePendingPermissionRequestParams, x) + + def _timeout_kwargs(timeout: float | None) -> dict: """Build keyword arguments for optional timeout forwarding.""" if timeout is not None: @@ -1156,7 +1336,7 @@ def _timeout_kwargs(timeout: float | None) -> dict: return {} -class ModelsApi: +class ServerModelsApi: def __init__(self, client: "JsonRpcClient"): self._client = client @@ -1164,7 +1344,7 @@ async def list(self, *, timeout: float | None = None) -> ModelsListResult: return ModelsListResult.from_dict(await self._client.request("models.list", {}, **_timeout_kwargs(timeout))) -class ToolsApi: +class ServerToolsApi: def __init__(self, client: "JsonRpcClient"): self._client = client @@ -1173,7 +1353,7 @@ async def list(self, params: ToolsListParams, *, timeout: float | None = None) - return ToolsListResult.from_dict(await self._client.request("tools.list", params_dict, **_timeout_kwargs(timeout))) -class AccountApi: +class ServerAccountApi: def __init__(self, client: "JsonRpcClient"): self._client = client @@ -1185,9 +1365,9 @@ class ServerRpc: """Typed server-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient"): self._client = client - self.models = ModelsApi(client) - self.tools = ToolsApi(client) - self.account = AccountApi(client) + self.models = ServerModelsApi(client) + self.tools = ServerToolsApi(client) + self.account = ServerAccountApi(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} @@ -1298,6 +1478,28 @@ async def compact(self, *, timeout: float | None = None) -> SessionCompactionCom return SessionCompactionCompactResult.from_dict(await self._client.request("session.compaction.compact", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) +class ToolsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def handle_pending_tool_call(self, params: SessionToolsHandlePendingToolCallParams, *, timeout: float | None = None) -> SessionToolsHandlePendingToolCallResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionToolsHandlePendingToolCallResult.from_dict(await self._client.request("session.tools.handlePendingToolCall", params_dict, **_timeout_kwargs(timeout))) + + +class PermissionsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def handle_pending_permission_request(self, params: SessionPermissionsHandlePendingPermissionRequestParams, *, timeout: float | None = None) -> SessionPermissionsHandlePendingPermissionRequestResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionPermissionsHandlePendingPermissionRequestResult.from_dict(await self._client.request("session.permissions.handlePendingPermissionRequest", params_dict, **_timeout_kwargs(timeout))) + + class SessionRpc: """Typed session-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient", session_id: str): @@ -1310,4 +1512,6 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.fleet = FleetApi(client, session_id) self.agent = AgentApi(client, session_id) self.compaction = CompactionApi(client, session_id) + self.tools = ToolsApi(client, session_id) + self.permissions = PermissionsApi(client, session_id) diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 74d3c64d6..1b442530d 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -80,6 +80,8 @@ def from_int(x: Any) -> int: class AgentMode(Enum): + """The agent mode that was active when this message was sent""" + AUTOPILOT = "autopilot" INTERACTIVE = "interactive" PLAN = "plan" @@ -88,8 +90,13 @@ class AgentMode(Enum): @dataclass class LineRange: + """Optional line range to scope the attachment to a specific section of the file""" + end: float + """End line number (1-based, inclusive)""" + start: float + """Start line number (1-based)""" @staticmethod def from_dict(obj: Any) -> 'LineRange': @@ -106,6 +113,8 @@ def to_dict(self) -> dict: class ReferenceType(Enum): + """Type of GitHub reference""" + DISCUSSION = "discussion" ISSUE = "issue" PR = "pr" @@ -114,7 +123,10 @@ class ReferenceType(Enum): @dataclass class End: character: float + """End character offset within the line (0-based)""" + line: float + """End line number (0-based)""" @staticmethod def from_dict(obj: Any) -> 'End': @@ -133,7 +145,10 @@ def to_dict(self) -> dict: @dataclass class Start: character: float + """Start character offset within the line (0-based)""" + line: float + """Start line number (0-based)""" @staticmethod def from_dict(obj: Any) -> 'Start': @@ -151,6 +166,8 @@ def to_dict(self) -> dict: @dataclass class Selection: + """Position range of the selection within the file""" + end: End start: Start @@ -178,17 +195,42 @@ class AttachmentType(Enum): @dataclass class Attachment: type: AttachmentType + """Attachment type discriminator""" + display_name: str | None = None + """User-facing display name for the attachment + + User-facing display name for the selection + """ line_range: LineRange | None = None + """Optional line range to scope the attachment to a specific section of the file""" + path: str | None = None + """Absolute file or directory path""" + file_path: str | None = None + """Absolute path to the file containing the selection""" + selection: Selection | None = None + """Position range of the selection within the file""" + text: str | None = None + """The selected text content""" + number: float | None = None + """Issue, pull request, or discussion number""" + reference_type: ReferenceType | None = None + """Type of GitHub reference""" + state: str | None = None + """Current state of the referenced item (e.g., open, closed, merged)""" + title: str | None = None + """Title of the referenced item""" + url: str | None = None + """URL to the referenced item on GitHub""" @staticmethod def from_dict(obj: Any) -> 'Attachment': @@ -235,11 +277,93 @@ def to_dict(self) -> dict: return result +@dataclass +class Agent: + agent_id: str + """Unique identifier of the background agent""" + + agent_type: str + """Type of the background agent""" + + description: str | None = None + """Human-readable description of the agent task""" + + @staticmethod + def from_dict(obj: Any) -> 'Agent': + assert isinstance(obj, dict) + agent_id = from_str(obj.get("agentId")) + agent_type = from_str(obj.get("agentType")) + description = from_union([from_str, from_none], obj.get("description")) + return Agent(agent_id, agent_type, description) + + def to_dict(self) -> dict: + result: dict = {} + result["agentId"] = from_str(self.agent_id) + result["agentType"] = from_str(self.agent_type) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + return result + + +@dataclass +class Shell: + shell_id: str + """Unique identifier of the background shell""" + + description: str | None = None + """Human-readable description of the shell command""" + + @staticmethod + def from_dict(obj: Any) -> 'Shell': + assert isinstance(obj, dict) + shell_id = from_str(obj.get("shellId")) + description = from_union([from_str, from_none], obj.get("description")) + return Shell(shell_id, description) + + def to_dict(self) -> dict: + result: dict = {} + result["shellId"] = from_str(self.shell_id) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + return result + + +@dataclass +class BackgroundTasks: + """Background tasks still running when the agent became idle""" + + agents: list[Agent] + """Currently running background agents""" + + shells: list[Shell] + """Currently running background shell commands""" + + @staticmethod + def from_dict(obj: Any) -> 'BackgroundTasks': + assert isinstance(obj, dict) + agents = from_list(Agent.from_dict, obj.get("agents")) + shells = from_list(Shell.from_dict, obj.get("shells")) + return BackgroundTasks(agents, shells) + + def to_dict(self) -> dict: + result: dict = {} + result["agents"] = from_list(lambda x: to_class(Agent, x), self.agents) + result["shells"] = from_list(lambda x: to_class(Shell, x), self.shells) + return result + + @dataclass class CodeChanges: + """Aggregate code change metrics for the session""" + files_modified: list[str] + """List of file paths that were modified during the session""" + lines_added: float + """Total number of lines added during the session""" + lines_removed: float + """Total number of lines removed during the session""" @staticmethod def from_dict(obj: Any) -> 'CodeChanges': @@ -259,9 +383,16 @@ def to_dict(self) -> dict: @dataclass class CompactionTokensUsed: + """Token usage breakdown for the compaction LLM call""" + cached_input: float + """Cached input tokens reused in the compaction LLM call""" + input: float + """Input tokens consumed by the compaction LLM call""" + output: float + """Output tokens produced by the compaction LLM call""" @staticmethod def from_dict(obj: Any) -> 'CompactionTokensUsed': @@ -281,10 +412,21 @@ def to_dict(self) -> dict: @dataclass class ContextClass: + """Working directory and git context at session start + + Updated working directory and git context at resume time + """ cwd: str + """Current working directory path""" + branch: str | None = None + """Current git branch name""" + git_root: str | None = None + """Root directory of the git repository, resolved via git rev-parse""" + repository: str | None = None + """Repository identifier in "owner/name" format, derived from the git remote URL""" @staticmethod def from_dict(obj: Any) -> 'ContextClass': @@ -310,9 +452,16 @@ def to_dict(self) -> dict: @dataclass class TokenDetail: batch_size: float + """Number of tokens in this billing batch""" + cost_per_batch: float + """Cost per batch of tokens""" + token_count: float + """Total token count for this entry""" + token_type: str + """Token category (e.g., "input", "output")""" @staticmethod def from_dict(obj: Any) -> 'TokenDetail': @@ -334,8 +483,13 @@ def to_dict(self) -> dict: @dataclass class CopilotUsage: + """Per-request cost and usage data from the CAPI copilot_usage response field""" + token_details: list[TokenDetail] + """Itemized token usage breakdown""" + total_nano_aiu: float + """Total cost in nano-AIU (AI Units) for this request""" @staticmethod def from_dict(obj: Any) -> 'CopilotUsage': @@ -353,9 +507,18 @@ def to_dict(self) -> dict: @dataclass class ErrorClass: + """Error details when the tool execution failed + + Error details when the hook failed + """ message: str + """Human-readable error message""" + code: str | None = None + """Machine-readable error code""" + stack: str | None = None + """Error stack trace, when available""" @staticmethod def from_dict(obj: Any) -> 'ErrorClass': @@ -377,8 +540,13 @@ def to_dict(self) -> dict: @dataclass class Metadata: + """Metadata about the prompt template and its construction""" + prompt_version: str | None = None + """Version identifier of the prompt template used""" + variables: dict[str, Any] | None = None + """Template variables used when constructing the prompt""" @staticmethod def from_dict(obj: Any) -> 'Metadata': @@ -402,8 +570,13 @@ class Mode(Enum): @dataclass class Requests: + """Request count and cost metrics""" + cost: float + """Cumulative cost multiplier for requests to this model""" + count: float + """Total number of API requests made to this model""" @staticmethod def from_dict(obj: Any) -> 'Requests': @@ -421,10 +594,19 @@ def to_dict(self) -> dict: @dataclass class Usage: + """Token usage breakdown""" + cache_read_tokens: float + """Total tokens read from prompt cache across all requests""" + cache_write_tokens: float + """Total tokens written to prompt cache across all requests""" + input_tokens: float + """Total input tokens consumed across all requests to this model""" + output_tokens: float + """Total output tokens produced across all requests to this model""" @staticmethod def from_dict(obj: Any) -> 'Usage': @@ -447,7 +629,10 @@ def to_dict(self) -> dict: @dataclass class ModelMetric: requests: Requests + """Request count and cost metrics""" + usage: Usage + """Token usage breakdown""" @staticmethod def from_dict(obj: Any) -> 'ModelMetric': @@ -464,6 +649,10 @@ def to_dict(self) -> dict: class Operation(Enum): + """The type of operation performed on the plan file + + Whether the file was newly created or updated + """ CREATE = "create" DELETE = "delete" UPDATE = "update" @@ -472,7 +661,10 @@ class Operation(Enum): @dataclass class Command: identifier: str + """Command identifier (e.g., executable name)""" + read_only: bool + """Whether this command is read-only (no side effects)""" @staticmethod def from_dict(obj: Any) -> 'Command': @@ -488,7 +680,7 @@ def to_dict(self) -> dict: return result -class Kind(Enum): +class PermissionRequestKind(Enum): CUSTOM_TOOL = "custom-tool" MCP = "mcp" MEMORY = "memory" @@ -501,6 +693,7 @@ class Kind(Enum): @dataclass class PossibleURL: url: str + """URL that may be accessed by the command""" @staticmethod def from_dict(obj: Any) -> 'PossibleURL': @@ -516,35 +709,94 @@ def to_dict(self) -> dict: @dataclass class PermissionRequest: - kind: Kind + """Details of the permission being requested""" + + kind: PermissionRequestKind + """Permission kind discriminator""" + can_offer_session_approval: bool | None = None + """Whether the UI can offer session-wide approval for this command pattern""" + commands: list[Command] | None = None + """Parsed command identifiers found in the command text""" + full_command_text: str | None = None + """The complete shell command text to be executed""" + has_write_file_redirection: bool | None = None + """Whether the command includes a file write redirection (e.g., > or >>)""" + intention: str | None = None + """Human-readable description of what the command intends to do + + Human-readable description of the intended file change + + Human-readable description of why the file is being read + + Human-readable description of why the URL is being accessed + """ possible_paths: list[str] | None = None + """File paths that may be read or written by the command""" + possible_urls: list[PossibleURL] | None = None + """URLs that may be accessed by the command""" + tool_call_id: str | None = None + """Tool call ID that triggered this permission request""" + warning: str | None = None + """Optional warning message about risks of running this command""" + diff: str | None = None + """Unified diff showing the proposed changes""" + file_name: str | None = None + """Path of the file being written to""" + new_file_contents: str | None = None + """Complete new file contents for newly created files""" + path: str | None = None + """Path of the file or directory being read""" + args: Any = None + """Arguments to pass to the MCP tool + + Arguments to pass to the custom tool + """ read_only: bool | None = None + """Whether this MCP tool is read-only (no side effects)""" + server_name: str | None = None + """Name of the MCP server providing the tool""" + tool_name: str | None = None + """Internal name of the MCP tool + + Name of the custom tool + """ tool_title: str | None = None + """Human-readable title of the MCP tool""" + url: str | None = None + """URL to be fetched""" + citations: str | None = None + """Source references for the stored fact""" + fact: str | None = None + """The fact or convention being stored""" + subject: str | None = None + """Topic or subject of the memory being stored""" + tool_description: str | None = None + """Description of what the custom tool does""" @staticmethod def from_dict(obj: Any) -> 'PermissionRequest': assert isinstance(obj, dict) - kind = Kind(obj.get("kind")) + kind = PermissionRequestKind(obj.get("kind")) can_offer_session_approval = from_union([from_bool, from_none], obj.get("canOfferSessionApproval")) commands = from_union([lambda x: from_list(Command.from_dict, x), from_none], obj.get("commands")) full_command_text = from_union([from_str, from_none], obj.get("fullCommandText")) @@ -572,7 +824,7 @@ def from_dict(obj: Any) -> 'PermissionRequest': def to_dict(self) -> dict: result: dict = {} - result["kind"] = to_enum(Kind, self.kind) + result["kind"] = to_enum(PermissionRequestKind, self.kind) if self.can_offer_session_approval is not None: result["canOfferSessionApproval"] = from_union([from_bool, from_none], self.can_offer_session_approval) if self.commands is not None: @@ -625,13 +877,28 @@ def to_dict(self) -> dict: @dataclass class QuotaSnapshot: entitlement_requests: float + """Total requests allowed by the entitlement""" + is_unlimited_entitlement: bool + """Whether the user has an unlimited usage entitlement""" + overage: float + """Number of requests over the entitlement limit""" + overage_allowed_with_exhausted_quota: bool + """Whether overage is allowed when quota is exhausted""" + remaining_percentage: float + """Percentage of quota remaining (0.0 to 1.0)""" + usage_allowed_with_exhausted_quota: bool + """Whether usage is still permitted after quota exhaustion""" + used_requests: float + """Number of requests already consumed""" + reset_date: datetime | None = None + """Date when the quota resets""" @staticmethod def from_dict(obj: Any) -> 'QuotaSnapshot': @@ -662,9 +929,16 @@ def to_dict(self) -> dict: @dataclass class RepositoryClass: + """Repository context for the handed-off session""" + name: str + """Repository name""" + owner: str + """Repository owner (user or organization)""" + branch: str | None = None + """Git branch name, if applicable""" @staticmethod def from_dict(obj: Any) -> 'RepositoryClass': @@ -689,9 +963,14 @@ class RequestedSchemaType(Enum): @dataclass class RequestedSchema: + """JSON Schema describing the form fields to present to the user""" + properties: dict[str, Any] + """Form field definitions, keyed by field name""" + type: RequestedSchemaType required: list[str] | None = None + """List of required field names""" @staticmethod def from_dict(obj: Any) -> 'RequestedSchema': @@ -711,6 +990,8 @@ def to_dict(self) -> dict: class Theme(Enum): + """Theme variant this icon is intended for""" + DARK = "dark" LIGHT = "light" @@ -718,9 +999,16 @@ class Theme(Enum): @dataclass class Icon: src: str + """URL or path to the icon image""" + mime_type: str | None = None + """MIME type of the icon image""" + sizes: list[str] | None = None + """Available icon sizes (e.g., ['16x16', '32x32'])""" + theme: Theme | None = None + """Theme variant this icon is intended for""" @staticmethod def from_dict(obj: Any) -> 'Icon': @@ -745,10 +1033,21 @@ def to_dict(self) -> dict: @dataclass class Resource: + """The embedded resource contents, either text or base64-encoded binary""" + uri: str + """URI identifying the resource""" + mime_type: str | None = None + """MIME type of the text content + + MIME type of the blob content + """ text: str | None = None + """Text content of the resource""" + blob: str | None = None + """Base64-encoded binary content of the resource""" @staticmethod def from_dict(obj: Any) -> 'Resource': @@ -783,18 +1082,51 @@ class ContentType(Enum): @dataclass class Content: type: ContentType + """Content block type discriminator""" + text: str | None = None + """The text content + + Terminal/shell output text + """ cwd: str | None = None + """Working directory where the command was executed""" + exit_code: float | None = None + """Process exit code, if the command has completed""" + data: str | None = None + """Base64-encoded image data + + Base64-encoded audio data + """ mime_type: str | None = None + """MIME type of the image (e.g., image/png, image/jpeg) + + MIME type of the audio (e.g., audio/wav, audio/mpeg) + + MIME type of the resource content + """ description: str | None = None + """Human-readable description of the resource""" + icons: list[Icon] | None = None + """Icons associated with this resource""" + name: str | None = None + """Resource name identifier""" + size: float | None = None + """Size of the resource in bytes""" + title: str | None = None + """Human-readable display title for the resource""" + uri: str | None = None + """URI identifying the resource""" + resource: Resource | None = None + """The embedded resource contents, either text or base64-encoded binary""" @staticmethod def from_dict(obj: Any) -> 'Content': @@ -844,46 +1176,84 @@ def to_dict(self) -> dict: return result +class ResultKind(Enum): + """The outcome of the permission request""" + + APPROVED = "approved" + DENIED_BY_CONTENT_EXCLUSION_POLICY = "denied-by-content-exclusion-policy" + DENIED_BY_RULES = "denied-by-rules" + DENIED_INTERACTIVELY_BY_USER = "denied-interactively-by-user" + DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER = "denied-no-approval-rule-and-could-not-request-from-user" + + @dataclass class Result: - content: str + """Tool execution result on success + + The result of the permission request + """ + content: str | None = None + """Concise tool result text sent to the LLM for chat completion, potentially truncated for + token efficiency + """ contents: list[Content] | None = None + """Structured content blocks (text, images, audio, resources) returned by the tool in their + native format + """ detailed_content: str | None = None + """Full detailed tool result for UI/timeline display, preserving complete content such as + diffs. Falls back to content when absent. + """ + kind: ResultKind | None = None + """The outcome of the permission request""" @staticmethod def from_dict(obj: Any) -> 'Result': assert isinstance(obj, dict) - content = from_str(obj.get("content")) + content = from_union([from_str, from_none], obj.get("content")) contents = from_union([lambda x: from_list(Content.from_dict, x), from_none], obj.get("contents")) detailed_content = from_union([from_str, from_none], obj.get("detailedContent")) - return Result(content, contents, detailed_content) + kind = from_union([ResultKind, from_none], obj.get("kind")) + return Result(content, contents, detailed_content, kind) def to_dict(self) -> dict: result: dict = {} - result["content"] = from_str(self.content) + if self.content is not None: + result["content"] = from_union([from_str, from_none], self.content) if self.contents is not None: result["contents"] = from_union([lambda x: from_list(lambda x: to_class(Content, x), x), from_none], self.contents) if self.detailed_content is not None: result["detailedContent"] = from_union([from_str, from_none], self.detailed_content) + if self.kind is not None: + result["kind"] = from_union([lambda x: to_enum(ResultKind, x), from_none], self.kind) return result class Role(Enum): + """Message role: "system" for system prompts, "developer" for developer-injected instructions""" + DEVELOPER = "developer" SYSTEM = "system" class ShutdownType(Enum): + """Whether the session ended normally ("routine") or due to a crash/fatal error ("error")""" + ERROR = "error" ROUTINE = "routine" class SourceType(Enum): + """Origin type of the session being handed off""" + LOCAL = "local" REMOTE = "remote" class ToolRequestType(Enum): + """Tool call type: "function" for standard tool calls, "custom" for grammar-based tool + calls. Defaults to "function" when absent. + """ CUSTOM = "custom" FUNCTION = "function" @@ -891,9 +1261,18 @@ class ToolRequestType(Enum): @dataclass class ToolRequest: name: str + """Name of the tool being invoked""" + tool_call_id: str + """Unique identifier for this tool call""" + arguments: Any = None + """Arguments to pass to the tool, format depends on the tool""" + type: ToolRequestType | None = None + """Tool call type: "function" for standard tool calls, "custom" for grammar-based tool + calls. Defaults to "function" when absent. + """ @staticmethod def from_dict(obj: Any) -> 'ToolRequest': @@ -917,131 +1296,532 @@ def to_dict(self) -> dict: @dataclass class Data: + """Payload indicating the agent is idle; includes any background tasks still in flight + + Empty payload; the event signals that LLM-powered conversation compaction has begun + + Empty payload; the event signals that the pending message queue has changed + + Empty payload; the event signals that the custom agent was deselected, returning to the + default agent + """ context: ContextClass | str | None = None + """Working directory and git context at session start + + Updated working directory and git context at resume time + + Additional context information for the handoff + """ copilot_version: str | None = None + """Version string of the Copilot application""" + producer: str | None = None + """Identifier of the software producing the events (e.g., "copilot-agent")""" + selected_model: str | None = None + """Model selected at session creation time, if any""" + session_id: str | None = None + """Unique identifier for the session + + Session ID that this external tool request belongs to + """ start_time: datetime | None = None + """ISO 8601 timestamp when the session was created""" + version: float | None = None + """Schema version number for the session event format""" + event_count: float | None = None + """Total number of persisted events in the session at the time of resume""" + resume_time: datetime | None = None + """ISO 8601 timestamp when the session was resumed""" + error_type: str | None = None + """Category of error (e.g., "authentication", "authorization", "quota", "rate_limit", + "query") + """ message: str | None = None + """Human-readable error message + + Human-readable informational message for display in the timeline + + Human-readable warning message for display in the timeline + + Message describing what information is needed from the user + """ provider_call_id: str | None = None + """GitHub request tracing ID (x-github-request-id header) for correlating with server-side + logs + + GitHub request tracing ID (x-github-request-id header) for server-side log correlation + """ stack: str | None = None + """Error stack trace, when available""" + status_code: int | None = None + """HTTP status code from the upstream request, if applicable""" + + background_tasks: BackgroundTasks | None = None + """Background tasks still running when the agent became idle""" + title: str | None = None + """The new display title for the session""" + info_type: str | None = None + """Category of informational message (e.g., "notification", "timing", "context_window", + "mcp", "snapshot", "configuration", "authentication", "model") + """ warning_type: str | None = None + """Category of warning (e.g., "subscription", "policy", "mcp")""" + new_model: str | None = None + """Newly selected model identifier""" + previous_model: str | None = None + """Model that was previously selected, if any""" + new_mode: str | None = None + """Agent mode after the change (e.g., "interactive", "plan", "autopilot")""" + previous_mode: str | None = None + """Agent mode before the change (e.g., "interactive", "plan", "autopilot")""" + operation: Operation | None = None + """The type of operation performed on the plan file + + Whether the file was newly created or updated + """ path: str | None = None - """Relative path within the workspace files directory""" - + """Relative path within the session workspace files directory + + File path to the SKILL.md definition + """ handoff_time: datetime | None = None + """ISO 8601 timestamp when the handoff occurred""" + remote_session_id: str | None = None + """Session ID of the remote session being handed off""" + repository: RepositoryClass | str | None = None + """Repository context for the handed-off session + + Repository identifier in "owner/name" format, derived from the git remote URL + """ source_type: SourceType | None = None + """Origin type of the session being handed off""" + summary: str | None = None + """Summary of the work done in the source session + + Optional summary of the completed task, provided by the agent + + Summary of the plan that was created + """ messages_removed_during_truncation: float | None = None + """Number of messages removed by truncation""" + performed_by: str | None = None + """Identifier of the component that performed truncation (e.g., "BasicTruncator")""" + post_truncation_messages_length: float | None = None + """Number of conversation messages after truncation""" + post_truncation_tokens_in_messages: float | None = None + """Total tokens in conversation messages after truncation""" + pre_truncation_messages_length: float | None = None + """Number of conversation messages before truncation""" + pre_truncation_tokens_in_messages: float | None = None + """Total tokens in conversation messages before truncation""" + token_limit: float | None = None + """Maximum token count for the model's context window""" + tokens_removed_during_truncation: float | None = None + """Number of tokens removed by truncation""" + events_removed: float | None = None + """Number of events that were removed by the rewind""" + up_to_event_id: str | None = None + """Event ID that was rewound to; all events after this one were removed""" + code_changes: CodeChanges | None = None + """Aggregate code change metrics for the session""" + current_model: str | None = None + """Model that was selected at the time of shutdown""" + error_reason: str | None = None + """Error description when shutdownType is "error\"""" + model_metrics: dict[str, ModelMetric] | None = None + """Per-model usage breakdown, keyed by model identifier""" + session_start_time: float | None = None + """Unix timestamp (milliseconds) when the session started""" + shutdown_type: ShutdownType | None = None + """Whether the session ended normally ("routine") or due to a crash/fatal error ("error")""" + total_api_duration_ms: float | None = None + """Cumulative time spent in API calls during the session, in milliseconds""" + total_premium_requests: float | None = None + """Total number of premium API requests used during the session""" + branch: str | None = None + """Current git branch name""" + cwd: str | None = None + """Current working directory path""" + git_root: str | None = None + """Root directory of the git repository, resolved via git rev-parse""" + current_tokens: float | None = None + """Current number of tokens in the context window""" + messages_length: float | None = None + """Current number of messages in the conversation""" + checkpoint_number: float | None = None + """Checkpoint snapshot number created for recovery""" + checkpoint_path: str | None = None + """File path where the checkpoint was stored""" + compaction_tokens_used: CompactionTokensUsed | None = None + """Token usage breakdown for the compaction LLM call""" + error: ErrorClass | str | None = None + """Error message if compaction failed + + Error details when the tool execution failed + + Error message describing why the sub-agent failed + + Error details when the hook failed + """ messages_removed: float | None = None + """Number of messages removed during compaction""" + post_compaction_tokens: float | None = None + """Total tokens in conversation after compaction""" + pre_compaction_messages_length: float | None = None + """Number of messages before compaction""" + pre_compaction_tokens: float | None = None + """Total tokens in conversation before compaction""" + request_id: str | None = None + """GitHub request tracing ID (x-github-request-id header) for the compaction LLM call + + Unique identifier for this permission request; used to respond via + session.respondToPermission() + + Request ID of the resolved permission request; clients should dismiss any UI for this + request + + Unique identifier for this input request; used to respond via + session.respondToUserInput() + + Request ID of the resolved user input request; clients should dismiss any UI for this + request + + Unique identifier for this elicitation request; used to respond via + session.respondToElicitation() + + Request ID of the resolved elicitation request; clients should dismiss any UI for this + request + + Unique identifier for this request; used to respond via session.respondToExternalTool() + + Request ID of the resolved external tool request; clients should dismiss any UI for this + request + + Unique identifier for this request; used to respond via session.respondToQueuedCommand() + + Request ID of the resolved command request; clients should dismiss any UI for this + request + + Unique identifier for this request; used to respond via session.respondToExitPlanMode() + + Request ID of the resolved exit plan mode request; clients should dismiss any UI for this + request + """ success: bool | None = None + """Whether compaction completed successfully + + Whether the tool execution completed successfully + + Whether the hook completed successfully + """ summary_content: str | None = None + """LLM-generated summary of the compacted conversation history""" + tokens_removed: float | None = None + """Number of tokens removed during compaction""" + agent_mode: AgentMode | None = None + """The agent mode that was active when this message was sent""" + attachments: list[Attachment] | None = None + """Files, selections, or GitHub references attached to the message""" + content: str | None = None + """The user's message text as displayed in the timeline + + The complete extended thinking text from the model + + The assistant's text response content + + Full content of the skill file, injected into the conversation for the model + + The system or developer prompt text + """ interaction_id: str | None = None + """CAPI interaction ID for correlating this user message with its turn + + CAPI interaction ID for correlating this turn with upstream telemetry + + CAPI interaction ID for correlating this message with upstream telemetry + + CAPI interaction ID for correlating this tool execution with upstream telemetry + """ source: str | None = None + """Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected + messages that should be hidden from the user) + """ transformed_content: str | None = None + """Transformed version of the message sent to the model, with XML wrapping, timestamps, and + other augmentations for prompt caching + """ turn_id: str | None = None + """Identifier for this turn within the agentic loop, typically a stringified turn number + + Identifier of the turn that has ended, matching the corresponding assistant.turn_start + event + """ intent: str | None = None + """Short description of what the agent is currently doing or planning to do""" + reasoning_id: str | None = None + """Unique identifier for this reasoning block + + Reasoning block ID this delta belongs to, matching the corresponding assistant.reasoning + event + """ delta_content: str | None = None + """Incremental text chunk to append to the reasoning content + + Incremental text chunk to append to the message content + """ total_response_size_bytes: float | None = None + """Cumulative total bytes received from the streaming response so far""" + encrypted_content: str | None = None + """Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume.""" + message_id: str | None = None + """Unique identifier for this assistant message + + Message ID this delta belongs to, matching the corresponding assistant.message event + """ + output_tokens: float | None = None + """Actual output token count from the API response (completion_tokens), used for accurate + token accounting + + Number of output tokens produced + """ parent_tool_call_id: str | None = None + """Tool call ID of the parent tool invocation when this event originates from a sub-agent + + Parent tool call ID when this usage originates from a sub-agent + """ phase: str | None = None + """Generation phase for phased-output models (e.g., thinking vs. response phases)""" + reasoning_opaque: str | None = None + """Opaque/encrypted extended thinking data from Anthropic models. Session-bound and stripped + on resume. + """ reasoning_text: str | None = None + """Readable reasoning text from the model's extended thinking""" + tool_requests: list[ToolRequest] | None = None + """Tool invocations requested by the assistant in this message""" + api_call_id: str | None = None + """Completion ID from the model provider (e.g., chatcmpl-abc123)""" + cache_read_tokens: float | None = None + """Number of tokens read from prompt cache""" + cache_write_tokens: float | None = None + """Number of tokens written to prompt cache""" + copilot_usage: CopilotUsage | None = None + """Per-request cost and usage data from the CAPI copilot_usage response field""" + cost: float | None = None + """Model multiplier cost for billing purposes""" + duration: float | None = None + """Duration of the API call in milliseconds""" + initiator: str | None = None + """What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls""" + input_tokens: float | None = None + """Number of input tokens consumed""" + model: str | None = None - output_tokens: float | None = None + """Model identifier used for this API call + + Model identifier that generated this tool call + """ quota_snapshots: dict[str, QuotaSnapshot] | None = None + """Per-quota resource usage snapshots, keyed by quota identifier""" + reason: str | None = None + """Reason the current turn was aborted (e.g., "user initiated")""" + arguments: Any = None + """Arguments for the tool invocation + + Arguments passed to the tool + + Arguments to pass to the external tool + """ tool_call_id: str | None = None + """Unique identifier for this tool call + + Tool call ID this partial result belongs to + + Tool call ID this progress notification belongs to + + Unique identifier for the completed tool call + + Tool call ID of the parent tool invocation that spawned this sub-agent + + Tool call ID assigned to this external tool invocation + """ tool_name: str | None = None + """Name of the tool the user wants to invoke + + Name of the tool being executed + + Name of the external tool to invoke + """ mcp_server_name: str | None = None + """Name of the MCP server hosting this tool, when the tool is an MCP tool""" + mcp_tool_name: str | None = None + """Original tool name on the MCP server, when the tool is an MCP tool""" + partial_output: str | None = None + """Incremental output chunk from the running tool""" + progress_message: str | None = None + """Human-readable progress status message (e.g., from an MCP server)""" + is_user_requested: bool | None = None + """Whether this tool call was explicitly requested by the user rather than the assistant""" + result: Result | None = None + """Tool execution result on success + + The result of the permission request + """ tool_telemetry: dict[str, Any] | None = None + """Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts)""" + allowed_tools: list[str] | None = None + """Tool names that should be auto-approved when this skill is active""" + name: str | None = None + """Name of the invoked skill + + Optional name identifier for the message source + """ plugin_name: str | None = None + """Name of the plugin this skill originated from, when applicable""" + plugin_version: str | None = None + """Version of the plugin this skill originated from, when applicable""" + agent_description: str | None = None + """Description of what the sub-agent does""" + agent_display_name: str | None = None + """Human-readable display name of the sub-agent + + Human-readable display name of the selected custom agent + """ agent_name: str | None = None + """Internal name of the sub-agent + + Internal name of the selected custom agent + """ tools: list[str] | None = None + """List of tool names available to this agent, or null for all tools""" + hook_invocation_id: str | None = None + """Unique identifier for this hook invocation + + Identifier matching the corresponding hook.start event + """ hook_type: str | None = None + """Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + + Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + """ input: Any = None + """Input data passed to the hook""" + output: Any = None + """Output data produced by the hook""" + metadata: Metadata | None = None + """Metadata about the prompt template and its construction""" + role: Role | None = None + """Message role: "system" for system prompts, "developer" for developer-injected instructions""" + permission_request: PermissionRequest | None = None + """Details of the permission being requested""" + allow_freeform: bool | None = None + """Whether the user can provide a free-form text response in addition to predefined choices""" + choices: list[str] | None = None + """Predefined choices for the user to select from, if applicable""" + question: str | None = None + """The question or prompt to present to the user""" + mode: Mode | None = None + """Elicitation mode; currently only "form" is supported. Defaults to "form" when absent.""" + requested_schema: RequestedSchema | None = None + """JSON Schema describing the form fields to present to the user""" + + command: str | None = None + """The slash command text to be executed (e.g., /help, /clear)""" + + actions: list[str] | None = None + """Available actions the user can take (e.g., approve, edit, reject)""" + + plan_content: str | None = None + """Full content of the plan file""" + + recommended_action: str | None = None + """The recommended action for the user to take""" @staticmethod def from_dict(obj: Any) -> 'Data': @@ -1060,6 +1840,7 @@ def from_dict(obj: Any) -> 'Data': provider_call_id = from_union([from_str, from_none], obj.get("providerCallId")) stack = from_union([from_str, from_none], obj.get("stack")) status_code = from_union([from_int, from_none], obj.get("statusCode")) + 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")) warning_type = from_union([from_str, from_none], obj.get("warningType")) @@ -1122,6 +1903,7 @@ def from_dict(obj: Any) -> 'Data': total_response_size_bytes = from_union([from_float, from_none], obj.get("totalResponseSizeBytes")) encrypted_content = from_union([from_str, from_none], obj.get("encryptedContent")) message_id = from_union([from_str, from_none], obj.get("messageId")) + output_tokens = from_union([from_float, from_none], obj.get("outputTokens")) parent_tool_call_id = from_union([from_str, from_none], obj.get("parentToolCallId")) phase = from_union([from_str, from_none], obj.get("phase")) reasoning_opaque = from_union([from_str, from_none], obj.get("reasoningOpaque")) @@ -1136,7 +1918,6 @@ def from_dict(obj: Any) -> 'Data': initiator = from_union([from_str, from_none], obj.get("initiator")) input_tokens = from_union([from_float, from_none], obj.get("inputTokens")) model = from_union([from_str, from_none], obj.get("model")) - output_tokens = from_union([from_float, from_none], obj.get("outputTokens")) quota_snapshots = from_union([lambda x: from_dict(QuotaSnapshot.from_dict, x), from_none], obj.get("quotaSnapshots")) reason = from_union([from_str, from_none], obj.get("reason")) arguments = obj.get("arguments") @@ -1169,7 +1950,11 @@ def from_dict(obj: Any) -> 'Data': question = from_union([from_str, from_none], obj.get("question")) mode = from_union([Mode, from_none], obj.get("mode")) requested_schema = from_union([RequestedSchema.from_dict, from_none], obj.get("requestedSchema")) - return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, title, info_type, warning_type, new_model, previous_model, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, branch, cwd, git_root, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, model, output_tokens, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role, permission_request, allow_freeform, choices, question, mode, requested_schema) + command = from_union([from_str, from_none], obj.get("command")) + actions = from_union([lambda x: from_list(from_str, x), from_none], obj.get("actions")) + plan_content = from_union([from_str, from_none], obj.get("planContent")) + recommended_action = from_union([from_str, from_none], obj.get("recommendedAction")) + return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, background_tasks, title, info_type, warning_type, new_model, previous_model, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, branch, cwd, git_root, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, model, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role, permission_request, allow_freeform, choices, question, mode, requested_schema, command, actions, plan_content, recommended_action) def to_dict(self) -> dict: result: dict = {} @@ -1201,6 +1986,8 @@ def to_dict(self) -> dict: result["stack"] = from_union([from_str, from_none], self.stack) if self.status_code is not None: result["statusCode"] = from_union([from_int, from_none], self.status_code) + if self.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: result["title"] = from_union([from_str, from_none], self.title) if self.info_type is not None: @@ -1325,6 +2112,8 @@ def to_dict(self) -> dict: result["encryptedContent"] = from_union([from_str, from_none], self.encrypted_content) if self.message_id is not None: result["messageId"] = from_union([from_str, from_none], self.message_id) + if self.output_tokens is not None: + result["outputTokens"] = from_union([to_float, from_none], self.output_tokens) if self.parent_tool_call_id is not None: result["parentToolCallId"] = from_union([from_str, from_none], self.parent_tool_call_id) if self.phase is not None: @@ -1353,8 +2142,6 @@ def to_dict(self) -> dict: result["inputTokens"] = from_union([to_float, from_none], self.input_tokens) if self.model is not None: result["model"] = from_union([from_str, from_none], self.model) - if self.output_tokens is not None: - result["outputTokens"] = from_union([to_float, from_none], self.output_tokens) if self.quota_snapshots is not None: result["quotaSnapshots"] = from_union([lambda x: from_dict(lambda x: to_class(QuotaSnapshot, x), x), from_none], self.quota_snapshots) if self.reason is not None: @@ -1419,6 +2206,14 @@ def to_dict(self) -> dict: result["mode"] = from_union([lambda x: to_enum(Mode, x), from_none], self.mode) if self.requested_schema is not None: result["requestedSchema"] = from_union([lambda x: to_class(RequestedSchema, x), from_none], self.requested_schema) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.actions is not None: + result["actions"] = from_union([lambda x: from_list(from_str, x), from_none], self.actions) + if self.plan_content is not None: + result["planContent"] = from_union([from_str, from_none], self.plan_content) + if self.recommended_action is not None: + result["recommendedAction"] = from_union([from_str, from_none], self.recommended_action) return result @@ -1433,8 +2228,14 @@ class SessionEventType(Enum): ASSISTANT_TURN_END = "assistant.turn_end" ASSISTANT_TURN_START = "assistant.turn_start" ASSISTANT_USAGE = "assistant.usage" + COMMAND_COMPLETED = "command.completed" + COMMAND_QUEUED = "command.queued" ELICITATION_COMPLETED = "elicitation.completed" ELICITATION_REQUESTED = "elicitation.requested" + EXIT_PLAN_MODE_COMPLETED = "exit_plan_mode.completed" + EXIT_PLAN_MODE_REQUESTED = "exit_plan_mode.requested" + EXTERNAL_TOOL_COMPLETED = "external_tool.completed" + EXTERNAL_TOOL_REQUESTED = "external_tool.requested" HOOK_END = "hook.end" HOOK_START = "hook.start" PENDING_MESSAGES_MODIFIED = "pending_messages.modified" @@ -1488,11 +2289,29 @@ def _missing_(cls, value: object) -> "SessionEventType": @dataclass class SessionEvent: data: Data + """Payload indicating the agent is idle; includes any background tasks still in flight + + Empty payload; the event signals that LLM-powered conversation compaction has begun + + Empty payload; the event signals that the pending message queue has changed + + Empty payload; the event signals that the custom agent was deselected, returning to the + default agent + """ id: UUID + """Unique event identifier (UUID v4), generated when the event is emitted""" + timestamp: datetime + """ISO 8601 timestamp when the event was created""" + type: SessionEventType ephemeral: bool | None = None + """When true, the event is transient and not persisted to the session event log on disk""" + parent_id: UUID | None = None + """ID of the chronologically preceding event in the session, forming a linked chain. Null + for the first event. + """ @staticmethod def from_dict(obj: Any) -> 'SessionEvent': diff --git a/python/copilot/sdk_protocol_version.py b/python/copilot/sdk_protocol_version.py index 770082670..7af648d62 100644 --- a/python/copilot/sdk_protocol_version.py +++ b/python/copilot/sdk_protocol_version.py @@ -6,7 +6,7 @@ This must match the version expected by the copilot-agent-runtime server. """ -SDK_PROTOCOL_VERSION = 2 +SDK_PROTOCOL_VERSION = 3 def get_sdk_protocol_version() -> int: diff --git a/python/copilot/session.py b/python/copilot/session.py index 49adb7d2e..e0e72fc68 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -11,8 +11,17 @@ from collections.abc import Callable from typing import Any, cast -from .generated.rpc import SessionModelSwitchToParams, SessionRpc +from .generated.rpc import ( + Kind, + ResultResult, + SessionModelSwitchToParams, + SessionPermissionsHandlePendingPermissionRequestParams, + SessionPermissionsHandlePendingPermissionRequestParamsResult, + SessionRpc, + SessionToolsHandlePendingToolCallParams, +) from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict +from .jsonrpc import JsonRpcError, ProcessExitedError from .types import ( MessageOptions, PermissionRequest, @@ -20,6 +29,8 @@ SessionHooks, Tool, ToolHandler, + ToolInvocation, + ToolResult, UserInputHandler, UserInputRequest, UserInputResponse, @@ -236,12 +247,19 @@ def _dispatch_event(self, event: SessionEvent) -> None: """ Dispatch an event to all registered handlers. + Broadcast request events (external_tool.requested, permission.requested) are handled + internally before being forwarded to user handlers. + Note: This method is internal and should not be called directly. Args: event: The session event to dispatch to all handlers. """ + # Handle broadcast request events (protocol v3) before dispatching to user handlers. + # Fire-and-forget: the response is sent asynchronously via RPC. + self._handle_broadcast_event(event) + with self._event_handlers_lock: handlers = list(self._event_handlers) @@ -251,6 +269,150 @@ def _dispatch_event(self, event: SessionEvent) -> None: except Exception as e: print(f"Error in session event handler: {e}") + def _handle_broadcast_event(self, event: SessionEvent) -> None: + """Handle broadcast request events by executing local handlers and responding via RPC. + + Implements the protocol v3 broadcast model where tool calls and permission requests + are broadcast as session events to all clients. + """ + if event.type == SessionEventType.EXTERNAL_TOOL_REQUESTED: + request_id = event.data.request_id + tool_name = event.data.tool_name + if not request_id or not tool_name: + return + + handler = self._get_tool_handler(tool_name) + if not handler: + return # This client doesn't handle this tool; another client will. + + tool_call_id = event.data.tool_call_id or "" + arguments = event.data.arguments + asyncio.ensure_future( + self._execute_tool_and_respond( + request_id, tool_name, tool_call_id, arguments, handler + ) + ) + + elif event.type == SessionEventType.PERMISSION_REQUESTED: + request_id = event.data.request_id + permission_request = event.data.permission_request + if not request_id or not permission_request: + return + + with self._permission_handler_lock: + perm_handler = self._permission_handler + if not perm_handler: + return # This client doesn't handle permissions; another client will. + + asyncio.ensure_future( + self._execute_permission_and_respond(request_id, permission_request, perm_handler) + ) + + async def _execute_tool_and_respond( + self, + request_id: str, + tool_name: str, + tool_call_id: str, + arguments: Any, + handler: ToolHandler, + ) -> None: + """Execute a tool handler and send the result back via HandlePendingToolCall RPC.""" + try: + invocation = ToolInvocation( + session_id=self.session_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + arguments=arguments, + ) + + result = handler(invocation) + if inspect.isawaitable(result): + result = await result + + tool_result: ToolResult + if result is None: + tool_result = ToolResult( + text_result_for_llm="Tool returned no result.", + result_type="failure", + error="tool returned no result", + tool_telemetry={}, + ) + else: + tool_result = result # type: ignore[assignment] + + # If the tool reported a failure with an error message, send it via the + # top-level error param so the server formats the tool message consistently + # with other SDKs (e.g., "Failed to execute 'tool' ... due to error: ..."). + if tool_result.result_type == "failure" and tool_result.error: + await self.rpc.tools.handle_pending_tool_call( + SessionToolsHandlePendingToolCallParams( + request_id=request_id, + error=tool_result.error, + ) + ) + else: + await self.rpc.tools.handle_pending_tool_call( + SessionToolsHandlePendingToolCallParams( + request_id=request_id, + result=ResultResult( + text_result_for_llm=tool_result.text_result_for_llm, + result_type=tool_result.result_type, + tool_telemetry=tool_result.tool_telemetry, + ), + ) + ) + except Exception as exc: + try: + await self.rpc.tools.handle_pending_tool_call( + SessionToolsHandlePendingToolCallParams( + request_id=request_id, + error=str(exc), + ) + ) + except (JsonRpcError, ProcessExitedError, OSError): + pass # Connection lost or RPC error — nothing we can do + + async def _execute_permission_and_respond( + self, + request_id: str, + permission_request: Any, + handler: _PermissionHandlerFn, + ) -> None: + """Execute a permission handler and respond via RPC.""" + try: + result = handler(permission_request, {"session_id": self.session_id}) + if inspect.isawaitable(result): + result = await result + + result = cast(PermissionRequestResult, result) + + perm_result = SessionPermissionsHandlePendingPermissionRequestParamsResult( + kind=Kind(result.kind), + rules=result.rules, + feedback=result.feedback, + message=result.message, + path=result.path, + ) + + await self.rpc.permissions.handle_pending_permission_request( + SessionPermissionsHandlePendingPermissionRequestParams( + request_id=request_id, + result=perm_result, + ) + ) + except Exception: + try: + await self.rpc.permissions.handle_pending_permission_request( + SessionPermissionsHandlePendingPermissionRequestParams( + request_id=request_id, + result=SessionPermissionsHandlePendingPermissionRequestParamsResult( + kind=Kind.DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER, + ), + ) + ) + except (JsonRpcError, ProcessExitedError, OSError): + pass # Connection lost or RPC error — nothing we can do + def _register_tools(self, tools: list[Tool] | None) -> None: """ Register custom tool handlers for this session. @@ -329,7 +491,7 @@ async def _handle_permission_request( if not handler: # No handler registered, deny permission - return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + return PermissionRequestResult() try: result = handler(request, {"session_id": self.session_id}) @@ -338,7 +500,7 @@ async def _handle_permission_request( return cast(PermissionRequestResult, result) except Exception: # pylint: disable=broad-except # Handler failed, deny permission - return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + return PermissionRequestResult() def _register_user_input_handler(self, handler: UserInputHandler | None) -> None: """ diff --git a/python/copilot/tools.py b/python/copilot/tools.py index af32bd04f..573992cd5 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -122,7 +122,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: # Build args based on detected signature call_args = [] if takes_params: - args = invocation["arguments"] or {} + args = invocation.arguments or {} if ptype is not None and _is_pydantic_model(ptype): call_args.append(ptype.model_validate(args)) else: @@ -141,11 +141,11 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: # Don't expose detailed error information to the LLM for security reasons. # The actual error is stored in the 'error' field for debugging. return ToolResult( - textResultForLlm="Invoking this tool produced an error. " + text_result_for_llm="Invoking this tool produced an error. " "Detailed information is not available.", - resultType="failure", + result_type="failure", error=str(exc), - toolTelemetry={}, + tool_telemetry={}, ) return Tool( @@ -185,19 +185,19 @@ def _normalize_result(result: Any) -> ToolResult: """ if result is None: return ToolResult( - textResultForLlm="", - resultType="success", + text_result_for_llm="", + result_type="success", ) - # ToolResult passes through directly - if isinstance(result, dict) and "resultType" in result and "textResultForLlm" in result: + # ToolResult dataclass passes through directly + if isinstance(result, ToolResult): return result # Strings pass through directly if isinstance(result, str): return ToolResult( - textResultForLlm=result, - resultType="success", + text_result_for_llm=result, + result_type="success", ) # Everything else gets JSON-serialized (with Pydantic model support) @@ -212,6 +212,6 @@ def default(obj: Any) -> Any: raise TypeError(f"Failed to serialize tool result: {exc}") from exc return ToolResult( - textResultForLlm=json_str, - resultType="success", + text_result_for_llm=json_str, + result_type="success", ) diff --git a/python/copilot/types.py b/python/copilot/types.py index 42006d6b4..6c484ce40 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -9,7 +9,10 @@ from typing import Any, Literal, NotRequired, TypedDict # Import generated SessionEvent types -from .generated.session_events import SessionEvent +from .generated.session_events import ( + PermissionRequest, + SessionEvent, +) # SessionEvent is now imported from generated types # It provides proper type discrimination for all event types @@ -100,29 +103,36 @@ class CopilotClientOptions(TypedDict, total=False): ToolResultType = Literal["success", "failure", "rejected", "denied"] -class ToolBinaryResult(TypedDict, total=False): - data: str - mimeType: str - type: str - description: str +@dataclass +class ToolBinaryResult: + """Binary content returned by a tool.""" + + data: str = "" + mime_type: str = "" + type: str = "" + description: str = "" -class ToolResult(TypedDict, total=False): +@dataclass +class ToolResult: """Result of a tool invocation.""" - textResultForLlm: str - binaryResultsForLlm: list[ToolBinaryResult] - resultType: ToolResultType - error: str - sessionLog: str - toolTelemetry: dict[str, Any] + text_result_for_llm: str = "" + result_type: ToolResultType = "success" + error: str | None = None + binary_results_for_llm: list[ToolBinaryResult] | None = None + session_log: str | None = None + tool_telemetry: dict[str, Any] | None = None -class ToolInvocation(TypedDict): - session_id: str - tool_call_id: str - tool_name: str - arguments: Any +@dataclass +class ToolInvocation: + """Context passed to a tool handler when invoked.""" + + session_id: str = "" + tool_call_id: str = "" + tool_name: str = "" + arguments: Any = None ToolHandler = Callable[[ToolInvocation], ToolResult | Awaitable[ToolResult]] @@ -164,25 +174,26 @@ class SystemMessageReplaceConfig(TypedDict): SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig -# Permission request types -class PermissionRequest(TypedDict, total=False): - """Permission request from the server""" - - kind: Literal["shell", "write", "mcp", "read", "url", "custom-tool"] - toolCallId: str - # Additional fields vary by kind +# Permission result types +PermissionRequestResultKind = Literal[ + "approved", + "denied-by-rules", + "denied-by-content-exclusion-policy", + "denied-no-approval-rule-and-could-not-request-from-user", + "denied-interactively-by-user", +] -class PermissionRequestResult(TypedDict, total=False): - """Result of a permission request""" - kind: Literal[ - "approved", - "denied-by-rules", - "denied-no-approval-rule-and-could-not-request-from-user", - "denied-interactively-by-user", - ] - rules: list[Any] +@dataclass +class PermissionRequestResult: + """Result of a permission request.""" + + kind: PermissionRequestResultKind = "denied-no-approval-rule-and-could-not-request-from-user" + rules: list[Any] | None = None + feedback: str | None = None + message: str | None = None + path: str | None = None _PermissionHandlerFn = Callable[ diff --git a/python/e2e/test_multi_client.py b/python/e2e/test_multi_client.py new file mode 100644 index 000000000..caf58cd55 --- /dev/null +++ b/python/e2e/test_multi_client.py @@ -0,0 +1,461 @@ +"""E2E Multi-Client Broadcast Tests + +Tests that verify the protocol v3 broadcast model works correctly when +multiple clients are connected to the same CLI server session. +""" + +import asyncio +import os +import shutil +import tempfile + +import pytest +import pytest_asyncio +from pydantic import BaseModel, Field + +from copilot import ( + CopilotClient, + PermissionHandler, + PermissionRequestResult, + ToolInvocation, + define_tool, +) + +from .testharness import get_final_assistant_message +from .testharness.proxy import CapiProxy + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class MultiClientContext: + """Extended test context that manages two clients connected to the same CLI server.""" + + def __init__(self): + self.cli_path: str = "" + self.home_dir: str = "" + self.work_dir: str = "" + self.proxy_url: str = "" + self._proxy: CapiProxy | None = None + self._client1: CopilotClient | None = None + self._client2: CopilotClient | None = None + + async def setup(self): + from .testharness.context import get_cli_path_for_tests + + self.cli_path = get_cli_path_for_tests() + self.home_dir = tempfile.mkdtemp(prefix="copilot-multi-config-") + self.work_dir = tempfile.mkdtemp(prefix="copilot-multi-work-") + + self._proxy = CapiProxy() + self.proxy_url = await self._proxy.start() + + github_token = ( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ) + + # Client 1 uses TCP mode so a second client can connect to the same server + opts: dict = { + "cli_path": self.cli_path, + "cwd": self.work_dir, + "env": self.get_env(), + "use_stdio": False, + } + if github_token: + opts["github_token"] = github_token + self._client1 = CopilotClient(opts) + + # Trigger connection by creating and disconnecting an init session + init_session = await self._client1.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + await init_session.disconnect() + + # Read the actual port from client 1 and create client 2 + actual_port = self._client1.actual_port + assert actual_port is not None, "Client 1 should have an actual port after connecting" + + self._client2 = CopilotClient({"cli_url": f"localhost:{actual_port}"}) + + async def teardown(self, test_failed: bool = False): + if self._client2: + try: + await self._client2.stop() + except Exception: + pass + self._client2 = None + + if self._client1: + try: + await self._client1.stop() + except Exception: + pass + self._client1 = None + + if self._proxy: + await self._proxy.stop(skip_writing_cache=test_failed) + self._proxy = None + + if self.home_dir and os.path.exists(self.home_dir): + shutil.rmtree(self.home_dir, ignore_errors=True) + if self.work_dir and os.path.exists(self.work_dir): + shutil.rmtree(self.work_dir, ignore_errors=True) + + async def configure_for_test(self, test_file: str, test_name: str): + import re + + sanitized_name = re.sub(r"[^a-zA-Z0-9]", "_", test_name).lower() + # Use the same snapshot directory structure as the standard context + from .testharness.context import SNAPSHOTS_DIR + + snapshot_path = SNAPSHOTS_DIR / test_file / f"{sanitized_name}.yaml" + abs_snapshot_path = str(snapshot_path.resolve()) + + if self._proxy: + await self._proxy.configure(abs_snapshot_path, self.work_dir) + + # Clear temp directories between tests + from pathlib import Path + + for item in Path(self.home_dir).iterdir(): + if item.is_dir(): + shutil.rmtree(item, ignore_errors=True) + else: + item.unlink(missing_ok=True) + for item in Path(self.work_dir).iterdir(): + if item.is_dir(): + shutil.rmtree(item, ignore_errors=True) + else: + item.unlink(missing_ok=True) + + def get_env(self) -> dict: + env = os.environ.copy() + env.update( + { + "COPILOT_API_URL": self.proxy_url, + "XDG_CONFIG_HOME": self.home_dir, + "XDG_STATE_HOME": self.home_dir, + } + ) + return env + + @property + def client1(self) -> CopilotClient: + if not self._client1: + raise RuntimeError("Context not set up") + return self._client1 + + @property + def client2(self) -> CopilotClient: + if not self._client2: + raise RuntimeError("Context not set up") + return self._client2 + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + if rep.when == "call" and rep.failed: + item.session.stash.setdefault("any_test_failed", False) + item.session.stash["any_test_failed"] = True + + +@pytest_asyncio.fixture(scope="module", loop_scope="module") +async def mctx(request): + """Multi-client test context fixture.""" + context = MultiClientContext() + await context.setup() + yield context + any_failed = request.session.stash.get("any_test_failed", False) + await context.teardown(test_failed=any_failed) + + +@pytest_asyncio.fixture(autouse=True, loop_scope="module") +async def configure_multi_test(request, mctx): + """Automatically configure the proxy for each test.""" + module_name = request.module.__name__.split(".")[-1] + test_file = module_name[5:] if module_name.startswith("test_") else module_name + test_name = request.node.name + if test_name.startswith("test_"): + test_name = test_name[5:] + await mctx.configure_for_test(test_file, test_name) + yield + + +class TestMultiClientBroadcast: + async def test_both_clients_see_tool_request_and_completion_events( + self, mctx: MultiClientContext + ): + """Both clients see tool request and completion events.""" + + class SeedParams(BaseModel): + seed: str = Field(description="A seed value") + + @define_tool("magic_number", description="Returns a magic number") + def magic_number(params: SeedParams, invocation: ToolInvocation) -> str: + return f"MAGIC_{params.seed}_42" + + # Client 1 creates a session with a custom tool + session1 = await mctx.client1.create_session( + {"on_permission_request": PermissionHandler.approve_all, "tools": [magic_number]} + ) + + # Client 2 resumes with NO tools — should not overwrite client 1's tools + session2 = await mctx.client2.resume_session( + session1.session_id, {"on_permission_request": PermissionHandler.approve_all} + ) + + # Track events seen by each client + client1_events = [] + client2_events = [] + session1.on(lambda event: client1_events.append(event)) + session2.on(lambda event: client2_events.append(event)) + + # Send a prompt that triggers the custom tool + await session1.send( + {"prompt": "Use the magic_number tool with seed 'hello' and tell me the result"} + ) + response = await get_final_assistant_message(session1) + assert "MAGIC_hello_42" in (response.data.content or "") + + # Both clients should have seen the external_tool.requested event + c1_tool_requested = [e for e in client1_events if e.type.value == "external_tool.requested"] + c2_tool_requested = [e for e in client2_events if e.type.value == "external_tool.requested"] + assert len(c1_tool_requested) > 0 + assert len(c2_tool_requested) > 0 + + # Both clients should have seen the external_tool.completed event + c1_tool_completed = [e for e in client1_events if e.type.value == "external_tool.completed"] + c2_tool_completed = [e for e in client2_events if e.type.value == "external_tool.completed"] + assert len(c1_tool_completed) > 0 + assert len(c2_tool_completed) > 0 + + await session2.disconnect() + + async def test_one_client_approves_permission_and_both_see_the_result( + self, mctx: MultiClientContext + ): + """One client approves a permission request and both see the result.""" + permission_requests = [] + + # Client 1 creates a session and manually approves permission requests + session1 = await mctx.client1.create_session( + { + "on_permission_request": lambda request, invocation: ( + permission_requests.append(request) or PermissionRequestResult(kind="approved") + ), + } + ) + + # Client 2 resumes — its handler never resolves, so only client 1's approval takes effect + session2 = await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": lambda request, invocation: asyncio.Future()}, + ) + + client1_events = [] + client2_events = [] + session1.on(lambda event: client1_events.append(event)) + session2.on(lambda event: client2_events.append(event)) + + # Send a prompt that triggers a write operation (requires permission) + await session1.send( + {"prompt": "Create a file called hello.txt containing the text 'hello world'"} + ) + response = await get_final_assistant_message(session1) + assert response.data.content + + # Client 1 should have handled permission requests + assert len(permission_requests) > 0 + + # Both clients should have seen permission.requested events + c1_perm_requested = [e for e in client1_events if e.type.value == "permission.requested"] + c2_perm_requested = [e for e in client2_events if e.type.value == "permission.requested"] + assert len(c1_perm_requested) > 0 + assert len(c2_perm_requested) > 0 + + # Both clients should have seen permission.completed events with approved result + c1_perm_completed = [e for e in client1_events if e.type.value == "permission.completed"] + c2_perm_completed = [e for e in client2_events if e.type.value == "permission.completed"] + assert len(c1_perm_completed) > 0 + assert len(c2_perm_completed) > 0 + for event in c1_perm_completed + c2_perm_completed: + assert event.data.result.kind.value == "approved" + + await session2.disconnect() + + async def test_one_client_rejects_permission_and_both_see_the_result( + self, mctx: MultiClientContext + ): + """One client rejects a permission request and both see the result.""" + # Client 1 creates a session and denies all permission requests + session1 = await mctx.client1.create_session( + { + "on_permission_request": lambda request, invocation: PermissionRequestResult( + kind="denied-interactively-by-user" + ), + } + ) + + # Client 2 resumes — its handler never resolves + session2 = await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": lambda request, invocation: asyncio.Future()}, + ) + + client1_events = [] + client2_events = [] + session1.on(lambda event: client1_events.append(event)) + session2.on(lambda event: client2_events.append(event)) + + # Create a file that the agent will try to edit + test_file = os.path.join(mctx.work_dir, "protected.txt") + with open(test_file, "w") as f: + f.write("protected content") + + await session1.send({"prompt": "Edit protected.txt and replace 'protected' with 'hacked'."}) + await get_final_assistant_message(session1) + + # Verify the file was NOT modified (permission was denied) + with open(test_file) as f: + content = f.read() + assert content == "protected content" + + # Both clients should have seen permission.requested and permission.completed + c1_perm_requested = [e for e in client1_events if e.type.value == "permission.requested"] + c2_perm_requested = [e for e in client2_events if e.type.value == "permission.requested"] + assert len(c1_perm_requested) > 0 + assert len(c2_perm_requested) > 0 + + # Both clients should see the denial + c1_perm_completed = [e for e in client1_events if e.type.value == "permission.completed"] + c2_perm_completed = [e for e in client2_events if e.type.value == "permission.completed"] + assert len(c1_perm_completed) > 0 + assert len(c2_perm_completed) > 0 + for event in c1_perm_completed + c2_perm_completed: + assert event.data.result.kind.value == "denied-interactively-by-user" + + await session2.disconnect() + + @pytest.mark.timeout(90) + async def test_two_clients_register_different_tools_and_agent_uses_both( + self, mctx: MultiClientContext + ): + """Two clients register different tools and agent uses both.""" + + class CountryCodeParams(BaseModel): + model_config = {"populate_by_name": True} + country_code: str = Field(alias="countryCode", description="A two-letter country code") + + @define_tool("city_lookup", description="Returns a city name for a given country code") + def city_lookup(params: CountryCodeParams, invocation: ToolInvocation) -> str: + return f"CITY_FOR_{params.country_code}" + + @define_tool("currency_lookup", description="Returns a currency for a given country code") + def currency_lookup(params: CountryCodeParams, invocation: ToolInvocation) -> str: + return f"CURRENCY_FOR_{params.country_code}" + + # Client 1 creates a session with tool A + session1 = await mctx.client1.create_session( + {"on_permission_request": PermissionHandler.approve_all, "tools": [city_lookup]} + ) + + # Client 2 resumes with tool B (different tool, union should have both) + session2 = await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": PermissionHandler.approve_all, "tools": [currency_lookup]}, + ) + + # Send prompts sequentially to avoid nondeterministic tool_call ordering + await session1.send( + {"prompt": "Use the city_lookup tool with countryCode 'US' and tell me the result."} + ) + response1 = await get_final_assistant_message(session1) + assert "CITY_FOR_US" in (response1.data.content or "") + + await session1.send( + { + "prompt": ( + "Now use the currency_lookup tool with countryCode 'US' and tell me the result." + ) + } + ) + response2 = await get_final_assistant_message(session1) + assert "CURRENCY_FOR_US" in (response2.data.content or "") + + await session2.disconnect() + + @pytest.mark.timeout(90) + @pytest.mark.skip( + reason="Flaky on CI: Python TCP socket close detection is too slow for snapshot replay" + ) + async def test_disconnecting_client_removes_its_tools(self, mctx: MultiClientContext): + """Disconnecting a client removes its tools from the session.""" + + class InputParams(BaseModel): + input: str = Field(description="Input value") + + @define_tool("stable_tool", description="A tool that persists across disconnects") + def stable_tool(params: InputParams, invocation: ToolInvocation) -> str: + return f"STABLE_{params.input}" + + @define_tool( + "ephemeral_tool", + description="A tool that will disappear when its client disconnects", + ) + def ephemeral_tool(params: InputParams, invocation: ToolInvocation) -> str: + return f"EPHEMERAL_{params.input}" + + # Client 1 creates a session with stable_tool + session1 = await mctx.client1.create_session( + {"on_permission_request": PermissionHandler.approve_all, "tools": [stable_tool]} + ) + + # Client 2 resumes with ephemeral_tool + await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": PermissionHandler.approve_all, "tools": [ephemeral_tool]}, + ) + + # Verify both tools work before disconnect. + # Sequential prompts avoid nondeterministic tool_call ordering. + await session1.send( + { + "prompt": "Use the stable_tool with input 'test1' and tell me the result.", + } + ) + stable_response = await get_final_assistant_message(session1) + assert "STABLE_test1" in (stable_response.data.content or "") + + await session1.send( + { + "prompt": "Use the ephemeral_tool with input 'test2' and tell me the result.", + } + ) + ephemeral_response = await get_final_assistant_message(session1) + assert "EPHEMERAL_test2" in (ephemeral_response.data.content or "") + + # Force disconnect client 2 without destroying the shared session + await mctx.client2.force_stop() + + # Give the server time to process the connection close and remove tools + await asyncio.sleep(0.5) + + # Recreate client2 for future tests (but don't rejoin the session) + actual_port = mctx.client1.actual_port + mctx._client2 = CopilotClient({"cli_url": f"localhost:{actual_port}"}) + + # Now only stable_tool should be available + await session1.send( + { + "prompt": ( + "Use the stable_tool with input 'still_here'." + " Also try using ephemeral_tool" + " if it is available." + ) + } + ) + after_response = await get_final_assistant_message(session1) + assert "STABLE_still_here" in (after_response.data.content or "") + # ephemeral_tool should NOT have produced a result + assert "EPHEMERAL_" not in (after_response.data.content or "") diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 722ddc338..609003e87 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -24,8 +24,7 @@ def on_permission_request( ) -> PermissionRequestResult: permission_requests.append(request) assert invocation["session_id"] == session.session_id - # Approve the permission - return {"kind": "approved"} + return PermissionRequestResult(kind="approved") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) @@ -39,7 +38,7 @@ def on_permission_request( assert len(permission_requests) > 0 # Should include write permission request - write_requests = [req for req in permission_requests if req.get("kind") == "write"] + write_requests = [req for req in permission_requests if req.kind.value == "write"] assert len(write_requests) > 0 await session.disconnect() @@ -50,8 +49,7 @@ async def test_should_deny_permission_when_handler_returns_denied(self, ctx: E2E def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: - # Deny all permissions - return {"kind": "denied-interactively-by-user"} + return PermissionRequestResult(kind="denied-interactively-by-user") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) @@ -74,7 +72,7 @@ async def test_should_deny_tool_operations_when_handler_explicitly_denies( """Test that tool operations are denied when handler explicitly denies""" def deny_all(request, invocation): - return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + return PermissionRequestResult() session = await ctx.client.create_session({"on_permission_request": deny_all}) @@ -114,7 +112,7 @@ async def test_should_deny_tool_operations_when_handler_explicitly_denies_after_ await session1.send_and_wait({"prompt": "What is 1+1?"}) def deny_all(request, invocation): - return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + return PermissionRequestResult() session2 = await ctx.client.resume_session(session_id, {"on_permission_request": deny_all}) @@ -166,7 +164,7 @@ async def on_permission_request( permission_requests.append(request) # Simulate async permission check (e.g., user prompt) await asyncio.sleep(0.01) - return {"kind": "approved"} + return PermissionRequestResult(kind="approved") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) @@ -192,7 +190,7 @@ def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: permission_requests.append(request) - return {"kind": "approved"} + return PermissionRequestResult(kind="approved") session2 = await ctx.client.resume_session( session_id, {"on_permission_request": on_permission_request} @@ -234,11 +232,11 @@ def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: nonlocal received_tool_call_id - if request.get("toolCallId"): + if request.tool_call_id: received_tool_call_id = True - assert isinstance(request["toolCallId"], str) - assert len(request["toolCallId"]) > 0 - return {"kind": "approved"} + assert isinstance(request.tool_call_id, str) + assert len(request.tool_call_id) > 0 + return PermissionRequestResult(kind="approved") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index a70867632..60cb7c875 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -5,7 +5,7 @@ import pytest from copilot import CopilotClient, PermissionHandler -from copilot.types import Tool +from copilot.types import Tool, ToolResult from .testharness import E2ETestContext, get_final_assistant_message, get_next_event_of_type @@ -323,11 +323,11 @@ async def test_should_get_last_session_id(self, ctx: E2ETestContext): async def test_should_create_session_with_custom_tool(self, ctx: E2ETestContext): # This test uses the low-level Tool() API to show that Pydantic is optional def get_secret_number_handler(invocation): - key = invocation["arguments"].get("key", "") - return { - "textResultForLlm": "54321" if key == "ALPHA" else "unknown", - "resultType": "success", - } + key = invocation.arguments.get("key", "") if invocation.arguments else "" + return ToolResult( + text_result_for_llm="54321" if key == "ALPHA" else "unknown", + result_type="success", + ) session = await ctx.client.create_session( { diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 35400464f..b692e3f65 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -5,7 +5,12 @@ import pytest from pydantic import BaseModel, Field -from copilot import PermissionHandler, ToolInvocation, define_tool +from copilot import ( + PermissionHandler, + PermissionRequestResult, + ToolInvocation, + define_tool, +) from .testharness import E2ETestContext, get_final_assistant_message @@ -105,7 +110,7 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert params.query.table == "cities" assert params.query.ids == [12, 19] assert params.query.sortAscending is True - assert invocation["session_id"] == expected_session_id + assert invocation.session_id == expected_session_id return [ City(countryId=19, cityName="Passos", population=135460), @@ -165,7 +170,7 @@ def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: def on_permission_request(request, invocation): permission_requests.append(request) - return {"kind": "approved"} + return PermissionRequestResult(kind="approved") session = await ctx.client.create_session( { @@ -179,9 +184,9 @@ def on_permission_request(request, invocation): assert "HELLO" in assistant_message.data.content # Should have received a custom-tool permission request - custom_tool_requests = [r for r in permission_requests if r.get("kind") == "custom-tool"] + custom_tool_requests = [r for r in permission_requests if r.kind.value == "custom-tool"] assert len(custom_tool_requests) > 0 - assert custom_tool_requests[0].get("toolName") == "encrypt_string" + assert custom_tool_requests[0].tool_name == "encrypt_string" async def test_denies_custom_tool_when_permission_denied(self, ctx: E2ETestContext): tool_handler_called = False @@ -196,7 +201,7 @@ def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: return params.input.upper() def on_permission_request(request, invocation): - return {"kind": "denied-interactively-by-user"} + return PermissionRequestResult(kind="denied-interactively-by-user") session = await ctx.client.create_session( { diff --git a/python/e2e/test_tools_unit.py b/python/e2e/test_tools_unit.py index 7481c986f..c1a9163e1 100644 --- a/python/e2e/test_tools_unit.py +++ b/python/e2e/test_tools_unit.py @@ -5,7 +5,7 @@ import pytest from pydantic import BaseModel, Field -from copilot import ToolInvocation, define_tool +from copilot import ToolInvocation, ToolResult, define_tool from copilot.tools import _normalize_result @@ -62,12 +62,12 @@ def test_tool(params: Params, invocation: ToolInvocation) -> str: received_params = params return "ok" - invocation: ToolInvocation = { - "session_id": "session-1", - "tool_call_id": "call-1", - "tool_name": "test", - "arguments": {"name": "Alice", "count": 42}, - } + invocation = ToolInvocation( + session_id="session-1", + tool_call_id="call-1", + tool_name="test", + arguments={"name": "Alice", "count": 42}, + ) await test_tool.handler(invocation) @@ -87,17 +87,17 @@ def test_tool(params: Params, invocation: ToolInvocation) -> str: received_inv = invocation return "ok" - invocation: ToolInvocation = { - "session_id": "session-123", - "tool_call_id": "call-456", - "tool_name": "test", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="session-123", + tool_call_id="call-456", + tool_name="test", + arguments={}, + ) await test_tool.handler(invocation) - assert received_inv["session_id"] == "session-123" - assert received_inv["tool_call_id"] == "call-456" + assert received_inv.session_id == "session-123" + assert received_inv.tool_call_id == "call-456" async def test_zero_param_handler(self): """Handler with no parameters: def handler() -> str""" @@ -109,17 +109,17 @@ def test_tool() -> str: called = True return "ok" - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "test", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="test", + arguments={}, + ) result = await test_tool.handler(invocation) assert called - assert result["textResultForLlm"] == "ok" + assert result.text_result_for_llm == "ok" async def test_invocation_only_handler(self): """Handler with only invocation: def handler(invocation) -> str""" @@ -131,17 +131,17 @@ def test_tool(invocation: ToolInvocation) -> str: received_inv = invocation return "ok" - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "test", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="test", + arguments={}, + ) await test_tool.handler(invocation) assert received_inv is not None - assert received_inv["session_id"] == "s1" + assert received_inv.session_id == "s1" async def test_params_only_handler(self): """Handler with only params: def handler(params) -> str""" @@ -157,12 +157,12 @@ def test_tool(params: Params) -> str: received_params = params return "ok" - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "test", - "arguments": {"value": "hello"}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="test", + arguments={"value": "hello"}, + ) await test_tool.handler(invocation) @@ -177,20 +177,20 @@ class Params(BaseModel): def failing_tool(params: Params, invocation: ToolInvocation) -> str: raise ValueError("secret error message") - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "failing", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="failing", + arguments={}, + ) result = await failing_tool.handler(invocation) - assert result["resultType"] == "failure" - assert "secret error message" not in result["textResultForLlm"] - assert "error" in result["textResultForLlm"].lower() + assert result.result_type == "failure" + assert "secret error message" not in result.text_result_for_llm + assert "error" in result.text_result_for_llm.lower() # But the actual error is stored internally - assert result["error"] == "secret error message" + assert result.error == "secret error message" async def test_function_style_api(self): class Params(BaseModel): @@ -207,14 +207,14 @@ class Params(BaseModel): assert tool.description == "My tool" result = await tool.handler( - { - "session_id": "s", - "tool_call_id": "c", - "tool_name": "my_tool", - "arguments": {"value": "hello"}, - } + ToolInvocation( + session_id="s", + tool_call_id="c", + tool_name="my_tool", + arguments={"value": "hello"}, + ) ) - assert result["textResultForLlm"] == "HELLO" + assert result.text_result_for_llm == "HELLO" def test_function_style_requires_name(self): class Params(BaseModel): @@ -231,34 +231,34 @@ class Params(BaseModel): class TestNormalizeResult: def test_none_returns_empty_success(self): result = _normalize_result(None) - assert result["textResultForLlm"] == "" - assert result["resultType"] == "success" + assert result.text_result_for_llm == "" + assert result.result_type == "success" def test_string_passes_through(self): result = _normalize_result("hello world") - assert result["textResultForLlm"] == "hello world" - assert result["resultType"] == "success" - - def test_dict_with_result_type_passes_through(self): - input_result = { - "textResultForLlm": "custom", - "resultType": "failure", - "error": "some error", - } + assert result.text_result_for_llm == "hello world" + assert result.result_type == "success" + + def test_tool_result_passes_through(self): + input_result = ToolResult( + text_result_for_llm="custom", + result_type="failure", + error="some error", + ) result = _normalize_result(input_result) - assert result["textResultForLlm"] == "custom" - assert result["resultType"] == "failure" + assert result.text_result_for_llm == "custom" + assert result.result_type == "failure" def test_dict_is_json_serialized(self): result = _normalize_result({"key": "value", "num": 42}) - parsed = json.loads(result["textResultForLlm"]) + parsed = json.loads(result.text_result_for_llm) assert parsed == {"key": "value", "num": 42} - assert result["resultType"] == "success" + assert result.result_type == "success" def test_list_is_json_serialized(self): result = _normalize_result(["a", "b", "c"]) - assert result["textResultForLlm"] == '["a", "b", "c"]' - assert result["resultType"] == "success" + assert result.text_result_for_llm == '["a", "b", "c"]' + assert result.result_type == "success" def test_pydantic_model_is_serialized(self): class Response(BaseModel): @@ -266,7 +266,7 @@ class Response(BaseModel): count: int result = _normalize_result(Response(status="ok", count=5)) - parsed = json.loads(result["textResultForLlm"]) + parsed = json.loads(result.text_result_for_llm) assert parsed == {"status": "ok", "count": 5} def test_list_of_pydantic_models_is_serialized(self): @@ -276,9 +276,9 @@ class Item(BaseModel): items = [Item(name="a", value=1), Item(name="b", value=2)] result = _normalize_result(items) - parsed = json.loads(result["textResultForLlm"]) + parsed = json.loads(result.text_result_for_llm) assert parsed == [{"name": "a", "value": 1}, {"name": "b", "value": 2}] - assert result["resultType"] == "success" + assert result.result_type == "success" def test_raises_for_unserializable_value(self): # Functions cannot be JSON serialized diff --git a/python/test_client.py b/python/test_client.py index 05b324228..bcc249f30 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -35,32 +35,6 @@ async def test_resume_session_raises_without_permission_handler(self): await client.force_stop() -class TestHandleToolCallRequest: - @pytest.mark.asyncio - async def test_returns_failure_when_tool_not_registered(self): - client = CopilotClient({"cli_path": CLI_PATH}) - await client.start() - - try: - session = await client.create_session( - {"on_permission_request": PermissionHandler.approve_all} - ) - - response = await client._handle_tool_call_request( - { - "sessionId": session.session_id, - "toolCallId": "123", - "toolName": "missing_tool", - "arguments": {}, - } - ) - - assert response["result"]["resultType"] == "failure" - assert response["result"]["error"] == "tool 'missing_tool' not supported" - finally: - await client.force_stop() - - class TestURLParsing: def test_parse_port_only_url(self): client = CopilotClient({"cli_url": "8080", "log_level": "error"}) diff --git a/python/test_rpc_timeout.py b/python/test_rpc_timeout.py index af8f699a4..7fca7615b 100644 --- a/python/test_rpc_timeout.py +++ b/python/test_rpc_timeout.py @@ -8,11 +8,11 @@ FleetApi, Mode, ModeApi, - ModelsApi, PlanApi, + ServerModelsApi, + ServerToolsApi, SessionFleetStartParams, SessionModeSetParams, - ToolsApi, ToolsListParams, ) @@ -91,7 +91,7 @@ async def test_default_timeout_on_session_no_params_method(self): async def test_timeout_on_server_params_method(self): client = AsyncMock() client.request = AsyncMock(return_value={"tools": []}) - api = ToolsApi(client) + api = ServerToolsApi(client) await api.list(ToolsListParams(), timeout=60.0) @@ -102,7 +102,7 @@ async def test_timeout_on_server_params_method(self): async def test_default_timeout_on_server_params_method(self): client = AsyncMock() client.request = AsyncMock(return_value={"tools": []}) - api = ToolsApi(client) + api = ServerToolsApi(client) await api.list(ToolsListParams()) @@ -115,7 +115,7 @@ async def test_default_timeout_on_server_params_method(self): async def test_timeout_on_server_no_params_method(self): client = AsyncMock() client.request = AsyncMock(return_value={"models": []}) - api = ModelsApi(client) + api = ServerModelsApi(client) await api.list(timeout=45.0) @@ -126,7 +126,7 @@ async def test_timeout_on_server_no_params_method(self): async def test_default_timeout_on_server_no_params_method(self): client = AsyncMock() client.request = AsyncMock(return_value={"models": []}) - api = ModelsApi(client) + api = ServerModelsApi(client) await api.list() diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index a759c1135..463d856c8 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -535,6 +535,7 @@ function emitRpcClass(className: string, schema: JSONSchema7, visibility: "publi let defaultVal = ""; if (isReq && !csharpType.endsWith("?")) { if (csharpType === "string") defaultVal = " = string.Empty;"; + else if (csharpType === "object") defaultVal = " = null!;"; else if (csharpType.startsWith("List<") || csharpType.startsWith("Dictionary<")) defaultVal = " = [];"; else if (emittedRpcClasses.has(csharpType)) defaultVal = " = new();"; } @@ -567,7 +568,7 @@ function emitServerRpcClasses(node: Record, classes: string[]): srLines.push(` {`); srLines.push(` _rpc = rpc;`); for (const [groupName] of groups) { - srLines.push(` ${toPascalCase(groupName)} = new ${toPascalCase(groupName)}Api(rpc);`); + srLines.push(` ${toPascalCase(groupName)} = new Server${toPascalCase(groupName)}Api(rpc);`); } srLines.push(` }`); @@ -581,7 +582,7 @@ function emitServerRpcClasses(node: Record, classes: string[]): for (const [groupName] of groups) { srLines.push(""); srLines.push(` /// ${toPascalCase(groupName)} APIs.`); - srLines.push(` public ${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); + srLines.push(` public Server${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); } srLines.push(`}`); @@ -589,7 +590,7 @@ function emitServerRpcClasses(node: Record, classes: string[]): // Per-group API classes for (const [groupName, groupNode] of groups) { - result.push(emitServerApiClass(`${toPascalCase(groupName)}Api`, groupNode as Record, classes)); + result.push(emitServerApiClass(`Server${toPascalCase(groupName)}Api`, groupNode as Record, classes)); } return result; @@ -597,7 +598,8 @@ function emitServerRpcClasses(node: Record, classes: string[]): function emitServerApiClass(className: string, node: Record, classes: string[]): string { const lines: string[] = []; - lines.push(`/// Server-scoped ${className.replace("Api", "")} APIs.`); + const displayName = className.replace(/^Server/, "").replace(/Api$/, ""); + lines.push(`/// Server-scoped ${displayName} APIs.`); lines.push(`public class ${className}`); lines.push(`{`); lines.push(` private readonly JsonRpc _rpc;`); diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 411d1c90f..1ebc50797 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -198,7 +198,8 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio // Emit API structs for groups for (const [groupName, groupNode] of groups) { - const apiName = toPascalCase(groupName) + apiSuffix; + const prefix = isSession ? "" : "Server"; + const apiName = prefix + toPascalCase(groupName) + apiSuffix; const fields = isSession ? "client *jsonrpc2.Client; sessionID string" : "client *jsonrpc2.Client"; lines.push(`type ${apiName} struct { ${fields} }`); lines.push(``); @@ -214,7 +215,8 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(` client *jsonrpc2.Client`); if (isSession) lines.push(` sessionID string`); for (const [groupName] of groups) { - lines.push(` ${toPascalCase(groupName)} *${toPascalCase(groupName)}${apiSuffix}`); + const prefix = isSession ? "" : "Server"; + lines.push(` ${toPascalCase(groupName)} *${prefix}${toPascalCase(groupName)}${apiSuffix}`); } lines.push(`}`); lines.push(``); @@ -231,9 +233,10 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(`func New${wrapperName}(${ctorParams}) *${wrapperName} {`); lines.push(` return &${wrapperName}{${ctorFields}`); for (const [groupName] of groups) { + const prefix = isSession ? "" : "Server"; const apiInit = isSession ? `&${toPascalCase(groupName)}${apiSuffix}{client: client, sessionID: sessionID}` - : `&${toPascalCase(groupName)}${apiSuffix}{client: client}`; + : `&${prefix}${toPascalCase(groupName)}${apiSuffix}{client: client}`; lines.push(` ${toPascalCase(groupName)}: ${apiInit},`); } lines.push(` }`); diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index 6b26ea8d3..65563d741 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -257,7 +257,8 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio // Emit API classes for groups for (const [groupName, groupNode] of groups) { - const apiName = toPascalCase(groupName) + "Api"; + const prefix = isSession ? "" : "Server"; + const apiName = prefix + toPascalCase(groupName) + "Api"; if (isSession) { lines.push(`class ${apiName}:`); lines.push(` def __init__(self, client: "JsonRpcClient", session_id: str):`); @@ -292,7 +293,7 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(` def __init__(self, client: "JsonRpcClient"):`); lines.push(` self._client = client`); for (const [groupName] of groups) { - lines.push(` self.${toSnakeCase(groupName)} = ${toPascalCase(groupName)}Api(client)`); + lines.push(` self.${toSnakeCase(groupName)} = Server${toPascalCase(groupName)}Api(client)`); } } lines.push(``); diff --git a/sdk-protocol-version.json b/sdk-protocol-version.json index 4bb5680c7..cd2f236b2 100644 --- a/sdk-protocol-version.json +++ b/sdk-protocol-version.json @@ -1,3 +1,3 @@ { - "version": 2 + "version": 3 } diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 1a8fbc243..7481bc2f7 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import type { retrieveAvailableModels } from "@github/copilot/sdk"; import { existsSync } from "fs"; import { mkdir, readFile, writeFile } from "fs/promises"; import type { @@ -663,9 +662,23 @@ function transformOpenAIRequestMessage( } else if (m.role === "user" && typeof m.content === "string") { content = normalizeUserMessage(m.content); } else if (m.role === "tool" && typeof m.content === "string") { - // If it's a JSON tool call result, normalize the whitespace and property ordering + // If it's a JSON tool call result, normalize the whitespace and property ordering. + // For successful tool results wrapped in {resultType, textResultForLlm}, unwrap to + // just the inner value so snapshots stay stable across envelope format changes. try { - content = JSON.stringify(sortJsonKeys(JSON.parse(m.content))); + const parsed = JSON.parse(m.content); + if ( + parsed && + typeof parsed === "object" && + parsed.resultType === "success" && + "textResultForLlm" in parsed + ) { + content = typeof parsed.textResultForLlm === "string" + ? parsed.textResultForLlm + : JSON.stringify(sortJsonKeys(parsed.textResultForLlm)); + } else { + content = JSON.stringify(sortJsonKeys(parsed)); + } } catch { content = m.content.trim(); } @@ -950,9 +963,7 @@ function convertToStreamingResponseChunks( return chunks; } -function createGetModelsResponse(modelIds: string[]): { - data: Awaited>; -} { +function createGetModelsResponse(modelIds: string[]) { // Obviously the following might not match any given model. We could track the original responses from /models, // but that risks invalidating the caches too frequently and making this unmaintainable. If this approximation // turns out to be insufficient, we can tweak the logic here based on known model IDs. diff --git a/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml b/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml index 7dfc9ec98..0fa78ce4f 100644 --- a/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml +++ b/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml @@ -45,43 +45,8 @@ conversations: content: Intent logged - role: tool tool_call_id: toolcall_1 - content: |- - ./src/index.ts - ./src/app.ts - - role: assistant - content: |- - Found 2 TypeScript files: - - `src/app.ts` - - `src/index.ts` - # Windows returns files in alphabetical order - - messages: - - role: system - content: ${system} - - role: user - content: Find all .ts files in this directory (recursively). List the filenames you found. - - role: assistant - content: I'll search for all TypeScript files recursively in the current directory. - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Finding TypeScript files"}' - - id: toolcall_1 - type: function - function: - name: glob - arguments: '{"pattern":"**/*.ts"}' - - role: tool - tool_call_id: toolcall_0 - content: Intent logged - - role: tool - tool_call_id: toolcall_1 - content: |- - ./src/app.ts - ./src/index.ts + content: ./src/index.ts - role: assistant content: |- - Found 2 TypeScript files: - - `src/app.ts` + Found **1 TypeScript file**: - `src/index.ts` diff --git a/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml b/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml new file mode 100644 index 000000000..b4b14d0ea --- /dev/null +++ b/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml @@ -0,0 +1,50 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the magic_number tool with seed 'hello' and tell me the result + - role: assistant + content: I'll use the magic_number tool with seed 'hello' for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Getting magic number"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: magic_number + arguments: '{"seed":"hello"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the magic_number tool with seed 'hello' and tell me the result + - role: assistant + content: I'll use the magic_number tool with seed 'hello' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Getting magic number"}' + - id: toolcall_1 + type: function + function: + name: magic_number + arguments: '{"seed":"hello"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: MAGIC_hello_42 + - role: assistant + content: The magic number for seed 'hello' is **MAGIC_hello_42**. diff --git a/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml b/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml new file mode 100644 index 000000000..bf3628fa3 --- /dev/null +++ b/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml @@ -0,0 +1,236 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: STABLE_test1 + - role: assistant + content: "The stable_tool returned: **STABLE_test1**" + - role: user + content: Use the ephemeral_tool with input 'test2' and tell me the result. + - role: assistant + content: I'll call the ephemeral_tool with input 'test2' for you. + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing ephemeral_tool"}' + - role: assistant + tool_calls: + - id: toolcall_3 + type: function + function: + name: ephemeral_tool + arguments: '{"input":"test2"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: STABLE_test1 + - role: assistant + content: "The stable_tool returned: **STABLE_test1**" + - role: user + content: Use the ephemeral_tool with input 'test2' and tell me the result. + - role: assistant + content: I'll call the ephemeral_tool with input 'test2' for you. + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing ephemeral_tool"}' + - id: toolcall_3 + type: function + function: + name: ephemeral_tool + arguments: '{"input":"test2"}' + - role: tool + tool_call_id: toolcall_2 + content: Intent logged + - role: tool + tool_call_id: toolcall_3 + content: EPHEMERAL_test2 + - role: assistant + content: "The ephemeral_tool returned: **EPHEMERAL_test2**" + - role: user + content: >- + + + Tools no longer available: ephemeral_tool + + + Important: Do not attempt to call tools that are no longer available unless you've been notified that they're + available again. + + + + + Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available. + - role: assistant + content: I'll call the stable_tool with input 'still_here'. The ephemeral_tool is no longer available, so I can only use + the stable_tool. + - role: assistant + tool_calls: + - id: toolcall_4 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing tool availability"}' + - role: assistant + tool_calls: + - id: toolcall_5 + type: function + function: + name: stable_tool + arguments: '{"input":"still_here"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: STABLE_test1 + - role: assistant + content: "The stable_tool returned: **STABLE_test1**" + - role: user + content: Use the ephemeral_tool with input 'test2' and tell me the result. + - role: assistant + content: I'll call the ephemeral_tool with input 'test2' for you. + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing ephemeral_tool"}' + - id: toolcall_3 + type: function + function: + name: ephemeral_tool + arguments: '{"input":"test2"}' + - role: tool + tool_call_id: toolcall_2 + content: Intent logged + - role: tool + tool_call_id: toolcall_3 + content: EPHEMERAL_test2 + - role: assistant + content: "The ephemeral_tool returned: **EPHEMERAL_test2**" + - role: user + content: >- + + + Tools no longer available: ephemeral_tool + + + Important: Do not attempt to call tools that are no longer available unless you've been notified that they're + available again. + + + + + Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available. + - role: assistant + content: I'll call the stable_tool with input 'still_here'. The ephemeral_tool is no longer available, so I can only use + the stable_tool. + tool_calls: + - id: toolcall_4 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing tool availability"}' + - id: toolcall_5 + type: function + function: + name: stable_tool + arguments: '{"input":"still_here"}' + - role: tool + tool_call_id: toolcall_4 + content: Intent logged + - role: tool + tool_call_id: toolcall_5 + content: STABLE_still_here + - role: assistant + content: |- + The stable_tool returned: **STABLE_still_here** + + The ephemeral_tool is not available anymore (it was removed), so I could only call the stable_tool. diff --git a/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml b/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml new file mode 100644 index 000000000..b86427936 --- /dev/null +++ b/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml @@ -0,0 +1,50 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called hello.txt containing the text 'hello world' + - role: assistant + content: I'll create the hello.txt file with the text 'hello world'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating hello.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called hello.txt containing the text 'hello world' + - role: assistant + content: I'll create the hello.txt file with the text 'hello world'. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating hello.txt file"}' + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Created file ${workdir}/hello.txt with 11 characters + - role: assistant + content: Done! I've created the file `hello.txt` containing the text 'hello world'. diff --git a/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml b/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml new file mode 100644 index 000000000..ba9db87d0 --- /dev/null +++ b/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml @@ -0,0 +1,25 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'protected' with 'hacked'. + - role: assistant + content: I'll help you edit protected.txt to replace 'protected' with 'hacked'. Let me first view the file and then make + the change. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' diff --git a/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml b/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml new file mode 100644 index 000000000..d0c95ba8a --- /dev/null +++ b/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml @@ -0,0 +1,117 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the city_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the city_lookup tool with the country code 'US' for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up city information"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: city_lookup + arguments: '{"countryCode":"US"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the city_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the city_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up city information"}' + - id: toolcall_1 + type: function + function: + name: city_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: CITY_FOR_US + - role: assistant + content: The city_lookup tool returned **"CITY_FOR_US"** for the country code 'US'. + - role: user + content: Now use the currency_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the currency_lookup tool with the country code 'US' for you. + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up currency information"}' + - role: assistant + tool_calls: + - id: toolcall_3 + type: function + function: + name: currency_lookup + arguments: '{"countryCode":"US"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the city_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the city_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up city information"}' + - id: toolcall_1 + type: function + function: + name: city_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: CITY_FOR_US + - role: assistant + content: The city_lookup tool returned **"CITY_FOR_US"** for the country code 'US'. + - role: user + content: Now use the currency_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the currency_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up currency information"}' + - id: toolcall_3 + type: function + function: + name: currency_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_2 + content: Intent logged + - role: tool + tool_call_id: toolcall_3 + content: CURRENCY_FOR_US + - role: assistant + content: The currency_lookup tool returned **"CURRENCY_FOR_US"** for the country code 'US'. diff --git a/test/snapshots/session_config/should_accept_message_attachments.yaml b/test/snapshots/session_config/should_accept_message_attachments.yaml index 5e269753b..3ea9f830a 100644 --- a/test/snapshots/session_config/should_accept_message_attachments.yaml +++ b/test/snapshots/session_config/should_accept_message_attachments.yaml @@ -8,6 +8,8 @@ conversations: content: |- Summarize the attached file + + * ${workdir}/attached.txt (1 lines) 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 fdb7ebca0..cf55fcc17 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 1+1? Reply with just the number. + content: What is 3+3? Reply with just the number. - role: assistant - content: "2" + content: "6" - messages: - 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" diff --git a/test/snapshots/tool_results/should_handle_tool_result_with_failure_resulttype.yaml b/test/snapshots/tool_results/should_handle_tool_result_with_failure_resulttype.yaml index 3fddb1600..7c5ac7301 100644 --- a/test/snapshots/tool_results/should_handle_tool_result_with_failure_resulttype.yaml +++ b/test/snapshots/tool_results/should_handle_tool_result_with_failure_resulttype.yaml @@ -15,6 +15,6 @@ conversations: arguments: "{}" - role: tool tool_call_id: toolcall_0 - content: Service unavailable + content: '{"error":"API timeout","resultType":"failure","textResultForLlm":"Service unavailable"}' - role: assistant content: service is down diff --git a/test/snapshots/tools/handles_tool_calling_errors.yaml b/test/snapshots/tools/handles_tool_calling_errors.yaml index d6f5fba29..33226722d 100644 --- a/test/snapshots/tools/handles_tool_calling_errors.yaml +++ b/test/snapshots/tools/handles_tool_calling_errors.yaml @@ -15,6 +15,6 @@ conversations: arguments: "{}" - role: tool tool_call_id: toolcall_0 - content: Invoking this tool produced an error. Detailed information is not available. + content: "Failed to execute `get_user_location` tool with arguments: {} due to error: Error: Tool execution failed" - role: assistant - content: unknown + content: Your location is unknown. diff --git a/test/snapshots/tools/invokes_built_in_tools.yaml b/test/snapshots/tools/invokes_built_in_tools.yaml index a0b83d959..068cc4acc 100644 --- a/test/snapshots/tools/invokes_built_in_tools.yaml +++ b/test/snapshots/tools/invokes_built_in_tools.yaml @@ -17,8 +17,4 @@ conversations: tool_call_id: toolcall_0 content: "1. # ELIZA, the only chatbot you'll ever need" - role: assistant - content: |- - The first line of README.md is: - ``` - # ELIZA, the only chatbot you'll ever need - ``` + content: "The first line of README.md is: `# ELIZA, the only chatbot you'll ever need`" diff --git a/test/snapshots/tools/invokes_custom_tool.yaml b/test/snapshots/tools/invokes_custom_tool.yaml index fcb6fa726..6f212e4a7 100644 --- a/test/snapshots/tools/invokes_custom_tool.yaml +++ b/test/snapshots/tools/invokes_custom_tool.yaml @@ -17,4 +17,7 @@ conversations: tool_call_id: toolcall_0 content: HELLO - role: assistant - content: "The encrypted string is: **HELLO**" + content: |- + The encrypted string is: **HELLO** + + (This is a simple cipher that converts the string to uppercase.) diff --git a/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml b/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml index 5b046d4c3..fcb6fa726 100644 --- a/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml +++ b/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml @@ -17,4 +17,4 @@ conversations: tool_call_id: toolcall_0 content: HELLO - role: assistant - content: "The encrypted result is: **HELLO**" + content: "The encrypted string is: **HELLO**" diff --git a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml index 6865beeb5..ec8dc20ef 100644 --- a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml +++ b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml @@ -7,14 +7,45 @@ conversations: - role: user content: Use grep to search for the word 'hello' - role: assistant + content: I'll search for the word 'hello' in the current directory. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: "{\"intent\":\"Searching for 'hello'\"}" + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: grep + arguments: '{"query":"hello"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use grep to search for the word 'hello' + - role: assistant + content: I'll search for the word 'hello' in the current directory. tool_calls: - id: toolcall_0 + type: function + function: + name: report_intent + arguments: "{\"intent\":\"Searching for 'hello'\"}" + - id: toolcall_1 type: function function: name: grep arguments: '{"query":"hello"}' - role: tool tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 content: "CUSTOM_GREP_RESULT: hello" - role: assistant - content: "The grep result is: **CUSTOM_GREP_RESULT: hello**" + content: 'The grep search found a result for "hello" in the current directory. The output shows `CUSTOM_GREP_RESULT: + hello`, indicating the custom grep implementation found a match.'