diff --git a/.changeset/assistant-history-scope-and-focus.md b/.changeset/assistant-history-scope-and-focus.md new file mode 100644 index 000000000..8b911ad56 --- /dev/null +++ b/.changeset/assistant-history-scope-and-focus.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Preserve scoped mixed history, per-workspace refresh ordering, and explicit Terminal focus. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index caa5790f1..0f2977fa6 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -207,3 +207,9 @@ Visibility changes publish on the existing Assistant state revision clock, and an older read cannot overwrite newer completed evidence. An unreadable index blocks discovery and execution while exact authorized End remains available; previously verified pending children remain private during that failure. + +History groups Terminal and Assistant records only for the same Studio and cwd. +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. diff --git a/packages/harness/web/e2e/assistant-history.spec.ts b/packages/harness/web/e2e/assistant-history.spec.ts index 3dff8074c..fc30e0dad 100644 --- a/packages/harness/web/e2e/assistant-history.spec.ts +++ b/packages/harness/web/e2e/assistant-history.spec.ts @@ -658,7 +658,7 @@ test("resumed Assistant yields to newer foreground Terminal work", async ({ for (const revisit of [false, true]) { test(`Resume keeps the original revision after its bus commit and lost ACK${revisit ? " across Close and Back" : ""}`, async ({ page, - }) => { + }, info) => { const probe = await setup(page); probe.holdResume = true; await review(page); @@ -681,6 +681,10 @@ for (const revisit of [false, true]) { await expect(page.getByRole("alert")).toContainText( "Retry to check the same operation", ); + if (!revisit) + await page.screenshot({ + path: info.outputPath("retry-resume-original-request.png"), + }); if (revisit) { await page .getByTestId("assistant-history-pane") @@ -721,3 +725,310 @@ test("a definite Resume conflict permits a fresh availability check without auto "Saved answer assistant-only", ); }); + +const historyUnavailable = (page: Page) => + page.getByText("Assistant history is unavailable. Reopen history to retry.", { + exact: true, + }); +const refreshDirectory = (page: Page, directory: string) => + publish(page, { + type: "session.status", + session: { + id: "sess-boot", + cwd: directory, + title: "Directory history check", + harness: "claude-code", + agentSessionId: "unlisted-native", + boundWorkflowPath: null, + status: "exited", + ready: false, + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }, + }); +const otherCwd = "/Users/demo/rfq-agent"; + +test("a successful directory refresh preserves another directory's history failure until that directory succeeds", async ({ + page, +}, info) => { + await setup(page); + let failed = true; + const requests: string[] = []; + await page.route("**/api/sessions/assistant-history?**", (route) => { + const directory = new URL(route.request().url()).searchParams.get("cwd")!; + requests.push(directory); + return route.fulfill( + directory === cwd && failed + ? { status: 503, json: {} } + : { json: { entries: directory === cwd ? [entry("recovered")] : [] } }, + ); + }); + await rows(page); + await expect(historyUnavailable(page)).toBeVisible(); + const before = requests.filter((dir) => dir === otherCwd).length; + await refreshDirectory(page, otherCwd); + await expect + .poll(() => requests.filter((dir) => dir === otherCwd).length) + .toBeGreaterThan(before); + await expect(page.getByText("Loading…", { exact: true })).toHaveCount(0); + await expect(historyUnavailable(page)).toBeVisible(); + await historyUnavailable(page).scrollIntoViewIfNeeded(); + await page.screenshot({ + path: info.outputPath("history-directory-unavailable.png"), + }); + failed = false; + await refreshDirectory(page, cwd); + await expect(page.getByTestId("assistant-history-recovered")).toBeVisible(); + await expect(historyUnavailable(page)).toHaveCount(0); + await page + .getByTestId("assistant-history-recovered") + .scrollIntoViewIfNeeded(); + await page.screenshot({ + path: info.outputPath("history-directory-recovered.png"), + }); +}); + +for (const oldResult of ["failure", "old metadata"] as const) { + test(`a slow multi-directory batch cannot restore ${oldResult} over a newer directory result`, async ({ + page, + }) => { + await setup(page); + // Make Terminal discovery immediate; this test holds only the Assistant + // directory fan-out and must allow its first cwd request to settle. + 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.sessionHistory = async () => []; + }); + const firstResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + "/api/sessions/assistant-history" && + new URL(response.url()).searchParams.get("cwd") === cwd, + ); + let acmeRequests = 0; + let held: Route | null = null; + await page.route("**/api/sessions/assistant-history?**", (route) => { + const directory = new URL(route.request().url()).searchParams.get("cwd"); + if (directory === otherCwd && held === null) { + held = route; + return; + } + if (directory !== cwd) return route.fulfill({ json: { entries: [] } }); + acmeRequests++; + return route.fulfill( + acmeRequests === 1 && oldResult === "failure" + ? { status: 503, json: {} } + : { + json: { + entries: [ + { + ...entry("ordered"), + title: + acmeRequests === 1 + ? "Old saved history" + : "Fresh saved history", + }, + ], + }, + }, + ); + }); + await rows(page); + await expect.poll(() => held !== null && acmeRequests === 1).toBe(true); + await (await firstResponse).finished(); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await refreshDirectory(page, cwd); + await expect(page.getByTestId("assistant-history-ordered")).toContainText( + "Fresh saved history", + ); + await held!.fulfill({ json: { entries: [] } }); + await expect(page.getByText("Loading…", { exact: true })).toHaveCount(0); + await expect(page.getByTestId("assistant-history-ordered")).toContainText( + "Fresh saved history", + ); + await expect(historyUnavailable(page)).toHaveCount(0); + }); +} + +test("a mixed Assistant row retains its transcript-only Terminal record without starting either engine", async ({ + page, +}, info) => { + const probe = await setup(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); + const history = MockApi.prototype.sessionHistory, + record = MockApi.prototype.sessionRecord; + MockApi.prototype.sessionHistory = async function (cwd: string) { + const rows = await history.call(this, cwd); + return cwd === "/Users/demo/acme-app" + ? [ + ...rows, + { + harnessSessionId: "assistant-only", + agentSessionId: "retained-terminal-native", + harness: "claude-code", + cwd, + title: "Retained Terminal record", + lastActiveAt: "2026-09-14T00:00:00.000Z", + source: "transcript", + resumeMode: "agent-resume", + }, + ] + : rows; + }; + MockApi.prototype.sessionRecord = async function (id: string) { + if (id !== "assistant-only") return record.call(this, id); + return { + ...(await record.call(this, "2b6d9e10-7711-4c2a-8b0a-9e4f2d1c5a33")), + harnessSessionId: id, + mergedSessionIds: [id], + agentSessionId: "retained-terminal-native", + }; + }; + }); + await rows(page); + await expect( + page.getByTestId("assistant-history-assistant-only"), + ).toContainText("Terminal + Assistant"); + await expect( + page.getByTestId("history-retained-terminal-native"), + ).toHaveCount(0); + await page.getByTestId("assistant-history-assistant-only").click(); + await expect(page.getByTestId("assistant-transcript")).toContainText( + "Saved answer assistant-only", + ); + await page.screenshot({ + path: info.outputPath("mixed-assistant-record.png"), + }); + await page + .getByRole("button", { name: "View Terminal history", exact: true }) + .click(); + await expect(page.getByTestId("past-session-pane")).toContainText( + "Retained Terminal record", + ); + await expect(page.getByTestId("session-transcript")).toContainText( + "Wire the screening webhook", + ); + expect(probe.calls).toEqual([]); + await page.screenshot({ path: info.outputPath("mixed-terminal-record.png") }); +}); + +test("Open Terminal from history outranks an earlier Assistant Resume focus", async ({ + page, +}) => { + const probe = await setup(page); + await review(page); + await checkResume(page).click(); + await resumeButton(page).click(); + await expect(page.getByText("Paused", { exact: true })).toBeVisible(); + await review(page); + await page + .getByRole("button", { name: "View Terminal history", exact: true }) + .click(); + await page.getByRole("button", { name: "Terminal", exact: true }).waitFor(); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await expect( + page.getByRole("button", { name: "Terminal", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("dead-session-pane")).toBeVisible(); + expect(probe.resumes).toHaveLength(1); +}); + +test("a resumed dormant Terminal is not labelled as an ended Studio before its lifecycle bus arrives", async ({ + page, +}) => { + const probe = await setup(page); + probe.holdResume = true; + await review(page); + await checkResume(page).click(); + await resumeButton(page).click(); + await expect.poll(() => probe.resumes.length).toBe(1); + const value = probe.resumed("assistant-only"); + await probe.resumes[0]!.fulfill({ + json: { + ...value, + session: { ...value.session, terminalState: "not-started" }, + }, + }); + await expect(page.getByText("Paused", { exact: true })).toBeVisible(); + await expect(page.getByText("Session ended", { exact: true })).toHaveCount(0); + await expect( + page.getByText("Nothing generated yet", { exact: true }), + ).toBeVisible(); + await publish(page, { + type: "assistant.state", + snapshot: { + hostInstanceId: "history-host", + authorityRevision: "account-a", + revision: 2, + enabled: true, + sessions: [], + lifecycles: [{ ...entry("assistant-only").lifecycle, revision: 4 }], + }, + }); + await expect(page.getByText("Session ended", { exact: true })).toBeVisible(); +}); + +test("a Terminal summary in another directory remains separate from an Assistant with the same Studio ID", async ({ + page, +}) => { + await setup(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); + const history = MockApi.prototype.sessionHistory; + MockApi.prototype.sessionHistory = async function (cwd: string) { + const rows = await history.call(this, cwd); + return cwd === "/Users/demo/rfq-agent" + ? [ + ...rows, + { + harnessSessionId: "assistant-only", + agentSessionId: "different-directory-native", + harness: "codex", + cwd, + title: "Other directory Terminal", + lastActiveAt: "2026-09-14T00:00:00.000Z", + source: "transcript", + resumeMode: "agent-resume", + }, + ] + : rows; + }; + }); + await rows(page); + await expect( + page.getByTestId("assistant-history-assistant-only"), + ).not.toContainText("Terminal + Assistant"); + await expect( + page.getByTestId("history-different-directory-native"), + ).toBeVisible(); + await page.getByTestId("assistant-history-assistant-only").click(); + await expect( + page.getByRole("button", { name: "View Terminal history", exact: true }), + ).toHaveCount(0); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 6d0182071..e745b993f 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -749,6 +749,10 @@ export const App = (): JSX.Element => { }, []); const activeAssistantReview = assistantReview?.authority === harness.assistantHistoryAuthority && assistantReview.navigation === studioRestoreGenerationRef.current ? assistantReview : null; + const reviewedAssistantTerminal = activeAssistantReview ? harness.state?.sessions.find((session) => + session.id === activeAssistantReview.entry.harnessSessionId && session.cwd === activeAssistantReview.entry.cwd) : undefined; + const reviewedTerminalSummary = activeAssistantReview ? harness.history.find((summary) => + summary.harnessSessionId === activeAssistantReview.entry.harnessSessionId && summary.cwd === activeAssistantReview.entry.cwd) : undefined; // Template gallery opened from the command palette (browse is reachable // from anywhere, not only the add dialog / welcome panel entries). const [templatesOpen, setTemplatesOpen] = useState(false); @@ -3430,11 +3434,11 @@ export const App = (): JSX.Element => { return true; }} />} - terminalNotStarted={state.sessions.find((session) => session.id === activeAssistantReview.entry.harnessSessionId)?.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} + terminalNotStarted={reviewedAssistantTerminal?.terminalState === "not-started"} + terminalLabel={reviewedAssistantTerminal && (reviewedAssistantTerminal.terminalState === "not-started" || reviewedAssistantTerminal.status !== "exited") ? "Open Terminal" : "View Terminal history"} + onOpenTerminal={reviewedAssistantTerminal + ? () => { harness.revealTerminal(reviewedAssistantTerminal.id); openSession(reviewedAssistantTerminal.id); } + : reviewedTerminalSummary ? () => reviewPastSession(reviewedTerminalSummary) : undefined} /> ) : showReview && reviewSummary ? ( { source={canvasSource} loadWorkflowGraph={shellApi.getWorkflowGraph.bind(shellApi)} overviewActive={showComposer} - sessionExited={showDead && !!conversationSession && !assistantNeedsEnd(conversationSession.id)} + sessionExited={showDead && !!conversationSession && !assistantNeedsEnd(conversationSession.id) && + (conversationSession.terminalState !== "not-started" || assistantLifecycles.some((row) => row.harnessSessionId === conversationSession.id && row.lifecycle === "ended"))} onCanvasState={(hasContent) => { // The board keeps its mount behind the map; its probe must // not reveal or collapse the pane while the PROJECT is what diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index 16f08eadd..7037108e5 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -678,12 +678,12 @@ export function WorkflowsRail({ ) && !registryAgentIds.has(summary.agentSessionId), ); const pastRows = [ - ...exitedSessions.filter((session) => !assistantByStudio.has(session.id)).map((session) => ({ + ...exitedSessions.filter((session) => assistantByStudio.get(session.id)?.cwd !== session.cwd).map((session) => ({ kind: "exited" as const, at: session.lastActiveAt, session, })), - ...pastSummaries.filter((summary) => !summary.harnessSessionId || !assistantByStudio.has(summary.harnessSessionId)).map((summary) => ({ + ...pastSummaries.filter((summary) => !summary.harnessSessionId || assistantByStudio.get(summary.harnessSessionId)?.cwd !== summary.cwd).map((summary) => ({ kind: "summary" as const, at: summary.lastActiveAt, summary, @@ -1191,12 +1191,13 @@ export function WorkflowsRail({ > {pastRows.map((row) => { if (row.kind === "assistant") { - const terminal = sessions.find((session) => session.id === row.entry.harnessSessionId); + const terminal = sessions.find((session) => session.id === row.entry.harnessSessionId && session.cwd === row.entry.cwd); + const summary = history.find((candidate) => candidate.harnessSessionId === row.entry.harnessSessionId && candidate.cwd === row.entry.cwd); return Promise; /** Reveal app-driven foreground PTY work in the matching conversation pane. */ terminalRevealBySession: Map; + revealTerminal: (sessionId: string) => void; /** Expose the toast setter so panels can push their own toasts. Defaults * to the "error" tone; callers announcing a result opt into "info". */ showToast: (message: string, tone?: ToastTone) => void; @@ -444,8 +445,9 @@ export function useHarnessState(): HarnessStateHook { : null; }, [assistantOrder]); const [assistantHistory, setAssistantHistory] = useState<{ - authority: string | null; entries: AssistantHistoryEntry[]; unavailable: boolean; - }>({ authority: null, entries: [], unavailable: false }); + authority: string | null; entries: AssistantHistoryEntry[]; + failedCwds: Set; versions: Map; + }>({ authority: null, entries: [], failedCwds: new Set(), versions: new Map() }); const endingSessions = useRef(new Set()); const [endingSessionIds, setEndingSessionIds] = useState>( new Set(), @@ -541,11 +543,12 @@ export function useHarnessState(): HarnessStateHook { // History fan-out bookkeeping — see loadHistory. Refs, not state: neither // affects the render, and both are read/written within a single call. const inFlightHistory = useRef>>( new Map(), ); const historyLoads = useRef(0); + const historyRequestRevision = useRef(0); // Deploys in flight, keyed by workflow path. A second click while one is // running must not start another: for an UNLINKED project each deploy calls // link({ create: true }), which is a read-then-write with no uniqueness @@ -1463,12 +1466,14 @@ export function useHarnessState(): HarnessStateHook { const key = `${authority ?? "terminal"}:${cwd}`; const pending = inFlightHistory.current.get(key); if (pending) return pending; + const revision = ++historyRequestRevision.current; const request = Promise.allSettled([ api.sessionHistory(cwd), authority ? readAssistantHistory(cwd, getBootToken(), AbortSignal.timeout(5000)) : Promise.resolve([]), ]).then(([terminal, assistant]) => ({ + revision, terminal: terminal.status === "fulfilled" ? terminal.value : null, assistant: assistant.status === "fulfilled" ? assistant.value : null, })).finally(() => inFlightHistory.current.delete(key)); @@ -1486,28 +1491,34 @@ export function useHarnessState(): HarnessStateHook { const results = await Promise.allSettled(requests); const refreshed: SessionSummary[] = []; const refreshedCwds = new Set(); - const assistantEntries: AssistantHistoryEntry[] = []; - const assistantCwds = new Set(); - let unavailable = false; results.forEach((result, index) => { if (result.status !== "fulfilled") return; if (result.value.terminal) { refreshedCwds.add(unique[index]!); refreshed.push(...result.value.terminal); } - if (result.value.assistant) { - assistantCwds.add(unique[index]!); - assistantEntries.push(...result.value.assistant); - } else unavailable = true; }); setHistory((prev) => mergeHistory(prev, refreshed, refreshedCwds)); if (authority && authority === assistantAuthorityKey()) { - setAssistantHistory((prev) => ({ authority, unavailable, entries: [ - ...new Map([ - ...(prev.authority === authority ? prev.entries.filter((entry) => !assistantCwds.has(entry.cwd)) : []), - ...assistantEntries, - ].map((entry) => [entry.harnessSessionId, entry])).values(), - ] })); + setAssistantHistory((prev) => { + if (authority !== assistantAuthorityKey()) return prev; + const sameAuthority = prev.authority === authority; + const entries = new Map((sameAuthority ? prev.entries : []).map((entry) => [entry.harnessSessionId, entry])); + const failedCwds = new Set(sameAuthority ? prev.failedCwds : []); + const versions = new Map(sameAuthority ? prev.versions : []); + results.forEach((result, index) => { + if (result.status !== "fulfilled") return; + const cwd = unique[index]!, { revision, assistant } = result.value; + // A slow multi-directory batch cannot roll back a newer one-dir read. + if (revision <= (versions.get(cwd) ?? -1)) return; + versions.set(cwd, revision); + if (assistant === null) { failedCwds.add(cwd); return; } + failedCwds.delete(cwd); + for (const [id, entry] of entries) if (entry.cwd === cwd) entries.delete(id); + for (const entry of assistant) entries.set(entry.harnessSessionId, entry); + }); + return { authority, entries: [...entries.values()], failedCwds, versions }; + }); } } finally { historyLoads.current -= 1; @@ -2612,7 +2623,7 @@ export function useHarnessState(): HarnessStateHook { assistantHistory: assistantHistory.authority === assistantAuthorityKey() ? assistantHistory.entries : [], assistantHistoryAuthority: assistantAuthorityKey(), assistantAuthorityCurrent: (authority) => authority !== null && authority === assistantAuthorityKey(), - assistantHistoryUnavailable: assistantHistory.authority === assistantAuthorityKey() && assistantHistory.unavailable, + assistantHistoryUnavailable: assistantHistory.authority === assistantAuthorityKey() && assistantHistory.failedCwds.size > 0, resumeAssistant, continueAssistant, assistantHistoryEntry, @@ -2664,6 +2675,7 @@ export function useHarnessState(): HarnessStateHook { runLocal, injectInput, terminalRevealBySession, + revealTerminal, showToast, lastDeployErrorFor, deployStateByPath,