Restore live-turn state on session reconnect

ws ready now carries running (server truth); the GUI restores Stop + the waiting row and never shows the intro hero mid-turn.
This commit is contained in:
Rohit C Prasad
2026-08-24 12:23:29 -07:00
parent ed75ab4b57
commit 50c2b04678
6 changed files with 72 additions and 4 deletions
+4
View File
@@ -2438,6 +2438,10 @@ def create_app(manager: SessionManager) -> FastAPI:
"type": "ready",
"data": {
"session_id": session_id,
# A reconnect can land MID-TURN (sidebar revisit, app relaunch, WS
# drop). Without server truth the GUI never learns a turn is live —
# no Stop button, no waiting row (owner catch 2026-08-24).
"running": manager.is_running(session_id),
"agent": getattr(engine, "agent_name", "code"),
"model": engine.model,
"mode": engine.permissions.mode.value,
+21 -1
View File
@@ -126,6 +126,25 @@ const OPS_SESSION = {
subscriptions: [],
};
// A session whose turn is LIVE on the server — its ws `ready` carries running:true, the
// reconnect-mid-turn case (owner catch 2026-08-24): Stop + waiting row must show without
// a local turn_start. Older than the pinned session so boot-resume stays deterministic.
const LIVE_SESSION = {
session_id: "resume-live-1",
title: "Long audit",
workspace: "",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-06-20 10:00:00",
messages: 2,
pinned: false,
archived: false,
attention: 0,
liveness: "working",
subscriptions: [],
};
// §31: a mention-spawned session — lives in the sidebar's collapsed "From Slack" group, never
// in Recent. Older than everything else so boot-resume stays deterministic.
const SLACK_SESSION = {
@@ -555,6 +574,7 @@ export async function mockApi(page: import("@playwright/test").Page) {
{ ...PINNED_SESSION },
...EXTRA_SESSIONS.map((s) => ({ ...s })),
{ ...OPS_SESSION },
{ ...LIVE_SESSION },
{ ...SLACK_SESSION },
];
// Inbox items + the outbound routing binding — mutable for resolve + the inline Slack config.
@@ -654,7 +674,7 @@ export async function mockApi(page: import("@playwright/test").Page) {
// The page's session id, from the socket URL — team approval stamps THIS session
// as the lead (the active conversation IS the lead; workers hang off it).
const sid = ws.url().split("/ws/session/")[1]?.split("?")[0] || "sess-lead";
send("ready");
send("ready", sid === "resume-live-1" ? { running: true } : {});
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
+3 -2
View File
@@ -8,12 +8,13 @@ import { test, expect } from "./fixtures";
test("session list caps at the peek count with Show more", async ({ page }) => {
await page.goto("/");
// Boot resumes a cowork session, so the Coworker accordion body is expanded. The body holds
// 8 sessions (7 weekly plans + the Slack-origin one, §31 rev) against sessions_peek=5.
// 9 sessions (7 weekly plans + the Slack-origin one §31 rev + the live-turn one) against
// sessions_peek=5.
await expect(page.getByTitle("Weekly plan 1")).toBeVisible();
await expect(page.getByTitle("Weekly plan 5")).toBeVisible();
await expect(page.getByTitle("Weekly plan 6")).toHaveCount(0);
await page.getByRole("button", { name: "Show more (3)" }).click();
await page.getByRole("button", { name: "Show more (4)" }).click();
await expect(page.getByTitle("Weekly plan 6")).toBeVisible();
await expect(page.getByTitle("Weekly plan 7")).toBeVisible();
});
+21
View File
@@ -0,0 +1,21 @@
// Reconnect-mid-turn (owner catch 2026-08-24, v0.2.0 walkthrough): opening a session
// whose turn is already running server-side never sees a live `turn_start`, so `running`
// must be restored from the ws `ready` payload — otherwise the Stop button and the
// "Waiting for agent" row vanish and the user cannot stop the turn.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("opening a session with a live turn shows Stop and the waiting row", async ({ page }) => {
await page.goto("/");
// "Long audit" is below the sidebar's peek cap — expand the list first.
await page.getByRole("button", { name: /Show more/ }).first().click();
await page.getByTitle("Long audit").click();
// ready carried running:true — Stop replaces Send, the waiting row spins.
await expect(page.getByRole("button", { name: /Stop/ })).toBeVisible();
await expect(page.getByText("Waiting for agent...")).toBeVisible();
// An idle session still gets the plain send arrow (running:false path).
await page.getByText("Draft the launch note").first().click();
await expect(page.getByRole("button", { name: /Stop/ })).toHaveCount(0);
});
+6 -1
View File
@@ -700,6 +700,9 @@ export function App() {
if (d.workspace) setWorkspace((cur) => cur || d.workspace);
// UX-029: server truth on whether this session runs in a temporary folder.
if (typeof d.temp_workspace === "boolean") setTempWorkspace(d.temp_workspace);
// Server truth on a live turn: a reconnect mid-turn never sees turn_start, so
// without this the Stop button and waiting row vanish (owner catch 2026-08-24).
if (typeof d.running === "boolean") setRunning(d.running);
break;
case "turn_start":
setRunning(true);
@@ -1524,7 +1527,9 @@ export function App() {
openRunSession(r.session_id, r.workspace, r.agent, { id: taskId, title: title || "" });
};
const idle = items.length === 0 && !streaming;
// `running` too: a mid-turn reconnect may land before any item is rebuilt — a live
// session must show the transcript (waiting row, Stop), never the intro hero.
const idle = items.length === 0 && !streaming && !running;
const pendingApproval = [...items].reverse().find((i) => i.kind === "approval" && !i.resolved);
const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved);
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
+17
View File
@@ -1103,3 +1103,20 @@ def test_mcp_connect_route_flags_authorizing_immediately(tmp_path, monkeypatch):
monkeypatch.setattr("coworker.server.manager.load_mcp_servers", lambda *a, **k: [])
res = asyncio.run(mgr.connect_mcp("sales-db"))
assert not res["ok"] and "sales-db" not in mgr._mcp_authorizing
def test_ws_ready_reports_live_turn(tmp_path):
# A reconnect can land mid-turn (sidebar revisit, relaunch, dropped socket). `ready`
# must carry server truth on the running turn or the GUI loses Stop + the waiting row
# (owner catch 2026-08-24, v0.2.0 walkthrough).
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([_text("hi")]))
client = TestClient(create_app(manager))
with client.websocket_connect("/ws/session/live1") as ws:
assert ws.receive_json()["data"]["running"] is False
manager.mark_running("live1")
try:
with client.websocket_connect("/ws/session/live1") as ws:
assert ws.receive_json()["data"]["running"] is True
finally:
manager.mark_idle("live1")