diff --git a/.changeset/assistant-terminal-start-controls.md b/.changeset/assistant-terminal-start-controls.md new file mode 100644 index 000000000..f5ff72577 --- /dev/null +++ b/.changeset/assistant-terminal-start-controls.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Offer an explicit Start Terminal action for continued Assistant sessions without starting work automatically. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 9343c95bb..f5cdf85ee 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -167,3 +167,8 @@ bound to the exact runtime that the operation started. Temporary-file cleanup cannot replace the primary storage failure. An unconfirmed publication remains distinguishable for Resume reconciliation even when rollback and cleanup also fail; cleanup failures after successful publication are surfaced. + +A continued session shows a distinct Terminal-not-started pane. Start Terminal +launches its coding agent in that same Studio workspace only after an explicit +click. Failed starts retain the session for retry, immediate exits show the real +exit state, and stale responses cannot override End or a newer selected session. diff --git a/packages/harness/web/e2e/assistant-continuation.spec.ts b/packages/harness/web/e2e/assistant-continuation.spec.ts index 533ad4c15..4acd3e299 100644 --- a/packages/harness/web/e2e/assistant-continuation.spec.ts +++ b/packages/harness/web/e2e/assistant-continuation.spec.ts @@ -142,6 +142,7 @@ async function setup(page: Page) { missingNative: false, prompts: [] as Route[], messages: [] as any[], + starts: [] as Route[], }; const response = (request: Record) => { const continuation = { @@ -201,6 +202,9 @@ async function setup(page: Page) { : { json: response(route.request().postDataJSON()) }, ); }); + await page.route("**/api/sessions/*/terminal/start", (route) => { + probe.starts.push(route); + }); await page.route("**/api/assistant/access", (route) => route.fulfill({ json: { enabled: true, authorityRevision: "account-a" } }), ); @@ -350,7 +354,15 @@ async function setup(page: Page) { await page.goto("/?seed=0"); await expect(page.locator(".harness-terminal")).toBeVisible(); await project(page); - return Object.assign(probe, { response }); + return Object.assign(probe, { + response, + started: () => ({ + ...response({}).session, + terminalState: undefined, + status: "running", + ready: true, + }), + }); } test("Continue opens a distinct paused child from the displayed record and preserves source history", async ({ @@ -619,3 +631,283 @@ test("missing native history offers the saved record without automatic Continue" await expect.poll(() => probe.requests.length).toBe(1); expect(probe.requests[0]!.request().postDataJSON().expectedRevision).toBe(3); }); + +async function dormant(page: Page) { + const probe = await setup(page); + await review(page); + await button(page).click(); + await expect(page.getByText("Paused", { exact: true })).toBeVisible(); + await project(page, 2, "account-a", [lifecycle(childId)]); + await page.getByRole("button", { name: "Terminal", exact: true }).click(); + await expect(page.getByTestId("dormant-terminal-pane")).toBeVisible(); + return probe; +} +const startTerminal = (page: Page) => + page.getByRole("button", { name: "Start Terminal", exact: true }); + +test("dormant Terminal starts only on an explicit click and keeps the same paused Assistant", async ({ + page, +}, info) => { + const probe = await dormant(page); + await expect(page.getByTestId("dormant-terminal-pane")).toContainText( + "Terminal has not started", + ); + await expect(page.getByTestId("dead-session-pane")).toHaveCount(0); + expect(probe.starts).toHaveLength(0); + await page.screenshot({ path: info.outputPath("dormant-terminal.png") }); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + await expect( + page.getByRole("button", { name: "Starting Terminal…" }), + ).toBeDisabled(); + expect(probe.starts[0]!.request().postDataJSON()).toEqual({}); + expect(probe.starts[0]!.request().headers()).toHaveProperty( + "x-harness-token", + ); + await probe.starts[0]!.fulfill({ json: probe.started() }); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + childId, + ); + await page.screenshot({ path: info.outputPath("terminal-started.png") }); + await page.getByRole("button", { name: "Assistant", exact: true }).click(); + await expect(page.getByText("Paused", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("assistant-continuation-context"), + ).toBeVisible(); + expect( + probe.calls.filter((url) => /prompt_async|final-response/.test(url)), + ).toEqual([]); +}); + +test("failed Start Terminal retains the dormant session and can retry", async ({ + page, +}, info) => { + const probe = await dormant(page); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + await probe.starts[0]!.fulfill({ + status: 409, + json: { code: "SESSION_PREPARATION_CANCELLED" }, + }); + await expect(page.getByTestId("dormant-terminal-pane")).toContainText( + "Terminal could not be started", + ); + await expect(startTerminal(page)).toBeEnabled(); + await page.screenshot({ + path: info.outputPath("dormant-terminal-retry.png"), + }); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(2); + await probe.starts[1]!.fulfill({ json: probe.started() }); + await expect(page.locator(".harness-terminal")).toBeVisible(); +}); + +for (const field of ["id", "project", "user", "harness", "not-started"]) { + test( + "Start Terminal rejects a foreign or unstarted " + field + " response", + async ({ page }) => { + const probe = await dormant(page); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + const response: any = probe.started(); + if (field === "id") response.id = "another-studio"; + if (field === "project") + response.agentMapIdentity.projectId = "another-project"; + if (field === "user") response.agentMapIdentity.userId = "another-user"; + if (field === "harness") response.harness = "codex"; + if (field === "not-started") response.terminalState = "not-started"; + await probe.starts[0]!.fulfill({ json: response }); + await expect(page.getByTestId("dormant-terminal-pane")).toContainText( + "could not be verified", + ); + await expect(page.locator(".harness-terminal")).toHaveCount(0); + }, + ); +} + +for (const barrier of ["navigation", "account", "End", "bus-exit"]) { + test("late Start Terminal cannot cross " + barrier, async ({ page }) => { + const probe = await dormant(page); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + if (barrier === "navigation") + await selectMockSessionFromPalette(page, "acme-app"); + if (barrier === "account") + await publish(page, { + type: "auth.changed", + authenticated: true, + organizationName: "New account", + }); + if (barrier === "End") { + await page.evaluate(async () => { + const url = performance + .getEntriesByType("resource") + .find( + (entry) => new URL(entry.name).pathname === "/src/lib/api.ts", + )!.name; + const { MockApi, ApiError } = await import(url); + MockApi.prototype.killSession = async () => { + throw new ApiError(409, "controlled End failure"); + }; + }); + await page.getByTestId("session-menu").click(); + await page.getByTestId("session-end-btn").click(); + await page.getByTestId("end-session-confirm-btn").click(); + await expect( + page.getByText(/Session cleanup is incomplete/), + ).toBeVisible(); + } + if (barrier === "bus-exit") + await publish(page, { + type: "session.status", + session: { ...probe.started(), status: "exited", ready: false }, + }); + await probe.starts[0]!.fulfill({ json: probe.started() }); + if (barrier === "navigation") + await expect(page.getByTestId("session-context")).not.toHaveAttribute( + "data-session-id", + childId, + ); + else if (barrier === "End") + await expect(page.getByTestId("dormant-terminal-pane")).toContainText( + "session changed while Terminal was starting", + ); + else if (barrier === "account") + await expect(startTerminal(page)).toBeEnabled(); + else await expect(page.getByTestId("dead-session-pane")).toBeVisible(); + if (barrier !== "navigation") + await expect(page.locator(".harness-terminal")).toHaveCount(0); + }); +} + +test("Start Terminal preserves newer bus state before its response", async ({ + page, +}) => { + const probe = await dormant(page); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + await publish(page, { + type: "session.status", + session: { ...probe.started(), title: "Newer Terminal state" }, + }); + await probe.starts[0]!.fulfill({ + json: { ...probe.started(), ready: false }, + }); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await expect(page.getByTestId("session-context")).toContainText( + "Newer Terminal state", + ); +}); + +test("Start Terminal commits after an earlier preparation frame", async ({ + page, +}) => { + const probe = await dormant(page); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + await publish(page, { + type: "session.status", + session: { ...probe.response({}).session, status: "starting" }, + }); + await expect(page.getByTestId("dormant-terminal-pane")).toBeVisible(); + await probe.starts[0]!.fulfill({ json: probe.started() }); + await expect(page.locator(".harness-terminal")).toBeVisible(); +}); + +test("a Terminal that exits during startup shows its actual exit instead of a dormant session", async ({ + page, +}, info) => { + const probe = await dormant(page); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + await probe.starts[0]!.fulfill({ + json: { + ...probe.started(), + status: "exited", + ready: false, + exitCode: 2, + exitTail: "Agent exited during startup", + }, + }); + await expect(page.getByTestId("dead-session-pane")).toContainText( + "Terminal exited", + ); + await expect(page.getByTestId("dead-session-pane")).toContainText( + "exit code 2", + ); + await expect(page.getByTestId("dormant-terminal-pane")).toHaveCount(0); + await page.screenshot({ path: info.outputPath("terminal-startup-exit.png") }); +}); + +test("Start Terminal remains available when Assistant access is disabled", async ({ + page, +}) => { + const probe = await dormant(page); + await page.route("**/api/assistant/access", (route) => + route.fulfill({ json: { enabled: false, authorityRevision: "account-a" } }), + ); + await publish(page, { + type: "auth.changed", + authenticated: true, + organizationName: "Same account", + }); + await expect( + page.getByRole("button", { name: "Assistant", exact: true }), + ).toHaveCount(0); + await startTerminal(page).click(); + await expect.poll(() => probe.starts.length).toBe(1); + await probe.starts[0]!.fulfill({ json: probe.started() }); + await expect(page.locator(".harness-terminal")).toBeVisible(); +}); + +for (const field of ["project", "user", "harness", "source-rebound"]) { + test( + "Continue fences the registry source's " + field + " in its child response", + async ({ page }) => { + const probe = await setup(page); + probe.hold = true; + const source = { + ...probe.response({}).session, + id: sourceId, + agentMapIdentity: { + projectId: "project-acme", + sessionId: sourceId, + userId: "user-a", + }, + }; + await publish(page, { type: "session.status", session: source }); + await review(page); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(1); + const response = probe.response( + probe.requests[0]!.request().postDataJSON(), + ); + if (field === "project") + response.session.agentMapIdentity.projectId = "foreign-project"; + if (field === "user") + response.session.agentMapIdentity.userId = "foreign-user"; + if (field === "harness") response.session.harness = "codex"; + if (field === "source-rebound") + await publish(page, { + type: "session.status", + session: { + ...source, + agentMapIdentity: { + ...source.agentMapIdentity, + projectId: "new-source-project", + }, + }, + }); + await probe.requests[0]!.fulfill({ json: response }); + await expect(page.getByRole("alert")).toContainText( + "selection or session changed", + ); + await expect(page.getByTestId("assistant-transcript")).toContainText( + "Original saved task", + ); + expect(probe.calls).toEqual([]); + }, + ); +} diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 2576628e3..848934d9b 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -90,6 +90,7 @@ import { AssistantHistoryPane } from "./components/AssistantHistoryPane"; import type { AssistantHistoryEntry } from "../../src/shared/assistant-history"; import { AssistantHistoryActions } from "./components/AssistantHistoryActions"; import { AssistantContinueAction } from "./components/AssistantContinueAction"; +import { DormantTerminalPane } from "./components/DormantTerminalPane"; import type { ChatDraftStore } from "./components/OpenCodeChat"; import { Toast } from "./components/Toast"; import { TooltipLayer } from "./components/TooltipLayer"; @@ -1620,7 +1621,7 @@ export const App = (): JSX.Element => { ? null : activeSession; const showReview = reviewSummary != null || activeAssistantReview != null; - const showDead = !showReview && conversationSession?.status === "exited"; + const showDead = !showReview && (conversationSession?.status === "exited" || conversationSession?.terminalState === "not-started"); // An agent selected with no session that can WORK on it: honest absence, and // opening one lands on the "start a session" state. // @@ -3429,8 +3430,8 @@ export const App = (): JSX.Element => { }} />} terminalNotStarted={state.sessions.find((session) => session.id === activeAssistantReview.entry.harnessSessionId)?.terminalState === "not-started"} - terminalLabel={state.sessions.find((session) => session.id === activeAssistantReview.entry.harnessSessionId)?.status !== "exited" ? "Open Terminal" : "View Terminal history"} - onOpenTerminal={state.sessions.some((session) => session.id === activeAssistantReview.entry.harnessSessionId && session.terminalState !== "not-started") + terminalLabel={state.sessions.find((session) => session.id === activeAssistantReview.entry.harnessSessionId)?.terminalState === "not-started" || state.sessions.find((session) => session.id === activeAssistantReview.entry.harnessSessionId)?.status !== "exited" ? "Open Terminal" : "View Terminal history"} + onOpenTerminal={state.sessions.some((session) => session.id === activeAssistantReview.entry.harnessSessionId) ? () => openSession(activeAssistantReview.entry.harnessSessionId) : undefined} /> @@ -3466,7 +3467,15 @@ export const App = (): JSX.Element => { 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; + }} + /> : { .closeSession(conversationSession.id) .catch(() => undefined); }} - /> + />} ) : showAgentEmpty && focusedWorkflow ? ( /* Honest absence: no session that can WORK on this agent — its diff --git a/packages/harness/web/src/components/DormantTerminalPane.tsx b/packages/harness/web/src/components/DormantTerminalPane.tsx new file mode 100644 index 000000000..29aea624e --- /dev/null +++ b/packages/harness/web/src/components/DormantTerminalPane.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from "react"; +import type { HarnessSession } from "@shared/types"; +import { Icon } from "./Icon"; + +export function DormantTerminalPane({ + session, + authRevision, + ending, + onStart, +}: { + session: HarnessSession; + authRevision: number; + ending: boolean; + onStart: (signal: AbortSignal) => Promise; +}) { + const pending = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + setBusy(false); + setError(null); + return () => { + pending.current?.abort(); + pending.current = null; + }; + }, [session.id, authRevision]); + const start = async () => { + if (pending.current) return; + const abort = new AbortController(); + pending.current = abort; + setBusy(true); + setError(null); + try { + if (!(await onStart(abort.signal)) && !abort.signal.aborted) + setError( + "The session changed while Terminal was starting. Check its current state before trying again.", + ); + } catch (failure) { + if (!abort.signal.aborted) + setError( + failure instanceof Error + ? failure.message + : "Terminal could not be started. Try again.", + ); + } finally { + if (pending.current === abort) { + pending.current = null; + setBusy(false); + } + } + }; + return ( +
+
+ +
Terminal has not started
+
{session.cwd}
+

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

+
+ +
+ {ending && ( +

+ Finish ending this session before starting Terminal. +

+ )} + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/packages/harness/web/src/lib/terminal-start-client.ts b/packages/harness/web/src/lib/terminal-start-client.ts new file mode 100644 index 000000000..3c81db493 --- /dev/null +++ b/packages/harness/web/src/lib/terminal-start-client.ts @@ -0,0 +1,58 @@ +import type { HarnessSession } from "@shared/types"; +import { assistantSessionSchema } from "./assistant-resume-client"; + +/** Workspace and principal are immutable across activation of an existing Studio. */ +export function sameStudioAuthority( + source: HarnessSession, + target: HarnessSession, +): boolean { + return ( + source.cwd === target.cwd && + source.harness === target.harness && + (!source.agentMapIdentity || + (source.agentMapIdentity.sessionId === source.id && + target.agentMapIdentity?.sessionId === target.id && + target.agentMapIdentity.projectId === + source.agentMapIdentity.projectId && + target.agentMapIdentity.userId === source.agentMapIdentity.userId)) + ); +} + +export async function startTerminalRequest( + source: HarnessSession, + bootToken: string, + signal: AbortSignal, +): Promise { + const response = await fetch( + "/api/sessions/" + encodeURIComponent(source.id) + "/terminal/start", + { + method: "POST", + headers: { + "X-Harness-Token": bootToken, + "Content-Type": "application/json", + }, + credentials: "omit", + cache: "no-store", + signal, + body: "{}", + }, + ); + if (!response.ok) + throw new Error( + "Terminal could not be started. This session is still available; try again.", + ); + const result = assistantSessionSchema.safeParse( + await response.json().catch(() => null), + ); + if ( + !result.success || + result.data.id !== source.id || + !sameStudioAuthority(source, result.data) || + result.data.agentMapIdentity?.sessionId !== source.id || + result.data.terminalState !== undefined + ) + throw new Error( + "Terminal start could not be verified. This session is still available.", + ); + return result.data; +} diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index dc93e5a70..e08ca3d06 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -3,6 +3,7 @@ import type { AssistantHistoryEntry } from "../../../src/shared/assistant-histor import { readAssistantHistory } from "./assistant-history-client"; import { resumeAssistantRequest } from "./assistant-resume-client"; import { continueAssistantRequest, type ContinueRequest } from "./assistant-continuation-client"; +import { sameStudioAuthority, startTerminalRequest } from "./terminal-start-client"; import { parseAgentMapInitializationStatus, type AgentMapInitializationStatus } from "@shared/agent-map-initialization"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { @@ -148,6 +149,7 @@ export interface HarnessStateHook { resumeAssistant: (entry: AssistantHistoryEntry, operationId: string, signal: AbortSignal, authority: string, isCurrent: () => boolean) => Promise; continueAssistant: (entry: AssistantHistoryEntry, request: ContinueRequest, signal: AbortSignal, authority: string, isCurrent: () => boolean) => Promise; assistantHistoryEntry: (id: string, signal: AbortSignal) => Promise; + startTerminal: (id: string, signal: AbortSignal, isCurrent: () => boolean) => Promise; assistantRevealBySession: Map; endingSessionIds: ReadonlySet; authRevision: number; @@ -594,6 +596,8 @@ export function useHarnessState(): HarnessStateHook { // the network. The bus owns the newer full-session snapshot, so an older // response must not roll its planning metadata back after that snapshot. const sessionStatusRevisions = useRef>(new Map()); + const sessionStatusSnapshots = useRef(new Map()); + const sessionOperationGenerations = useRef(new Map()); const [busySessionIds, setBusySessionIds] = useState>(new Set()); const [tasks, setTasks] = useState([]); const busyTimers = useRef>>( @@ -1276,6 +1280,7 @@ export function useHarnessState(): HarnessStateHook { // Studio for records no mounted transcript is watching. if (message.type !== "session.record.changed") setLastMessage(message); if (message.type === "session.status") { + sessionStatusSnapshots.current.set(message.session.id, message.session); sessionStatusRevisions.current.set( message.session.id, (sessionStatusRevisions.current.get(message.session.id) ?? 0) + 1, @@ -1833,12 +1838,15 @@ export function useHarnessState(): HarnessStateHook { }, [assistantAuthorityKey, assistantOrder]); const continueAssistant = useCallback(async (entry: AssistantHistoryEntry, request: ContinueRequest, signal: AbortSignal, authority: string, isCurrent: () => boolean): Promise => { + const source = sessionsRef.current.find((row) => row.id === entry.harnessSessionId); const selection = switchSeqRef.current; const foreground = conversationRevealSequence.current; const valid = () => !signal.aborted && authority === assistantAuthorityKey() && isCurrent() && selection === switchSeqRef.current && foreground === conversationRevealSequence.current; if (!valid()) return null; const result = await continueAssistantRequest(entry, request, getBootToken(), signal); if (!valid()) return null; + const currentSource = sessionsRef.current.find((row) => row.id === entry.harnessSessionId); + if (source && (!currentSource || !sameStudioAuthority(source, currentSource) || !sameStudioAuthority(source, result.session))) return null; const latest = assistantOrder.current().snapshot?.lifecycles?.find((row) => row.harnessSessionId === result.session.id); if (latest && (latest.revision > result.lifecycle.revision || (latest.revision === result.lifecycle.revision && latest.lifecycle !== "open"))) return null; const current = sessionsRef.current.find((row) => row.id === result.session.id); @@ -1859,9 +1867,42 @@ export function useHarnessState(): HarnessStateHook { return entries.find((entry) => entry.harnessSessionId === id) ?? null; }, [assistantAuthorityKey]); + const startTerminal = useCallback(async (id: string, signal: AbortSignal, isCurrent: () => boolean): Promise => { + const source = sessionsRef.current.find((row) => row.id === id); + if (!source || source.terminalState !== "not-started" || endingSessions.current.has(id)) return null; + const boundary = () => { + const snapshot = assistantOrder.current().snapshot; + return JSON.stringify([authoritySequence.current, snapshot?.hostInstanceId, snapshot?.authorityRevision]); + }; + const authority = boundary(), selection = switchSeqRef.current, foreground = conversationRevealSequence.current; + const operation = (sessionOperationGenerations.current.get(id) ?? 0) + 1; + sessionOperationGenerations.current.set(id, operation); + const before = sessionStatusRevisions.current.get(id) ?? 0; + const lifecycle = assistantOrder.current().snapshot?.lifecycles?.find((row) => row.harnessSessionId === id); + const valid = () => !signal.aborted && isCurrent() && authority === boundary() && selection === switchSeqRef.current && + foreground === conversationRevealSequence.current && sessionOperationGenerations.current.get(id) === operation && !endingSessions.current.has(id); + if (!valid()) return null; + const result = await startTerminalRequest(source, getBootToken(), signal); + if (!valid()) return null; + const latestLifecycle = assistantOrder.current().snapshot?.lifecycles?.find((row) => row.harnessSessionId === id); + if (latestLifecycle && latestLifecycle.revision > (lifecycle?.revision ?? -1) && latestLifecycle.lifecycle !== "open") return null; + const changed = (sessionStatusRevisions.current.get(id) ?? 0) !== before; + const bus = changed ? sessionStatusSnapshots.current.get(id) : undefined; + const current = sessionsRef.current.find((row) => row.id === id); + if (!current || !sameStudioAuthority(source, current) || (bus && !sameStudioAuthority(source, bus))) return null; + // Preparation still carries not-started. The committed POST clears that + // marker permanently; subsequent bus status (including exit) owns the row. + const preserve = bus !== undefined && bus.terminalState === undefined; + const latest = preserve ? bus : result; + setState((prev) => prev ? { ...prev, sessions: prev.sessions.map((row) => row.id === id && !preserve ? result : row) } : prev); + revealTerminal(id); + return latest; + }, [assistantOrder, revealTerminal]); + const closeSession = useCallback( async (id: string): Promise => { if (endingSessions.current.has(id)) return; + sessionOperationGenerations.current.set(id, (sessionOperationGenerations.current.get(id) ?? 0) + 1); endingSessions.current.add(id); setEndingSessionIds(new Set(endingSessions.current)); const selectionAtStart = switchSeqRef.current; @@ -2563,6 +2604,7 @@ export function useHarnessState(): HarnessStateHook { resumeAssistant, continueAssistant, assistantHistoryEntry, + startTerminal, assistantRevealBySession, endingSessionIds, state,