Allow mid-session model switching with a persisted transcript marker

Picker stays live for the session; switches persist a model_switch notice (§17 revised).
Rebinds refused mid-turn; images become placeholders for non-vision targets at send time.
This commit is contained in:
Rohit C Prasad
2026-07-22 15:43:54 -07:00
committed by Rohit P
parent a63710ecfe
commit f1eb652d61
13 changed files with 261 additions and 56 deletions
+3 -3
View File
@@ -21,9 +21,9 @@ test("send → user bubble → streamed echo reply renders", async ({ page }) =>
// The message carried the composer's visible model (model-per-message contract): what the
// user sees at send time is exactly what serves the turn.
await expect(page.getByText("[model=anthropic:claude-opus-4-8]")).toBeVisible();
// …and having sent, the model is now FIXED for this session (§17/§22): the composer picker is
// gone and the fact reads in the topbar's facts subtitle instead.
await expect(page.locator(".dd").filter({ hasText: "Claude Opus" })).toHaveCount(0);
// …and the picker STAYS actionable after the first turn (§17 rev 2026-07-22 — mid-session
// switching shipped); the fact also reads in the topbar's facts subtitle.
await expect(page.locator(".dd").filter({ hasText: "Claude Opus" })).toBeVisible();
await expect(page.getByTestId("session-subtitle")).toContainText("Claude Opus 4.8");
// Composer cleared and re-armed for the next turn.
await expect(box).toHaveValue("");
+10
View File
@@ -568,9 +568,11 @@ export async function mockApi(page: import("@playwright/test").Page) {
send("ready");
let pendingTool = "run_shell"; // which proposal the next approval decision resolves
let epicTimer: ReturnType<typeof setInterval> | null = null; // the slow stream, stoppable via interrupt
let hadTurn = false; // a user_message landed — set_model is now a mid-session switch
ws.onMessage((raw) => {
const msg = JSON.parse(String(raw));
if (msg.type === "user_message") {
hadTurn = true;
send("turn_start", { input: msg.text });
if (/run a tool/i.test(msg.text)) {
pendingTool = "run_shell";
@@ -703,6 +705,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
}
send("interrupted", {});
send("turn_done");
} else if (msg.type === "set_model") {
// Mid-session switch: the server applies it and broadcasts the persisted marker.
// Like the real server, the FIRST bind (fresh session) is silent.
if (hadTurn)
send("model_changed", {
model: msg.model,
text: `Model switched to ${msg.model}`,
});
} else if (msg.type === "retry") {
// Like the real engine: re-runs with NO new user message (turn_start input is empty).
send("turn_start", { input: "" });
+33
View File
@@ -0,0 +1,33 @@
// Model-layer roadmap item 3 (2026-07-22): the model picker stays actionable for the
// session's whole life (supersedes the 2026-07-04 lock that hid it after the first turn).
// A mid-session switch drops a persisted info marker into the transcript, and later
// messages ride the new model.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("mid-session model switch shows the marker and later turns use the new model", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello there");
await box.press("Enter");
await expect(page.getByText("Echo: hello there", { exact: false }).first()).toBeVisible();
// The picker is still in the composer after the first turn (the old lock hid it).
const picker = page.locator(".dd").filter({ hasText: "Claude Opus 4.8" });
await expect(picker).toBeVisible();
await picker.locator(".pill").click();
await page.locator(".dd-item").filter({ hasText: "GPT-5.5" }).click();
// The switch marker lands in the transcript…
await expect(page.getByText(/Model switched to gpt-5.5/).first()).toBeVisible();
// …and the next message carries the new model (the fixture echoes it back).
await box.fill("after the switch");
await box.press("Enter");
await expect(
page.getByText("Echo: after the switch [model=gpt-5.5]", { exact: false }).first(),
).toBeVisible();
});
+3 -2
View File
@@ -44,7 +44,8 @@ test("facts subtitle: absent on a fresh session, persona · model after the firs
await expect(page.getByRole("button", { name: "About this persona" })).toHaveCount(0);
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
// First turn → the model chip leaves the composer; the facts move up to the subtitle.
// First turn → the facts move up to the subtitle; the picker STAYS in the composer
// (§17 rev 2026-07-22: mid-session model switching shipped, so it remains actionable).
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await page.getByRole("button", { name: "Send" }).click();
@@ -52,7 +53,7 @@ test("facts subtitle: absent on a fresh session, persona · model after the firs
const sub = page.getByTestId("session-subtitle");
await expect(sub).toContainText("Coworker · Claude Opus 4.8");
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toHaveCount(0);
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
// The subtitle is the session's fixed facts — clicking it opens the coworker (persona) page,
// replacing the old topbar sliders button.