mirror of
https://github.com/andrewyng/openworker.git
synced 2026-08-30 22:53:41 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user