Skip to content

Commit bd98e3a

Browse files
patnikoCopilotCopilotSteveSandersonMS
authored
Add session.setModel() for mid-session model switching (#621)
* 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<void> 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> * Refactor setModel to be thin wrappers around session.Rpc.Model.SwitchTo() Co-authored-by: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com> * 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> * Fix Prettier formatting in client.test.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SteveSandersonMS <1101362+SteveSandersonMS@users.noreply.github.com> Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com>
1 parent b9f746a commit bd98e3a

File tree

8 files changed

+151
-1
lines changed

8 files changed

+151
-1
lines changed

dotnet/src/Session.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,22 @@ await InvokeRpcAsync<object>(
507507
"session.abort", [new SessionAbortRequest { SessionId = SessionId }], cancellationToken);
508508
}
509509

510+
/// <summary>
511+
/// Changes the model for this session.
512+
/// The new model takes effect for the next message. Conversation history is preserved.
513+
/// </summary>
514+
/// <param name="model">Model ID to switch to (e.g., "gpt-4.1").</param>
515+
/// <param name="cancellationToken">Optional cancellation token.</param>
516+
/// <example>
517+
/// <code>
518+
/// await session.SetModelAsync("gpt-4.1");
519+
/// </code>
520+
/// </example>
521+
public async Task SetModelAsync(string model, CancellationToken cancellationToken = default)
522+
{
523+
await Rpc.Model.SwitchToAsync(model, cancellationToken);
524+
}
525+
510526
/// <summary>
511527
/// Disposes the <see cref="CopilotSession"/> and releases all associated resources.
512528
/// </summary>

dotnet/test/SessionTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,4 +441,19 @@ public async Task Should_Create_Session_With_Custom_Config_Dir()
441441
Assert.NotNull(assistantMessage);
442442
Assert.Contains("2", assistantMessage!.Data.Content);
443443
}
444+
445+
[Fact]
446+
public async Task Should_Set_Model_On_Existing_Session()
447+
{
448+
var session = await CreateSessionAsync();
449+
450+
// Subscribe for the model change event before calling SetModelAsync
451+
var modelChangedTask = TestHelper.GetNextEventOfTypeAsync<SessionModelChangeEvent>(session);
452+
453+
await session.SetModelAsync("gpt-4.1");
454+
455+
// Verify a model_change event was emitted with the new model
456+
var modelChanged = await modelChangedTask;
457+
Assert.Equal("gpt-4.1", modelChanged.Data.NewModel);
458+
}
444459
}

go/internal/e2e/rpc_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,23 @@ func TestSessionRpc(t *testing.T) {
189189
}
190190
})
191191

192+
// session.model.switchTo is defined in schema but not yet implemented in CLI
193+
t.Run("should call session.SetModel", func(t *testing.T) {
194+
t.Skip("session.model.switchTo not yet implemented in CLI")
195+
196+
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
197+
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
198+
Model: "claude-sonnet-4.5",
199+
})
200+
if err != nil {
201+
t.Fatalf("Failed to create session: %v", err)
202+
}
203+
204+
if err := session.SetModel(t.Context(), "gpt-4.1"); err != nil {
205+
t.Fatalf("SetModel returned error: %v", err)
206+
}
207+
})
208+
192209
t.Run("should get and set session mode", func(t *testing.T) {
193210
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll})
194211
if err != nil {

go/session.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,3 +576,20 @@ func (s *Session) Abort(ctx context.Context) error {
576576

577577
return nil
578578
}
579+
580+
// SetModel changes the model for this session.
581+
// The new model takes effect for the next message. Conversation history is preserved.
582+
//
583+
// Example:
584+
//
585+
// if err := session.SetModel(context.Background(), "gpt-4.1"); err != nil {
586+
// log.Printf("Failed to set model: %v", err)
587+
// }
588+
func (s *Session) SetModel(ctx context.Context, model string) error {
589+
_, err := s.RPC.Model.SwitchTo(ctx, &rpc.SessionModelSwitchToParams{ModelID: model})
590+
if err != nil {
591+
return fmt.Errorf("failed to set model: %w", err)
592+
}
593+
594+
return nil
595+
}

nodejs/src/session.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,4 +549,19 @@ export class CopilotSession {
549549
sessionId: this.sessionId,
550550
});
551551
}
552+
553+
/**
554+
* Change the model for this session.
555+
* The new model takes effect for the next message. Conversation history is preserved.
556+
*
557+
* @param model - Model ID to switch to
558+
*
559+
* @example
560+
* ```typescript
561+
* await session.setModel("gpt-4.1");
562+
* ```
563+
*/
564+
async setModel(model: string): Promise<void> {
565+
await this.rpc.model.switchTo({ modelId: model });
566+
}
552567
}

