From 4070da2cc0d7926c7f189a4dda6c8e9572f5d5c0 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 15 Sep 2026 00:09:18 +0000 Subject: [PATCH] fix(harness): preserve Terminal start through status updates --- .changeset/terminal-start-acknowledgment.md | 5 + packages/harness/docs/assistant-history.md | 5 + .../web/e2e/assistant-continuation.spec.ts | 122 ++++++++++++++ .../harness/web/e2e/opencode-chat.spec.ts | 31 +++- packages/harness/web/src/App.tsx | 152 ++++++++---------- .../src/components/DormantTerminalPane.tsx | 12 +- 6 files changed, 232 insertions(+), 95 deletions(-) create mode 100644 .changeset/terminal-start-acknowledgment.md diff --git a/.changeset/terminal-start-acknowledgment.md b/.changeset/terminal-start-acknowledgment.md new file mode 100644 index 000000000..446822f65 --- /dev/null +++ b/.changeset/terminal-start-acknowledgment.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Keep explicit Terminal start mounted through session status updates so its acknowledgement preserves Terminal focus. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 0f2977fa6..dd202933b 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -213,3 +213,8 @@ Transcript-only Terminal records remain reachable, and explicit Terminal focus outranks an earlier Assistant reveal. Assistant history tracks failures and newer results per cwd; another directory's refresh cannot erase or roll them back. A dormant Terminal does not label the Studio ended before lifecycle state arrives. + +Starting Terminal keeps the same conversation and pending request through host +status updates, including readiness before the HTTP acknowledgement. Terminal +focus stays selected, and settling the request shows the latest live or exited +state. Navigation, account changes and End still invalidate stale operations. diff --git a/packages/harness/web/e2e/assistant-continuation.spec.ts b/packages/harness/web/e2e/assistant-continuation.spec.ts index 948834948..a66aa853c 100644 --- a/packages/harness/web/e2e/assistant-continuation.spec.ts +++ b/packages/harness/web/e2e/assistant-continuation.spec.ts @@ -817,6 +817,128 @@ test("Start Terminal commits after an earlier preparation frame", async ({ await expect(page.locator(".harness-terminal")).toBeVisible(); }); +async function startingBeforeAcknowledgment(page: Page) { + const probe = await dormant(page); + const nativeCalls = () => + probe.calls.filter((url) => url.includes("/opencode/")); + const before = nativeCalls(); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + // spawn() clears not-started before emitting starting. Let React commit + // that frame while the HTTP response remains held; an immediate fulfill + // after publish can miss the real component-unmount race. + await publish(page, { + type: "session.status", + session: { ...probe.started(), status: "starting", ready: false }, + }); + await expect( + page.getByTestId("session-context").locator('[data-status="starting"]'), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Starting Terminal…" }), + ).toBeDisabled(); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + expect(nativeCalls()).toEqual(before); + return { ...probe, nativeCalls, before }; +} + +test("starting and ready bus frames retain Terminal focus and the pending Start acknowledgment", async ({ + page, +}, info) => { + const probe = await startingBeforeAcknowledgment(page); + await page.screenshot({ + path: info.outputPath("terminal-starting-before-ack.png"), + }); + await publish(page, { + type: "session.status", + session: { ...probe.started(), title: "Ready before acknowledgment" }, + }); + await expect(page.getByTestId("session-context")).toContainText( + "Ready before acknowledgment", + ); + await expect( + page.getByRole("button", { name: "Starting Terminal…" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Terminal", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + const acknowledged = page.waitForResponse((response) => + response.url().endsWith("/terminal/start"), + ); + await probe.starts[0]!.fulfill({ + json: { ...probe.started(), ready: false }, + }); + expect(await (await acknowledged).finished()).toBeNull(); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.getByTestId("session-context")).toContainText( + "Ready before acknowledgment", + ); + expect(probe.starts).toHaveLength(1); + expect(probe.nativeCalls()).toEqual(probe.before); + await page.screenshot({ + path: info.outputPath("terminal-ready-after-ack.png"), + }); +}); + +test("a failed Start acknowledgment after spawn retains the actual Terminal exit", async ({ + page, +}) => { + const probe = await startingBeforeAcknowledgment(page); + await publish(page, { + type: "session.status", + session: { + ...probe.started(), + status: "exited", + ready: false, + exitCode: 2, + exitTail: "Agent exited during startup", + }, + }); + await expect( + page.getByRole("button", { name: "Starting Terminal…" }), + ).toBeDisabled(); + await probe.starts[0]!.fulfill({ status: 503, json: {} }); + await expect(page.getByTestId("dead-session-pane")).toContainText( + "exit code 2", + ); + await expect(page.getByTestId("dormant-terminal-pane")).toHaveCount(0); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + expect(probe.nativeCalls()).toEqual(probe.before); +}); + +test("End after the starting bus frame still outranks the late Start acknowledgment", async ({ + page, +}) => { + const probe = await startingBeforeAcknowledgment(page); + await page.evaluate(async () => { + const url = performance + .getEntriesByType("resource") + .find( + (entry) => new URL(entry.name).pathname === "/src/lib/api.ts", + )!.name; + const { MockApi } = await import(url); + MockApi.prototype.killSession = async () => {}; + }); + await page.getByTestId("session-menu").click(); + await page.getByTestId("session-end-btn").click(); + await page.getByTestId("end-session-confirm-btn").click(); + await expect(page.getByTestId("session-context")).not.toHaveAttribute( + "data-session-id", + childId, + ); + await probe.starts[0]!.fulfill({ json: probe.started() }); + await expect(page.getByTestId("session-context")).not.toHaveAttribute( + "data-session-id", + childId, + ); + expect(probe.nativeCalls()).toEqual(probe.before); +}); + test("a Terminal that exits during startup shows its actual exit instead of a dormant session", async ({ page, }, info) => { diff --git a/packages/harness/web/e2e/opencode-chat.spec.ts b/packages/harness/web/e2e/opencode-chat.spec.ts index c379568bc..42c236a77 100644 --- a/packages/harness/web/e2e/opencode-chat.spec.ts +++ b/packages/harness/web/e2e/opencode-chat.spec.ts @@ -137,14 +137,17 @@ test.beforeEach(async ({ page }) => { }; if (path === "lifecycle") { res.json(lifecycle); - if (endBeforeAttach) { - lifecycleState = "ended"; - lifecycleRevision++; - } return; } if (path === "attach") { attachRevisions.push(req.body.expectedRevision); + // StrictMode can abort a lifecycle GET after the fixture receives it. + // End must race the surviving Attach, not that discarded read. + if (endBeforeAttach) { + endBeforeAttach = false; + lifecycleState = "ended"; + lifecycleRevision++; + } if ( req.body.expectedRevision !== lifecycleRevision || lifecycleState !== "open" @@ -933,7 +936,7 @@ test("keeps Working after streamed text until the engine finishes", async ({ await expect(status).toHaveText("Finished"); }); -test("opens saved Assistant history when its Terminal session has exited", async ({ +test("preserves Assistant focus and saved history when its Terminal session exits", async ({ page, }) => { await openAssistant(page); @@ -966,6 +969,13 @@ test("opens saved Assistant history when its Terminal session has exited", async }, }), ); + await expect( + page.getByRole("button", { name: "Assistant", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("status", { name: "Assistant status" }), + ).toHaveText("Finished"); + await page.getByRole("button", { name: "Terminal", exact: true }).click(); await expect(page.getByTestId("dead-session-pane")).toBeVisible(); await page.getByRole("button", { name: "Assistant", exact: true }).click(); await expect( @@ -1290,7 +1300,7 @@ test("streams, disposes the old tab's connection, restores history, and sends a await expect(page.locator(".harness-terminal")).toBeVisible(); }); -test("keeps principal-scoped session drafts across centre-pane routes and exited-session remounts", async ({ +test("keeps principal-scoped session drafts across centre-pane routes and Terminal exits", async ({ page, }) => { await openAssistant(page); @@ -1325,8 +1335,8 @@ test("keeps principal-scoped session drafts across centre-pane routes and exited await tabs.nth(0).click(); await expect(input).toHaveValue("First session draft"); - // Natural Terminal exit swaps the live workbench for the dead-session pane. - // The remounted Assistant still owns the same session-keyed draft. + // Natural Terminal exit preserves the selected Assistant and its draft. + // Explicitly opening Terminal still exposes its saved exit state. await page.evaluate(() => (window as any).__HARNESS_TEST__.publish({ type: "session.status", @@ -1345,6 +1355,11 @@ test("keeps principal-scoped session drafts across centre-pane routes and exited }, }), ); + await expect( + page.getByRole("button", { name: "Assistant", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect(input).toHaveValue("First session draft"); + await page.getByRole("button", { name: "Terminal", exact: true }).click(); await expect(page.getByTestId("dead-session-pane")).toBeVisible(); await page.getByRole("button", { name: "Assistant", exact: true }).click(); await expect(input).toHaveValue("First session draft"); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index e745b993f..477b743ba 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -3451,60 +3451,73 @@ export const App = (): JSX.Element => { }} onClose={() => setReviewSummary(null)} /> - ) : showDead && conversationSession ? ( - setSettingsOpen(true)} - terminalRevision={ - harness.terminalRevealBySession.get( - conversationSession.id, - ) ?? 0 - } - assistantRevision={harness.assistantRevealBySession.get(conversationSession.id) ?? 0} - onReviewHistory={() => void reviewLiveAssistant(conversationSession.id)} - > - {conversationSession.terminalState === "not-started" ? row.harnessSessionId === conversationSession.id && row.lifecycle === "ending")} - onStart={async (signal) => { - const navigation = studioRestoreGenerationRef.current; - return (await harness.startTerminal(conversationSession.id, signal, () => navigation === studioRestoreGenerationRef.current)) !== null; - }} - /> : - void harness.resumeSession(conversationSession.id) - } - onContinue={() => - void harness.rehydrateSession({ - cwd: conversationSession.cwd, - harness: conversationSession.harness, - from: conversationSession.id, - }) - } - onClose={() => { - if (assistantNeedsEnd(conversationSession.id)) - setConfirmEndSessionId(conversationSession.id); - else - void harness - .closeSession(conversationSession.id) - .catch(() => undefined); - }} - />} - + ) : (showDead || showWorkbench) && conversationSession ? ( + /* Status updates must not replace the conversation or the + owner awaiting an explicit Terminal start acknowledgment. */ +
+
+ setSettingsOpen(true)} + terminalRevision={ + harness.terminalRevealBySession.get( + conversationSession.id, + ) ?? 0 + } + assistantRevision={harness.assistantRevealBySession.get(conversationSession.id) ?? 0} + onReviewHistory={() => void reviewLiveAssistant(conversationSession.id)} + > + row.harnessSessionId === conversationSession.id && row.lifecycle === "ending")} + onStart={async (signal) => { + const navigation = studioRestoreGenerationRef.current; + return (await harness.startTerminal(conversationSession.id, signal, () => navigation === studioRestoreGenerationRef.current)) !== null; + }} + > + {conversationSession.status === "exited" ? + void harness.resumeSession(conversationSession.id) + } + onContinue={() => + void harness.rehydrateSession({ + cwd: conversationSession.cwd, + harness: conversationSession.harness, + from: conversationSession.id, + }) + } + onClose={() => { + if (assistantNeedsEnd(conversationSession.id)) + setConfirmEndSessionId(conversationSession.id); + else + void harness + .closeSession(conversationSession.id) + .catch(() => undefined); + }} + /> : } + + +
+
) : showAgentEmpty && focusedWorkflow ? ( /* Honest absence: no session that can WORK on this agent — its board still draws on the right, from the workflow-keyed route @@ -3565,37 +3578,6 @@ export const App = (): JSX.Element => { title={`Starting a session in ${startingProject.label}…`} body="Sessions boot at the project root, so the coding agent comes up with this project's instructions and skills." /> - ) : showWorkbench && conversationSession ? ( -
-
- setSettingsOpen(true)} - terminalRevision={ - harness.terminalRevealBySession.get( - conversationSession.id, - ) ?? 0 - } - assistantRevision={harness.assistantRevealBySession.get(conversationSession.id) ?? 0} - onReviewHistory={() => void reviewLiveAssistant(conversationSession.id)} - > - - -
-
) : ( /* The composer-first home: no terminal, no canvas yet. Describe an outcome (or pick a template) and a session starts; this diff --git a/packages/harness/web/src/components/DormantTerminalPane.tsx b/packages/harness/web/src/components/DormantTerminalPane.tsx index 29aea624e..d3e3dfb60 100644 --- a/packages/harness/web/src/components/DormantTerminalPane.tsx +++ b/packages/harness/web/src/components/DormantTerminalPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import type { HarnessSession } from "@shared/types"; import { Icon } from "./Icon"; @@ -7,11 +7,13 @@ export function DormantTerminalPane({ authRevision, ending, onStart, + children, }: { session: HarnessSession; authRevision: number; ending: boolean; onStart: (signal: AbortSignal) => Promise; + children: ReactNode; }) { const pending = useRef(null); const [busy, setBusy] = useState(false); @@ -49,13 +51,19 @@ export function DormantTerminalPane({ } } }; + // The host publishes starting/ready before its POST acknowledgment. Keep + // this operation's owner mounted until it settles, then show the latest + // Terminal state. Navigation, a different Studio, or auth still abort it. + if (session.terminalState !== "not-started" && !busy) return children; return (
-
Terminal has not started
+
+ {busy ? "Starting Terminal" : "Terminal has not started"} +
{session.cwd}

Start a terminal for your coding agent in this session's workspace.