Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/terminal-start-acknowledgment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Keep explicit Terminal start mounted through session status updates so its acknowledgement preserves Terminal focus.
5 changes: 5 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
122 changes: 122 additions & 0 deletions packages/harness/web/e2e/assistant-continuation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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) => {
Expand Down
31 changes: 23 additions & 8 deletions packages/harness/web/e2e/opencode-chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand All @@ -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");
Expand Down
152 changes: 67 additions & 85 deletions packages/harness/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3451,60 +3451,73 @@ export const App = (): JSX.Element => {
}}
onClose={() => setReviewSummary(null)}
/>
) : showDead && conversationSession ? (
<AssistantPane
sessionId={conversationSession.id}
selectedAgentPath={
atMapAltitude ? null : effectiveFocusedAgentPath
}
bootToken={harness.bootToken}
authRevision={harness.authRevision}
drafts={assistantDrafts}
authorityRevision={assistantAuthorityRevision}
onAuthorityRevision={setAssistantAuthorityRevision}
onSignIn={signInForAssistant}
onOpenSettings={() => 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" ? <DormantTerminalPane
session={conversationSession}
authRevision={harness.authRevision}
ending={harness.endingSessionIds.has(conversationSession.id) || assistantLifecycles.some((row) => 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;
}}
/> : <DeadSessionPane
session={conversationSession}
terminalOnly={assistantNeedsEnd(conversationSession.id)}
resumeMode={deadResumeMode}
loadRecord={harness.sessionRecord}
onResume={() =>
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);
}}
/>}
</AssistantPane>
) : (showDead || showWorkbench) && conversationSession ? (
/* Status updates must not replace the conversation or the
owner awaiting an explicit Terminal start acknowledgment. */
<div className="agent-view" data-testid={showWorkbench ? "agent-view" : undefined}>
<div className="agent-view-panel" id="agent-panel-terminal">
<AssistantPane
sessionId={conversationSession.id}
selectedAgentPath={
atMapAltitude ? null : effectiveFocusedAgentPath
}
bootToken={harness.bootToken}
authRevision={harness.authRevision}
drafts={assistantDrafts}
authorityRevision={assistantAuthorityRevision}
onAuthorityRevision={setAssistantAuthorityRevision}
onSignIn={signInForAssistant}
onOpenSettings={() => setSettingsOpen(true)}
terminalRevision={
harness.terminalRevealBySession.get(
conversationSession.id,
) ?? 0
}
assistantRevision={harness.assistantRevealBySession.get(conversationSession.id) ?? 0}
onReviewHistory={() => void reviewLiveAssistant(conversationSession.id)}
>
<DormantTerminalPane
key={conversationSession.id}
session={conversationSession}
authRevision={harness.authRevision}
ending={harness.endingSessionIds.has(conversationSession.id) || assistantLifecycles.some((row) => 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" ? <DeadSessionPane
session={conversationSession}
terminalOnly={assistantNeedsEnd(conversationSession.id)}
resumeMode={deadResumeMode}
loadRecord={harness.sessionRecord}
onResume={() =>
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);
}}
/> : <Terminal
sessionId={conversationSession.id}
token={harness.bootToken}
cwd={conversationSession.cwd}
/>}
</DormantTerminalPane>
</AssistantPane>
</div>
</div>
) : showAgentEmpty && focusedWorkflow ? (
/* Honest absence: no session that can WORK on this agent — its
board still draws on the right, from the workflow-keyed route
Expand Down Expand Up @@ -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 ? (
<div className="agent-view" data-testid="agent-view">
<div className="agent-view-panel" id="agent-panel-terminal">
<AssistantPane
sessionId={conversationSession.id}
selectedAgentPath={
atMapAltitude ? null : effectiveFocusedAgentPath
}
bootToken={harness.bootToken}
authRevision={harness.authRevision}
drafts={assistantDrafts}
authorityRevision={assistantAuthorityRevision}
onAuthorityRevision={setAssistantAuthorityRevision}
onSignIn={signInForAssistant}
onOpenSettings={() => setSettingsOpen(true)}
terminalRevision={
harness.terminalRevealBySession.get(
conversationSession.id,
) ?? 0
}
assistantRevision={harness.assistantRevealBySession.get(conversationSession.id) ?? 0}
onReviewHistory={() => void reviewLiveAssistant(conversationSession.id)}
>
<Terminal
sessionId={conversationSession.id}
token={harness.bootToken}
cwd={conversationSession.cwd}
/>
</AssistantPane>
</div>
</div>
) : (
/* The composer-first home: no terminal, no canvas yet. Describe
an outcome (or pick a template) and a session starts; this
Expand Down
Loading
Loading