diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 926a39b75..3870465eb 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -75,6 +75,7 @@ public void SessionConfig_Clone_CopiesAllProperties() WorkingDirectory = "/workspace", Streaming = true, EnableSessionTelemetry = false, + EnableOnDemandInstructionDiscovery = true, IncludeSubAgentStreamingEvents = false, McpServers = new Dictionary { ["server1"] = new McpStdioServerConfig { Command = "echo" } }, McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, @@ -112,6 +113,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); Assert.Equal(original.Streaming, clone.Streaming); Assert.Equal(original.EnableSessionTelemetry, clone.EnableSessionTelemetry); + Assert.Equal(original.EnableOnDemandInstructionDiscovery, clone.EnableOnDemandInstructionDiscovery); Assert.Equal(original.IncludeSubAgentStreamingEvents, clone.IncludeSubAgentStreamingEvents); Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); Assert.Equal(original.McpOAuthTokenStorage, clone.McpOAuthTokenStorage); @@ -423,6 +425,52 @@ public void ResumeSessionConfig_Clone_PreservesEnableSessionTelemetryDefault() Assert.Null(clone.EnableSessionTelemetry); } + [Fact] + public void SessionConfig_Clone_CopiesEnableOnDemandInstructionDiscovery() + { + var original = new SessionConfig + { + EnableOnDemandInstructionDiscovery = false, + }; + + var clone = original.Clone(); + + Assert.False(clone.EnableOnDemandInstructionDiscovery); + } + + [Fact] + public void ResumeSessionConfig_Clone_CopiesEnableOnDemandInstructionDiscovery() + { + var original = new ResumeSessionConfig + { + EnableOnDemandInstructionDiscovery = true, + }; + + var clone = original.Clone(); + + Assert.True(clone.EnableOnDemandInstructionDiscovery); + } + + [Fact] + public void SessionConfig_Clone_PreservesEnableOnDemandInstructionDiscoveryDefault() + { + var original = new SessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableOnDemandInstructionDiscovery); + } + + [Fact] + public void ResumeSessionConfig_Clone_PreservesEnableOnDemandInstructionDiscoveryDefault() + { + var original = new ResumeSessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.EnableOnDemandInstructionDiscovery); + } + [Fact] public void SessionConfig_Clone_CopiesMcpOAuthTokenStorage() { diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 321ff61fe..3538f39d9 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -274,6 +274,60 @@ public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptio Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); } + [Fact] + public void CreateSessionRequest_CanSerializeEnableOnDemandInstructionDiscovery_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); + + var requestTrue = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableOnDemandInstructionDiscovery", true)); + var rootTrue = JsonDocument.Parse(JsonSerializer.Serialize(requestTrue, requestType, options)).RootElement; + Assert.True(rootTrue.GetProperty("enableOnDemandInstructionDiscovery").GetBoolean()); + + var requestFalse = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableOnDemandInstructionDiscovery", false)); + var rootFalse = JsonDocument.Parse(JsonSerializer.Serialize(requestFalse, requestType, options)).RootElement; + Assert.False(rootFalse.GetProperty("enableOnDemandInstructionDiscovery").GetBoolean()); + + var requestOmitted = CreateInternalRequest( + requestType, + ("SessionId", "session-id")); + var rootOmitted = JsonDocument.Parse(JsonSerializer.Serialize(requestOmitted, requestType, options)).RootElement; + Assert.False(rootOmitted.TryGetProperty("enableOnDemandInstructionDiscovery", out _)); + } + + [Fact] + public void ResumeSessionRequest_CanSerializeEnableOnDemandInstructionDiscovery_WithSdkOptions() + { + var options = GetSerializerOptions(); + var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); + + var requestTrue = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableOnDemandInstructionDiscovery", true)); + var rootTrue = JsonDocument.Parse(JsonSerializer.Serialize(requestTrue, requestType, options)).RootElement; + Assert.True(rootTrue.GetProperty("enableOnDemandInstructionDiscovery").GetBoolean()); + + var requestFalse = CreateInternalRequest( + requestType, + ("SessionId", "session-id"), + ("EnableOnDemandInstructionDiscovery", false)); + var rootFalse = JsonDocument.Parse(JsonSerializer.Serialize(requestFalse, requestType, options)).RootElement; + Assert.False(rootFalse.GetProperty("enableOnDemandInstructionDiscovery").GetBoolean()); + + var requestOmitted = CreateInternalRequest( + requestType, + ("SessionId", "session-id")); + var rootOmitted = JsonDocument.Parse(JsonSerializer.Serialize(requestOmitted, requestType, options)).RootElement; + Assert.False(rootOmitted.TryGetProperty("enableOnDemandInstructionDiscovery", out _)); + } + [Fact] public void ResumeSessionRequest_CanSerializeOpenCanvases_WithSdkOptions() { diff --git a/go/client_test.go b/go/client_test.go index 155f81368..012084701 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1445,6 +1445,100 @@ func TestResumeSessionRequest_IncludeSubAgentStreamingEvents(t *testing.T) { }) } +func TestCreateSessionRequest_EnableOnDemandInstructionDiscovery(t *testing.T) { + t.Run("forwards explicit true", func(t *testing.T) { + req := createSessionRequest{ + EnableOnDemandInstructionDiscovery: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableOnDemandInstructionDiscovery"] != true { + t.Errorf("Expected enableOnDemandInstructionDiscovery to be true, got %v", m["enableOnDemandInstructionDiscovery"]) + } + }) + + t.Run("preserves explicit false", func(t *testing.T) { + req := createSessionRequest{ + EnableOnDemandInstructionDiscovery: Bool(false), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableOnDemandInstructionDiscovery"] != false { + t.Errorf("Expected enableOnDemandInstructionDiscovery to be false, got %v", m["enableOnDemandInstructionDiscovery"]) + } + }) + + t.Run("omits enableOnDemandInstructionDiscovery when not set", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableOnDemandInstructionDiscovery"]; ok { + t.Error("Expected enableOnDemandInstructionDiscovery to be omitted when not set") + } + }) +} + +func TestResumeSessionRequest_EnableOnDemandInstructionDiscovery(t *testing.T) { + t.Run("forwards explicit true", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + EnableOnDemandInstructionDiscovery: Bool(true), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableOnDemandInstructionDiscovery"] != true { + t.Errorf("Expected enableOnDemandInstructionDiscovery to be true, got %v", m["enableOnDemandInstructionDiscovery"]) + } + }) + + t.Run("preserves explicit false", func(t *testing.T) { + req := resumeSessionRequest{ + SessionID: "s1", + EnableOnDemandInstructionDiscovery: Bool(false), + } + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["enableOnDemandInstructionDiscovery"] != false { + t.Errorf("Expected enableOnDemandInstructionDiscovery to be false, got %v", m["enableOnDemandInstructionDiscovery"]) + } + }) + + t.Run("omits enableOnDemandInstructionDiscovery when not set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["enableOnDemandInstructionDiscovery"]; ok { + t.Error("Expected enableOnDemandInstructionDiscovery to be omitted when not set") + } + }) +} + func TestCreateSessionResponse_Capabilities(t *testing.T) { t.Run("reads capabilities from session.create response", func(t *testing.T) { responseJSON := `{"sessionId":"s1","workspacePath":"/tmp","capabilities":{"ui":{"elicitation":true}}}` diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index 7f4121af2..205714f34 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -159,9 +159,10 @@ func TestClientOptionsE2E(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - EnableConfigDiscovery: true, - IncludeSubAgentStreamingEvents: copilot.Bool(false), - OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + EnableConfigDiscovery: true, + EnableOnDemandInstructionDiscovery: copilot.Bool(true), + IncludeSubAgentStreamingEvents: copilot.Bool(false), + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) if err != nil { t.Fatalf("CreateSession failed: %v", err) @@ -187,6 +188,9 @@ func TestClientOptionsE2E(t *testing.T) { if v, ok := params["enableConfigDiscovery"].(bool); !ok || v != true { t.Errorf("Expected session.create.params.enableConfigDiscovery=true, got %v", params["enableConfigDiscovery"]) } + if v, ok := params["enableOnDemandInstructionDiscovery"].(bool); !ok || v != true { + t.Errorf("Expected session.create.params.enableOnDemandInstructionDiscovery=true, got %v", params["enableOnDemandInstructionDiscovery"]) + } if v, ok := params["includeSubAgentStreamingEvents"].(bool); !ok || v != false { t.Errorf("Expected session.create.params.includeSubAgentStreamingEvents=false, got %v", params["includeSubAgentStreamingEvents"]) } diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 621599eed..0a22e6b6c 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -433,6 +433,50 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("forwards enableOnDemandInstructionDiscovery in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + enableOnDemandInstructionDiscovery: false, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ enableOnDemandInstructionDiscovery: false }) + ); + }); + + it("forwards enableOnDemandInstructionDiscovery in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + enableOnDemandInstructionDiscovery: false, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ + enableOnDemandInstructionDiscovery: false, + sessionId: session.sessionId, + }) + ); + spy.mockRestore(); + }); + it("defaults includeSubAgentStreamingEvents to true in session.create when not specified", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index cd67cf672..dadce08e1 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -291,6 +291,7 @@ describe("Client options", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, enableConfigDiscovery: true, + enableOnDemandInstructionDiscovery: true, includeSubAgentStreamingEvents: false, }); @@ -300,6 +301,7 @@ describe("Client options", async () => { method: string; params: { enableConfigDiscovery?: boolean; + enableOnDemandInstructionDiscovery?: boolean; includeSubAgentStreamingEvents?: boolean; }; }[]; @@ -307,6 +309,7 @@ describe("Client options", async () => { const createRequests = updated.requests.filter((r) => r.method === "session.create"); expect(createRequests).toHaveLength(1); expect(createRequests[0].params.enableConfigDiscovery).toBe(true); + expect(createRequests[0].params.enableOnDemandInstructionDiscovery).toBe(true); expect(createRequests[0].params.includeSubAgentStreamingEvents).toBe(false); await session.disconnect(); diff --git a/python/e2e/test_client_options_e2e.py b/python/e2e/test_client_options_e2e.py index 52490010e..8a503e4cb 100644 --- a/python/e2e/test_client_options_e2e.py +++ b/python/e2e/test_client_options_e2e.py @@ -254,6 +254,7 @@ async def test_should_propagate_process_options_to_spawned_cli(self, ctx: E2ETes session = await client.create_session( on_permission_request=PermissionHandler.approve_all, enable_config_discovery=True, + enable_on_demand_instruction_discovery=True, include_sub_agent_streaming_events=False, ) try: @@ -264,6 +265,7 @@ async def test_should_propagate_process_options_to_spawned_cli(self, ctx: E2ETes ) params = create_request["params"] assert params["enableConfigDiscovery"] is True + assert params["enableOnDemandInstructionDiscovery"] is True assert params["includeSubAgentStreamingEvents"] is False finally: await session.disconnect() diff --git a/python/test_client.py b/python/test_client.py index f023149b7..76823b34e 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -696,6 +696,57 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_session_forwards_enable_on_demand_instruction_discovery(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.create_session( + on_permission_request=PermissionHandler.approve_all, + enable_on_demand_instruction_discovery=False, + ) + assert captured["session.create"]["enableOnDemandInstructionDiscovery"] is False + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_forwards_enable_on_demand_instruction_discovery(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + + try: + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.resume": + return {"sessionId": session.session_id} + return await original_request(method, params, **kwargs) + + client._client.request = mock_request + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + enable_on_demand_instruction_discovery=False, + ) + assert captured["session.resume"]["enableOnDemandInstructionDiscovery"] is False + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_session_forwards_provider_headers(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index f902f8301..a0118e4b2 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -4402,6 +4402,7 @@ mod tests { cfg.enable_session_telemetry = Some(false); cfg.reasoning_summary = Some(ReasoningSummary::Concise); cfg.remote_session = Some(crate::generated::api_types::RemoteSessionMode::Export); + cfg.enable_on_demand_instruction_discovery = Some(false); cfg.cloud = Some(CloudSessionOptions::with_repository( CloudSessionRepository::new("github", "copilot-sdk").with_branch("main"), )); @@ -4418,6 +4419,7 @@ mod tests { assert_eq!(wire_json["enableSessionTelemetry"], false); assert_eq!(wire_json["reasoningSummary"], "concise"); assert_eq!(wire_json["remoteSession"], "export"); + assert_eq!(wire_json["enableOnDemandInstructionDiscovery"], false); assert_eq!(wire_json["cloud"]["repository"]["owner"], "github"); assert_eq!(wire_json["cloud"]["repository"]["name"], "copilot-sdk"); assert_eq!(wire_json["cloud"]["repository"]["branch"], "main"); @@ -4431,6 +4433,11 @@ mod tests { assert!(empty_json.get("enableSessionTelemetry").is_none()); assert!(empty_json.get("reasoningSummary").is_none()); assert!(empty_json.get("remoteSession").is_none()); + assert!( + empty_json + .get("enableOnDemandInstructionDiscovery") + .is_none() + ); assert!(empty_json.get("cloud").is_none()); } @@ -4478,6 +4485,7 @@ mod tests { cfg.enable_session_telemetry = Some(false); cfg.reasoning_summary = Some(ReasoningSummary::Detailed); cfg.remote_session = Some(crate::generated::api_types::RemoteSessionMode::On); + cfg.enable_on_demand_instruction_discovery = Some(false); let (wire, _) = cfg.into_wire().expect("no duplicate handlers"); let wire_json = serde_json::to_value(&wire).unwrap(); @@ -4489,6 +4497,7 @@ mod tests { assert_eq!(wire_json["enableSessionTelemetry"], false); assert_eq!(wire_json["reasoningSummary"], "detailed"); assert_eq!(wire_json["remoteSession"], "on"); + assert_eq!(wire_json["enableOnDemandInstructionDiscovery"], false); // Unset remote_session is omitted on the wire. let (empty_wire, _) = ResumeSessionConfig::new(SessionId::from("sess-2")) @@ -4497,6 +4506,11 @@ mod tests { let empty_json = serde_json::to_value(&empty_wire).unwrap(); assert!(empty_json.get("reasoningSummary").is_none()); assert!(empty_json.get("remoteSession").is_none()); + assert!( + empty_json + .get("enableOnDemandInstructionDiscovery") + .is_none() + ); } #[test] @@ -4544,6 +4558,7 @@ mod tests { .with_mcp_servers(HashMap::new()) .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) + .with_enable_on_demand_instruction_discovery(true) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) .with_agent("researcher") @@ -4572,6 +4587,7 @@ mod tests { assert!(cfg.mcp_servers.is_some()); assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("persistent")); assert_eq!(cfg.enable_config_discovery, Some(true)); + assert_eq!(cfg.enable_on_demand_instruction_discovery, Some(true)); assert_eq!( cfg.skill_directories.as_deref(), Some(&[PathBuf::from("/tmp/skills")][..]) @@ -4606,6 +4622,7 @@ mod tests { .with_mcp_servers(HashMap::new()) .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) + .with_enable_on_demand_instruction_discovery(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) .with_agent("researcher") @@ -4634,6 +4651,7 @@ mod tests { assert!(cfg.mcp_servers.is_some()); assert_eq!(cfg.mcp_oauth_token_storage.as_deref(), Some("persistent")); assert_eq!(cfg.enable_config_discovery, Some(true)); + assert_eq!(cfg.enable_on_demand_instruction_discovery, Some(false)); assert_eq!( cfg.skill_directories.as_deref(), Some(&[PathBuf::from("/tmp/skills")][..]) diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index caa63e84f..786ba97de 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -3224,6 +3224,11 @@ fn resume_session_config_serializes_bucket_b_fields() { // `ResumeSessionConfig::into_wire`. } +// Wire-format coverage for `enable_on_demand_instruction_discovery` lives in +// the in-crate unit tests alongside `SessionConfig::into_wire` / +// `ResumeSessionConfig::into_wire` (the wire conversion is crate-private and +// the public config types are intentionally not `Serialize`). + // ===================================================================== // Slash commands (ยง 4.1) // =====================================================================