mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
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:
@@ -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("");
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -638,6 +638,12 @@ export function App() {
|
||||
if (d.status === "max_iterations_exceeded")
|
||||
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Stopped: max iterations reached." }]);
|
||||
break;
|
||||
case "model_changed":
|
||||
// Mid-session switch (server-applied): update the header fact and drop the
|
||||
// persisted marker into the live transcript (replay renders it from history).
|
||||
if (d.model) setModel(d.model);
|
||||
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]);
|
||||
break;
|
||||
case "interrupted":
|
||||
flushPartialStream();
|
||||
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
|
||||
@@ -812,6 +818,7 @@ export function App() {
|
||||
sessionRef.current?.setMode(m);
|
||||
};
|
||||
const changeModel = (m: string) => {
|
||||
if (running) return; // the server refuses mid-turn rebinds — don't let the header lie
|
||||
setModel(m);
|
||||
sessionRef.current?.setModel(m);
|
||||
};
|
||||
@@ -1451,7 +1458,6 @@ export function App() {
|
||||
model={model}
|
||||
models={models}
|
||||
modelLabels={modelLabels}
|
||||
modelLocked={items.length > 0}
|
||||
running={running}
|
||||
connected={connected}
|
||||
modelReady={modelReady}
|
||||
|
||||
@@ -49,7 +49,6 @@ interface Props {
|
||||
// The model is FIXED once the session has history (§17): the picker renders ONLY on a fresh
|
||||
// session; after the first turn the fact lives in the topbar subtitle (§22) — no
|
||||
// interactive-then-disabled control.
|
||||
modelLocked?: boolean;
|
||||
running: boolean;
|
||||
connected: boolean;
|
||||
// False when the default model's provider has no key — the composer shows a "connect a model"
|
||||
@@ -461,8 +460,9 @@ export function Composer(props: Props) {
|
||||
|
||||
<span className="ml-auto" />
|
||||
|
||||
{/* model — a quiet chip on a FRESH session only; once the session has history the
|
||||
fact moves up to the topbar subtitle (§17 expressed spatially). */}
|
||||
{/* model — a quiet chip, now for the session's whole life (§17 rev 2026-07-22:
|
||||
mid-session switching shipped, so the picker stays actionable; the topbar
|
||||
subtitle still states the current model). */}
|
||||
{!dictation?.recording && (needsModel ? (
|
||||
<button
|
||||
className="pill model-warn chip"
|
||||
@@ -473,20 +473,17 @@ export function Composer(props: Props) {
|
||||
<span className="pill-label">No model</span>
|
||||
<span className="model-warn-ico" aria-hidden>⚠</span>
|
||||
</button>
|
||||
) : modelsLoaded ? (
|
||||
<Dropdown value={props.model} options={modelOptions} onChange={props.onModelChange} align="right" />
|
||||
) : (
|
||||
!props.modelLocked &&
|
||||
(modelsLoaded ? (
|
||||
<Dropdown value={props.model} options={modelOptions} onChange={props.onModelChange} align="right" />
|
||||
) : (
|
||||
<button
|
||||
className="pill chip text-faint cursor-default"
|
||||
disabled
|
||||
data-testid="models-loading"
|
||||
title="Fetching the model list from the server"
|
||||
>
|
||||
<span className="pill-label">Loading models…</span>
|
||||
</button>
|
||||
))
|
||||
<button
|
||||
className="pill chip text-faint cursor-default"
|
||||
disabled
|
||||
data-testid="models-loading"
|
||||
title="Fetching the model list from the server"
|
||||
>
|
||||
<span className="pill-label">Loading models…</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* mic — immediately before send (owner call, DMG #28 walkthrough) */}
|
||||
|
||||
@@ -68,3 +68,17 @@ describe("itemsFromMessages notices", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemsFromMessages model switch", () => {
|
||||
it("replays the persisted model_switch marker as an info notice", () => {
|
||||
const items = itemsFromMessages([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "notice", kind: "model_switch", text: "Model switched to Kimi K2.6 · Moonshot" },
|
||||
] as any);
|
||||
expect(items[1]).toEqual({
|
||||
kind: "notice",
|
||||
tone: "info",
|
||||
text: "Model switched to Kimi K2.6 · Moonshot",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,13 +59,15 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
|
||||
});
|
||||
}
|
||||
} else if (m.role === "notice") {
|
||||
// Persisted turn-ending marker (engine `_append_notice`): error/interrupted survive
|
||||
// Persisted markers (engine `_append_notice`): error/interrupted/model-switch survive
|
||||
// reload exactly like the live view rendered them. An error notice is retriable —
|
||||
// the Transcript only offers the button when it's the transcript tail.
|
||||
items.push(
|
||||
m.kind === "interrupted"
|
||||
? { kind: "notice", tone: "warn", text: "Interrupted." }
|
||||
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
||||
: m.kind === "model_switch"
|
||||
? { kind: "notice", tone: "info", text: m.text || "Model switched" }
|
||||
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
||||
);
|
||||
}
|
||||
// system messages are omitted; tool-result messages are folded into the tool row above
|
||||
|
||||
@@ -15,6 +15,7 @@ export type EventType =
|
||||
| "turn_end"
|
||||
| "error"
|
||||
| "interrupted"
|
||||
| "model_changed"
|
||||
| "turn_done";
|
||||
|
||||
export interface WsEvent {
|
||||
|
||||
Reference in New Issue
Block a user