diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 07a818c2..606c0b05 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -720,15 +720,25 @@ await InvokeRpcAsync( /// The new model takes effect for the next message. Conversation history is preserved. /// /// Model ID to switch to (e.g., "gpt-4.1"). + /// Reasoning effort level (e.g., "low", "medium", "high", "xhigh"). /// Optional cancellation token. /// /// /// await session.SetModelAsync("gpt-4.1"); + /// await session.SetModelAsync("claude-sonnet-4.6", "high"); /// /// - public async Task SetModelAsync(string model, CancellationToken cancellationToken = default) + public async Task SetModelAsync(string model, string? reasoningEffort, CancellationToken cancellationToken = default) { - await Rpc.Model.SwitchToAsync(model, cancellationToken: cancellationToken); + await Rpc.Model.SwitchToAsync(model, reasoningEffort, cancellationToken); + } + + /// + /// Changes the model for this session. + /// + public Task SetModelAsync(string model, CancellationToken cancellationToken = default) + { + return SetModelAsync(model, reasoningEffort: null, cancellationToken); } /// diff --git a/dotnet/test/RpcTests.cs b/dotnet/test/RpcTests.cs index a1369558..e041033b 100644 --- a/dotnet/test/RpcTests.cs +++ b/dotnet/test/RpcTests.cs @@ -72,8 +72,8 @@ public async Task Should_Call_Session_Rpc_Model_SwitchTo() var before = await session.Rpc.Model.GetCurrentAsync(); Assert.NotNull(before.ModelId); - // Switch to a different model - var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-4.1"); + // Switch to a different model with reasoning effort + var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-4.1", reasoningEffort: "high"); Assert.Equal("gpt-4.1", result.ModelId); // Verify the switch persisted diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index ea9d0da8..8cd4c84e 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -440,6 +440,20 @@ public async Task Should_Set_Model_On_Existing_Session() Assert.Equal("gpt-4.1", modelChanged.Data.NewModel); } + [Fact] + public async Task Should_Set_Model_With_ReasoningEffort() + { + var session = await CreateSessionAsync(); + + var modelChangedTask = TestHelper.GetNextEventOfTypeAsync(session); + + await session.SetModelAsync("gpt-4.1", "high"); + + var modelChanged = await modelChangedTask; + Assert.Equal("gpt-4.1", modelChanged.Data.NewModel); + Assert.Equal("high", modelChanged.Data.ReasoningEffort); + } + [Fact] public async Task Should_Log_Messages_At_Various_Levels() { diff --git a/go/internal/e2e/rpc_test.go b/go/internal/e2e/rpc_test.go index 61a5e338..ebcbe113 100644 --- a/go/internal/e2e/rpc_test.go +++ b/go/internal/e2e/rpc_test.go @@ -168,9 +168,11 @@ func TestSessionRpc(t *testing.T) { t.Error("Expected initial modelId to be defined") } - // Switch to a different model + // Switch to a different model with reasoning effort + re := "high" result, err := session.RPC.Model.SwitchTo(t.Context(), &rpc.SessionModelSwitchToParams{ - ModelID: "gpt-4.1", + ModelID: "gpt-4.1", + ReasoningEffort: &re, }) if err != nil { t.Fatalf("Failed to switch model: %v", err) @@ -201,7 +203,7 @@ func TestSessionRpc(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - if err := session.SetModel(t.Context(), "gpt-4.1"); err != nil { + if err := session.SetModel(t.Context(), "gpt-4.1", copilot.SetModelOptions{ReasoningEffort: "high"}); err != nil { t.Fatalf("SetModel returned error: %v", err) } }) diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 4590301d..c3c9cc00 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -895,6 +895,49 @@ func getSystemMessage(exchange testharness.ParsedHttpExchange) string { return "" } +func TestSetModelWithReasoningEffort(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) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + modelChanged := make(chan copilot.SessionEvent, 1) + session.On(func(event copilot.SessionEvent) { + if event.Type == copilot.SessionModelChange { + select { + case modelChanged <- event: + default: + } + } + }) + + if err := session.SetModel(t.Context(), "gpt-4.1", copilot.SetModelOptions{ReasoningEffort: "high"}); err != nil { + t.Fatalf("SetModel returned error: %v", err) + } + + select { + case evt := <-modelChanged: + if evt.Data.NewModel == nil || *evt.Data.NewModel != "gpt-4.1" { + t.Errorf("Expected newModel 'gpt-4.1', got %v", evt.Data.NewModel) + } + if evt.Data.ReasoningEffort == nil || *evt.Data.ReasoningEffort != "high" { + t.Errorf("Expected reasoningEffort 'high', got %v", evt.Data.ReasoningEffort) + } + case <-time.After(30 * time.Second): + t.Fatal("Timed out waiting for session.model_change event") + } +} + func getToolNames(exchange testharness.ParsedHttpExchange) []string { var names []string for _, tool := range exchange.Request.Tools { diff --git a/go/session.go b/go/session.go index f7a1ba4c..d2a5785b 100644 --- a/go/session.go +++ b/go/session.go @@ -737,6 +737,12 @@ func (s *Session) Abort(ctx context.Context) error { return nil } +// SetModelOptions configures optional parameters for SetModel. +type SetModelOptions struct { + // ReasoningEffort sets the reasoning effort level for the new model (e.g., "low", "medium", "high", "xhigh"). + ReasoningEffort string +} + // SetModel changes the model for this session. // The new model takes effect for the next message. Conversation history is preserved. // @@ -745,8 +751,16 @@ func (s *Session) Abort(ctx context.Context) error { // if err := session.SetModel(context.Background(), "gpt-4.1"); err != nil { // log.Printf("Failed to set model: %v", err) // } -func (s *Session) SetModel(ctx context.Context, model string) error { - _, err := s.RPC.Model.SwitchTo(ctx, &rpc.SessionModelSwitchToParams{ModelID: model}) +// if err := session.SetModel(context.Background(), "claude-sonnet-4.6", SetModelOptions{ReasoningEffort: "high"}); err != nil { +// log.Printf("Failed to set model: %v", err) +// } +func (s *Session) SetModel(ctx context.Context, model string, opts ...SetModelOptions) error { + params := &rpc.SessionModelSwitchToParams{ModelID: model} + if len(opts) > 0 && opts[0].ReasoningEffort != "" { + re := opts[0].ReasoningEffort + params.ReasoningEffort = &re + } + _, err := s.RPC.Model.SwitchTo(ctx, params) if err != nil { return fmt.Errorf("failed to set model: %w", err) } diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index ed08326b..67452676 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -16,6 +16,7 @@ import type { PermissionHandler, PermissionRequest, PermissionRequestResult, + ReasoningEffort, SessionEvent, SessionEventHandler, SessionEventPayload, @@ -718,14 +719,16 @@ export class CopilotSession { * The new model takes effect for the next message. Conversation history is preserved. * * @param model - Model ID to switch to + * @param options - Optional settings for the new model * * @example * ```typescript * await session.setModel("gpt-4.1"); + * await session.setModel("claude-sonnet-4.6", { reasoningEffort: "high" }); * ``` */ - async setModel(model: string): Promise { - await this.rpc.model.switchTo({ modelId: model }); + async setModel(model: string, options?: { reasoningEffort?: ReasoningEffort }): Promise { + await this.rpc.model.switchTo({ modelId: model, ...options }); } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index c8ae9488..3d13d27f 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -123,6 +123,31 @@ describe("CopilotClient", () => { spy.mockRestore(); }); + it("sends reasoningEffort with session.model.switchTo when provided", 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.model.switchTo") return {}; + throw new Error(`Unexpected method: ${method}`); + }); + + await session.setModel("claude-sonnet-4.6", { reasoningEffort: "high" }); + + expect(spy).toHaveBeenCalledWith("session.model.switchTo", { + sessionId: session.sessionId, + modelId: "claude-sonnet-4.6", + reasoningEffort: "high", + }); + + spy.mockRestore(); + }); + describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ diff --git a/nodejs/test/e2e/rpc.test.ts b/nodejs/test/e2e/rpc.test.ts index 62a885d0..d4d732ef 100644 --- a/nodejs/test/e2e/rpc.test.ts +++ b/nodejs/test/e2e/rpc.test.ts @@ -92,8 +92,11 @@ describe("Session RPC", async () => { const before = await session.rpc.model.getCurrent(); expect(before.modelId).toBeDefined(); - // Switch to a different model - const result = await session.rpc.model.switchTo({ modelId: "gpt-4.1" }); + // Switch to a different model with reasoning effort + const result = await session.rpc.model.switchTo({ + modelId: "gpt-4.1", + reasoningEffort: "high", + }); expect(result.modelId).toBe("gpt-4.1"); // Verify the switch persisted diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 0ad60edc..1eb8a175 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -461,4 +461,16 @@ describe("Send Blocking Behavior", async () => { session.sendAndWait({ prompt: "Run 'sleep 2 && echo done'" }, 100) ).rejects.toThrow(/Timeout after 100ms/); }); + + it("should set model with reasoningEffort", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const modelChangePromise = getNextEventOfType(session, "session.model_change"); + + await session.setModel("gpt-4.1", { reasoningEffort: "high" }); + + const event = await modelChangePromise; + expect(event.data.newModel).toBe("gpt-4.1"); + expect(event.data.reasoningEffort).toBe("high"); + }); }); diff --git a/python/copilot/session.py b/python/copilot/session.py index ad049811..cf09cb28 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -728,7 +728,7 @@ async def abort(self) -> None: """ await self._client.request("session.abort", {"sessionId": self.session_id}) - async def set_model(self, model: str) -> None: + async def set_model(self, model: str, *, reasoning_effort: str | None = None) -> None: """ Change the model for this session. @@ -737,14 +737,22 @@ async def set_model(self, model: str) -> None: Args: model: Model ID to switch to (e.g., "gpt-4.1", "claude-sonnet-4"). + reasoning_effort: Optional reasoning effort level for the new model + (e.g., "low", "medium", "high", "xhigh"). Raises: Exception: If the session has been destroyed or the connection fails. Example: >>> await session.set_model("gpt-4.1") + >>> await session.set_model("claude-sonnet-4.6", reasoning_effort="high") """ - await self.rpc.model.switch_to(SessionModelSwitchToParams(model_id=model)) + await self.rpc.model.switch_to( + SessionModelSwitchToParams( + model_id=model, + reasoning_effort=reasoning_effort, + ) + ) async def log( self, diff --git a/python/e2e/test_rpc.py b/python/e2e/test_rpc.py index 0db2b4fe..ddf843ba 100644 --- a/python/e2e/test_rpc.py +++ b/python/e2e/test_rpc.py @@ -99,8 +99,10 @@ async def test_should_call_session_rpc_model_switch_to(self, ctx: E2ETestContext before = await session.rpc.model.get_current() assert before.model_id is not None - # Switch to a different model - result = await session.rpc.model.switch_to(SessionModelSwitchToParams(model_id="gpt-4.1")) + # Switch to a different model with reasoning effort + result = await session.rpc.model.switch_to( + SessionModelSwitchToParams(model_id="gpt-4.1", reasoning_effort="high") + ) assert result.model_id == "gpt-4.1" # Verify the switch persisted diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index a779fd07..9e663fcc 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -558,6 +558,28 @@ def on_event(event): assert by_message["Ephemeral message"].type.value == "session.info" assert by_message["Ephemeral message"].data.info_type == "notification" + async def test_should_set_model_with_reasoning_effort(self, ctx: E2ETestContext): + """Test that setModel passes reasoningEffort and it appears in the model_change event.""" + import asyncio + + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + model_change_event = asyncio.get_event_loop().create_future() + + def on_event(event): + if not model_change_event.done() and event.type.value == "session.model_change": + model_change_event.set_result(event) + + session.on(on_event) + + await session.set_model("gpt-4.1", reasoning_effort="high") + + event = await asyncio.wait_for(model_change_event, timeout=30) + assert event.data.new_model == "gpt-4.1" + assert event.data.reasoning_effort == "high" + def _get_system_message(exchange: dict) -> str: messages = exchange.get("request", {}).get("messages", [])