From 19465c3bc4873f3ea577a5cfddd63a88ad554f83 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 30 Jun 2026 21:43:27 -0400 Subject: [PATCH 1/4] Expose new session options across SDKs Adds enable citations, excluded built-in agents, and session limits to create and resume session options across the SDKs, with forwarding tests and replay-backed E2E coverage for session limits, agent exclusion, and citation request shaping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 13 + dotnet/src/Types.cs | 33 ++ dotnet/test/E2E/CopilotRequestE2EProvider.cs | 13 +- dotnet/test/E2E/SessionConfigE2ETests.cs | 282 +++++++++ dotnet/test/Harness/CapiProxy.cs | 2 +- dotnet/test/Unit/CloneTests.cs | 13 + dotnet/test/Unit/SerializationTests.cs | 37 ++ go/client.go | 6 + go/client_test.go | 77 +++ .../e2e/copilot_request_helpers_test.go | 14 + .../copilot_request_session_id_e2e_test.go | 11 +- go/internal/e2e/session_config_e2e_test.go | 338 +++++++++++ go/internal/e2e/testharness/proxy.go | 5 +- go/types.go | 36 ++ .../github/copilot/SessionRequestBuilder.java | 6 + .../copilot/rpc/CreateSessionRequest.java | 40 ++ .../copilot/rpc/ResumeSessionConfig.java | 94 ++- .../copilot/rpc/ResumeSessionRequest.java | 40 ++ .../com/github/copilot/rpc/SessionConfig.java | 94 ++- .../com/github/copilot/ConfigCloneTest.java | 31 + .../copilot/CopilotRequestTestSupport.java | 20 +- .../github/copilot/SessionConfigE2ETest.java | 265 +++++++++ .../copilot/SessionRequestBuilderTest.java | 27 + nodejs/src/client.ts | 6 + nodejs/src/types.ts | 22 + nodejs/test/client.test.ts | 40 ++ nodejs/test/e2e/session_config.e2e.test.ts | 346 ++++++++++- python/copilot/__init__.py | 2 + python/copilot/client.py | 43 ++ python/copilot/session.py | 7 + python/e2e/_copilot_request_helpers.py | 18 + python/e2e/test_session_config_e2e.py | 258 +++++++- python/test_client.py | 41 ++ rust/src/types.rs | 86 ++- rust/src/wire.rs | 15 +- rust/tests/e2e/session_config.rs | 549 ++++++++++++++++++ rust/tests/session_test.rs | 84 ++- ...ly_excluded_built_in_agents_on_create.yaml | 17 + ...ly_excluded_built_in_agents_on_resume.yaml | 10 + ...should_apply_session_limits_on_create.yaml | 18 + ...should_apply_session_limits_on_resume.yaml | 18 + 41 files changed, 3052 insertions(+), 25 deletions(-) create mode 100644 test/snapshots/session_config/should_apply_excluded_built_in_agents_on_create.yaml create mode 100644 test/snapshots/session_config/should_apply_excluded_built_in_agents_on_resume.yaml create mode 100644 test/snapshots/session_config/should_apply_session_limits_on_create.yaml create mode 100644 test/snapshots/session_config/should_apply_session_limits_on_resume.yaml diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 9fbe8c5a72..cf4677fabd 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -980,9 +980,11 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.ReasoningSummary, config.ContextTier, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.EnableCitations, wireSystemMessage, toolFilter.AvailableTools, toolFilter.ExcludedTools, + config.ExcludedBuiltInAgents, config.Provider, config.Capi, config.EnableSessionTelemetry, @@ -1013,6 +1015,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.SkillDirectories, config.DisabledSkills, config.InfiniteSessions, + config.SessionLimits, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, RequestMcpApps: config.EnableMcpApps ? true : null, @@ -1189,9 +1192,11 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.ReasoningSummary, config.ContextTier, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.EnableCitations, wireSystemMessage, toolFilter.AvailableTools, toolFilter.ExcludedTools, + config.ExcludedBuiltInAgents, config.Provider, config.Capi, config.EnableSessionTelemetry, @@ -1223,6 +1228,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.SkillDirectories, config.DisabledSkills, config.InfiniteSessions, + config.SessionLimits, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description)).ToList(), RequestElicitation: config.OnElicitationRequest != null, RequestMcpApps: config.EnableMcpApps ? true : null, @@ -2431,9 +2437,11 @@ internal record CreateSessionRequest( ReasoningSummary? ReasoningSummary, ContextTier? ContextTier, IList? Tools, + bool? EnableCitations, SystemMessageConfig? SystemMessage, IList? AvailableTools, IList? ExcludedTools, + [property: JsonPropertyName("excludedBuiltinAgents")] IList? ExcludedBuiltInAgents, ProviderConfig? Provider, CapiSessionOptions? Capi, bool? EnableSessionTelemetry, @@ -2464,6 +2472,7 @@ internal record CreateSessionRequest( IList? SkillDirectories, IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, + SessionLimitsConfig? SessionLimits, IList? Commands = null, bool? RequestElicitation = null, bool? RequestMcpApps = null, @@ -2525,9 +2534,11 @@ internal record ResumeSessionRequest( ReasoningSummary? ReasoningSummary, ContextTier? ContextTier, IList? Tools, + bool? EnableCitations, SystemMessageConfig? SystemMessage, IList? AvailableTools, IList? ExcludedTools, + [property: JsonPropertyName("excludedBuiltinAgents")] IList? ExcludedBuiltInAgents, ProviderConfig? Provider, CapiSessionOptions? Capi, bool? EnableSessionTelemetry, @@ -2559,6 +2570,7 @@ internal record ResumeSessionRequest( IList? SkillDirectories, IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, + SessionLimitsConfig? SessionLimits, IList? Commands = null, bool? RequestElicitation = null, bool? RequestMcpApps = null, @@ -2660,6 +2672,7 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(CapiSessionOptions))] [JsonSerializable(typeof(NamedProviderConfig))] [JsonSerializable(typeof(ProviderModelConfig))] + [JsonSerializable(typeof(SessionLimitsConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionCapabilities))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index ecb2774398..172d9305f1 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2758,6 +2758,7 @@ protected SessionConfigBase(SessionConfigBase? other) DefaultAgent = other.DefaultAgent; Agent = other.Agent; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; + EnableCitations = other.EnableCitations; EnableConfigDiscovery = other.EnableConfigDiscovery; SkipEmbeddingRetrieval = other.SkipEmbeddingRetrieval; EmbeddingCacheStorage = other.EmbeddingCacheStorage; @@ -2768,6 +2769,7 @@ protected SessionConfigBase(SessionConfigBase? other) EnableSessionStore = other.EnableSessionStore; EnableSkills = other.EnableSkills; EnableMcpApps = other.EnableMcpApps; + ExcludedBuiltInAgents = other.ExcludedBuiltInAgents is not null ? [.. other.ExcludedBuiltInAgents] : null; ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; Hooks = other.Hooks; InfiniteSessions = other.InfiniteSessions; @@ -2815,6 +2817,7 @@ protected SessionConfigBase(SessionConfigBase? other) SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; PluginDirectories = other.PluginDirectories is not null ? [.. other.PluginDirectories] : null; InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null; + SessionLimits = other.SessionLimits; Streaming = other.Streaming; IncludeSubAgentStreamingEvents = other.IncludeSubAgentStreamingEvents; SystemMessage = other.SystemMessage; @@ -2853,6 +2856,16 @@ protected SessionConfigBase(SessionConfigBase? other) /// Per-property overrides for model capabilities, deep-merged over runtime defaults. public ModelCapabilitiesOverride? ModelCapabilities { get; set; } + /// + /// Enables native model citations for models that support them. + /// + /// + /// Citations are experimental, off by default, and currently available for Anthropic models. + /// This option may change or be removed while citation support is experimental. + /// + [Experimental(Diagnostics.Experimental)] + public bool? EnableCitations { get; set; } + /// /// Override the default configuration directory location. /// When specified, the session will use this directory for storing config and state. @@ -2945,6 +2958,16 @@ protected SessionConfigBase(SessionConfigBase? other) /// List of tool names to exclude from the session. public IList? ExcludedTools { get; set; } + /// + /// Built-in subagent names to exclude from this session. + /// + /// + /// Excluded built-ins are hidden from agent discovery and cannot be dispatched unless a + /// custom agent with the same name is available. + /// + [JsonPropertyName("excludedBuiltinAgents")] + public IList? ExcludedBuiltInAgents { get; set; } + /// Custom model provider configuration for the session. public ProviderConfig? Provider { get; set; } @@ -3137,6 +3160,16 @@ protected SessionConfigBase(SessionConfigBase? other) /// public InfiniteSessionConfig? InfiniteSessions { get; set; } + /// + /// Optional limits for the session's current accounting window. + /// + /// + /// These settings only model the caller's configured limits. Enforcement and + /// limit-exhaustion behavior are handled by the runtime. + /// + [Experimental(Diagnostics.Experimental)] + public SessionLimitsConfig? SessionLimits { get; set; } + /// /// Configuration for handling large tool outputs. When a tool produces /// output exceeding the configured size, the output is written to a temp diff --git a/dotnet/test/E2E/CopilotRequestE2EProvider.cs b/dotnet/test/E2E/CopilotRequestE2EProvider.cs index e92df5fae4..e8e483556c 100644 --- a/dotnet/test/E2E/CopilotRequestE2EProvider.cs +++ b/dotnet/test/E2E/CopilotRequestE2EProvider.cs @@ -49,8 +49,6 @@ internal sealed class RecordingRequestHandler : CopilotRequestHandler protected override async Task SendRequestAsync(HttpRequestMessage request, CopilotRequestContext ctx) { var url = request.RequestUri!.ToString(); - _records.Enqueue(new InterceptedRequest(url, ctx.SessionId)); - var bodyText = request.Content is null ? string.Empty #if NET8_0_OR_GREATER @@ -58,6 +56,7 @@ protected override async Task SendRequestAsync(HttpRequestM #else : await request.Content.ReadAsStringAsync().ConfigureAwait(false); #endif + _records.Enqueue(new InterceptedRequest(url, ctx.SessionId, bodyText)); return IsInferenceUrl(url) ? BuildInferenceResponse(url, bodyText) @@ -95,6 +94,11 @@ private static HttpResponseMessage BuildInferenceResponse(string url, string bod return Sse(string.Concat(ChatCompletionStreamEvents)); } + if (u.EndsWith("/messages", StringComparison.Ordinal)) + { + return Json(BufferedAnthropicMessageJson); + } + // /chat/completions non-streaming (and any other inference url) — buffered JSON. return Json(BufferedChatCompletionJson); } @@ -160,9 +164,12 @@ internal static HttpResponseMessage BuildNonInferenceResponse(string url) private static readonly string BufferedChatCompletionJson = "{\"id\":\"chatcmpl-stub-1\",\"object\":\"chat.completion\",\"created\":1,\"model\":\"claude-sonnet-4.5\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"" + SyntheticText + "\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":7,\"total_tokens\":12}}"; + private static readonly string BufferedAnthropicMessageJson = + "{\"id\":\"msg_stub_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4.5\",\"content\":[{\"type\":\"text\",\"text\":\"" + SyntheticText + "\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":5,\"output_tokens\":7}}"; + private const string ModelCatalogJson = "{\"data\":[{\"id\":\"claude-sonnet-4.5\",\"name\":\"Claude Sonnet 4.5\",\"object\":\"model\",\"vendor\":\"Anthropic\",\"version\":\"1\",\"preview\":false,\"model_picker_enabled\":true,\"capabilities\":{\"type\":\"chat\",\"family\":\"claude-sonnet-4.5\",\"tokenizer\":\"o200k_base\",\"limits\":{\"max_context_window_tokens\":200000,\"max_output_tokens\":8192},\"supports\":{\"streaming\":true,\"tool_calls\":true,\"parallel_tool_calls\":true,\"vision\":true}}}]}"; } /// A single request the callback intercepted. -internal sealed record InterceptedRequest(string Url, string? SessionId); +internal sealed record InterceptedRequest(string Url, string? SessionId, string Body); diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index 30c7ce5007..45cdef2016 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -4,6 +4,7 @@ using GitHub.Copilot.Rpc; using GitHub.Copilot.Test.Harness; +using System.Text; using System.Text.Json; using Xunit; using Xunit.Abstractions; @@ -448,6 +449,198 @@ public async Task Should_Apply_AvailableTools_On_Session_Resume() } } + [Fact] + public async Task Should_Apply_Session_Limits_On_Create() + { + var session = await CreateSessionAsync(new SessionConfig + { + SessionLimits = new SessionLimitsConfig + { + MaxAiCredits = 30, + }, + }); + + try + { + var exchange = await SendAndGetNextExchangeAsync( + session, + "Acknowledge the current session limits."); + + AssertSessionLimitsStatus(exchange, "30 AI credits"); + } + finally + { + await session.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Apply_Session_Limits_On_Resume() + { + var session1 = await CreateSessionAsync(); + var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + SessionLimits = new SessionLimitsConfig + { + MaxAiCredits = 30, + }, + }); + + try + { + var exchange = await SendAndGetNextExchangeAsync( + session2, + "Acknowledge the current session limits."); + + AssertSessionLimitsStatus(exchange, "30 AI credits"); + } + finally + { + await session2.DisposeAsync(); + await session1.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Apply_Excluded_Built_In_Agents_On_Create() + { + const string excludedAgent = "explore"; + const string prompt = "What is 1+1?"; + + var baselineSession = await CreateSessionAsync(); + try + { + var baselineExchange = await SendAndGetNextExchangeAsync(baselineSession, prompt); + Assert.Contains(excludedAgent, GetTaskAgentTypes(baselineExchange)); + } + finally + { + await baselineSession.DisposeAsync(); + } + + var excludedSession = await CreateSessionAsync(new SessionConfig + { + ExcludedBuiltInAgents = [excludedAgent], + }); + + try + { + var excludedExchange = await SendAndGetNextExchangeAsync(excludedSession, prompt); + var agentTypes = GetTaskAgentTypes(excludedExchange); + + Assert.NotEmpty(agentTypes); + Assert.DoesNotContain(excludedAgent, agentTypes); + } + finally + { + await excludedSession.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Apply_Excluded_Built_In_Agents_On_Resume() + { + const string excludedAgent = "explore"; + + var session1 = await CreateSessionAsync(); + var session2 = await ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + ExcludedBuiltInAgents = [excludedAgent], + }); + + try + { + var exchange = await SendAndGetNextExchangeAsync(session2, "What is 1+1?"); + var agentTypes = GetTaskAgentTypes(exchange); + + Assert.NotEmpty(agentTypes); + Assert.DoesNotContain(excludedAgent, agentTypes); + } + finally + { + await session2.DisposeAsync(); + await session1.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Enable_Citations_For_Anthropic_File_Attachments_On_Create() + { + var handler = new RecordingRequestHandler(); + await using var client = CreateClientWithRequestHandler(handler); + await client.StartAsync(); + + var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Model = "claude-sonnet-4.5", + EnableCitations = true, + Provider = CreateAnthropicProvider(), + }); + + try + { + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Summarize the attached PDF with citations enabled.", + Attachments = [CreatePdfAttachment()], + }); + + AssertAnthropicDocumentCitationsEnabled(Assert.Single(handler.InferenceRequests).Body); + } + finally + { + await session.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Enable_Citations_For_Anthropic_File_Attachments_On_Resume() + { + const string connectionToken = "citation-resume-token"; + var handler = new RecordingRequestHandler(); + await using var client = CreateClientWithRequestHandler( + handler, + RuntimeConnection.ForTcp(connectionToken: connectionToken)); + await client.StartAsync(); + + var session1 = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + var sessionId = session1.SessionId; + var port = client.RuntimePort + ?? throw new InvalidOperationException("The handler-backed E2E client must use TCP transport to support multi-client resume."); + await using var resumeClient = Ctx.CreateClient(options: new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri($"localhost:{port}", connectionToken: connectionToken), + }); + + var session2 = await resumeClient.ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Model = "claude-sonnet-4.5", + EnableCitations = true, + Provider = CreateAnthropicProvider(), + }); + + try + { + await session2.SendAndWaitAsync(new MessageOptions + { + Prompt = "Summarize the attached PDF with citations enabled.", + Attachments = [CreatePdfAttachment()], + }); + + AssertAnthropicDocumentCitationsEnabled(Assert.Single(handler.InferenceRequests).Body); + } + finally + { + await session2.DisposeAsync(); + await session1.DisposeAsync(); + } + } + [Fact] public async Task Should_Create_Session_With_Custom_Provider_Config() { @@ -542,6 +735,95 @@ private static bool HasImageUrlContent(List messages) typeProp.GetString() == "image_url")); } + private CopilotClient CreateClientWithRequestHandler( + CopilotRequestHandler handler, + RuntimeConnection? connection = null) + { + return Ctx.CreateClient(options: new CopilotClientOptions + { + Connection = connection ?? RuntimeConnection.ForStdio(), + RequestHandler = handler, + }); + } + + private async Task SendAndGetNextExchangeAsync(CopilotSession session, string prompt) + { + var existingCount = (await Ctx.GetExchangesAsync()).Count; + var exchanges = await SendAndWaitForExchangesAsync( + session, + new MessageOptions { Prompt = prompt }, + minimumCount: existingCount + 1); + return exchanges[existingCount]; + } + + private static void AssertSessionLimitsStatus(ParsedHttpExchange exchange, string expectedRemaining) + { + var message = exchange.Request.Messages.SingleOrDefault(m => + m.Role == "user" + && m.StringContent?.Contains("", StringComparison.Ordinal) == true); + + Assert.NotNull(message); + Assert.Contains($"Remaining session limits: {expectedRemaining}.", message!.StringContent); + Assert.Contains( + "Be frugal; avoid optional exploration and unnecessary tool calls.", + message.StringContent); + } + + private static IReadOnlyList GetTaskAgentTypes(ParsedHttpExchange exchange) + { + var taskTool = Assert.Single( + exchange.Request.Tools ?? [], + tool => string.Equals(tool.Function.Name, "task", StringComparison.Ordinal)); + var parameters = taskTool.Function.Parameters; + + Assert.NotNull(parameters); + var enumValues = parameters!.Value + .GetProperty("properties") + .GetProperty("agent_type") + .GetProperty("enum"); + + return [.. enumValues.EnumerateArray().Select(value => value.GetString()).OfType()]; + } + + private static AttachmentBlob CreatePdfAttachment() + { + const string pdfText = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + + return new AttachmentBlob + { + Data = Convert.ToBase64String(Encoding.ASCII.GetBytes(pdfText)), + DisplayName = "citation-source.pdf", + MimeType = "application/pdf", + }; + } + + private static ProviderConfig CreateAnthropicProvider() + { + return new ProviderConfig + { + Type = "anthropic", + BaseUrl = "https://anthropic-citations.invalid/v1", + ApiKey = "test-provider-key", + ModelId = "claude-sonnet-4.5", + WireModel = "claude-sonnet-4.5", + }; + } + + private static void AssertAnthropicDocumentCitationsEnabled(string requestBody) + { + using var document = JsonDocument.Parse(requestBody); + var documentBlocks = document.RootElement + .GetProperty("messages") + .EnumerateArray() + .SelectMany(message => message.GetProperty("content").EnumerateArray()) + .Where(block => block.GetProperty("type").GetString() == "document") + .ToList(); + + var documentBlock = Assert.Single(documentBlocks); + Assert.Equal("citation-source.pdf", documentBlock.GetProperty("title").GetString()); + Assert.True(documentBlock.GetProperty("citations").GetProperty("enabled").GetBoolean()); + } + private ProviderConfig CreateProxyProvider(string headerValue) { return new ProviderConfig diff --git a/dotnet/test/Harness/CapiProxy.cs b/dotnet/test/Harness/CapiProxy.cs index 905aa192ba..39c95fa683 100644 --- a/dotnet/test/Harness/CapiProxy.cs +++ b/dotnet/test/Harness/CapiProxy.cs @@ -268,7 +268,7 @@ public record ChatCompletionToolCallFunction(string Name, string? Arguments); public record ChatCompletionTool(string Type, ChatCompletionToolFunction Function); -public record ChatCompletionToolFunction(string Name, string? Description); +public record ChatCompletionToolFunction(string Name, string? Description, JsonElement? Parameters); public record ChatCompletionResponse(string Id, string Model, List Choices); diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 9df9a200c9..425b580a1b 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -73,8 +73,10 @@ public void SessionConfig_Clone_CopiesAllProperties() ConfigDirectory = "/config", AvailableTools = ["tool1", "tool2"], ExcludedTools = ["tool3"], + ExcludedBuiltInAgents = ["explore", "task"], WorkingDirectory = "/workspace", Streaming = true, + EnableCitations = true, EnableSessionTelemetry = false, EnableOnDemandInstructionDiscovery = true, IncludeSubAgentStreamingEvents = false, @@ -99,6 +101,7 @@ public void SessionConfig_Clone_CopiesAllProperties() PluginDirectories = ["/plugins"], LargeOutput = new LargeToolOutputConfig { Enabled = true, MaxSizeBytes = 2048, OutputDirectory = "/tmp/out" }, Memory = new MemoryConfiguration { Enabled = true }, + SessionLimits = new SessionLimitsConfig { MaxAiCredits = 42.5 }, OnExitPlanModeRequest = static (_, _) => Task.FromResult(new ExitPlanModeResult()), OnAutoModeSwitchRequest = static (_, _) => Task.FromResult(AutoModeSwitchResponse.No), }; @@ -114,8 +117,10 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.ConfigDirectory, clone.ConfigDirectory); Assert.Equal(original.AvailableTools, clone.AvailableTools); Assert.Equal(original.ExcludedTools, clone.ExcludedTools); + Assert.Equal(original.ExcludedBuiltInAgents, clone.ExcludedBuiltInAgents); Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); Assert.Equal(original.Streaming, clone.Streaming); + Assert.Equal(original.EnableCitations, clone.EnableCitations); Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry); Assert.Equal(original.EnableOnDemandInstructionDiscovery, clone.EnableOnDemandInstructionDiscovery); Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents); @@ -133,6 +138,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.PluginDirectories, clone.PluginDirectories); Assert.Same(original.LargeOutput, clone.LargeOutput); Assert.Same(original.Memory, clone.Memory); + Assert.Same(original.SessionLimits, clone.SessionLimits); Assert.Same(original.OnExitPlanModeRequest, clone.OnExitPlanModeRequest); Assert.Same(original.OnAutoModeSwitchRequest, clone.OnAutoModeSwitchRequest); } @@ -144,6 +150,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() { AvailableTools = ["tool1"], ExcludedTools = ["tool2"], + ExcludedBuiltInAgents = ["explore"], McpServers = new Dictionary { ["s1"] = new McpStdioServerConfig { Command = "echo" } }, CustomAgents = [new CustomAgentConfig { Name = "a1" }], SkillDirectories = ["/skills"], @@ -156,6 +163,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() // Mutate clone collections clone.AvailableTools!.Add("tool99"); clone.ExcludedTools!.Add("tool99"); + clone.ExcludedBuiltInAgents!.Add("task"); clone.McpServers!["s2"] = new McpStdioServerConfig { Command = "echo" }; clone.CustomAgents!.Add(new CustomAgentConfig { Name = "a2" }); clone.SkillDirectories!.Add("/more"); @@ -165,6 +173,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() // Original is unaffected Assert.Single(original.AvailableTools!); Assert.Single(original.ExcludedTools!); + Assert.Single(original.ExcludedBuiltInAgents!); Assert.Single(original.McpServers!); Assert.Single(original.CustomAgents!); Assert.Single(original.SkillDirectories!); @@ -190,6 +199,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() { AvailableTools = ["tool1"], ExcludedTools = ["tool2"], + ExcludedBuiltInAgents = ["explore"], McpServers = new Dictionary { ["s1"] = new McpStdioServerConfig { Command = "echo" } }, CustomAgents = [new CustomAgentConfig { Name = "a1" }], SkillDirectories = ["/skills"], @@ -202,6 +212,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() // Mutate clone collections clone.AvailableTools!.Add("tool99"); clone.ExcludedTools!.Add("tool99"); + clone.ExcludedBuiltInAgents!.Add("task"); clone.McpServers!["s2"] = new McpStdioServerConfig { Command = "echo" }; clone.CustomAgents!.Add(new CustomAgentConfig { Name = "a2" }); clone.SkillDirectories!.Add("/more"); @@ -211,6 +222,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() // Original is unaffected Assert.Single(original.AvailableTools!); Assert.Single(original.ExcludedTools!); + Assert.Single(original.ExcludedBuiltInAgents!); Assert.Single(original.McpServers!); Assert.Single(original.CustomAgents!); Assert.Single(original.SkillDirectories!); @@ -270,6 +282,7 @@ public void Clone_WithNullCollections_ReturnsNullCollections() Assert.Null(clone.AvailableTools); Assert.Null(clone.ExcludedTools); + Assert.Null(clone.ExcludedBuiltInAgents); Assert.Null(clone.McpServers); Assert.Null(clone.CustomAgents); Assert.Null(clone.SkillDirectories); diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 48ef2e5538..f6fa31d88c 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -422,6 +422,43 @@ public void SessionRequests_CanSerializeMemory_WithSdkOptions() Assert.False(resumeRoot.GetProperty("memory").GetProperty("enabled").GetBoolean()); } + [Fact] + public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkOptions() + { + var options = GetSerializerOptions(); + var excludedAgents = new List { "explore", "task" }; + + var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + var createRequest = CreateInternalRequest( + createRequestType, + ("SessionId", "session-id"), + ("EnableCitations", true), + ("ExcludedBuiltInAgents", excludedAgents), + ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 })); + + var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options); + using var createDocument = JsonDocument.Parse(createJson); + var createRoot = createDocument.RootElement; + Assert.True(createRoot.GetProperty("enableCitations").GetBoolean()); + Assert.Equal("explore", createRoot.GetProperty("excludedBuiltinAgents")[0].GetString()); + Assert.Equal(12.5, createRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble()); + + var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + var resumeRequest = CreateInternalRequest( + resumeRequestType, + ("SessionId", "session-id"), + ("EnableCitations", true), + ("ExcludedBuiltInAgents", excludedAgents), + ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 })); + + var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options); + using var resumeDocument = JsonDocument.Parse(resumeJson); + var resumeRoot = resumeDocument.RootElement; + Assert.True(resumeRoot.GetProperty("enableCitations").GetBoolean()); + Assert.Equal("task", resumeRoot.GetProperty("excludedBuiltinAgents")[1].GetString()); + Assert.Equal(7.25, resumeRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble()); + } + [Fact] public void SessionRequests_OmitMemory_WhenUnset() { diff --git a/go/client.go b/go/client.go index 9e2819047e..a4ba844c57 100644 --- a/go/client.go +++ b/go/client.go @@ -696,11 +696,14 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.AvailableTools = availableTools req.ExcludedTools = excludedTools req.ToolFilterPrecedence = precedence + req.ExcludedBuiltInAgents = config.ExcludedBuiltInAgents req.Provider = config.Provider req.Capi = config.Capi req.Providers = config.Providers req.Models = config.Models req.EnableSessionTelemetry = config.EnableSessionTelemetry + req.EnableCitations = config.EnableCitations + req.SessionLimits = config.SessionLimits req.SkipCustomInstructions = config.SkipCustomInstructions req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly req.CoauthorEnabled = config.CoauthorEnabled @@ -1024,6 +1027,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.AvailableTools = availableTools req.ExcludedTools = excludedTools req.ToolFilterPrecedence = precedence + req.ExcludedBuiltInAgents = config.ExcludedBuiltInAgents + req.EnableCitations = config.EnableCitations + req.SessionLimits = config.SessionLimits if config.Streaming != nil { req.Streaming = config.Streaming } diff --git a/go/client_test.go b/go/client_test.go index c889ced8d5..33513df146 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -250,6 +250,49 @@ func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { assertCapiEnableWebSocketResponses(t, <-resumeParams) } +func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } + + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + _, err := client.CreateSession(t.Context(), &SessionConfig{ + ExcludedBuiltInAgents: []string{"explore"}, + EnableCitations: Bool(true), + SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(30)}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertNewSessionOptions(t, <-createParams, true, "explore", 30) + + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed-options","workspacePath":"/workspace"}`), nil + }) + + _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-options", &ResumeSessionConfig{ + ExcludedBuiltInAgents: []string{"task"}, + EnableCitations: Bool(false), + SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(15)}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertNewSessionOptions(t, <-resumeParams, false, "task", 15) +} + func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { t.Helper() @@ -257,6 +300,7 @@ func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { if err := json.Unmarshal(params, &decoded); err != nil { t.Fatalf("failed to unmarshal request params: %v", err) } + capi, ok := decoded["capi"].(map[string]any) if !ok { t.Fatalf("expected capi object in request params, got %T", decoded["capi"]) @@ -266,6 +310,39 @@ func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { } } +func assertNewSessionOptions( + t *testing.T, + params json.RawMessage, + expectedCitations bool, + expectedAgent string, + expectedCredits float64, +) { + t.Helper() + + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + if decoded["enableCitations"] != expectedCitations { + t.Fatalf("expected enableCitations=%v, got %v", expectedCitations, decoded["enableCitations"]) + } + agents, ok := decoded["excludedBuiltinAgents"].([]any) + if !ok || len(agents) != 1 || agents[0] != expectedAgent { + t.Fatalf("expected excludedBuiltinAgents=[%q], got %#v", expectedAgent, decoded["excludedBuiltinAgents"]) + } + limits, ok := decoded["sessionLimits"].(map[string]any) + if !ok { + t.Fatalf("expected sessionLimits object, got %T", decoded["sessionLimits"]) + } + if limits["maxAiCredits"] != expectedCredits { + t.Fatalf("expected sessionLimits.maxAiCredits=%v, got %v", expectedCredits, limits["maxAiCredits"]) + } +} + +func float64Ptr(value float64) *float64 { + return &value +} + func sessionIDFromParams(t *testing.T, params json.RawMessage) string { t.Helper() diff --git a/go/internal/e2e/copilot_request_helpers_test.go b/go/internal/e2e/copilot_request_helpers_test.go index 69e82c2ab9..7c6058207a 100644 --- a/go/internal/e2e/copilot_request_helpers_test.go +++ b/go/internal/e2e/copilot_request_helpers_test.go @@ -166,6 +166,20 @@ func buildInferenceResponse(url string, bodyText string) *http.Response { return buildSSEResponse(sb.String()) } + if strings.HasSuffix(u, "/messages") { + raw, _ := json.Marshal(map[string]any{ + "id": "msg_stub_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4.5", + "content": []any{map[string]any{"type": "text", "text": syntheticResponseText}}, + "stop_reason": "end_turn", + "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 5, "output_tokens": 7}, + }) + return buildJSONResponse(200, string(raw)) + } + raw, _ := json.Marshal(map[string]any{ "id": "chatcmpl-stub-1", "object": "chat.completion", "created": 1, "model": "claude-sonnet-4.5", "choices": []any{map[string]any{"index": 0, "message": map[string]any{"role": "assistant", "content": syntheticResponseText}, "finish_reason": "stop"}}, diff --git a/go/internal/e2e/copilot_request_session_id_e2e_test.go b/go/internal/e2e/copilot_request_session_id_e2e_test.go index 809f77da76..e88b91c971 100644 --- a/go/internal/e2e/copilot_request_session_id_e2e_test.go +++ b/go/internal/e2e/copilot_request_session_id_e2e_test.go @@ -6,18 +6,19 @@ package e2e import ( "io" + "net/http" "strings" "sync" "testing" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" - "net/http" ) type interceptedRequest struct { url string sessionID string + body string } // recordingTransport intercepts every model-layer request, records its URL and @@ -34,16 +35,16 @@ func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, erro if rctx != nil { sessionID = rctx.SessionID } - rt.mu.Lock() - rt.records = append(rt.records, interceptedRequest{url: req.URL.String(), sessionID: sessionID}) - rt.mu.Unlock() - bodyBytes := []byte(nil) if req.Body != nil { bodyBytes, _ = io.ReadAll(req.Body) } bodyText := string(bodyBytes) + rt.mu.Lock() + rt.records = append(rt.records, interceptedRequest{url: req.URL.String(), sessionID: sessionID, body: bodyText}) + rt.mu.Unlock() + if isInferenceURL(req.URL.String()) { return buildInferenceResponse(req.URL.String(), bodyText), nil } diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index e5daf931b9..6dc1c59ad4 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -12,6 +12,7 @@ import ( copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" ) // hasImageURLContent returns true if any user message in the given exchanges @@ -36,6 +37,126 @@ func hasImageURLContent(exchanges []testharness.ParsedHttpExchange) bool { return false } +func sendAndGetNextExchange(t *testing.T, ctx *testharness.TestContext, session *copilot.Session, prompt string) testharness.ParsedHttpExchange { + t.Helper() + + existing, err := ctx.GetExchanges() + if err != nil { + t.Fatalf("GetExchanges failed: %v", err) + } + if _, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: prompt}); err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + exchanges := ctx.WaitForExchanges(t, len(existing)+1) + return exchanges[len(existing)] +} + +func assertSessionLimitsStatus(t *testing.T, exchange testharness.ParsedHttpExchange, expectedRemaining string) { + t.Helper() + + for _, message := range exchange.Request.Messages { + if message.Role != "user" || !strings.Contains(message.Content, "") { + continue + } + if !strings.Contains(message.Content, "Remaining session limits: "+expectedRemaining+".") { + t.Fatalf("Expected session limits status to include remaining %q, got %q", expectedRemaining, message.Content) + } + if !strings.Contains(message.Content, "Be frugal; avoid optional exploration and unnecessary tool calls.") { + t.Fatalf("Expected frugality instruction in session limits status, got %q", message.Content) + } + return + } + t.Fatal("Expected session limits status message") +} + +func getTaskAgentTypes(t *testing.T, exchange testharness.ParsedHttpExchange) []string { + t.Helper() + + for _, tool := range exchange.Request.Tools { + if tool.Function.Name != "task" { + continue + } + var parameters struct { + Properties struct { + AgentType struct { + Enum []string `json:"enum"` + } `json:"agent_type"` + } `json:"properties"` + } + if err := json.Unmarshal(tool.Function.Parameters, ¶meters); err != nil { + t.Fatalf("Failed to unmarshal task tool parameters: %v", err) + } + return parameters.Properties.AgentType.Enum + } + t.Fatal("Expected task tool in request") + return nil +} + +func containsAgentType(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func createPDFAttachment() copilot.Attachment { + pdfText := "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n" + data := base64.StdEncoding.EncodeToString([]byte(pdfText)) + displayName := "citation-source.pdf" + return copilot.AttachmentBlob{ + Data: &data, + DisplayName: &displayName, + MIMEType: "application/pdf", + } +} + +func createAnthropicProvider() *copilot.ProviderConfig { + return &copilot.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://anthropic-citations.invalid/v1", + APIKey: "test-provider-key", + ModelID: "claude-sonnet-4.5", + WireModel: "claude-sonnet-4.5", + } +} + +func assertAnthropicDocumentCitationsEnabled(t *testing.T, requestBody string) { + t.Helper() + + var body struct { + Messages []struct { + Content []map[string]any `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(requestBody), &body); err != nil { + t.Fatalf("Failed to unmarshal Anthropic request body: %v", err) + } + var documents []map[string]any + for _, message := range body.Messages { + for _, block := range message.Content { + if block["type"] == "document" { + documents = append(documents, block) + } + } + } + if len(documents) != 1 { + t.Fatalf("Expected one Anthropic document block, got %d in body %s", len(documents), requestBody) + } + if documents[0]["title"] != "citation-source.pdf" { + t.Fatalf("Expected document title citation-source.pdf, got %v", documents[0]["title"]) + } + citations, ok := documents[0]["citations"].(map[string]any) + if !ok || citations["enabled"] != true { + t.Fatalf("Expected document citations.enabled=true, got %#v", documents[0]["citations"]) + } +} + +func float64Ref(value float64) *float64 { + return &value +} + func TestSessionConfigE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient() @@ -165,6 +286,223 @@ func TestSessionConfigE2E(t *testing.T) { }) } +func TestSessionConfigNewOptionsE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + t.Run("should apply session limits on create", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ref(30)}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + exchange := sendAndGetNextExchange(t, ctx, session, "Acknowledge the current session limits.") + assertSessionLimitsStatus(t, exchange, "30 AI credits") + }) + + t.Run("should apply session limits on resume", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session1.Disconnect() + + session2, err := client.ResumeSessionWithOptions(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ref(30)}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + defer session2.Disconnect() + + exchange := sendAndGetNextExchange(t, ctx, session2, "Acknowledge the current session limits.") + assertSessionLimitsStatus(t, exchange, "30 AI credits") + }) + + t.Run("should apply excluded built in agents on create", func(t *testing.T) { + ctx.ConfigureForTest(t) + + const excludedAgent = "explore" + const prompt = "What is 1+1?" + baseline, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession baseline failed: %v", err) + } + baselineExchange := sendAndGetNextExchange(t, ctx, baseline, prompt) + if !containsAgentType(getTaskAgentTypes(t, baselineExchange), excludedAgent) { + t.Fatalf("Expected baseline task agents to include %q", excludedAgent) + } + _ = baseline.Disconnect() + + excluded, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + ExcludedBuiltInAgents: []string{excludedAgent}, + }) + if err != nil { + t.Fatalf("CreateSession excluded failed: %v", err) + } + defer excluded.Disconnect() + + excludedExchange := sendAndGetNextExchange(t, ctx, excluded, prompt) + agentTypes := getTaskAgentTypes(t, excludedExchange) + if len(agentTypes) == 0 { + t.Fatal("Expected task tool agent types") + } + if containsAgentType(agentTypes, excludedAgent) { + t.Fatalf("Expected excluded task agents not to include %q; got %v", excludedAgent, agentTypes) + } + }) + + t.Run("should apply excluded built in agents on resume", func(t *testing.T) { + ctx.ConfigureForTest(t) + + const excludedAgent = "explore" + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session1.Disconnect() + + session2, err := client.ResumeSessionWithOptions(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + ExcludedBuiltInAgents: []string{excludedAgent}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + defer session2.Disconnect() + + exchange := sendAndGetNextExchange(t, ctx, session2, "What is 1+1?") + agentTypes := getTaskAgentTypes(t, exchange) + if len(agentTypes) == 0 { + t.Fatal("Expected task tool agent types") + } + if containsAgentType(agentTypes, excludedAgent) { + t.Fatalf("Expected excluded task agents not to include %q; got %v", excludedAgent, agentTypes) + } + }) +} + +func TestSessionConfigNewOptionsCopilotRequestE2E(t *testing.T) { + t.Run("should enable citations for Anthropic file attachments on create", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + transport := &recordingTransport{} + handler := &copilot.CopilotRequestHandler{Transport: transport} + client := newCopilotRequestClient(ctx, handler) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", + EnableCitations: copilot.Bool(true), + Provider: createAnthropicProvider(), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Summarize the attached PDF with citations enabled.", + Attachments: []copilot.Attachment{createPDFAttachment()}, + }) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + + inference := transport.inferenceRecords() + if len(inference) != 1 { + t.Fatalf("Expected exactly one intercepted inference request, got %d", len(inference)) + } + assertAnthropicDocumentCitationsEnabled(t, inference[0].body) + }) + + t.Run("should enable citations for Anthropic file attachments on resume", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + transport := &recordingTransport{} + handler := &copilot.CopilotRequestHandler{Transport: transport} + const connectionToken = "go-citation-resume-token" + server := ctx.NewClient(func(o *copilot.ClientOptions) { + o.Connection = copilot.TCPConnection{Path: ctx.CLIPath, ConnectionToken: connectionToken} + o.RequestHandler = handler + }) + t.Cleanup(func() { server.ForceStop() }) + + if err := server.Start(t.Context()); err != nil { + t.Fatalf("Failed to start server client: %v", err) + } + + session1, err := server.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session1.Disconnect() + + runtimePort := server.RuntimePort() + if runtimePort == 0 { + t.Fatal("Expected non-zero runtime port") + } + resumeClient := ctx.NewClient(func(o *copilot.ClientOptions) { + o.Connection = copilot.URIConnection{ + URL: fmt.Sprintf("localhost:%d", runtimePort), + ConnectionToken: connectionToken, + } + }) + t.Cleanup(func() { resumeClient.ForceStop() }) + + session2, err := resumeClient.ResumeSessionWithOptions(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", + EnableCitations: copilot.Bool(true), + Provider: createAnthropicProvider(), + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + defer session2.Disconnect() + + _, err = session2.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Summarize the attached PDF with citations enabled.", + Attachments: []copilot.Attachment{createPDFAttachment()}, + }) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + + inference := transport.inferenceRecords() + if len(inference) != 1 { + t.Fatalf("Expected exactly one intercepted inference request, got %d", len(inference)) + } + assertAnthropicDocumentCitationsEnabled(t, inference[0].body) + }) +} + // TestSessionConfigExtras mirrors the additional Should_* tests in dotnet/test/SessionConfigTests.cs: // // Should_Use_Custom_SessionId diff --git a/go/internal/e2e/testharness/proxy.go b/go/internal/e2e/testharness/proxy.go index e407f13e06..8933183c87 100644 --- a/go/internal/e2e/testharness/proxy.go +++ b/go/internal/e2e/testharness/proxy.go @@ -256,8 +256,9 @@ type ChatCompletionTool struct { // ChatCompletionToolFunction represents a function tool. type ChatCompletionToolFunction struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` } // ChatCompletionResponse represents an OpenAI chat completion response. diff --git a/go/types.go b/go/types.go index ffb8af12a4..3586a2a916 100644 --- a/go/types.go +++ b/go/types.go @@ -1035,6 +1035,11 @@ type SessionConfig struct { // ExcludedTools is a list of tool names to disable. All other tools remain available. // Ignored if AvailableTools is specified. ExcludedTools []string + // ExcludedBuiltInAgents is a list of built-in agent names to exclude from + // the session. Excluded built-in agents are hidden from discovery and cannot + // be selected or invoked unless a custom agent with the same name is + // configured. + ExcludedBuiltInAgents []string // OnPermissionRequest is an optional handler for permission requests from the server. // When nil, permission requests are surfaced as events and left pending for the // consumer to resolve via pending permission RPCs. @@ -1085,6 +1090,16 @@ type SessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // EnableCitations enables native model citations for supported providers. + // + // Experimental: EnableCitations is part of an experimental model capability + // surface and may change or be removed in future SDK or CLI releases. + EnableCitations *bool + // SessionLimits applies limits to this session's current accounting window. + // + // Experimental: SessionLimits is part of an experimental runtime accounting + // surface and may change or be removed in future SDK or CLI releases. + SessionLimits *rpc.SessionLimitsConfig // SkipCustomInstructions, when non-nil, controls whether the runtime loads // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. SkipCustomInstructions *bool @@ -1421,6 +1436,11 @@ type ResumeSessionConfig struct { // ExcludedTools is a list of tool names to disable. All other tools remain available. // Ignored if AvailableTools is specified. ExcludedTools []string + // ExcludedBuiltInAgents is a list of built-in agent names to exclude from + // the session. Excluded built-in agents are hidden from discovery and cannot + // be selected or invoked unless a custom agent with the same name is + // configured. + ExcludedBuiltInAgents []string // Provider configures a custom model provider Provider *ProviderConfig // Capi configures provider-scoped CAPI (Copilot API) session options. @@ -1444,6 +1464,16 @@ type ResumeSessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // EnableCitations enables native model citations for supported providers. + // + // Experimental: EnableCitations is part of an experimental model capability + // surface and may change or be removed in future SDK or CLI releases. + EnableCitations *bool + // SessionLimits applies limits to this session's current accounting window. + // + // Experimental: SessionLimits is part of an experimental runtime accounting + // surface and may change or be removed in future SDK or CLI releases. + SessionLimits *rpc.SessionLimitsConfig // SkipCustomInstructions, when non-nil, controls whether the runtime loads // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. SkipCustomInstructions *bool @@ -2030,11 +2060,14 @@ type createSessionRequest struct { AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` + ExcludedBuiltInAgents []string `json:"excludedBuiltinAgents,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` Capi *CapiSessionOptions `json:"capi,omitempty"` Providers []NamedProviderConfig `json:"providers,omitempty"` Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` + EnableCitations *bool `json:"enableCitations,omitempty"` + SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` @@ -2113,11 +2146,14 @@ type resumeSessionRequest struct { AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` + ExcludedBuiltInAgents []string `json:"excludedBuiltinAgents,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` Capi *CapiSessionOptions `json:"capi,omitempty"` Providers []NamedProviderConfig `json:"providers,omitempty"` Models []ProviderModelConfig `json:"models,omitempty"` EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` + EnableCitations *bool `json:"enableCitations,omitempty"` + SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 8a4b016e1b..f88bf2a85e 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -116,11 +116,14 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); request.setExcludedTools(config.getExcludedTools()); + request.setExcludedBuiltInAgents(config.getExcludedBuiltInAgents()); request.setProvider(config.getProvider()); request.setCapi(config.getCapi()); request.setProviders(config.getProviders()); request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); + config.getEnableCitations().ifPresent(request::setEnableCitations); + request.setSessionLimits(config.getSessionLimits()); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); } @@ -232,11 +235,14 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); request.setExcludedTools(config.getExcludedTools()); + request.setExcludedBuiltInAgents(config.getExcludedBuiltInAgents()); request.setProvider(config.getProvider()); request.setCapi(config.getCapi()); request.setProviders(config.getProviders()); request.setModels(config.getModels()); config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry); + config.getEnableCitations().ifPresent(request::setEnableCitations); + request.setSessionLimits(config.getSessionLimits()); if (config.getOnUserInputRequest() != null) { request.setRequestUserInput(true); } diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 8fc966c6f1..9d82e22791 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.SessionLimitsConfig; /** * Internal request object for creating a new session. @@ -57,6 +58,9 @@ public final class CreateSessionRequest { @JsonProperty("excludedTools") private List excludedTools; + @JsonProperty("excludedBuiltinAgents") + private List excludedBuiltInAgents; + @JsonProperty("toolFilterPrecedence") private String toolFilterPrecedence; @@ -74,6 +78,12 @@ public final class CreateSessionRequest { @JsonProperty("enableSessionTelemetry") private Boolean enableSessionTelemetry; + @JsonProperty("enableCitations") + private Boolean enableCitations; + + @JsonProperty("sessionLimits") + private SessionLimitsConfig sessionLimits; + @JsonProperty("requestPermission") private Boolean requestPermission; @@ -304,6 +314,16 @@ public void setExcludedTools(List excludedTools) { this.excludedTools = excludedTools; } + /** Gets excluded built-in agents. @return the built-in agent names */ + public List getExcludedBuiltInAgents() { + return excludedBuiltInAgents == null ? null : Collections.unmodifiableList(excludedBuiltInAgents); + } + + /** Sets excluded built-in agents. @param excludedBuiltInAgents the agent names */ + public void setExcludedBuiltInAgents(List excludedBuiltInAgents) { + this.excludedBuiltInAgents = excludedBuiltInAgents; + } + /** Gets the tool filter precedence. @return the precedence value */ public String getToolFilterPrecedence() { return toolFilterPrecedence; @@ -373,6 +393,26 @@ public void setEnableSessionTelemetry(boolean enableSessionTelemetry) { this.enableSessionTelemetry = enableSessionTelemetry; } + /** Gets enable citations flag. @return the flag */ + public Boolean getEnableCitations() { + return enableCitations; + } + + /** Sets enable citations flag. @param enableCitations the flag */ + public void setEnableCitations(boolean enableCitations) { + this.enableCitations = enableCitations; + } + + /** Gets the session limits. @return the session limits */ + public SessionLimitsConfig getSessionLimits() { + return sessionLimits; + } + + /** Sets the session limits. @param sessionLimits the session limits */ + public void setSessionLimits(SessionLimitsConfig sessionLimits) { + this.sessionLimits = sessionLimits; + } + /** * Clears the enableSessionTelemetry setting, reverting to the default behavior. */ diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 48e333f05b..895fc4d3d5 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -8,6 +8,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; @@ -16,7 +17,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; -import java.util.Optional; +import com.github.copilot.generated.rpc.SessionLimitsConfig; /** * Configuration for resuming an existing Copilot session. @@ -46,11 +47,14 @@ public class ResumeSessionConfig { private SystemMessageConfig systemMessage; private List availableTools; private List excludedTools; + private List excludedBuiltInAgents; private ProviderConfig provider; private CapiSessionOptions capi; private List providers; private List models; private Boolean enableSessionTelemetry; + private Boolean enableCitations; + private SessionLimitsConfig sessionLimits; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; private Boolean coauthorEnabled; @@ -239,6 +243,30 @@ public ResumeSessionConfig setExcludedTools(List excludedTools) { return this; } + /** + * Gets the built-in agent names excluded from the resumed session. + * + * @return the list of excluded built-in agent names + */ + public List getExcludedBuiltInAgents() { + return excludedBuiltInAgents == null ? null : Collections.unmodifiableList(excludedBuiltInAgents); + } + + /** + * Sets the built-in agent names to exclude from the resumed session. + *

+ * Excluded built-in agents are hidden from discovery and cannot be selected or + * invoked unless a custom agent with the same name is configured. + * + * @param excludedBuiltInAgents + * the built-in agent names to exclude + * @return this config instance for method chaining + */ + public ResumeSessionConfig setExcludedBuiltInAgents(List excludedBuiltInAgents) { + this.excludedBuiltInAgents = excludedBuiltInAgents; + return this; + } + /** * Gets the custom API provider configuration. * @@ -383,6 +411,65 @@ public ResumeSessionConfig clearEnableSessionTelemetry() { return this; } + /** + * Gets whether native model citations are enabled. + * + * @return an {@link java.util.Optional} containing whether citations are + * enabled, or {@link java.util.Optional#empty()} for the default + */ + @CopilotExperimental + @JsonIgnore + public Optional getEnableCitations() { + return Optional.ofNullable(enableCitations); + } + + /** + * Enables or disables native model citations for supported providers. + * + * @param enableCitations + * whether to enable citations + * @return this config instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setEnableCitations(boolean enableCitations) { + this.enableCitations = enableCitations; + return this; + } + + /** + * Clears the enableCitations setting, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig clearEnableCitations() { + this.enableCitations = null; + return this; + } + + /** + * Gets the limits for this session's current accounting window. + * + * @return the session limits, or {@code null} if not set + */ + @CopilotExperimental + public SessionLimitsConfig getSessionLimits() { + return sessionLimits; + } + + /** + * Sets limits for this session's current accounting window. + * + * @param sessionLimits + * the session limits + * @return this config instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setSessionLimits(SessionLimitsConfig sessionLimits) { + this.sessionLimits = sessionLimits; + return this; + } + /** * Gets whether custom instruction file loading is suppressed. * @@ -1678,11 +1765,16 @@ public ResumeSessionConfig clone() { copy.systemMessage = this.systemMessage; copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; copy.excludedTools = this.excludedTools != null ? new ArrayList<>(this.excludedTools) : null; + copy.excludedBuiltInAgents = this.excludedBuiltInAgents != null + ? new ArrayList<>(this.excludedBuiltInAgents) + : null; copy.provider = this.provider; copy.capi = this.capi; copy.providers = this.providers != null ? new ArrayList<>(this.providers) : null; copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; + copy.enableCitations = this.enableCitations; + copy.sessionLimits = this.sessionLimits; copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; copy.contextTier = this.contextTier; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 2b25875d7f..871c91ee2c 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.SessionLimitsConfig; /** * Internal request object for resuming an existing session. @@ -59,6 +60,9 @@ public final class ResumeSessionRequest { @JsonProperty("excludedTools") private List excludedTools; + @JsonProperty("excludedBuiltinAgents") + private List excludedBuiltInAgents; + @JsonProperty("toolFilterPrecedence") private String toolFilterPrecedence; @@ -76,6 +80,12 @@ public final class ResumeSessionRequest { @JsonProperty("enableSessionTelemetry") private Boolean enableSessionTelemetry; + @JsonProperty("enableCitations") + private Boolean enableCitations; + + @JsonProperty("sessionLimits") + private SessionLimitsConfig sessionLimits; + @JsonProperty("requestPermission") private Boolean requestPermission; @@ -309,6 +319,16 @@ public void setExcludedTools(List excludedTools) { this.excludedTools = excludedTools; } + /** Gets excluded built-in agents. @return the built-in agent names */ + public List getExcludedBuiltInAgents() { + return excludedBuiltInAgents == null ? null : Collections.unmodifiableList(excludedBuiltInAgents); + } + + /** Sets excluded built-in agents. @param excludedBuiltInAgents the agent names */ + public void setExcludedBuiltInAgents(List excludedBuiltInAgents) { + this.excludedBuiltInAgents = excludedBuiltInAgents; + } + /** Gets the tool filter precedence. @return the precedence value */ public String getToolFilterPrecedence() { return toolFilterPrecedence; @@ -378,6 +398,26 @@ public void setEnableSessionTelemetry(boolean enableSessionTelemetry) { this.enableSessionTelemetry = enableSessionTelemetry; } + /** Gets enable citations flag. @return the flag */ + public Boolean getEnableCitations() { + return enableCitations; + } + + /** Sets enable citations flag. @param enableCitations the flag */ + public void setEnableCitations(boolean enableCitations) { + this.enableCitations = enableCitations; + } + + /** Gets the session limits. @return the session limits */ + public SessionLimitsConfig getSessionLimits() { + return sessionLimits; + } + + /** Sets the session limits. @param sessionLimits the session limits */ + public void setSessionLimits(SessionLimitsConfig sessionLimits) { + this.sessionLimits = sessionLimits; + } + /** * Clears the enableSessionTelemetry setting, reverting to the default behavior. */ diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index e5e0e629e1..b7a5220a2b 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -8,6 +8,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; @@ -16,7 +17,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; -import java.util.Optional; +import com.github.copilot.generated.rpc.SessionLimitsConfig; /** * Configuration for creating a new Copilot session. @@ -50,11 +51,14 @@ public class SessionConfig { private SystemMessageConfig systemMessage; private List availableTools; private List excludedTools; + private List excludedBuiltInAgents; private ProviderConfig provider; private CapiSessionOptions capi; private List providers; private List models; private Boolean enableSessionTelemetry; + private Boolean enableCitations; + private SessionLimitsConfig sessionLimits; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; private Boolean coauthorEnabled; @@ -337,6 +341,30 @@ public SessionConfig setExcludedTools(List excludedTools) { return this; } + /** + * Gets the built-in agent names excluded from this session. + * + * @return the list of excluded built-in agent names + */ + public List getExcludedBuiltInAgents() { + return excludedBuiltInAgents == null ? null : Collections.unmodifiableList(excludedBuiltInAgents); + } + + /** + * Sets the built-in agent names to exclude from this session. + *

+ * Excluded built-in agents are hidden from discovery and cannot be selected or + * invoked unless a custom agent with the same name is configured. + * + * @param excludedBuiltInAgents + * the built-in agent names to exclude + * @return this config instance for method chaining + */ + public SessionConfig setExcludedBuiltInAgents(List excludedBuiltInAgents) { + this.excludedBuiltInAgents = excludedBuiltInAgents; + return this; + } + /** * Gets the custom API provider configuration. * @@ -485,6 +513,65 @@ public SessionConfig clearEnableSessionTelemetry() { return this; } + /** + * Gets whether native model citations are enabled. + * + * @return an {@link java.util.Optional} containing whether citations are + * enabled, or {@link java.util.Optional#empty()} for the default + */ + @CopilotExperimental + @JsonIgnore + public Optional getEnableCitations() { + return Optional.ofNullable(enableCitations); + } + + /** + * Enables or disables native model citations for supported providers. + * + * @param enableCitations + * whether to enable citations + * @return this config instance for method chaining + */ + @CopilotExperimental + public SessionConfig setEnableCitations(boolean enableCitations) { + this.enableCitations = enableCitations; + return this; + } + + /** + * Clears the enableCitations setting, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public SessionConfig clearEnableCitations() { + this.enableCitations = null; + return this; + } + + /** + * Gets the limits for this session's current accounting window. + * + * @return the session limits, or {@code null} if not set + */ + @CopilotExperimental + public SessionLimitsConfig getSessionLimits() { + return sessionLimits; + } + + /** + * Sets limits for this session's current accounting window. + * + * @param sessionLimits + * the session limits + * @return this config instance for method chaining + */ + @CopilotExperimental + public SessionConfig setSessionLimits(SessionLimitsConfig sessionLimits) { + this.sessionLimits = sessionLimits; + return this; + } + /** * Gets whether custom instruction file loading is suppressed. * @@ -1813,11 +1900,16 @@ public SessionConfig clone() { copy.systemMessage = this.systemMessage; copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; copy.excludedTools = this.excludedTools != null ? new ArrayList<>(this.excludedTools) : null; + copy.excludedBuiltInAgents = this.excludedBuiltInAgents != null + ? new ArrayList<>(this.excludedBuiltInAgents) + : null; copy.provider = this.provider; copy.capi = this.capi; copy.providers = this.providers != null ? new ArrayList<>(this.providers) : null; copy.models = this.models != null ? new ArrayList<>(this.models) : null; copy.enableSessionTelemetry = this.enableSessionTelemetry; + copy.enableCitations = this.enableCitations; + copy.sessionLimits = this.sessionLimits; copy.skipCustomInstructions = this.skipCustomInstructions; copy.customAgentsLocalOnly = this.customAgentsLocalOnly; copy.coauthorEnabled = this.coauthorEnabled; diff --git a/java/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/src/test/java/com/github/copilot/ConfigCloneTest.java index 462997f050..6986ef7f0e 100644 --- a/java/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.DefaultAgentConfig; @@ -171,6 +172,21 @@ void sessionConfigAgentAndOnEventCloned() { assertSame(handler, cloned.getOnEvent()); } + @Test + void sessionConfigSessionPolicyOptionsCloned() { + var sessionLimits = new SessionLimitsConfig(30.0); + var excludedAgents = new ArrayList<>(List.of("explore")); + SessionConfig original = new SessionConfig().setExcludedBuiltInAgents(excludedAgents).setEnableCitations(true) + .setSessionLimits(sessionLimits); + + SessionConfig cloned = original.clone(); + excludedAgents.add("task"); + + assertEquals(List.of("explore"), cloned.getExcludedBuiltInAgents()); + assertTrue(cloned.getEnableCitations().orElse(false)); + assertSame(sessionLimits, cloned.getSessionLimits()); + } + @Test void resumeSessionConfigCloneBasic() { ResumeSessionConfig original = new ResumeSessionConfig(); @@ -208,6 +224,21 @@ void resumeSessionConfigAgentAndOnEventCloned() { assertSame(handler, cloned.getOnEvent()); } + @Test + void resumeSessionConfigSessionPolicyOptionsCloned() { + var sessionLimits = new SessionLimitsConfig(30.0); + var excludedAgents = new ArrayList<>(List.of("explore")); + ResumeSessionConfig original = new ResumeSessionConfig().setExcludedBuiltInAgents(excludedAgents) + .setEnableCitations(true).setSessionLimits(sessionLimits); + + ResumeSessionConfig cloned = original.clone(); + excludedAgents.add("task"); + + assertEquals(List.of("explore"), cloned.getExcludedBuiltInAgents()); + assertTrue(cloned.getEnableCitations().orElse(false)); + assertSame(sessionLimits, cloned.getSessionLimits()); + } + @Test void messageOptionsCloneBasic() { MessageOptions original = new MessageOptions(); diff --git a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java index 3b01734bd9..7347724583 100644 --- a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java +++ b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java @@ -190,6 +190,19 @@ static HttpResponse buildInferenceResponse(String url, String bodyT return sseResponse(sb.toString()); } + if (u.endsWith("/messages")) { + Map body = new LinkedHashMap<>(); + body.put("id", "msg_stub_1"); + body.put("type", "message"); + body.put("role", "assistant"); + body.put("model", "claude-sonnet-4.5"); + body.put("content", List.of(Map.of("type", "text", "text", text))); + body.put("stop_reason", "end_turn"); + body.put("stop_sequence", null); + body.put("usage", Map.of("input_tokens", 5, "output_tokens", 7)); + return jsonResponse(json(body)); + } + return jsonResponse(json(chatCompletion(text))); } @@ -405,7 +418,7 @@ static String assistantText(AssistantMessageEvent event) { } /** A single request the handler intercepted. */ - record InterceptedRequest(String url, String sessionId) { + record InterceptedRequest(String url, String sessionId, String body) { } /** @@ -440,9 +453,10 @@ List inferenceRequests() { protected HttpResponse sendRequest(HttpRequest request, CopilotRequestContext ctx) throws Exception { String url = request.uri().toString(); - records.add(new InterceptedRequest(url, ctx.sessionId())); + String body = requestBodyText(request); + records.add(new InterceptedRequest(url, ctx.sessionId(), body)); if (isInferenceUrl(url)) { - return buildInferenceResponse(url, requestBodyText(request), text); + return buildInferenceResponse(url, body, text); } return buildNonInferenceResponse(url); } diff --git a/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java index dbae0fe9f9..47910ffa72 100644 --- a/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -4,10 +4,16 @@ package com.github.copilot; +import static com.github.copilot.CopilotRequestTestSupport.SYNTHETIC_TEXT; +import static com.github.copilot.CopilotRequestTestSupport.newLlmClient; +import static com.github.copilot.CopilotRequestTestSupport.setupCapiAuth; import static org.junit.jupiter.api.Assertions.*; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -16,6 +22,10 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.SessionLimitsConfig; +import com.github.copilot.rpc.BlobAttachment; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.ProviderConfig; @@ -27,6 +37,8 @@ */ public class SessionConfigE2ETest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static E2ETestContext ctx; @BeforeAll @@ -150,6 +162,259 @@ void testShouldUseProviderModelIdAsWireModel() throws Exception { } } + @Test + void testShouldApplySessionLimitsOnCreate() throws Exception { + ctx.configureForTest("session_config", "should_apply_session_limits_on_create"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig() + .setSessionLimits(new SessionLimitsConfig(30.0)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + try { + Map exchange = sendAndGetNextExchange(session, + "Acknowledge the current session limits."); + + assertSessionLimitsStatus(exchange, "30 AI credits"); + } finally { + session.close(); + } + } + } + + @Test + void testShouldApplySessionLimitsOnResume() throws Exception { + ctx.configureForTest("session_config", "should_apply_session_limits_on_resume"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + CopilotSession session2 = client.resumeSession(session1.getSessionId(), + new ResumeSessionConfig().setSessionLimits(new SessionLimitsConfig(30.0)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + try { + Map exchange = sendAndGetNextExchange(session2, + "Acknowledge the current session limits."); + + assertSessionLimitsStatus(exchange, "30 AI credits"); + } finally { + session2.close(); + session1.close(); + } + } + } + + @Test + void testShouldApplyExcludedBuiltInAgentsOnCreate() throws Exception { + ctx.configureForTest("session_config", "should_apply_excluded_built_in_agents_on_create"); + + final String excludedAgent = "explore"; + final String prompt = "What is 1+1?"; + + try (CopilotClient client = ctx.createClient()) { + CopilotSession baselineSession = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + try { + Map baselineExchange = sendAndGetNextExchange(baselineSession, prompt); + assertTrue(getTaskAgentTypes(baselineExchange).contains(excludedAgent)); + } finally { + baselineSession.close(); + } + + CopilotSession excludedSession = client.createSession(new SessionConfig() + .setExcludedBuiltInAgents(List.of(excludedAgent)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + try { + List agentTypes = getTaskAgentTypes(sendAndGetNextExchange(excludedSession, prompt)); + + assertFalse(agentTypes.isEmpty(), "Expected task tool agent types"); + assertFalse(agentTypes.contains(excludedAgent), "Expected excluded built-in agent to be omitted"); + } finally { + excludedSession.close(); + } + } + } + + @Test + void testShouldApplyExcludedBuiltInAgentsOnResume() throws Exception { + ctx.configureForTest("session_config", "should_apply_excluded_built_in_agents_on_resume"); + + final String excludedAgent = "explore"; + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + CopilotSession session2 = client.resumeSession(session1.getSessionId(), + new ResumeSessionConfig().setExcludedBuiltInAgents(List.of(excludedAgent)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + try { + List agentTypes = getTaskAgentTypes(sendAndGetNextExchange(session2, "What is 1+1?")); + + assertFalse(agentTypes.isEmpty(), "Expected task tool agent types"); + assertFalse(agentTypes.contains(excludedAgent), "Expected excluded built-in agent to be omitted"); + } finally { + session2.close(); + session1.close(); + } + } + } + + @Test + void testShouldEnableCitationsForAnthropicFileAttachmentsOnCreate() throws Exception { + setupCapiAuth(ctx); + var handler = new CopilotRequestTestSupport.RecordingRequestHandler(SYNTHETIC_TEXT); + + try (CopilotClient client = newLlmClient(ctx, handler)) { + CopilotSession session = client.createSession(new SessionConfig().setModel("claude-sonnet-4.5") + .setEnableCitations(true) + .setProvider(createAnthropicProvider()) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + try { + session.sendAndWait(new MessageOptions() + .setPrompt("Summarize the attached PDF with citations enabled.") + .setAttachments(List.of(createPdfAttachment()))).get(60, TimeUnit.SECONDS); + + assertAnthropicDocumentCitationsEnabled(singleInferenceRequestBody(handler)); + } finally { + session.close(); + } + } + } + + @Test + void testShouldEnableCitationsForAnthropicFileAttachmentsOnResume() throws Exception { + setupCapiAuth(ctx); + var handler = new CopilotRequestTestSupport.RecordingRequestHandler(SYNTHETIC_TEXT); + + try (CopilotClient client = newLlmClient(ctx, handler)) { + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + CopilotSession session2 = client.resumeSession(session1.getSessionId(), + new ResumeSessionConfig().setModel("claude-sonnet-4.5") + .setEnableCitations(true) + .setProvider(createAnthropicProvider()) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + try { + session2.sendAndWait(new MessageOptions() + .setPrompt("Summarize the attached PDF with citations enabled.") + .setAttachments(List.of(createPdfAttachment()))).get(60, TimeUnit.SECONDS); + + assertAnthropicDocumentCitationsEnabled(singleInferenceRequestBody(handler)); + } finally { + session2.close(); + session1.close(); + } + } + } + + private Map sendAndGetNextExchange(CopilotSession session, String prompt) throws Exception { + int existingCount = ctx.getExchanges().size(); + session.sendAndWait(new MessageOptions().setPrompt(prompt)).get(60, TimeUnit.SECONDS); + + List> exchanges = ctx.getExchanges(); + assertTrue(exchanges.size() > existingCount, "Expected at least one new exchange"); + return exchanges.get(existingCount); + } + + private static void assertSessionLimitsStatus(Map exchange, String expectedRemaining) { + String content = null; + for (Object message : getRequestMessages(exchange)) { + if (message instanceof Map messageMap && "user".equals(messageMap.get("role"))) { + Object messageContent = messageMap.get("content"); + if (messageContent instanceof String text && text.contains("")) { + content = text; + break; + } + } + } + + assertNotNull(content, "Expected session limits status user message"); + assertTrue(content.contains("Remaining session limits: " + expectedRemaining + ".")); + assertTrue(content.contains("Be frugal; avoid optional exploration and unnecessary tool calls.")); + } + + private static List getTaskAgentTypes(Map exchange) { + Object toolsObj = getRequest(exchange).get("tools"); + assertInstanceOf(List.class, toolsObj, "Expected request tools"); + + JsonNode parameters = null; + for (Object toolObj : (List) toolsObj) { + if (toolObj instanceof Map toolMap && toolMap.get("function") instanceof Map functionMap + && "task".equals(functionMap.get("name"))) { + parameters = MAPPER.valueToTree(functionMap.get("parameters")); + break; + } + } + + assertNotNull(parameters, "Expected task tool parameters"); + JsonNode enumValues = parameters.path("properties").path("agent_type").path("enum"); + assertTrue(enumValues.isArray(), "Expected task agent_type enum"); + + List values = new ArrayList<>(); + enumValues.forEach(value -> { + if (value.isTextual()) { + values.add(value.asText()); + } + }); + return values; + } + + private static List getRequestMessages(Map exchange) { + Object messages = getRequest(exchange).get("messages"); + assertInstanceOf(List.class, messages, "Expected request messages"); + return (List) messages; + } + + private static Map getRequest(Map exchange) { + Object request = exchange.get("request"); + assertInstanceOf(Map.class, request, "Expected exchange request"); + return (Map) request; + } + + private static BlobAttachment createPdfAttachment() { + String pdfText = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + return new BlobAttachment() + .setData(Base64.getEncoder().encodeToString(pdfText.getBytes(StandardCharsets.US_ASCII))) + .setDisplayName("citation-source.pdf") + .setMimeType("application/pdf"); + } + + private static ProviderConfig createAnthropicProvider() { + return new ProviderConfig().setType("anthropic").setBaseUrl("https://anthropic-citations.invalid/v1") + .setApiKey("test-provider-key").setModelId("claude-sonnet-4.5").setWireModel("claude-sonnet-4.5"); + } + + private static String singleInferenceRequestBody(CopilotRequestTestSupport.RecordingRequestHandler handler) { + List requests = handler.inferenceRequests(); + assertEquals(1, requests.size(), "Expected one intercepted inference request"); + return requests.get(0).body(); + } + + private static void assertAnthropicDocumentCitationsEnabled(String requestBody) throws Exception { + JsonNode root = MAPPER.readTree(requestBody); + List documentBlocks = new ArrayList<>(); + for (JsonNode message : root.path("messages")) { + for (JsonNode block : message.path("content")) { + if ("document".equals(block.path("type").asText())) { + documentBlocks.add(block); + } + } + } + + assertEquals(1, documentBlocks.size(), "Expected one Anthropic document block"); + JsonNode documentBlock = documentBlocks.get(0); + assertEquals("citation-source.pdf", documentBlock.path("title").asText()); + assertTrue(documentBlock.path("citations").path("enabled").asBoolean(false)); + } + @SuppressWarnings("unchecked") private static String getSystemMessage(Map exchange) { // The exchange structure is: { request: { messages: [...] }, response: ..., diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 5849a6b884..83c777521e 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; +import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; import com.github.copilot.rpc.CloudSessionRepository; @@ -160,6 +161,19 @@ void testBuildCreateRequestForwardsExplicitMcpOAuthTokenStorage() { assertEquals("persistent", request.getMcpOAuthTokenStorage()); } + @Test + void testBuildCreateRequestForwardsSessionPolicyOptions() { + var sessionLimits = new SessionLimitsConfig(30.0); + var config = new SessionConfig().setExcludedBuiltInAgents(List.of("explore")).setEnableCitations(true) + .setSessionLimits(sessionLimits); + + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + + assertEquals(List.of("explore"), request.getExcludedBuiltInAgents()); + assertTrue(request.getEnableCitations()); + assertSame(sessionLimits, request.getSessionLimits()); + } + @Test void testBuildCreateRequestNullConfigHasNullMcpOAuthTokenStorage() { CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(null); @@ -324,6 +338,19 @@ void testBuildResumeRequestForwardsExplicitMcpOAuthTokenStorage() { assertEquals("persistent", request.getMcpOAuthTokenStorage()); } + @Test + void testBuildResumeRequestForwardsSessionPolicyOptions() { + var sessionLimits = new SessionLimitsConfig(30.0); + var config = new ResumeSessionConfig().setExcludedBuiltInAgents(List.of("explore")).setEnableCitations(true) + .setSessionLimits(sessionLimits); + + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-policy", config); + + assertEquals(List.of("explore"), request.getExcludedBuiltInAgents()); + assertTrue(request.getEnableCitations()); + assertSame(sessionLimits, request.getSessionLimits()); + } + @Test void testBuildResumeRequestNullConfigHasNullMcpOAuthTokenStorage() { ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-14", null); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 613985103f..5bb36fbd59 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1406,11 +1406,14 @@ export class CopilotClient { availableTools: toolFilterOptions.availableTools, excludedTools: toolFilterOptions.excludedTools, toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, + excludedBuiltinAgents: config.excludedBuiltinAgents, provider: bearerWireProvider, capi: config.capi, providers: bearerWireProviders, models: config.models, enableSessionTelemetry: config.enableSessionTelemetry, + enableCitations: config.enableCitations, + sessionLimits: config.sessionLimits, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), requestPermission: !!config.onPermissionRequest, @@ -1598,6 +1601,9 @@ export class CopilotClient { excludedTools: toolFilterOptions.excludedTools, toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence, enableSessionTelemetry: config.enableSessionTelemetry, + excludedBuiltinAgents: config.excludedBuiltinAgents, + enableCitations: config.enableCitations, + sessionLimits: config.sessionLimits, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 4adb35b25a..898ca02569 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -12,6 +12,7 @@ import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; import type { ReasoningSummary, + SessionLimitsConfig, SessionEvent as GeneratedSessionEvent, } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; @@ -1875,6 +1876,13 @@ export interface SessionConfigBase { */ excludedTools?: string[] | ToolSet; + /** + * Names of built-in agents to exclude from the session. Excluded built-in + * agents are hidden from discovery and cannot be selected or invoked unless + * a custom agent with the same name is configured. + */ + excludedBuiltinAgents?: string[]; + /** * Custom provider configuration (BYOK - Bring Your Own Key). * When specified, uses the provided API endpoint instead of the Copilot API. @@ -1924,6 +1932,20 @@ export interface SessionConfigBase { */ enableSessionTelemetry?: boolean; + /** + * Enables native model citations for supported providers. + * + * @experimental + */ + enableCitations?: boolean; + + /** + * Limits applied to this session's current accounting window. + * + * @experimental + */ + sessionLimits?: SessionLimitsConfig; + /** * When true, the runtime skips loading custom-instruction sources * (e.g. `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`). diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index d02bafbbfb..60930c0a26 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -410,6 +410,46 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); + it("forwards new session options in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + enableCitations: true, + excludedBuiltinAgents: ["explore"], + sessionLimits: { maxAiCredits: 30 }, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + enableCitations: false, + excludedBuiltinAgents: ["task"], + sessionLimits: { maxAiCredits: 15 }, + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.enableCitations).toBe(true); + expect(createPayload.excludedBuiltinAgents).toEqual(["explore"]); + expect(createPayload.sessionLimits).toEqual({ maxAiCredits: 30 }); + expect(resumePayload.enableCitations).toBe(false); + expect(resumePayload.excludedBuiltinAgents).toEqual(["task"]); + expect(resumePayload.sessionLimits).toEqual({ maxAiCredits: 15 }); + }); + it("forwards expAssignments in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index acb31f0588..70ee6546e6 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from "vitest"; import { writeFile, mkdir } from "fs/promises"; import { join } from "path"; -import { approveAll } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { + approveAll, + CopilotClient, + CopilotRequestHandler, + RuntimeConnection, + type CopilotRequestContext, +} from "../../src/index.js"; +import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; import { retry } from "./harness/sdkTestHelper.js"; describe("Session Configuration", async () => { - const { copilotClient: client, workDir, openAiEndpoint } = await createSdkTestContext(); + const { copilotClient: client, workDir, openAiEndpoint, env } = await createSdkTestContext(); async function waitForExchanges(minimumCount = 1) { await retry( @@ -216,6 +222,340 @@ describe("Session Configuration", async () => { return (exchange.request.tools ?? []).map((t) => t.function.name); } + async function sendAndGetNextExchange( + session: { sendAndWait(options: { prompt: string }): Promise }, + prompt: string + ) { + const existingCount = (await openAiEndpoint.getExchanges()).length; + await session.sendAndWait({ prompt }); + const exchanges = await waitForExchanges(existingCount + 1); + return exchanges[existingCount]; + } + + function assertSessionLimitsStatus( + exchange: { request: { messages?: Array<{ role: string; content: unknown }> } }, + expectedRemaining: string + ) { + const message = (exchange.request.messages ?? []).find( + (m) => + m.role === "user" && + typeof m.content === "string" && + m.content.includes("") + ); + expect(message?.content).toContain(`Remaining session limits: ${expectedRemaining}.`); + expect(message?.content).toContain( + "Be frugal; avoid optional exploration and unnecessary tool calls." + ); + } + + function getTaskAgentTypes(exchange: { + request: { + tools?: Array<{ + function: { name: string; parameters?: unknown }; + }>; + }; + }): string[] { + const taskTool = (exchange.request.tools ?? []).find( + (tool) => tool.function.name === "task" + ); + expect(taskTool).toBeDefined(); + const parameters = taskTool?.function.parameters as + | { properties?: { agent_type?: { enum?: string[] } } } + | undefined; + const values = parameters?.properties?.agent_type?.enum; + expect(values).toBeDefined(); + return values ?? []; + } + + interface InterceptedRequest { + url: string; + body: string; + } + + class RecordingRequestHandler extends CopilotRequestHandler { + readonly records: InterceptedRequest[] = []; + + protected override async sendRequest( + request: Request, + _ctx: CopilotRequestContext + ): Promise { + const body = request.body ? await request.text() : ""; + this.records.push({ url: request.url, body }); + return isInferenceUrl(request.url) + ? buildInferenceResponse(request.url, body) + : buildNonInferenceResponse(request.url); + } + + inferenceRequests(): InterceptedRequest[] { + return this.records.filter((record) => isInferenceUrl(record.url)); + } + } + + function isInferenceUrl(url: string): boolean { + const u = url.toLowerCase(); + return ( + u.endsWith("/chat/completions") || + u.endsWith("/responses") || + u.endsWith("/v1/messages") || + u.endsWith("/messages") + ); + } + + function json(body: unknown): Response { + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + function buildNonInferenceResponse(url: string): Response { + const u = url.toLowerCase(); + if (u.endsWith("/models")) { + return json({ + data: [ + { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + object: "model", + vendor: "Anthropic", + version: "1", + preview: false, + model_picker_enabled: true, + capabilities: { + type: "chat", + family: "claude-sonnet-4.5", + tokenizer: "o200k_base", + limits: { max_context_window_tokens: 200000, max_output_tokens: 8192 }, + supports: { + streaming: true, + tool_calls: true, + parallel_tool_calls: true, + vision: true, + }, + }, + }, + ], + }); + } + if (u.includes("/models/session")) return json({}); + if (u.includes("/policy")) return json({ state: "enabled" }); + return json({}); + } + + function buildInferenceResponse(url: string, _body: string): Response { + const u = url.toLowerCase(); + if (u.endsWith("/messages")) { + return json({ + id: "msg_stub_1", + type: "message", + role: "assistant", + model: "claude-sonnet-4.5", + content: [{ type: "text", text: "OK from the synthetic stream." }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 7 }, + }); + } + return json({ + id: "chatcmpl-stub-1", + object: "chat.completion", + created: 1, + model: "claude-sonnet-4.5", + choices: [ + { + index: 0, + message: { role: "assistant", content: "OK from the synthetic stream." }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, + }); + } + + function createPdfAttachment() { + const pdfText = + "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + return { + type: "blob" as const, + data: Buffer.from(pdfText, "ascii").toString("base64"), + displayName: "citation-source.pdf", + mimeType: "application/pdf", + }; + } + + function createAnthropicProvider() { + return { + type: "anthropic" as const, + baseUrl: "https://anthropic-citations.invalid/v1", + apiKey: "test-provider-key", + modelId: "claude-sonnet-4.5", + wireModel: "claude-sonnet-4.5", + }; + } + + function assertAnthropicDocumentCitationsEnabled(requestBody: string) { + const body = JSON.parse(requestBody) as { + messages: Array<{ content: Array> }>; + }; + const documentBlocks = body.messages.flatMap((message) => + message.content.filter((block) => block.type === "document") + ); + expect(documentBlocks).toHaveLength(1); + expect(documentBlocks[0].title).toBe("citation-source.pdf"); + expect(documentBlocks[0].citations).toEqual({ enabled: true }); + } + + it("should apply session limits on create", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + sessionLimits: { maxAiCredits: 30 }, + }); + + const exchange = await sendAndGetNextExchange( + session, + "Acknowledge the current session limits." + ); + assertSessionLimitsStatus(exchange, "30 AI credits"); + + await session.disconnect(); + }); + + it("should apply session limits on resume", async () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const session2 = await client.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + sessionLimits: { maxAiCredits: 30 }, + }); + + const exchange = await sendAndGetNextExchange( + session2, + "Acknowledge the current session limits." + ); + assertSessionLimitsStatus(exchange, "30 AI credits"); + + await session2.disconnect(); + await session1.disconnect(); + }); + + it("should apply excluded built-in agents on create", async () => { + const excludedAgent = "explore"; + const prompt = "What is 1+1?"; + + const baselineSession = await client.createSession({ onPermissionRequest: approveAll }); + const baselineExchange = await sendAndGetNextExchange(baselineSession, prompt); + expect(getTaskAgentTypes(baselineExchange)).toContain(excludedAgent); + await baselineSession.disconnect(); + + const excludedSession = await client.createSession({ + onPermissionRequest: approveAll, + excludedBuiltinAgents: [excludedAgent], + }); + const excludedExchange = await sendAndGetNextExchange(excludedSession, prompt); + const agentTypes = getTaskAgentTypes(excludedExchange); + expect(agentTypes.length).toBeGreaterThan(0); + expect(agentTypes).not.toContain(excludedAgent); + + await excludedSession.disconnect(); + }); + + it("should apply excluded built-in agents on resume", async () => { + const excludedAgent = "explore"; + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const session2 = await client.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + excludedBuiltinAgents: [excludedAgent], + }); + + const exchange = await sendAndGetNextExchange(session2, "What is 1+1?"); + const agentTypes = getTaskAgentTypes(exchange); + expect(agentTypes.length).toBeGreaterThan(0); + expect(agentTypes).not.toContain(excludedAgent); + + await session2.disconnect(); + await session1.disconnect(); + }); + + it("should enable citations for Anthropic file attachments on create", async () => { + const handler = new RecordingRequestHandler(); + const citationClient = new CopilotClient({ + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + workingDirectory: workDir, + env, + gitHubToken: DEFAULT_GITHUB_TOKEN, + requestHandler: handler, + }); + + await citationClient.start(); + try { + const session = await citationClient.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + enableCitations: true, + provider: createAnthropicProvider(), + }); + try { + await session.sendAndWait({ + prompt: "Summarize the attached PDF with citations enabled.", + attachments: [createPdfAttachment()], + }); + expect(handler.inferenceRequests()).toHaveLength(1); + assertAnthropicDocumentCitationsEnabled(handler.inferenceRequests()[0].body); + } finally { + await session.disconnect(); + } + } finally { + await citationClient.stop(); + } + }); + + it("should enable citations for Anthropic file attachments on resume", async () => { + const handler = new RecordingRequestHandler(); + const connectionToken = "ts-citation-resume-token"; + const serverClient = new CopilotClient({ + connection: RuntimeConnection.forTcp({ + path: process.env.COPILOT_CLI_PATH, + connectionToken, + }), + workingDirectory: workDir, + env, + gitHubToken: DEFAULT_GITHUB_TOKEN, + requestHandler: handler, + }); + + await serverClient.start(); + try { + const session1 = await serverClient.createSession({ onPermissionRequest: approveAll }); + const port = (serverClient as unknown as { runtimePort: number | null }).runtimePort; + expect(port).not.toBeNull(); + const resumeClient = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), + }); + try { + const session2 = await resumeClient.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + enableCitations: true, + provider: createAnthropicProvider(), + }); + try { + await session2.sendAndWait({ + prompt: "Summarize the attached PDF with citations enabled.", + attachments: [createPdfAttachment()], + }); + expect(handler.inferenceRequests()).toHaveLength(1); + assertAnthropicDocumentCitationsEnabled(handler.inferenceRequests()[0].body); + } finally { + await session2.disconnect(); + } + } finally { + await resumeClient.stop(); + await session1.disconnect(); + } + } finally { + await serverClient.stop(); + } + }); + it("should apply instructionDirectories on session create", async () => { const projectDir = join(workDir, "instruction-create-project"); const instructionDir = join(workDir, "extra-create-instructions"); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 51be3727ac..cdcfdc6ae6 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -146,6 +146,7 @@ SessionFsCapabilities, SessionFsConfig, SessionHooks, + SessionLimitsConfig, SessionStartHandler, SessionStartHookInput, SessionStartHookOutput, @@ -299,6 +300,7 @@ "SessionFsSqliteProvider", "SessionFsSqliteQueryResult", "SessionHooks", + "SessionLimitsConfig", "SessionLifecycleEvent", "SessionLifecycleEventBase", "SessionLifecycleEventMetadata", diff --git a/python/copilot/client.py b/python/copilot/client.py index 7dade44403..de5e5fd620 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -104,6 +104,7 @@ SectionTransformFn, SessionFsConfig, SessionHooks, + SessionLimitsConfig, SystemMessageConfig, UserInputHandler, _capabilities_to_dict, @@ -245,6 +246,14 @@ def _memory_to_wire(config: Mapping[str, Any]) -> dict[str, Any]: return {"enabled": config["enabled"]} +def _session_limits_to_wire(config: Mapping[str, Any]) -> dict[str, Any]: + """Convert a ``SessionLimitsConfig`` mapping to wire format.""" + wire: dict[str, Any] = {} + if "max_ai_credits" in config: + wire["maxAiCredits"] = config["max_ai_credits"] + return wire + + class TelemetryConfig(TypedDict, total=False): """Configuration for OpenTelemetry integration with the Copilot CLI.""" @@ -1666,6 +1675,9 @@ async def create_session( providers: list[NamedProviderConfig] | None = None, models: list[ProviderModelConfig] | None = None, enable_session_telemetry: bool | None = None, + enable_citations: bool | None = None, + excluded_builtin_agents: list[str] | None = None, + session_limits: SessionLimitsConfig | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, coauthor_enabled: bool | None = None, @@ -1770,6 +1782,14 @@ async def create_session( a custom provider (BYOK) is configured, session telemetry is always disabled regardless of this setting. This is independent of the client OpenTelemetry configuration. + enable_citations: **Experimental.** Enables native model citations for + supported providers. + excluded_builtin_agents: Built-in agent names to exclude from the + session. Excluded built-in agents are hidden from discovery and + cannot be selected or invoked unless a custom agent with the same + name is configured. + session_limits: **Experimental.** Limits applied to this session's + current accounting window. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -1998,6 +2018,12 @@ async def create_session( if enable_session_telemetry is not None: payload["enableSessionTelemetry"] = enable_session_telemetry + if enable_citations is not None: + payload["enableCitations"] = enable_citations + if excluded_builtin_agents is not None: + payload["excludedBuiltinAgents"] = excluded_builtin_agents + if session_limits is not None: + payload["sessionLimits"] = _session_limits_to_wire(session_limits) # Add model capabilities override if provided if model_capabilities: @@ -2295,6 +2321,9 @@ async def resume_session( providers: list[NamedProviderConfig] | None = None, models: list[ProviderModelConfig] | None = None, enable_session_telemetry: bool | None = None, + enable_citations: bool | None = None, + excluded_builtin_agents: list[str] | None = None, + session_limits: SessionLimitsConfig | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, coauthor_enabled: bool | None = None, @@ -2400,6 +2429,14 @@ async def resume_session( a custom provider (BYOK) is configured, session telemetry is always disabled regardless of this setting. This is independent of the client OpenTelemetry configuration. + enable_citations: **Experimental.** Enables native model citations for + supported providers. + excluded_builtin_agents: Built-in agent names to exclude from the + resumed session. Excluded built-in agents are hidden from discovery + and cannot be selected or invoked unless a custom agent with the + same name is configured. + session_limits: **Experimental.** Limits applied to this session's + current accounting window. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -2565,6 +2602,12 @@ async def resume_session( payload["models"] = [self._convert_model_to_wire_format(m) for m in models] if enable_session_telemetry is not None: payload["enableSessionTelemetry"] = enable_session_telemetry + if enable_citations is not None: + payload["enableCitations"] = enable_citations + if excluded_builtin_agents is not None: + payload["excludedBuiltinAgents"] = excluded_builtin_agents + if session_limits is not None: + payload["sessionLimits"] = _session_limits_to_wire(session_limits) if model_capabilities: payload["modelCapabilities"] = _capabilities_to_dict(model_capabilities) if streaming is not None: diff --git a/python/copilot/session.py b/python/copilot/session.py index bf34a73340..50de96f191 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -1110,6 +1110,13 @@ class InfiniteSessionConfig(TypedDict, total=False): buffer_exhaustion_threshold: float +class SessionLimitsConfig(TypedDict, total=False): + """Experimental limits for the session's current accounting window.""" + + # Maximum AI credits available to the session in the current accounting window. + max_ai_credits: float + + class LargeToolOutputConfig(TypedDict, total=False): """ Configuration for handling large tool outputs. diff --git a/python/e2e/_copilot_request_helpers.py b/python/e2e/_copilot_request_helpers.py index c3c6a06ddc..6a3d3d1de2 100644 --- a/python/e2e/_copilot_request_helpers.py +++ b/python/e2e/_copilot_request_helpers.py @@ -223,6 +223,24 @@ def build_inference_response(request: httpx.Request, text: str = SYNTHETIC_TEXT) content=stream_body.encode(), ) + if url.endswith("/messages"): + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps( + { + "id": "msg_stub_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4.5", + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 5, "output_tokens": 7}, + } + ).encode(), + ) + return httpx.Response( 200, headers={"content-type": "application/json"}, diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 2ad34a29a5..62dc671893 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -1,15 +1,29 @@ """E2E tests for session configuration including model capabilities overrides.""" import base64 +import json import os import uuid +import httpx import pytest -from copilot import ModelCapabilitiesOverride, ModelSupportsOverride +from copilot import ( + CopilotClient, + CopilotRequestHandler, + ModelCapabilitiesOverride, + ModelSupportsOverride, + RuntimeConnection, +) +from copilot.copilot_request_handler import CopilotRequestContext from copilot.session import PermissionHandler -from .testharness import E2ETestContext +from ._copilot_request_helpers import ( + build_inference_response, + build_non_inference_response, + is_inference_url, +) +from .testharness import DEFAULT_GITHUB_TOKEN, E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -86,6 +100,91 @@ def _get_tool_names(exchange: dict) -> list[str]: return names +async def _send_and_get_next_exchange(session, ctx: E2ETestContext, prompt: str) -> dict: + existing_count = len(await ctx.get_exchanges()) + await session.send_and_wait(prompt) + exchanges = await ctx.get_exchanges() + assert len(exchanges) > existing_count + return exchanges[existing_count] + + +def _assert_session_limits_status(exchange: dict, expected_remaining: str) -> None: + for message in exchange.get("request", {}).get("messages", []): + content = message.get("content") + if message.get("role") == "user" and isinstance(content, str): + if "" in content: + assert f"Remaining session limits: {expected_remaining}." in content + assert ( + "Be frugal; avoid optional exploration and unnecessary tool calls." in content + ) + return + raise AssertionError("Expected session limits status message") + + +def _get_task_agent_types(exchange: dict) -> list[str]: + for tool in exchange.get("request", {}).get("tools", []) or []: + function = tool.get("function") if isinstance(tool, dict) else None + if isinstance(function, dict) and function.get("name") == "task": + parameters = function.get("parameters") + assert isinstance(parameters, dict) + values = parameters["properties"]["agent_type"]["enum"] + assert isinstance(values, list) + return [str(value) for value in values] + raise AssertionError("Expected task tool in request") + + +class _RecordingRequestHandler(CopilotRequestHandler): + def __init__(self): + self.records: list[tuple[str, bytes]] = [] + + async def send_request( + self, request: httpx.Request, ctx: CopilotRequestContext + ) -> httpx.Response: + del ctx + self.records.append((str(request.url), request.content)) + if is_inference_url(str(request.url)): + return build_inference_response(request) + return build_non_inference_response(str(request.url)) + + def inference_requests(self) -> list[tuple[str, bytes]]: + return [(url, body) for url, body in self.records if is_inference_url(url)] + + +def _create_pdf_attachment() -> dict: + pdf_text = ( + "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n" + ) + return { + "type": "blob", + "data": base64.b64encode(pdf_text.encode("ascii")).decode("ascii"), + "displayName": "citation-source.pdf", + "mimeType": "application/pdf", + } + + +def _create_anthropic_provider() -> dict: + return { + "type": "anthropic", + "base_url": "https://anthropic-citations.invalid/v1", + "api_key": "test-provider-key", + "model_id": "claude-sonnet-4.5", + "wire_model": "claude-sonnet-4.5", + } + + +def _assert_anthropic_document_citations_enabled(request_body: bytes) -> None: + body = json.loads(request_body.decode("utf-8")) + document_blocks = [ + block + for message in body["messages"] + for block in message["content"] + if block.get("type") == "document" + ] + assert len(document_blocks) == 1 + assert document_blocks[0]["title"] == "citation-source.pdf" + assert document_blocks[0]["citations"] == {"enabled": True} + + PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" ) @@ -287,6 +386,161 @@ async def test_should_use_provider_model_id_as_wire_model(self, ctx: E2ETestCont await session.disconnect() + async def test_should_apply_session_limits_on_create(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + session_limits={"max_ai_credits": 30}, + ) + + exchange = await _send_and_get_next_exchange( + session, ctx, "Acknowledge the current session limits." + ) + _assert_session_limits_status(exchange, "30 AI credits") + + await session.disconnect() + + async def test_should_apply_session_limits_on_resume(self, ctx: E2ETestContext): + session1 = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + session2 = await ctx.client.resume_session( + session1.session_id, + on_permission_request=PermissionHandler.approve_all, + session_limits={"max_ai_credits": 30}, + ) + + exchange = await _send_and_get_next_exchange( + session2, ctx, "Acknowledge the current session limits." + ) + _assert_session_limits_status(exchange, "30 AI credits") + + await session2.disconnect() + await session1.disconnect() + + async def test_should_apply_excluded_built_in_agents_on_create(self, ctx: E2ETestContext): + excluded_agent = "explore" + prompt = "What is 1+1?" + + baseline_session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + baseline_exchange = await _send_and_get_next_exchange(baseline_session, ctx, prompt) + assert excluded_agent in _get_task_agent_types(baseline_exchange) + await baseline_session.disconnect() + + excluded_session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + excluded_builtin_agents=[excluded_agent], + ) + excluded_exchange = await _send_and_get_next_exchange(excluded_session, ctx, prompt) + agent_types = _get_task_agent_types(excluded_exchange) + assert agent_types + assert excluded_agent not in agent_types + + await excluded_session.disconnect() + + async def test_should_apply_excluded_built_in_agents_on_resume(self, ctx: E2ETestContext): + excluded_agent = "explore" + session1 = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + session2 = await ctx.client.resume_session( + session1.session_id, + on_permission_request=PermissionHandler.approve_all, + excluded_builtin_agents=[excluded_agent], + ) + + exchange = await _send_and_get_next_exchange(session2, ctx, "What is 1+1?") + agent_types = _get_task_agent_types(exchange) + assert agent_types + assert excluded_agent not in agent_types + + await session2.disconnect() + await session1.disconnect() + + async def test_should_enable_citations_for_anthropic_file_attachments_on_create( + self, ctx: E2ETestContext + ): + handler = _RecordingRequestHandler() + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=ctx.get_env(), + github_token=DEFAULT_GITHUB_TOKEN, + request_handler=handler, + ) + await client.start() + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + model="claude-sonnet-4.5", + enable_citations=True, + provider=_create_anthropic_provider(), + ) + try: + await session.send_and_wait( + "Summarize the attached PDF with citations enabled.", + attachments=[_create_pdf_attachment()], + ) + inference_requests = handler.inference_requests() + assert len(inference_requests) == 1 + _assert_anthropic_document_citations_enabled(inference_requests[0][1]) + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_should_enable_citations_for_anthropic_file_attachments_on_resume( + self, ctx: E2ETestContext + ): + handler = _RecordingRequestHandler() + connection_token = "python-citation-resume-token" + server_client = CopilotClient( + connection=RuntimeConnection.for_tcp( + path=ctx.cli_path, + connection_token=connection_token, + ), + working_directory=ctx.work_dir, + env=ctx.get_env(), + github_token=DEFAULT_GITHUB_TOKEN, + request_handler=handler, + ) + await server_client.start() + try: + session1 = await server_client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + assert server_client.runtime_port is not None + resume_client = CopilotClient( + connection=RuntimeConnection.for_uri( + f"localhost:{server_client.runtime_port}", + connection_token=connection_token, + ) + ) + try: + session2 = await resume_client.resume_session( + session1.session_id, + on_permission_request=PermissionHandler.approve_all, + model="claude-sonnet-4.5", + enable_citations=True, + provider=_create_anthropic_provider(), + ) + try: + await session2.send_and_wait( + "Summarize the attached PDF with citations enabled.", + attachments=[_create_pdf_attachment()], + ) + inference_requests = handler.inference_requests() + assert len(inference_requests) == 1 + _assert_anthropic_document_citations_enabled(inference_requests[0][1]) + finally: + await session2.disconnect() + finally: + await resume_client.stop() + await session1.disconnect() + finally: + await server_client.stop() + async def test_should_use_workingdirectory_for_tool_execution(self, ctx: E2ETestContext): sub_dir = os.path.join(ctx.work_dir, "subproject") os.makedirs(sub_dir, exist_ok=True) diff --git a/python/test_client.py b/python/test_client.py index db6703c3b0..9e764ee460 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -556,6 +556,47 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_new_session_options(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + enable_citations=True, + excluded_builtin_agents=["explore"], + session_limits={"max_ai_credits": 30}, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + enable_citations=False, + excluded_builtin_agents=["task"], + session_limits={"max_ai_credits": 15}, + ) + + assert captured["session.create"]["enableCitations"] is True + assert captured["session.create"]["excludedBuiltinAgents"] == ["explore"] + assert captured["session.create"]["sessionLimits"] == {"maxAiCredits": 30} + assert captured["session.resume"]["enableCitations"] is False + assert captured["session.resume"]["excludedBuiltinAgents"] == ["task"] + assert captured["session.resume"]["sessionLimits"] == {"maxAiCredits": 15} + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_capi_options(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index 895c2f7109..20432ac9df 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -20,9 +20,9 @@ pub use crate::copilot_request_handler::{ CopilotWebSocketResponse, WebSocketTransform, forward_http, }; use crate::generated::api_types::OpenCanvasInstance; -/// Context window tier for models that support tiered context windows. -pub use crate::generated::session_events::ContextTier; use crate::generated::session_events::ReasoningSummary; +/// Context window tier for models that support tiered context windows. +pub use crate::generated::session_events::{ContextTier, SessionLimitsConfig}; use crate::handler::{ AutoModeSwitchHandler, ElicitationHandler, ExitPlanModeHandler, McpAuthHandler, PermissionHandler, UserInputHandler, @@ -1602,6 +1602,12 @@ pub struct SessionConfig { pub available_tools: Option>, /// Blocklist of built-in tool names the agent must not use. pub excluded_tools: Option>, + /// Names of built-in agents to exclude from the session. + /// + /// Excluded built-in agents are hidden from discovery and cannot be + /// selected or invoked unless a custom agent with the same name is + /// configured. + pub excluded_builtin_agents: Option>, /// MCP server configurations passed through to the CLI. pub mcp_servers: Option>, /// Controls how MCP OAuth tokens are stored for this session. @@ -1718,6 +1724,10 @@ pub struct SessionConfig { /// telemetry is always disabled regardless of this setting. This is /// independent of [`ClientOptions::telemetry`](crate::ClientOptions::telemetry). pub enable_session_telemetry: Option, + /// **Experimental.** Enables native model citations for supported providers. + pub enable_citations: Option, + /// **Experimental.** Limits applied to this session's current accounting window. + pub session_limits: Option, /// Per-property overrides for model capabilities, deep-merged over /// runtime defaults. pub model_capabilities: Option, @@ -1839,6 +1849,7 @@ impl std::fmt::Debug for SessionConfig { .field("extension_info", &self.extension_info) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) + .field("excluded_builtin_agents", &self.excluded_builtin_agents) .field("mcp_servers", &self.mcp_servers) .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) .field("embedding_cache_storage", &self.embedding_cache_storage) @@ -1876,6 +1887,8 @@ impl std::fmt::Debug for SessionConfig { .field("provider", &self.provider) .field("capi", &self.capi) .field("enable_session_telemetry", &self.enable_session_telemetry) + .field("enable_citations", &self.enable_citations) + .field("session_limits", &self.session_limits) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) .field("config_directory", &self.config_directory) @@ -1957,6 +1970,7 @@ impl Default for SessionConfig { extension_info: None, available_tools: None, excluded_tools: None, + excluded_builtin_agents: None, mcp_servers: None, mcp_oauth_token_storage: None, enable_config_discovery: None, @@ -1984,6 +1998,8 @@ impl Default for SessionConfig { providers: None, models: None, enable_session_telemetry: None, + enable_citations: None, + session_limits: None, model_capabilities: None, memory: None, config_directory: None, @@ -2102,6 +2118,7 @@ impl SessionConfig { extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, + excluded_builtin_agents: self.excluded_builtin_agents, tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, mcp_oauth_token_storage: self.mcp_oauth_token_storage, @@ -2136,6 +2153,8 @@ impl SessionConfig { providers: self.providers, models: self.models, enable_session_telemetry: self.enable_session_telemetry, + enable_citations: self.enable_citations, + session_limits: self.session_limits, model_capabilities: self.model_capabilities, memory: self.memory, config_dir: self.config_directory, @@ -2390,6 +2409,16 @@ impl SessionConfig { self } + /// Set the built-in agent names to exclude from the session. + pub fn with_excluded_builtin_agents(mut self, agents: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.excluded_builtin_agents = Some(agents.into_iter().map(Into::into).collect()); + self + } + /// Set MCP server configurations passed through to the CLI. pub fn with_mcp_servers(mut self, servers: HashMap) -> Self { self.mcp_servers = Some(servers); @@ -2595,6 +2624,18 @@ impl SessionConfig { self } + /// **Experimental.** Enable native model citations for supported providers. + pub fn with_enable_citations(mut self, enable: bool) -> Self { + self.enable_citations = Some(enable); + self + } + + /// **Experimental.** Set limits for this session's current accounting window. + pub fn with_session_limits(mut self, limits: SessionLimitsConfig) -> Self { + self.session_limits = Some(limits); + self + } + /// Set per-property overrides for model capabilities. pub fn with_model_capabilities( mut self, @@ -2741,6 +2782,12 @@ pub struct ResumeSessionConfig { pub available_tools: Option>, /// Blocklist of built-in tool names. pub excluded_tools: Option>, + /// Names of built-in agents to exclude from the resumed session. + /// + /// Excluded built-in agents are hidden from discovery and cannot be + /// selected or invoked unless a custom agent with the same name is + /// configured. + pub excluded_builtin_agents: Option>, /// Re-supply MCP servers so they remain available after app restart. pub mcp_servers: Option>, /// Controls how MCP OAuth tokens are stored for this session. @@ -2819,6 +2866,10 @@ pub struct ResumeSessionConfig { /// telemetry is always disabled regardless of this setting. This is /// independent of [`ClientOptions::telemetry`](crate::ClientOptions::telemetry). pub enable_session_telemetry: Option, + /// **Experimental.** Enables native model citations for supported providers. + pub enable_citations: Option, + /// **Experimental.** Limits applied to this session's current accounting window. + pub session_limits: Option, /// Per-property model capability overrides on resume. pub model_capabilities: Option, /// Per-session configuration for the runtime memory feature on resume. @@ -2917,6 +2968,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("extension_info", &self.extension_info) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) + .field("excluded_builtin_agents", &self.excluded_builtin_agents) .field("mcp_servers", &self.mcp_servers) .field("mcp_oauth_token_storage", &self.mcp_oauth_token_storage) .field("embedding_cache_storage", &self.embedding_cache_storage) @@ -2954,6 +3006,8 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("provider", &self.provider) .field("capi", &self.capi) .field("enable_session_telemetry", &self.enable_session_telemetry) + .field("enable_citations", &self.enable_citations) + .field("session_limits", &self.session_limits) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) .field("config_directory", &self.config_directory) @@ -3070,6 +3124,7 @@ impl ResumeSessionConfig { extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, + excluded_builtin_agents: self.excluded_builtin_agents, tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, mcp_oauth_token_storage: self.mcp_oauth_token_storage, @@ -3104,6 +3159,8 @@ impl ResumeSessionConfig { providers: self.providers, models: self.models, enable_session_telemetry: self.enable_session_telemetry, + enable_citations: self.enable_citations, + session_limits: self.session_limits, model_capabilities: self.model_capabilities, memory: self.memory, config_dir: self.config_directory, @@ -3160,6 +3217,7 @@ impl ResumeSessionConfig { extension_info: None, available_tools: None, excluded_tools: None, + excluded_builtin_agents: None, mcp_servers: None, mcp_oauth_token_storage: None, enable_config_discovery: None, @@ -3187,6 +3245,8 @@ impl ResumeSessionConfig { providers: None, models: None, enable_session_telemetry: None, + enable_citations: None, + session_limits: None, model_capabilities: None, memory: None, config_directory: None, @@ -3420,6 +3480,16 @@ impl ResumeSessionConfig { self } + /// Set the built-in agent names to exclude from the resumed session. + pub fn with_excluded_builtin_agents(mut self, agents: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.excluded_builtin_agents = Some(agents.into_iter().map(Into::into).collect()); + self + } + /// Re-supply MCP server configurations on resume. pub fn with_mcp_servers(mut self, servers: HashMap) -> Self { self.mcp_servers = Some(servers); @@ -3618,6 +3688,18 @@ impl ResumeSessionConfig { self } + /// **Experimental.** Enable native model citations for supported providers on resume. + pub fn with_enable_citations(mut self, enable: bool) -> Self { + self.enable_citations = Some(enable); + self + } + + /// **Experimental.** Set limits for this session's current accounting window. + pub fn with_session_limits(mut self, limits: SessionLimitsConfig) -> Self { + self.session_limits = Some(limits); + self + } + /// Set per-property model capability overrides on resume. pub fn with_model_capabilities( mut self, diff --git a/rust/src/wire.rs b/rust/src/wire.rs index e6dad66d58..e0a123ff11 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -26,7 +26,8 @@ use crate::generated::session_events::ReasoningSummary; use crate::types::{ CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, - NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SystemMessageConfig, Tool, + NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, + SystemMessageConfig, Tool, }; /// Wire representation of a slash command (name + description only). The @@ -76,6 +77,8 @@ pub(crate) struct SessionCreateWire { pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_builtin_agents: Option>, /// SDK always sends `"excluded"` so include + exclude lists compose /// naturally (everything matching X except Y). pub tool_filter_precedence: &'static str, @@ -138,6 +141,10 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub enable_session_telemetry: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub enable_citations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_limits: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, @@ -194,6 +201,8 @@ pub(crate) struct SessionResumeWire { pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_builtin_agents: Option>, /// SDK always sends `"excluded"`. See create-wire docs. pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] @@ -255,6 +264,10 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub enable_session_telemetry: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub enable_citations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_limits: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index 8b13789179..a716804f78 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -1 +1,550 @@ +use std::net::TcpListener; +use std::sync::Arc; +use std::time::Duration; +use async_trait::async_trait; +use base64::Engine; +use bytes::Bytes; +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::{ + Attachment, Client, CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, + CopilotRequestError, CopilotRequestHandler, MessageOptions, ProviderConfig, + ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, +}; +use http::{HeaderMap, HeaderValue}; +use parking_lot::Mutex; +use serde_json::{Value, json}; + +use super::support::{ + DEFAULT_TEST_TOKEN, E2eContext, with_e2e_context, with_e2e_context_no_snapshot, +}; + +const SYNTHETIC_TEXT: &str = "OK from the synthetic stream."; +const CITATION_PROMPT: &str = "Summarize the attached PDF with citations enabled."; + +fn session_limits(max_ai_credits: f64) -> SessionLimitsConfig { + SessionLimitsConfig { + max_ai_credits: Some(max_ai_credits), + } +} + +async fn send_and_get_next_exchange( + ctx: &E2eContext, + session: &github_copilot_sdk::session::Session, + prompt: &str, +) -> Value { + let existing_count = ctx.exchanges().len(); + session + .send_and_wait(MessageOptions::new(prompt).with_wait_timeout(Duration::from_secs(120))) + .await + .expect("send_and_wait"); + let exchanges = ctx.exchanges(); + assert!(exchanges.len() > existing_count); + exchanges[existing_count].clone() +} + +fn assert_session_limits_status(exchange: &Value, expected_remaining: &str) { + let messages = exchange["request"]["messages"] + .as_array() + .expect("request messages"); + for message in messages { + if message["role"] != "user" { + continue; + } + let Some(content) = message["content"].as_str() else { + continue; + }; + if !content.contains("") { + continue; + } + assert!( + content.contains(&format!("Remaining session limits: {expected_remaining}.")), + "expected session limits status to include remaining {expected_remaining:?}, got {content:?}" + ); + assert!( + content.contains("Be frugal; avoid optional exploration and unnecessary tool calls."), + "expected session limits status to include frugality instruction, got {content:?}" + ); + return; + } + panic!("expected session limits status message"); +} + +fn task_agent_types(exchange: &Value) -> Vec { + let tools = exchange["request"]["tools"] + .as_array() + .expect("request tools"); + for tool in tools { + if tool["function"]["name"] != "task" { + continue; + } + return tool["function"]["parameters"]["properties"]["agent_type"]["enum"] + .as_array() + .expect("agent type enum") + .iter() + .map(|value| value.as_str().expect("agent type").to_string()) + .collect(); + } + panic!("expected task tool in request"); +} + +#[tokio::test] +async fn should_apply_session_limits_on_create() { + with_e2e_context( + "session_config", + "should_apply_session_limits_on_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_session_limits(session_limits(30.0)), + ) + .await + .expect("create session"); + + let exchange = send_and_get_next_exchange( + ctx, + &session, + "Acknowledge the current session limits.", + ) + .await; + assert_session_limits_status(&exchange, "30 AI credits"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_session_limits_on_resume() { + with_e2e_context( + "session_config", + "should_apply_session_limits_on_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session2 = client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_session_limits(session_limits(30.0)), + ) + .await + .expect("resume session"); + + let exchange = send_and_get_next_exchange( + ctx, + &session2, + "Acknowledge the current session limits.", + ) + .await; + assert_session_limits_status(&exchange, "30 AI credits"); + + session2 + .disconnect() + .await + .expect("disconnect resumed session"); + session1 + .disconnect() + .await + .expect("disconnect original session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_excluded_built_in_agents_on_create() { + with_e2e_context( + "session_config", + "should_apply_excluded_built_in_agents_on_create", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + + let baseline = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create baseline session"); + let baseline_exchange = + send_and_get_next_exchange(ctx, &baseline, "What is 1+1?").await; + let baseline_agents = task_agent_types(&baseline_exchange); + assert!( + baseline_agents.iter().any(|agent| agent == "explore"), + "expected baseline task agents to include explore, got {baseline_agents:?}" + ); + baseline + .disconnect() + .await + .expect("disconnect baseline session"); + + let excluded = client + .create_session( + ctx.approve_all_session_config() + .with_excluded_builtin_agents(["explore"]), + ) + .await + .expect("create excluded-agent session"); + let excluded_exchange = + send_and_get_next_exchange(ctx, &excluded, "What is 1+1?").await; + let excluded_agents = task_agent_types(&excluded_exchange); + assert!(!excluded_agents.is_empty()); + assert!( + !excluded_agents.iter().any(|agent| agent == "explore"), + "expected task agents not to include explore, got {excluded_agents:?}" + ); + + excluded + .disconnect() + .await + .expect("disconnect excluded-agent session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_apply_excluded_built_in_agents_on_resume() { + with_e2e_context( + "session_config", + "should_apply_excluded_built_in_agents_on_resume", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session1 = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session2 = client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_excluded_builtin_agents(["explore"]), + ) + .await + .expect("resume session"); + + let exchange = send_and_get_next_exchange(ctx, &session2, "What is 1+1?").await; + let agent_types = task_agent_types(&exchange); + assert!(!agent_types.is_empty()); + assert!( + !agent_types.iter().any(|agent| agent == "explore"), + "expected task agents not to include explore, got {agent_types:?}" + ); + + session2 + .disconnect() + .await + .expect("disconnect resumed session"); + session1 + .disconnect() + .await + .expect("disconnect original session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[derive(Clone, Default)] +struct RecordingHandler { + records: Arc>>, +} + +#[derive(Clone)] +struct RecordedRequest { + url: String, + body: Vec, +} + +impl RecordingHandler { + fn inference_records(&self) -> Vec { + self.records + .lock() + .iter() + .filter(|record| is_inference_url(&record.url)) + .cloned() + .collect() + } +} + +#[async_trait] +impl CopilotRequestHandler for RecordingHandler { + async fn send_request( + &self, + request: CopilotHttpRequest, + _ctx: &CopilotRequestContext, + ) -> Result { + self.records.lock().push(RecordedRequest { + url: request.url.clone(), + body: request.body.clone(), + }); + if is_inference_url(&request.url) { + return Ok(synth_inference_response(&request.url)); + } + Ok(synth_non_inference_response(&request.url)) + } +} + +fn is_inference_url(url: &str) -> bool { + let url = url.to_lowercase(); + url.ends_with("/chat/completions") + || url.ends_with("/responses") + || url.ends_with("/v1/messages") + || url.ends_with("/messages") +} + +fn json_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers +} + +fn http_response(status: u16, headers: HeaderMap, body: Value) -> CopilotHttpResponse { + let bytes = serde_json::to_vec(&body).expect("serialize response"); + let stream = + futures_util::stream::once( + async move { Ok::(Bytes::from(bytes)) }, + ); + CopilotHttpResponse::new(status, None, headers, Box::pin(stream)) +} + +fn synth_non_inference_response(url: &str) -> CopilotHttpResponse { + let lower = url.to_lowercase(); + if lower.ends_with("/models") { + return http_response( + 200, + json_headers(), + json!({ + "data": [{ + "id": "claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "object": "model", + "vendor": "Anthropic", + "version": "1", + "preview": false, + "model_picker_enabled": true, + "capabilities": { + "type": "chat", + "family": "claude-sonnet-4.5", + "tokenizer": "o200k_base", + "limits": { + "max_context_window_tokens": 200000, + "max_output_tokens": 8192, + }, + "supports": { + "streaming": true, + "tool_calls": true, + "parallel_tool_calls": true, + "vision": true, + }, + }, + }], + }), + ); + } + if lower.contains("/policy") { + return http_response(200, json_headers(), json!({ "state": "enabled" })); + } + http_response(200, json_headers(), json!({})) +} + +fn synth_inference_response(url: &str) -> CopilotHttpResponse { + let lower = url.to_lowercase(); + if lower.ends_with("/messages") { + return http_response( + 200, + json_headers(), + json!({ + "id": "msg_stub_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4.5", + "content": [{ "type": "text", "text": SYNTHETIC_TEXT }], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { "input_tokens": 5, "output_tokens": 7 }, + }), + ); + } + http_response( + 200, + json_headers(), + json!({ + "id": "chatcmpl-stub-1", + "object": "chat.completion", + "created": 1, + "model": "claude-sonnet-4.5", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": SYNTHETIC_TEXT }, + "finish_reason": "stop", + }], + "usage": { "prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12 }, + }), + ) +} + +fn anthropic_provider() -> ProviderConfig { + ProviderConfig::new("https://anthropic-citations.invalid/v1") + .with_provider_type("anthropic") + .with_api_key("test-provider-key") + .with_model_id("claude-sonnet-4.5") + .with_wire_model("claude-sonnet-4.5") +} + +fn pdf_attachment() -> Attachment { + let pdf_text = + "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + Attachment::Blob { + data: base64::engine::general_purpose::STANDARD.encode(pdf_text), + mime_type: "application/pdf".to_string(), + display_name: Some("citation-source.pdf".to_string()), + } +} + +fn assert_anthropic_document_citations_enabled(request_body: &[u8]) { + let body: Value = serde_json::from_slice(request_body).expect("Anthropic request body"); + let documents: Vec<&Value> = body["messages"] + .as_array() + .expect("messages") + .iter() + .flat_map(|message| message["content"].as_array().expect("message content")) + .filter(|block| block["type"] == "document") + .collect(); + + assert_eq!(documents.len(), 1); + assert_eq!(documents[0]["title"], "citation-source.pdf"); + assert_eq!(documents[0]["citations"]["enabled"], true); +} + +#[tokio::test] +async fn should_enable_citations_for_anthropic_file_attachments_on_create() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let handler = RecordingHandler::default(); + let client = ctx.start_llm_client(handler.clone(), &[]).await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5") + .with_enable_citations(true) + .with_provider(anthropic_provider()), + ) + .await + .expect("create session"); + + session + .send_and_wait( + MessageOptions::new(CITATION_PROMPT) + .with_wait_timeout(Duration::from_secs(120)) + .with_attachments(vec![pdf_attachment()]), + ) + .await + .expect("send_and_wait"); + + let inference_records = handler.inference_records(); + assert_eq!(inference_records.len(), 1); + assert_anthropic_document_citations_enabled(&inference_records[0].body); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_enable_citations_for_anthropic_file_attachments_on_resume() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let handler = RecordingHandler::default(); + let port = free_tcp_port(); + let token = "rust-citation-resume-token".to_string(); + let server = Client::start( + ctx.client_options_with_transport(Transport::Tcp { + port, + connection_token: Some(token.clone()), + }) + .with_request_handler(handler.clone()), + ) + .await + .expect("start TCP server client"); + let session1 = server + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let resume_client = + Client::start(ctx.client_options_with_transport(Transport::External { + host: "127.0.0.1".to_string(), + port, + connection_token: Some(token), + })) + .await + .expect("start external client"); + let session2 = resume_client + .resume_session( + ResumeSessionConfig::new(session1.id().clone()) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_model("claude-sonnet-4.5") + .with_enable_citations(true) + .with_provider(anthropic_provider()), + ) + .await + .expect("resume session"); + + session2 + .send_and_wait( + MessageOptions::new(CITATION_PROMPT) + .with_wait_timeout(Duration::from_secs(120)) + .with_attachments(vec![pdf_attachment()]), + ) + .await + .expect("send_and_wait"); + + let inference_records = handler.inference_records(); + assert_eq!(inference_records.len(), 1); + assert_anthropic_document_citations_enabled(&inference_records[0].body); + + session2 + .disconnect() + .await + .expect("disconnect resumed session"); + session1 + .disconnect() + .await + .expect("disconnect original session"); + resume_client.stop().await.expect("stop external client"); + server.stop().await.expect("stop TCP server client"); + }) + }) + .await; +} + +fn free_tcp_port() -> u16 { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind free TCP port"); + listener.local_addr().expect("local addr").port() +} diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 31b0cc2330..c422c93035 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -16,7 +16,9 @@ use github_copilot_sdk::rpc::{ CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult, OpenCanvasInstance, }; -use github_copilot_sdk::session_events::{McpOauthRequiredData, ReasoningSummary}; +use github_copilot_sdk::session_events::{ + McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, +}; use github_copilot_sdk::types::{ CloudSessionOptions, CloudSessionRepository, CommandContext, CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, @@ -624,6 +626,86 @@ async fn create_session_sends_correct_rpc() { assert_eq!(session.workspace_path(), Some(Path::new("/ws"))); } +#[tokio::test] +async fn create_session_sends_new_session_options() { + let (client, mut server_read, mut server_write) = make_client(); + + let create_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_excluded_builtin_agents(["explore"]) + .with_enable_citations(true) + .with_session_limits(SessionLimitsConfig { + max_ai_credits: Some(30.0), + }), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!( + request["params"]["excludedBuiltinAgents"], + serde_json::json!(["explore"]) + ); + assert_eq!(request["params"]["enableCitations"], true); + assert_eq!(request["params"]["sessionLimits"]["maxAiCredits"], 30.0); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id, "workspacePath": "/ws" }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); +} + +#[tokio::test] +async fn resume_session_sends_new_session_options() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + async move { + client + .resume_session( + ResumeSessionConfig::new(SessionId::from("session-options")) + .with_excluded_builtin_agents(["task"]) + .with_enable_citations(false) + .with_session_limits(SessionLimitsConfig { + max_ai_credits: Some(15.0), + }), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!(request["params"]["sessionId"], "session-options"); + assert_eq!( + request["params"]["excludedBuiltinAgents"], + serde_json::json!(["task"]) + ); + assert_eq!(request["params"]["enableCitations"], false); + assert_eq!(request["params"]["sessionLimits"]["maxAiCredits"], 15.0); + + server_respond_create(&mut server_write, &request, "session-options").await; + respond_to_reload(&mut server_read, &mut server_write).await; + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + #[tokio::test] async fn create_session_sends_canvas_wire_fields() { let (client, mut server_read, mut server_write) = make_client(); diff --git a/test/snapshots/session_config/should_apply_excluded_built_in_agents_on_create.yaml b/test/snapshots/session_config/should_apply_excluded_built_in_agents_on_create.yaml new file mode 100644 index 0000000000..3cbf86e981 --- /dev/null +++ b/test/snapshots/session_config/should_apply_excluded_built_in_agents_on_create.yaml @@ -0,0 +1,17 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1 + 1 = 2 + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1 + 1 = 2 diff --git a/test/snapshots/session_config/should_apply_excluded_built_in_agents_on_resume.yaml b/test/snapshots/session_config/should_apply_excluded_built_in_agents_on_resume.yaml new file mode 100644 index 0000000000..250402101b --- /dev/null +++ b/test/snapshots/session_config/should_apply_excluded_built_in_agents_on_resume.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1 + 1 = 2 diff --git a/test/snapshots/session_config/should_apply_session_limits_on_create.yaml b/test/snapshots/session_config/should_apply_session_limits_on_create.yaml new file mode 100644 index 0000000000..904d69c872 --- /dev/null +++ b/test/snapshots/session_config/should_apply_session_limits_on_create.yaml @@ -0,0 +1,18 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Acknowledge the current session limits. + - role: user + content: >- + + + Remaining session limits: 30 AI credits. Later session_limits_status messages supersede earlier ones. Be + frugal; avoid optional exploration and unnecessary tool calls. + + + - role: assistant + content: Session limits acknowledged. diff --git a/test/snapshots/session_config/should_apply_session_limits_on_resume.yaml b/test/snapshots/session_config/should_apply_session_limits_on_resume.yaml new file mode 100644 index 0000000000..904d69c872 --- /dev/null +++ b/test/snapshots/session_config/should_apply_session_limits_on_resume.yaml @@ -0,0 +1,18 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Acknowledge the current session limits. + - role: user + content: >- + + + Remaining session limits: 30 AI credits. Later session_limits_status messages supersede earlier ones. Be + frugal; avoid optional exploration and unnecessary tool calls. + + + - role: assistant + content: Session limits acknowledged. From 6b1672c76ed658b058be9155bfca5447e8216bf4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 30 Jun 2026 23:32:51 -0400 Subject: [PATCH 2/4] Address Java PR review comments Defensively copy excluded built-in agents lists and use the non-deprecated Java session request builder overload in tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../main/java/com/github/copilot/rpc/ResumeSessionConfig.java | 2 +- java/src/main/java/com/github/copilot/rpc/SessionConfig.java | 2 +- .../test/java/com/github/copilot/SessionRequestBuilderTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 895fc4d3d5..83b365a410 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -263,7 +263,7 @@ public List getExcludedBuiltInAgents() { * @return this config instance for method chaining */ public ResumeSessionConfig setExcludedBuiltInAgents(List excludedBuiltInAgents) { - this.excludedBuiltInAgents = excludedBuiltInAgents; + this.excludedBuiltInAgents = excludedBuiltInAgents != null ? new ArrayList<>(excludedBuiltInAgents) : null; return this; } diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index b7a5220a2b..cc27386c0b 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -361,7 +361,7 @@ public List getExcludedBuiltInAgents() { * @return this config instance for method chaining */ public SessionConfig setExcludedBuiltInAgents(List excludedBuiltInAgents) { - this.excludedBuiltInAgents = excludedBuiltInAgents; + this.excludedBuiltInAgents = excludedBuiltInAgents != null ? new ArrayList<>(excludedBuiltInAgents) : null; return this; } diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 83c777521e..bf192e98a3 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -167,7 +167,7 @@ void testBuildCreateRequestForwardsSessionPolicyOptions() { var config = new SessionConfig().setExcludedBuiltInAgents(List.of("explore")).setEnableCitations(true) .setSessionLimits(sessionLimits); - CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config, "session-policy"); assertEquals(List.of("explore"), request.getExcludedBuiltInAgents()); assertTrue(request.getEnableCitations()); From fe501b7085dfd05c8fe37c34e760114300ae9b01 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 1 Jul 2026 00:17:50 -0400 Subject: [PATCH 3/4] Fix Rust and Java CI failures for session options PR Rust: add missing model field/builder to ResumeSessionConfig so sessions can switch models on resume, matching the existing capability in the other language SDKs (Go, Node.js, Python, .NET, Java). The newly added citations-on-resume e2e test called ResumeSessionConfig::with_model, which didn't exist, causing a compile failure on all three Rust CI runners. Also removed the resulting unused SessionConfig import in the e2e test file. Java: run 'mvn spotless:apply' to fix formatting violations in SessionConfigE2ETest.java, CreateSessionRequest.java, and ResumeSessionRequest.java introduced by the new session option changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../copilot/rpc/CreateSessionRequest.java | 4 ++- .../copilot/rpc/ResumeSessionRequest.java | 4 ++- .../github/copilot/SessionConfigE2ETest.java | 33 +++++++++---------- rust/src/types.rs | 12 +++++++ rust/src/wire.rs | 2 ++ rust/tests/e2e/session_config.rs | 2 +- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 9d82e22791..3ec3dc5698 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -319,7 +319,9 @@ public List getExcludedBuiltInAgents() { return excludedBuiltInAgents == null ? null : Collections.unmodifiableList(excludedBuiltInAgents); } - /** Sets excluded built-in agents. @param excludedBuiltInAgents the agent names */ + /** + * Sets excluded built-in agents. @param excludedBuiltInAgents the agent names + */ public void setExcludedBuiltInAgents(List excludedBuiltInAgents) { this.excludedBuiltInAgents = excludedBuiltInAgents; } diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 871c91ee2c..89776f86cf 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -324,7 +324,9 @@ public List getExcludedBuiltInAgents() { return excludedBuiltInAgents == null ? null : Collections.unmodifiableList(excludedBuiltInAgents); } - /** Sets excluded built-in agents. @param excludedBuiltInAgents the agent names */ + /** + * Sets excluded built-in agents. @param excludedBuiltInAgents the agent names + */ public void setExcludedBuiltInAgents(List excludedBuiltInAgents) { this.excludedBuiltInAgents = excludedBuiltInAgents; } diff --git a/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java index 47910ffa72..925fd6d873 100644 --- a/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -167,9 +167,10 @@ void testShouldApplySessionLimitsOnCreate() throws Exception { ctx.configureForTest("session_config", "should_apply_session_limits_on_create"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig() - .setSessionLimits(new SessionLimitsConfig(30.0)) - .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + CopilotSession session = client + .createSession(new SessionConfig().setSessionLimits(new SessionLimitsConfig(30.0)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); try { Map exchange = sendAndGetNextExchange(session, @@ -223,9 +224,10 @@ void testShouldApplyExcludedBuiltInAgentsOnCreate() throws Exception { baselineSession.close(); } - CopilotSession excludedSession = client.createSession(new SessionConfig() - .setExcludedBuiltInAgents(List.of(excludedAgent)) - .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + CopilotSession excludedSession = client + .createSession(new SessionConfig().setExcludedBuiltInAgents(List.of(excludedAgent)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); try { List agentTypes = getTaskAgentTypes(sendAndGetNextExchange(excludedSession, prompt)); @@ -271,13 +273,11 @@ void testShouldEnableCitationsForAnthropicFileAttachmentsOnCreate() throws Excep try (CopilotClient client = newLlmClient(ctx, handler)) { CopilotSession session = client.createSession(new SessionConfig().setModel("claude-sonnet-4.5") - .setEnableCitations(true) - .setProvider(createAnthropicProvider()) + .setEnableCitations(true).setProvider(createAnthropicProvider()) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); try { - session.sendAndWait(new MessageOptions() - .setPrompt("Summarize the attached PDF with citations enabled.") + session.sendAndWait(new MessageOptions().setPrompt("Summarize the attached PDF with citations enabled.") .setAttachments(List.of(createPdfAttachment()))).get(60, TimeUnit.SECONDS); assertAnthropicDocumentCitationsEnabled(singleInferenceRequestBody(handler)); @@ -296,16 +296,16 @@ void testShouldEnableCitationsForAnthropicFileAttachmentsOnResume() throws Excep CopilotSession session1 = client .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); CopilotSession session2 = client.resumeSession(session1.getSessionId(), - new ResumeSessionConfig().setModel("claude-sonnet-4.5") - .setEnableCitations(true) + new ResumeSessionConfig().setModel("claude-sonnet-4.5").setEnableCitations(true) .setProvider(createAnthropicProvider()) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(); try { - session2.sendAndWait(new MessageOptions() - .setPrompt("Summarize the attached PDF with citations enabled.") - .setAttachments(List.of(createPdfAttachment()))).get(60, TimeUnit.SECONDS); + session2.sendAndWait( + new MessageOptions().setPrompt("Summarize the attached PDF with citations enabled.") + .setAttachments(List.of(createPdfAttachment()))) + .get(60, TimeUnit.SECONDS); assertAnthropicDocumentCitationsEnabled(singleInferenceRequestBody(handler)); } finally { @@ -383,8 +383,7 @@ private static BlobAttachment createPdfAttachment() { String pdfText = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; return new BlobAttachment() .setData(Base64.getEncoder().encodeToString(pdfText.getBytes(StandardCharsets.US_ASCII))) - .setDisplayName("citation-source.pdf") - .setMimeType("application/pdf"); + .setDisplayName("citation-source.pdf").setMimeType("application/pdf"); } private static ProviderConfig createAnthropicProvider() { diff --git a/rust/src/types.rs b/rust/src/types.rs index 20432ac9df..e42ecdd118 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2743,6 +2743,9 @@ impl SessionConfig { pub struct ResumeSessionConfig { /// ID of the session to resume. pub session_id: SessionId, + /// Model to use for this session (e.g. `"gpt-4"`, `"claude-sonnet-4"`). + /// Can change the model when resuming. + pub model: Option, /// Application name sent as User-Agent context. pub client_name: Option, /// Desired reasoning effort to apply after resuming the session. @@ -2949,6 +2952,7 @@ impl std::fmt::Debug for ResumeSessionConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ResumeSessionConfig") .field("session_id", &self.session_id) + .field("model", &self.model) .field("client_name", &self.client_name) .field("reasoning_effort", &self.reasoning_effort) .field("reasoning_summary", &self.reasoning_summary) @@ -3109,6 +3113,7 @@ impl ResumeSessionConfig { let wire = crate::wire::SessionResumeWire { session_id: self.session_id, + model: self.model, client_name: self.client_name, reasoning_effort: self.reasoning_effort, reasoning_summary: self.reasoning_summary, @@ -3201,6 +3206,7 @@ impl ResumeSessionConfig { pub fn new(session_id: SessionId) -> Self { Self { session_id, + model: None, client_name: None, reasoning_effort: None, reasoning_summary: None, @@ -3369,6 +3375,12 @@ impl ResumeSessionConfig { self } + /// Set the model identifier to switch to on resume (e.g. `"claude-sonnet-4"`). + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + /// Set the application name sent as `User-Agent` context. pub fn with_client_name(mut self, name: impl Into) -> Self { self.client_name = Some(name.into()); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index e0a123ff11..f888e032a9 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -172,6 +172,8 @@ pub(crate) struct SessionCreateWire { pub(crate) struct SessionResumeWire { pub session_id: SessionId, #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index a716804f78..d3f77ce063 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -9,7 +9,7 @@ use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::{ Attachment, Client, CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError, CopilotRequestHandler, MessageOptions, ProviderConfig, - ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, + ResumeSessionConfig, SessionLimitsConfig, Transport, }; use http::{HeaderMap, HeaderValue}; use parking_lot::Mutex; From 0c6ccc89b5f177e18cf90517313b2ee3504ac034 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 1 Jul 2026 01:06:26 -0400 Subject: [PATCH 4/4] Fix Rust citation e2e tests: don't combine gitHubToken with provider The two new should_enable_citations_for_anthropic_file_attachments_on_create/ on_resume e2e tests set a session-level gitHubToken (via approve_all_session_config()/with_github_token) while also specifying a Provider, which the CLI rejects with 'Cannot specify both gitHubToken and provider in session.create/resume.' This matches the .NET SDK's equivalent tests, which never pass GitHubToken alongside Provider. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/tests/e2e/session_config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index d3f77ce063..d4948ba177 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -9,7 +9,7 @@ use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::{ Attachment, Client, CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError, CopilotRequestHandler, MessageOptions, ProviderConfig, - ResumeSessionConfig, SessionLimitsConfig, Transport, + ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, }; use http::{HeaderMap, HeaderValue}; use parking_lot::Mutex; @@ -447,7 +447,8 @@ async fn should_enable_citations_for_anthropic_file_attachments_on_create() { let client = ctx.start_llm_client(handler.clone(), &[]).await; let session = client .create_session( - ctx.approve_all_session_config() + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) .with_model("claude-sonnet-4.5") .with_enable_citations(true) .with_provider(anthropic_provider()), @@ -508,7 +509,6 @@ async fn should_enable_citations_for_anthropic_file_attachments_on_resume() { .resume_session( ResumeSessionConfig::new(session1.id().clone()) .with_permission_handler(Arc::new(ApproveAllHandler)) - .with_github_token(DEFAULT_TEST_TOKEN) .with_model("claude-sonnet-4.5") .with_enable_citations(true) .with_provider(anthropic_provider()),