From a0367b8b4919f827ba2085300816200c50b1d725 Mon Sep 17 00:00:00 2001 From: Patrick Nikoletich Date: Sun, 1 Mar 2026 18:43:08 -0800 Subject: [PATCH 1/4] Add session.setModel() across all 4 languages Allows changing the model mid-session without destroying it. The new model takes effect for the next message while preserving conversation history. Node.js: session.setModel(model: string): Promise Python: session.set_model(model: str) -> None Go: session.SetModel(ctx, model string) error .NET: session.SetModelAsync(model, cancellationToken): Task All send the 'session.setModel' JSON-RPC method with { sessionId, model } params. Tests added for all 4 languages: - Node.js: mocked sendRequest verifies correct RPC params (25/25 pass) - Python: mocked request verifies sessionId + model (unit test) - Go: JSON marshaling test for request type (pass) - .NET: e2e test creating session, calling SetModelAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Session.cs | 24 ++++++++++++++++++++++++ dotnet/test/SessionTests.cs | 14 ++++++++++++++ go/client_test.go | 20 ++++++++++++++++++++ go/session.go | 20 ++++++++++++++++++++ go/types.go | 6 ++++++ nodejs/src/session.ts | 18 ++++++++++++++++++ nodejs/test/client.test.ts | 25 +++++++++++++++++++++++++ python/copilot/session.py | 21 +++++++++++++++++++++ python/test_client.py | 26 ++++++++++++++++++++++++++ 9 files changed, 174 insertions(+) diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 923b193cc..90ed39abf 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -540,6 +540,23 @@ await InvokeRpcAsync( "session.abort", [new SessionAbortRequest { SessionId = SessionId }], cancellationToken); } + /// + /// Changes the model for this session. + /// The new model takes effect for the next message. Conversation history is preserved. + /// + /// Model ID to switch to (e.g., "gpt-4.1"). + /// Optional cancellation token. + /// + /// + /// await session.SetModelAsync("gpt-4.1"); + /// + /// + public async Task SetModelAsync(string model, CancellationToken cancellationToken = default) + { + await InvokeRpcAsync( + "session.setModel", [new SetModelRequest { SessionId = SessionId, Model = model }], cancellationToken); + } + /// /// Disposes the and releases all associated resources. /// @@ -638,6 +655,12 @@ internal record SessionDestroyRequest public string SessionId { get; init; } = string.Empty; } + internal record SetModelRequest + { + public string SessionId { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + } + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -650,6 +673,7 @@ internal record SessionDestroyRequest [JsonSerializable(typeof(SendMessageResponse))] [JsonSerializable(typeof(SessionAbortRequest))] [JsonSerializable(typeof(SessionDestroyRequest))] + [JsonSerializable(typeof(SetModelRequest))] [JsonSerializable(typeof(UserMessageDataAttachmentsItem))] [JsonSerializable(typeof(PreToolUseHookInput))] [JsonSerializable(typeof(PreToolUseHookOutput))] diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index e4b13fff7..e10b40ae0 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -441,4 +441,18 @@ public async Task Should_Create_Session_With_Custom_Config_Dir() Assert.NotNull(assistantMessage); Assert.Contains("2", assistantMessage!.Data.Content); } + + [Fact] + public async Task Should_Set_Model_On_Existing_Session() + { + var session = await CreateSessionAsync(); + + // SetModel should not throw + await session.SetModelAsync("gpt-4.1"); + + // Session should still be usable after model change + await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + } } diff --git a/go/client_test.go b/go/client_test.go index 752bdc758..c0da36876 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -448,6 +448,26 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { }) } +func TestSetModelRequest(t *testing.T) { + t.Run("includes sessionId and model in JSON", func(t *testing.T) { + req := sessionSetModelRequest{SessionID: "s1", Model: "gpt-4.1"} + 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["sessionId"] != "s1" { + t.Errorf("Expected sessionId 's1', got %v", m["sessionId"]) + } + if m["model"] != "gpt-4.1" { + t.Errorf("Expected model 'gpt-4.1', got %v", m["model"]) + } + }) +} + func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { client := NewClient(nil) diff --git a/go/session.go b/go/session.go index 12d1b1afa..18307b170 100644 --- a/go/session.go +++ b/go/session.go @@ -576,3 +576,23 @@ func (s *Session) Abort(ctx context.Context) error { return nil } + +// SetModel changes the model for this session. +// The new model takes effect for the next message. Conversation history is preserved. +// +// Example: +// +// 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.client.Request("session.setModel", sessionSetModelRequest{ + SessionID: s.SessionID, + Model: model, + }) + if err != nil { + return fmt.Errorf("failed to set model: %w", err) + } + + return nil +} diff --git a/go/types.go b/go/types.go index 225cc1266..bd5922f69 100644 --- a/go/types.go +++ b/go/types.go @@ -828,6 +828,12 @@ type sessionAbortRequest struct { SessionID string `json:"sessionId"` } +// sessionSetModelRequest is the request for session.setModel +type sessionSetModelRequest struct { + SessionID string `json:"sessionId"` + Model string `json:"model"` +} + type sessionSendRequest struct { SessionID string `json:"sessionId"` Prompt string `json:"prompt"` diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 04525d2bb..aa96b06ad 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -549,4 +549,22 @@ export class CopilotSession { sessionId: this.sessionId, }); } + + /** + * Change the model for this session. + * The new model takes effect for the next message. Conversation history is preserved. + * + * @param model - Model ID to switch to + * + * @example + * ```typescript + * await session.setModel("gpt-4.1"); + * ``` + */ + async setModel(model: string): Promise { + await this.connection.sendRequest("session.setModel", { + sessionId: this.sessionId, + model, + }); + } } diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 6fa33e9ec..e2daaf16f 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -80,6 +80,31 @@ describe("CopilotClient", () => { ); }); + it("sends session.setModel RPC with correct params", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + // Mock sendRequest to capture the call without hitting the runtime + const spy = vi.spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.setModel") return {}; + // Fall through for other methods (shouldn't be called) + throw new Error(`Unexpected method: ${method}`); + }); + + await session.setModel("gpt-4.1"); + + expect(spy).toHaveBeenCalledWith( + "session.setModel", + { sessionId: session.sessionId, model: "gpt-4.1" } + ); + + spy.mockRestore(); + }); + describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ diff --git a/python/copilot/session.py b/python/copilot/session.py index a02dcf1e9..14a5e4148 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -520,3 +520,24 @@ async def abort(self) -> None: >>> await session.abort() """ await self._client.request("session.abort", {"sessionId": self.session_id}) + + async def set_model(self, model: str) -> None: + """ + Change the model for this session. + + The new model takes effect for the next message. Conversation history + is preserved. + + Args: + model: Model ID to switch to (e.g., "gpt-4.1", "claude-sonnet-4"). + + Raises: + Exception: If the session has been destroyed or the connection fails. + + Example: + >>> await session.set_model("gpt-4.1") + """ + await self._client.request( + "session.setModel", + {"sessionId": self.session_id, "model": model}, + ) diff --git a/python/test_client.py b/python/test_client.py index f31c3e9ec..5443edb5b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -226,3 +226,29 @@ async def mock_request(method, params): assert captured["session.resume"]["clientName"] == "my-app" finally: await client.force_stop() + + @pytest.mark.asyncio + async def test_set_model_sends_correct_rpc(self): + client = CopilotClient({"cli_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): + captured[method] = params + if method == "session.setModel": + return {} + return await original_request(method, params) + + client._client.request = mock_request + await session.set_model("gpt-4.1") + assert captured["session.setModel"]["sessionId"] == session.session_id + assert captured["session.setModel"]["model"] == "gpt-4.1" + finally: + await client.force_stop() From 6936cce79ed0589bf4ef5d30d52c7bf8edd10d9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:53:15 +0000 Subject: [PATCH 2/4] Refactor setModel to be thin wrappers around session.Rpc.Model.SwitchTo() Co-authored-by: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com> --- dotnet/src/Session.cs | 10 +--------- go/client_test.go | 20 -------------------- go/session.go | 5 +---- go/types.go | 6 ------ nodejs/src/session.ts | 5 +---- nodejs/test/client.test.ts | 8 ++++---- python/copilot/session.py | 7 ++----- python/test_client.py | 6 +++--- 8 files changed, 12 insertions(+), 55 deletions(-) diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 90ed39abf..675165ae1 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -553,8 +553,7 @@ await InvokeRpcAsync( /// public async Task SetModelAsync(string model, CancellationToken cancellationToken = default) { - await InvokeRpcAsync( - "session.setModel", [new SetModelRequest { SessionId = SessionId, Model = model }], cancellationToken); + await Rpc.Model.SwitchToAsync(model, cancellationToken); } /// @@ -655,12 +654,6 @@ internal record SessionDestroyRequest public string SessionId { get; init; } = string.Empty; } - internal record SetModelRequest - { - public string SessionId { get; init; } = string.Empty; - public string Model { get; init; } = string.Empty; - } - [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -673,7 +666,6 @@ internal record SetModelRequest [JsonSerializable(typeof(SendMessageResponse))] [JsonSerializable(typeof(SessionAbortRequest))] [JsonSerializable(typeof(SessionDestroyRequest))] - [JsonSerializable(typeof(SetModelRequest))] [JsonSerializable(typeof(UserMessageDataAttachmentsItem))] [JsonSerializable(typeof(PreToolUseHookInput))] [JsonSerializable(typeof(PreToolUseHookOutput))] diff --git a/go/client_test.go b/go/client_test.go index c0da36876..752bdc758 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -448,26 +448,6 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { }) } -func TestSetModelRequest(t *testing.T) { - t.Run("includes sessionId and model in JSON", func(t *testing.T) { - req := sessionSetModelRequest{SessionID: "s1", Model: "gpt-4.1"} - 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["sessionId"] != "s1" { - t.Errorf("Expected sessionId 's1', got %v", m["sessionId"]) - } - if m["model"] != "gpt-4.1" { - t.Errorf("Expected model 'gpt-4.1', got %v", m["model"]) - } - }) -} - func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { client := NewClient(nil) diff --git a/go/session.go b/go/session.go index 18307b170..216c2c8be 100644 --- a/go/session.go +++ b/go/session.go @@ -586,10 +586,7 @@ func (s *Session) Abort(ctx context.Context) error { // log.Printf("Failed to set model: %v", err) // } func (s *Session) SetModel(ctx context.Context, model string) error { - _, err := s.client.Request("session.setModel", sessionSetModelRequest{ - SessionID: s.SessionID, - Model: model, - }) + _, err := s.RPC.Model.SwitchTo(ctx, &rpc.SessionModelSwitchToParams{ModelID: model}) if err != nil { return fmt.Errorf("failed to set model: %w", err) } diff --git a/go/types.go b/go/types.go index bd5922f69..225cc1266 100644 --- a/go/types.go +++ b/go/types.go @@ -828,12 +828,6 @@ type sessionAbortRequest struct { SessionID string `json:"sessionId"` } -// sessionSetModelRequest is the request for session.setModel -type sessionSetModelRequest struct { - SessionID string `json:"sessionId"` - Model string `json:"model"` -} - type sessionSendRequest struct { SessionID string `json:"sessionId"` Prompt string `json:"prompt"` diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index aa96b06ad..f7b0ee585 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -562,9 +562,6 @@ export class CopilotSession { * ``` */ async setModel(model: string): Promise { - await this.connection.sendRequest("session.setModel", { - sessionId: this.sessionId, - model, - }); + await this.rpc.model.switchTo({ modelId: model }); } } diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index e2daaf16f..266dc4b70 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -80,7 +80,7 @@ describe("CopilotClient", () => { ); }); - it("sends session.setModel RPC with correct params", async () => { + it("sends session.model.switchTo RPC with correct params", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); @@ -90,7 +90,7 @@ describe("CopilotClient", () => { // Mock sendRequest to capture the call without hitting the runtime const spy = vi.spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { - if (method === "session.setModel") return {}; + if (method === "session.model.switchTo") return {}; // Fall through for other methods (shouldn't be called) throw new Error(`Unexpected method: ${method}`); }); @@ -98,8 +98,8 @@ describe("CopilotClient", () => { await session.setModel("gpt-4.1"); expect(spy).toHaveBeenCalledWith( - "session.setModel", - { sessionId: session.sessionId, model: "gpt-4.1" } + "session.model.switchTo", + { sessionId: session.sessionId, modelId: "gpt-4.1" } ); spy.mockRestore(); diff --git a/python/copilot/session.py b/python/copilot/session.py index 14a5e4148..1fec27ef7 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -11,7 +11,7 @@ from collections.abc import Callable from typing import Any, cast -from .generated.rpc import SessionRpc +from .generated.rpc import SessionModelSwitchToParams, SessionRpc from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict from .types import ( MessageOptions, @@ -537,7 +537,4 @@ async def set_model(self, model: str) -> None: Example: >>> await session.set_model("gpt-4.1") """ - await self._client.request( - "session.setModel", - {"sessionId": self.session_id, "model": model}, - ) + await self.rpc.model.switch_to(SessionModelSwitchToParams(model_id=model)) diff --git a/python/test_client.py b/python/test_client.py index 5443edb5b..0dfe390c9 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -242,13 +242,13 @@ async def test_set_model_sends_correct_rpc(self): async def mock_request(method, params): captured[method] = params - if method == "session.setModel": + if method == "session.model.switchTo": return {} return await original_request(method, params) client._client.request = mock_request await session.set_model("gpt-4.1") - assert captured["session.setModel"]["sessionId"] == session.session_id - assert captured["session.setModel"]["model"] == "gpt-4.1" + assert captured["session.model.switchTo"]["sessionId"] == session.session_id + assert captured["session.model.switchTo"]["modelId"] == "gpt-4.1" finally: await client.force_stop() From b91f69cfd9645cedf325f113aa2a814d2d0c7fa6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:39:10 +0000 Subject: [PATCH 3/4] Fix ESLint error, update .NET test to verify model_change event, add skipped Go test Co-authored-by: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com> --- dotnet/test/SessionTests.cs | 11 ++++++----- go/internal/e2e/rpc_test.go | 17 +++++++++++++++++ nodejs/test/client.test.ts | 2 +- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index e10b40ae0..eac00b06e 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -447,12 +447,13 @@ public async Task Should_Set_Model_On_Existing_Session() { var session = await CreateSessionAsync(); - // SetModel should not throw + // Subscribe for the model change event before calling SetModelAsync + var modelChangedTask = TestHelper.GetNextEventOfTypeAsync(session); + await session.SetModelAsync("gpt-4.1"); - // Session should still be usable after model change - await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); - var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); - Assert.NotNull(assistantMessage); + // Verify a model_change event was emitted with the new model + var modelChanged = await modelChangedTask; + Assert.Equal("gpt-4.1", modelChanged.Data.NewModel); } } diff --git a/go/internal/e2e/rpc_test.go b/go/internal/e2e/rpc_test.go index 1f8f17c16..61a5e338d 100644 --- a/go/internal/e2e/rpc_test.go +++ b/go/internal/e2e/rpc_test.go @@ -189,6 +189,23 @@ func TestSessionRpc(t *testing.T) { } }) + // session.model.switchTo is defined in schema but not yet implemented in CLI + t.Run("should call session.SetModel", func(t *testing.T) { + t.Skip("session.model.switchTo not yet implemented in CLI") + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if err := session.SetModel(t.Context(), "gpt-4.1"); err != nil { + t.Fatalf("SetModel returned error: %v", err) + } + }) + t.Run("should get and set session mode", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 266dc4b70..edd4a9272 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -89,7 +89,7 @@ describe("CopilotClient", () => { // Mock sendRequest to capture the call without hitting the runtime const spy = vi.spyOn((client as any).connection!, "sendRequest") - .mockImplementation(async (method: string, params: any) => { + .mockImplementation(async (method: string, _params: any) => { if (method === "session.model.switchTo") return {}; // Fall through for other methods (shouldn't be called) throw new Error(`Unexpected method: ${method}`); From 03467b7d5c2e3bd6b0e852a5094dd09f4d2c843b Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 3 Mar 2026 15:09:40 +0000 Subject: [PATCH 4/4] Fix Prettier formatting in client.test.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index edd4a9272..32257a0b4 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -88,7 +88,8 @@ describe("CopilotClient", () => { const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime - const spy = vi.spyOn((client as any).connection!, "sendRequest") + const spy = vi + .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, _params: any) => { if (method === "session.model.switchTo") return {}; // Fall through for other methods (shouldn't be called) @@ -97,10 +98,10 @@ describe("CopilotClient", () => { await session.setModel("gpt-4.1"); - expect(spy).toHaveBeenCalledWith( - "session.model.switchTo", - { sessionId: session.sessionId, modelId: "gpt-4.1" } - ); + expect(spy).toHaveBeenCalledWith("session.model.switchTo", { + sessionId: session.sessionId, + modelId: "gpt-4.1", + }); spy.mockRestore(); });