nodejs/test/client.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,32 @@ describe("CopilotClient", () => {
8080
);
8181
});
8282

83+
it("sends session.model.switchTo RPC with correct params", async () => {
84+
const client = new CopilotClient();
85+
await client.start();
86+
onTestFinished(() => client.forceStop());
87+
88+
const session = await client.createSession({ onPermissionRequest: approveAll });
89+
90+
// Mock sendRequest to capture the call without hitting the runtime
91+
const spy = vi
92+
.spyOn((client as any).connection!, "sendRequest")
93+
.mockImplementation(async (method: string, _params: any) => {
94+
if (method === "session.model.switchTo") return {};
95+
// Fall through for other methods (shouldn't be called)
96+
throw new Error(`Unexpected method: ${method}`);
97+
});
98+
99+
await session.setModel("gpt-4.1");
100+
101+
expect(spy).toHaveBeenCalledWith("session.model.switchTo", {
102+
sessionId: session.sessionId,
103+
modelId: "gpt-4.1",
104+
});
105+
106+
spy.mockRestore();
107+
});
108+
83109
describe("URL parsing", () => {
84110
it("should parse port-only URL format", () => {
85111
const client = new CopilotClient({

python/copilot/session.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from collections.abc import Callable
1212
from typing import Any, cast
1313

14-
from .generated.rpc import SessionRpc
14+
from .generated.rpc import SessionModelSwitchToParams, SessionRpc
1515
from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict
1616
from .types import (
1717
MessageOptions,
@@ -520,3 +520,21 @@ async def abort(self) -> None:
520520
>>> await session.abort()
521521
"""
522522
await self._client.request("session.abort", {"sessionId": self.session_id})
523+
524+
async def set_model(self, model: str) -> None:
525+
"""
526+
Change the model for this session.
527+
528+
The new model takes effect for the next message. Conversation history
529+
is preserved.
530+
531+
Args:
532+
model: Model ID to switch to (e.g., "gpt-4.1", "claude-sonnet-4").
533+
534+
Raises:
535+
Exception: If the session has been destroyed or the connection fails.
536+
537+
Example:
538+
>>> await session.set_model("gpt-4.1")
539+
"""
540+
await self.rpc.model.switch_to(SessionModelSwitchToParams(model_id=model))

python/test_client.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,29 @@ async def mock_request(method, params):
226226
assert captured["session.resume"]["clientName"] == "my-app"
227227
finally:
228228
await client.force_stop()
229+
230+
@pytest.mark.asyncio
231+
async def test_set_model_sends_correct_rpc(self):
232+
client = CopilotClient({"cli_path": CLI_PATH})
233+
await client.start()
234+
235+
try:
236+
session = await client.create_session(
237+
{"on_permission_request": PermissionHandler.approve_all}
238+
)
239+
240+
captured = {}
241+
original_request = client._client.request
242+
243+
async def mock_request(method, params):
244+
captured[method] = params
245+
if method == "session.model.switchTo":
246+
return {}
247+
return await original_request(method, params)
248+
249+
client._client.request = mock_request
250+
await session.set_model("gpt-4.1")
251+
assert captured["session.model.switchTo"]["sessionId"] == session.session_id
252+
assert captured["session.model.switchTo"]["modelId"] == "gpt-4.1"
253+
finally:
254+
await client.force_stop()

0 commit comments

Comments
 (0)