From 962e88c0c9ee86cdc6dc2d43480b5bbc194698fe Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 22:29:33 +0000 Subject: [PATCH] feat(harness): continue recorded history in a new Assistant --- .changeset/assistant-continue-controls.md | 5 + packages/harness/docs/assistant-history.md | 5 + .../web/e2e/assistant-continuation.spec.ts | 621 ++++++++++++++++++ packages/harness/web/src/App.tsx | 33 +- .../components/AssistantContinueAction.tsx | 118 ++++ .../src/components/AssistantHistoryPane.tsx | 4 +- .../web/src/components/AssistantPane.tsx | 3 + .../web/src/components/OpenCodeChat.tsx | 2 +- .../src/lib/assistant-continuation-client.ts | 130 ++++ .../web/src/lib/assistant-history-client.ts | 7 +- .../web/src/lib/assistant-resume-client.ts | 6 +- .../harness/web/src/lib/use-harness-state.ts | 34 + 12 files changed, 959 insertions(+), 9 deletions(-) create mode 100644 .changeset/assistant-continue-controls.md create mode 100644 packages/harness/web/e2e/assistant-continuation.spec.ts create mode 100644 packages/harness/web/src/components/AssistantContinueAction.tsx create mode 100644 packages/harness/web/src/lib/assistant-continuation-client.ts diff --git a/.changeset/assistant-continue-controls.md b/.changeset/assistant-continue-controls.md new file mode 100644 index 000000000..d0666b047 --- /dev/null +++ b/.changeset/assistant-continue-controls.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Continue from retained Assistant history into a new paused conversation with recorded context. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 0eaafd2a2..a81748517 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -143,3 +143,8 @@ If publication and rollback both fail, metadata reports an unconfirmed commit; the new valid generation may already be readable and the previous generation is retained. Resume reconciles its exact durable operation proof after restart. An original-base replay cannot change the operation's committed execution state. + +Continue explicitly uses the record currently displayed in history review. Its +opaque request tuple survives reloads and uncertain responses; tabs serialize +creation before POST. A verified child opens Assistant paused with recorded +provenance. Account, navigation and newer bus state fence result adoption. diff --git a/packages/harness/web/e2e/assistant-continuation.spec.ts b/packages/harness/web/e2e/assistant-continuation.spec.ts new file mode 100644 index 000000000..533ad4c15 --- /dev/null +++ b/packages/harness/web/e2e/assistant-continuation.spec.ts @@ -0,0 +1,621 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { createHash } from "node:crypto"; +import { createServer, type ServerResponse } from "node:http"; +import type { AssistantHistoryEntry } from "../../src/shared/assistant-history"; +import { openCodeTransportFailure } from "../../src/shared/opencode-errors"; +import { selectMockSessionFromPalette } from "./mock-navigation"; + +const sourceId = "sess-leasing"; +const childId = "continued-child"; +const cwd = "/Users/demo/acme-app"; +const date = "2026-09-14T00:00:00.000Z"; +const seedText = + "Recorded brief: continue the saved task. This context may be incomplete."; +const lifecycle = (id: string, revision = 1) => ({ + version: 1, + harnessSessionId: id, + revision, + lifecycle: "open", + execution: "paused", + updatedAt: revision, +}); +const source = ( + revision = 1, + scope = "a".repeat(64), +): AssistantHistoryEntry => ({ + kind: "assistant", + harnessSessionId: sourceId, + title: "Saved Assistant source", + cwd, + createdAt: date, + updatedAt: date, + history: "partial", + nativeResume: "missing", + recordRevision: revision, + lifecycle: { + version: 1, + harnessSessionId: sourceId, + revision: 2, + lifecycle: "ended", + execution: "paused", + updatedAt: 2, + }, + continuationScope: scope, +}); +const record = (revision: number) => ({ + schemaVersion: 1, + reconstructed: true, + revision, + binding: { harnessSessionId: sourceId, conversationId: "ses_original", cwd }, + capturedAt: date, + turnCount: 1, + messageCount: 1, + limitations: ["dropped-early-turns"], + turns: [ + { + id: "user1", + incomplete: true, + messages: [ + { + id: "user1", + role: "user", + parentId: null, + createdAt: 1, + completedAt: null, + parts: [ + { + id: "part1", + type: "text", + text: "Original saved task", + truncated: false, + }, + ], + }, + ], + }, + ], +}); +const publish = (page: Page, message: unknown) => + page.evaluate( + (value) => (window as any).__HARNESS_TEST__.publish(value), + message, + ); +const project = ( + page: Page, + revision = 1, + authorityRevision = "account-a", + lifecycles: unknown[] = [], + hostInstanceId = "continue-host", +) => + publish(page, { + type: "assistant.state", + snapshot: { + hostInstanceId, + authorityRevision, + revision, + enabled: true, + sessions: [], + lifecycles, + }, + }); +const review = async (page: Page) => { + await page.getByTestId("history-trigger").click(); + await page.getByTestId("past-sessions-trigger").hover(); + await page.getByTestId("assistant-history-" + sourceId).click(); + await expect(page.getByTestId("assistant-transcript")).toContainText( + "Original saved task", + ); +}; +const button = (page: Page) => + page.getByTestId("assistant-continue-action").getByRole("button"); +const closes: Array<() => Promise> = []; +test.afterEach(async () => { + await Promise.all(closes.splice(0).map((close) => close())); +}); + +async function setup(page: Page) { + const streams = new Set(); + const events = createServer((req, res) => { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + }); + if (req.method === "OPTIONS") return void res.end(); + streams.add(res); + res.on("close", () => streams.delete(res)); + res.write('data: {"type":"server.connected","properties":{}}\n\n'); + }); + await new Promise((resolve) => events.listen(0, "127.0.0.1", resolve)); + closes.push(async () => { + for (const stream of streams) stream.end(); + await new Promise((resolve) => events.close(() => resolve())); + }); + const probe = { + requests: [] as Route[], + calls: [] as string[], + revision: 1, + scope: "a".repeat(64), + hold: false, + fail: false, + missingRecord: false, + missingNative: false, + prompts: [] as Route[], + messages: [] as any[], + }; + const response = (request: Record) => { + const continuation = { + operationId: request.operationId, + sourceSessionId: sourceId, + sourceRecordRevision: request.expectedRecordRevision, + capturedAt: date, + retainedTurns: 1, + omittedTurns: 3, + seed: { + conversationId: "ses_child", + messageId: "msg_seed", + partId: "prt_seed", + text: seedText, + sha256: createHash("sha256").update(seedText).digest("hex"), + }, + }; + return { + session: { + id: childId, + cwd, + title: "Continued Assistant", + harness: "claude-code", + agentSessionId: null, + boundWorkflowPath: null, + status: "exited", + ready: false, + terminalState: "not-started", + createdAt: date, + lastActiveAt: date, + agentMapIdentity: { + projectId: "project-acme", + sessionId: childId, + userId: "user-a", + }, + }, + attachment: { + conversationId: "ses_child", + lease: "11111111-1111-4111-8111-111111111111", + lifecycle: lifecycle(childId), + continuation, + }, + continuation, + }; + }; + await page.route("**/api/sessions/*/assistant/continue", (route) => { + probe.requests.push(route); + if (probe.hold) return; + return route.fulfill( + probe.fail + ? { + status: 503, + json: { + error: openCodeTransportFailure("continuation_unconfirmed"), + }, + } + : { json: response(route.request().postDataJSON()) }, + ); + }); + await page.route("**/api/assistant/access", (route) => + route.fulfill({ json: { enabled: true, authorityRevision: "account-a" } }), + ); + await page.route("**/api/sessions/assistant-history?**", (route) => + route.fulfill({ + json: { + entries: + new URL(route.request().url()).searchParams.get("cwd") === cwd + ? [source(probe.revision, probe.scope)] + : [], + }, + }), + ); + await page.route("**/api/sessions/*/assistant/record", (route) => + route.fulfill( + probe.missingRecord + ? { status: 404, json: {} } + : { json: { record: record(probe.revision) } }, + ), + ); + await page.route( + (url) => url.pathname.startsWith("/opencode/"), + (route) => { + const path = new URL(route.request().url()).pathname + .split("/") + .slice(3) + .join("/"); + if (probe.missingNative && path === "attach") + return route.fulfill({ + status: 404, + json: { error: openCodeTransportFailure("native_history_missing") }, + }); + const attachment = response( + probe.requests[0]?.request().postDataJSON() ?? { + operationId: "22222222-2222-4222-8222-222222222222", + expectedRecordRevision: 1, + }, + ).attachment; + if (path === "session/ses_child/prompt_async") { + probe.prompts.push(route); + const prompt = route.request().postDataJSON().parts[0].text; + for (const [role, text] of [ + ["user", prompt], + ["assistant", "Human task completed."], + ]) { + const id = "msg_human_" + role; + const message = { + info: { + id, + sessionID: "ses_child", + role, + agent: "build", + parentID: role === "assistant" ? "msg_human_user" : undefined, + finish: "stop", + time: { created: 3, completed: 4 }, + }, + parts: [ + { + id: "prt_" + id, + messageID: id, + sessionID: "ses_child", + type: "text", + text, + }, + ], + }; + probe.messages.push(message); + for (const [type, properties] of [ + ["message.updated", { info: message.info }], + ["message.part.updated", { part: message.parts[0] }], + ]) + for (const stream of streams) + stream.write( + "data: " + JSON.stringify({ type, properties }) + "\n\n", + ); + } + return route.fulfill({ + status: 204, + headers: { "X-Assistant-Execution": "enabled" }, + }); + } + if (path === "lifecycle") + return route.fulfill({ + json: lifecycle( + new URL(route.request().url()).pathname.split("/")[2]!, + probe.missingNative ? 3 : 1, + ), + }); + if (path === "attach") return route.fulfill({ json: attachment }); + if (path === "event") + return route.continue({ + url: + "http://127.0.0.1:" + + (events.address() as { port: number }).port + + "/event", + }); + const native = { + id: "ses_child", + title: "Prepared child", + time: { created: 1, updated: 2 }, + }; + if (path === "session/status") + return route.fulfill({ json: { ses_child: { type: "idle" } } }); + if (path === "session/ses_child") return route.fulfill({ json: native }); + if (path === "session/ses_child/message") + return route.fulfill({ + json: [ + { + info: { + id: "msg_seed", + sessionID: "ses_child", + role: "user", + time: { created: 1 }, + }, + parts: [ + { + id: "prt_seed", + messageID: "msg_seed", + sessionID: "ses_child", + type: "text", + text: seedText, + synthetic: true, + ignored: false, + metadata: { + sapiomContinuation: { + operationId: attachment.continuation.operationId, + briefHash: attachment.continuation.seed.sha256, + }, + }, + }, + ], + }, + ...probe.messages, + ], + }); + return route.fulfill({ + json: path === "experimental/session" ? [native] : [], + }); + }, + ); + page.on("request", (request) => { + if ( + /^\/opencode\/|\/terminal\/start$/.test(new URL(request.url()).pathname) + ) + probe.calls.push(request.url()); + }); + await page.goto("/?seed=0"); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await project(page); + return Object.assign(probe, { response }); +} + +test("Continue opens a distinct paused child from the displayed record and preserves source history", async ({ + page, +}, info) => { + const probe = await setup(page); + probe.hold = true; + await review(page); + await expect(button(page)).toHaveText("Continue in new Assistant"); + await expect(page.getByTestId("assistant-continue-action")).toContainText( + "brief may be incomplete", + ); + await page.screenshot({ path: info.outputPath("continue-review.png") }); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(1); + const body = probe.requests[0]!.request().postDataJSON(); + expect(body).toEqual({ + expectedRevision: 2, + expectedRecordRevision: 1, + operationId: expect.stringMatching(/^[a-f0-9-]{36}$/), + }); + await project(page, 2, "account-a", [lifecycle(childId)]); + await probe.requests[0]!.fulfill({ json: probe.response(body) }); + await expect(page.getByText("Paused", { exact: true })).toBeVisible(); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + childId, + ); + await expect( + page.getByRole("button", { name: "Assistant", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText("Working", { exact: true })).toHaveCount(0); + const brief = page.getByTestId("assistant-continuation-context"); + await expect(brief).toContainText( + "Recorded context from a previous Assistant session", + ); + await brief.locator("summary").click(); + await expect(brief).toContainText("3 earlier turns were omitted"); + await expect(brief).toContainText(seedText); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: info.outputPath("continued-paused.png") }); + await page.setViewportSize({ width: 1280, height: 720 }); + expect( + probe.calls.filter((url) => + /prompt_async|final-response|terminal\/start/.test(url), + ), + ).toEqual([]); + // A real browser reload re-receives the same dormant registry row from the bus. + await page.reload(); + await expect(page.locator(".harness-terminal")).toBeVisible(); + await publish(page, { + type: "session.status", + session: probe.response(body).session, + }); + await project(page, 1, "account-a", [lifecycle(childId)]); + await selectMockSessionFromPalette(page, "Continued Assistant"); + 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([]); + await page + .getByRole("textbox", { name: "Message Assistant" }) + .fill("Continue the human task"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect( + page.getByText("Human task completed.", { exact: true }), + ).toBeVisible(); + expect(probe.prompts).toHaveLength(1); + expect(probe.prompts[0]!.request().postDataJSON().parts[0].text).toBe( + "Continue the human task", + ); + expect(probe.calls.filter((url) => /final-response/.test(url))).toEqual([]); + await review(page); + await expect(page.getByTestId("assistant-transcript")).toContainText( + "Original saved task", + ); +}); + +test("lost Continue response survives refresh, host restart and a newer record with the same request tuple", async ({ + page, +}) => { + const probe = await setup(page); + probe.fail = true; + await review(page); + await button(page).click(); + await expect(page.getByRole("alert")).toContainText("Retry this operation"); + const request = probe.requests[0]!.request().postDataJSON(); + probe.revision = 2; + await page.reload(); + await project(page, 1, "new-process-authority", [], "restarted-host"); + await review(page); + await expect(button(page)).toHaveText("Retry Continue"); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(2); + expect(probe.requests[1]!.request().postDataJSON()).toEqual(request); + const persisted = await page.evaluate(() => + Object.entries(localStorage).filter(([key]) => + key.startsWith("studio.assistant-continue."), + ), + ); + expect(persisted).toHaveLength(1); + expect(JSON.parse(persisted[0]![1])).toEqual(request); +}); + +for (const invalid of ["source", "record", "hash", "child", "lease"]) { + test( + "unverified Continue " + invalid + " retains original readable history", + async ({ page }) => { + const probe = await setup(page); + probe.hold = true; + await review(page); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(1); + const value = probe.response(probe.requests[0]!.request().postDataJSON()); + if (invalid === "source") + value.continuation.sourceSessionId = "other-source"; + if (invalid === "record") value.continuation.sourceRecordRevision++; + if (invalid === "hash") value.continuation.seed.sha256 = "f".repeat(64); + if (invalid === "child") value.session.id = sourceId; + if (invalid === "lease") value.attachment.lease = "invalid"; + await probe.requests[0]!.fulfill({ json: value }); + await expect(page.getByRole("alert")).toContainText( + "prepared continuation could not be verified", + ); + await expect(page.getByTestId("assistant-transcript")).toContainText( + "Original saved task", + ); + expect(probe.calls).toEqual([]); + }, + ); +} + +for (const barrier of ["navigation", "account", "lifecycle"]) { + test( + "late Continue cannot cross the " + barrier + " barrier", + async ({ page }) => { + const probe = await setup(page); + probe.hold = true; + await review(page); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(1); + if (barrier === "navigation") + await page + .getByTestId("assistant-history-pane") + .getByRole("button", { name: "Close", exact: true }) + .click(); + if (barrier === "account") await project(page, 2, "account-b"); + if (barrier === "lifecycle") + await project(page, 2, "account-a", [ + { ...lifecycle(childId, 2), lifecycle: "ended" }, + ]); + await probe.requests[0]!.fulfill({ + json: probe.response(probe.requests[0]!.request().postDataJSON()), + }); + if (barrier === "lifecycle") + await expect(page.getByRole("alert")).toContainText( + "selection or session changed", + ); + else + await expect(page.getByTestId("assistant-history-pane")).toHaveCount(0); + await expect(page.getByTestId("session-context")).not.toHaveAttribute( + "data-session-id", + childId, + ); + expect(probe.calls).toEqual([]); + }, + ); +} + +test("unavailable storage fails before Continue dispatch", async ({ page }) => { + const probe = await setup(page); + await review(page); + await page.evaluate(() => { + Storage.prototype.setItem = () => { + throw new Error("blocked"); + }; + }); + await button(page).click(); + await expect(page.getByRole("alert")).toContainText( + "could not save or verify", + ); + expect(probe.requests).toHaveLength(0); +}); + +test("missing stable scope preserves history but disables Continue", async ({ + page, +}) => { + const probe = await setup(page); + probe.scope = "not-a-stable-scope"; + await review(page); + await expect(button(page)).toBeDisabled(); + await expect(page.getByTestId("assistant-continue-action")).toContainText( + "unavailable for this workspace", + ); + expect(probe.requests).toHaveLength(0); +}); + +test("another stable authority cannot inherit an uncertain Continue", async ({ + page, +}) => { + const probe = await setup(page); + probe.fail = true; + await review(page); + await button(page).click(); + await expect(page.getByRole("alert")).toContainText("Retry this operation"); + probe.scope = "b".repeat(64); + await page.reload(); + await project(page); + await review(page); + await expect(button(page)).toHaveText("Continue in new Assistant"); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(2); + expect(probe.requests[1]!.request().postDataJSON().operationId).not.toEqual( + probe.requests[0]!.request().postDataJSON().operationId, + ); +}); + +test("missing saved material cannot prepare a new continuation", async ({ + page, +}) => { + const probe = await setup(page); + probe.missingRecord = true; + await page.getByTestId("history-trigger").click(); + await page.getByTestId("past-sessions-trigger").hover(); + await page.getByTestId("assistant-history-" + sourceId).click(); + await expect(page.getByTestId("assistant-history-pane")).toContainText( + "No Assistant conversation was recorded", + ); + await expect(button(page)).toBeDisabled(); + expect(probe.requests).toHaveLength(0); +}); + +test("missing native history offers the saved record without automatic Continue", async ({ + page, +}, info) => { + const probe = await setup(page); + probe.missingNative = true; + await project(page, 2, "account-a", [lifecycle(sourceId, 3)]); + await review(page); + await page + .getByTestId("assistant-history-pane") + .getByRole("button", { name: "View Terminal history" }) + .click(); + await page.getByRole("button", { name: "Assistant", exact: true }).click(); + const recovery = page.getByRole("button", { + name: "Review saved Assistant history", + exact: true, + }); + await expect(recovery).toBeVisible(); + await page.screenshot({ path: info.outputPath("native-missing-review.png") }); + await recovery.click(); + await expect(page.getByTestId("assistant-transcript")).toContainText( + "Original saved task", + ); + await expect(button(page)).toBeEnabled(); + expect(probe.requests).toHaveLength(0); + expect( + probe.calls.filter((url) => /prompt_async|final-response/.test(url)), + ).toEqual([]); + probe.hold = true; + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(1); + expect(probe.requests[0]!.request().postDataJSON().expectedRevision).toBe(3); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index c57f19222..2576628e3 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -89,6 +89,7 @@ import { AssistantPane } from "./components/AssistantPane"; import { AssistantHistoryPane } from "./components/AssistantHistoryPane"; import type { AssistantHistoryEntry } from "../../src/shared/assistant-history"; import { AssistantHistoryActions } from "./components/AssistantHistoryActions"; +import { AssistantContinueAction } from "./components/AssistantContinueAction"; import type { ChatDraftStore } from "./components/OpenCodeChat"; import { Toast } from "./components/Toast"; import { TooltipLayer } from "./components/TooltipLayer"; @@ -2507,6 +2508,20 @@ export const App = (): JSX.Element => { setOverviewOpen(false); closeMobileDrawer(); }; + const reviewLiveAssistant = async (id: string): Promise => { + const navigation = ++studioRestoreGenerationRef.current; + const authority = harness.assistantHistoryAuthority; + const current = () => navigation === studioRestoreGenerationRef.current && harness.assistantAuthorityCurrent(authority); + try { + const entry = await harness.assistantHistoryEntry(id, AbortSignal.timeout(5000)); + if (!current()) return; + if (entry) reviewAssistant(entry); + else harness.showToast("Saved Assistant history is unavailable. Try again."); + } catch { + if (current()) + harness.showToast("Saved Assistant history is unavailable. Try again."); + } + }; // Jump from the Studio to the real code, in the editor the user picked. const openInEditor = (path: string): void => { @@ -3387,7 +3402,7 @@ export const App = (): JSX.Element => { entry={activeAssistantReview.entry} bootToken={harness.bootToken} onClose={() => setAssistantReview(null)} - actions={ <> row.harnessSessionId === activeAssistantReview.entry.harnessSessionId)} bootToken={harness.bootToken} @@ -3400,7 +3415,19 @@ export const App = (): JSX.Element => { openSession(session.id, session); return true; }} - />} + /> row.harnessSessionId === activeAssistantReview.entry.harnessSessionId && row.revision >= activeAssistantReview.entry.lifecycle.revision) ?? activeAssistantReview.entry.lifecycle }} + recordRevision={record?.turns.length ? record.revision : null} + bootToken={harness.bootToken} + onContinue={async (entry, request, signal) => { + const review = activeAssistantReview; + const current = () => studioRestoreGenerationRef.current === review.navigation; + const session = await harness.continueAssistant(entry, request, signal, review.authority, current); + if (!session || !current() || signal.aborted) return false; + openSession(session.id, session); + 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)?.status !== "exited" ? "Open Terminal" : "View Terminal history"} onOpenTerminal={state.sessions.some((session) => session.id === activeAssistantReview.entry.harnessSessionId && session.terminalState !== "not-started") @@ -3437,6 +3464,7 @@ export const App = (): JSX.Element => { ) ?? 0 } assistantRevision={harness.assistantRevealBySession.get(conversationSession.id) ?? 0} + onReviewHistory={() => void reviewLiveAssistant(conversationSession.id)} > { ) ?? 0 } assistantRevision={harness.assistantRevealBySession.get(conversationSession.id) ?? 0} + onReviewHistory={() => void reviewLiveAssistant(conversationSession.id)} > Promise; +}) { + const [saved, setSaved] = useState(() => { + try { + return savedContinueRequest(entry); + } catch { + return null; + } + }); + const [failure, setFailure] = useState(null); + const [busy, setBusy] = useState(false); + const pending = useRef(null); + useEffect(() => { + setBusy(false); + setFailure(null); + try { + setSaved(savedContinueRequest(entry)); + } catch { + setSaved(null); + } + return () => { + pending.current?.abort(); + pending.current = null; + }; + }, [ + entry.harnessSessionId, + entry.lifecycle.revision, + bootToken, + entry.continuationScope, + ]); + const run = async () => { + if (pending.current) return; + const abort = new AbortController(); + pending.current = abort; + setBusy(true); + setFailure(null); + try { + const request = await prepareContinueRequest(entry, recordRevision); + if (abort.signal.aborted) return; + setSaved(request); + const opened = await onContinue(entry, request, abort.signal); + if (!abort.signal.aborted && opened) + completeContinueRequest(entry, request.operationId); + else if (!abort.signal.aborted) + setFailure( + "Your selection or session changed. Return here to retry the saved continuation.", + ); + } catch (error) { + if (!abort.signal.aborted) + setFailure( + error instanceof Error + ? error.message + : "Continuation is unavailable. Your original history is still available.", + ); + } finally { + if (pending.current === abort) { + pending.current = null; + setBusy(false); + } + } + }; + return ( +
+ +

+ {!entry.continuationScope + ? "Continuation is unavailable for this workspace. Your saved history is still readable." + : saved + ? "Retry checks the same saved request and its frozen brief; it does not start a second conversation." + : recordRevision === null + ? "No readable Assistant record is available to carry forward." + : "Creates a new conversation from a recorded brief. The brief may be incomplete; the new Assistant stays paused until you send a message."} +

+ {failure && ( +

+ {failure} +

+ )} +
+ ); +} diff --git a/packages/harness/web/src/components/AssistantHistoryPane.tsx b/packages/harness/web/src/components/AssistantHistoryPane.tsx index dbdcea38d..4c8216782 100644 --- a/packages/harness/web/src/components/AssistantHistoryPane.tsx +++ b/packages/harness/web/src/components/AssistantHistoryPane.tsx @@ -34,7 +34,7 @@ export function AssistantHistoryPane({ entry: AssistantHistoryEntry; bootToken: string; onClose: () => void; - actions?: ReactNode; + actions?: (record: ReadableAssistantRecord | null) => ReactNode; onOpenTerminal?: () => void; terminalLabel?: string; terminalNotStarted?: boolean; @@ -89,7 +89,7 @@ export function AssistantHistoryPane({

)}
- {actions} + {actions?.(state.record)} {state.loading ? (
Loading Assistant history… diff --git a/packages/harness/web/src/components/AssistantPane.tsx b/packages/harness/web/src/components/AssistantPane.tsx index d172d40e7..13bbe0524 100644 --- a/packages/harness/web/src/components/AssistantPane.tsx +++ b/packages/harness/web/src/components/AssistantPane.tsx @@ -18,6 +18,7 @@ export function AssistantPane({ onAuthorityRevision, onSignIn, onOpenSettings, + onReviewHistory, children, }: { sessionId: string; @@ -31,6 +32,7 @@ export function AssistantPane({ onAuthorityRevision: (revision: string) => void; onSignIn: () => void; onOpenSettings: () => void; + onReviewHistory?: () => void; children: ReactNode; }) { const [access, setAccess] = useState<{ @@ -146,6 +148,7 @@ export function AssistantPane({ onSignIn={onSignIn} onOpenSettings={onOpenSettings} onOpenTerminal={() => setMode("Terminal")} + onReviewHistory={onReviewHistory} /> ) : ( children diff --git a/packages/harness/web/src/components/OpenCodeChat.tsx b/packages/harness/web/src/components/OpenCodeChat.tsx index 1eefdd048..888506aff 100644 --- a/packages/harness/web/src/components/OpenCodeChat.tsx +++ b/packages/harness/web/src/components/OpenCodeChat.tsx @@ -660,7 +660,7 @@ function ChatSurface({ Recorded context from a previous Assistant session

- Prepared from {continuation.retainedTurns} saved turns.{" "} + Prepared from {continuation.retainedTurns} saved {continuation.retainedTurns === 1 ? "turn" : "turns"}.{" "} {continuation.omittedTurns > 0 ? `${continuation.omittedTurns} earlier turns were omitted. ` : ""} diff --git a/packages/harness/web/src/lib/assistant-continuation-client.ts b/packages/harness/web/src/lib/assistant-continuation-client.ts new file mode 100644 index 000000000..d7cf11ab8 --- /dev/null +++ b/packages/harness/web/src/lib/assistant-continuation-client.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; +import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history"; +import { parseAssistantLifecycle } from "../../../src/shared/assistant-session"; +import { verifyAssistantContinuationView } from "../../../src/shared/assistant-continuation"; +import { + assistantActionRequest, + assistantSessionSchema, +} from "./assistant-resume-client"; + +const requestSchema = z + .object({ + operationId: z.string().uuid(), + expectedRevision: z.number().int().nonnegative().safe(), + expectedRecordRevision: z.number().int().positive().safe(), + }) + .strict(); +export type ContinueRequest = z.infer; +const key = (entry: AssistantHistoryEntry) => { + if ( + !entry.continuationScope || + !/^[a-f\d]{64}$/.test(entry.continuationScope) + ) + throw new Error( + "Continuation is unavailable for this workspace. Your saved history is still readable.", + ); + return `studio.assistant-continue.v1:${entry.continuationScope}:${entry.harnessSessionId}`; +}; +const storageError = () => + new Error( + "Studio could not save or verify this continuation request. Try again.", + ); +export function savedContinueRequest( + entry: AssistantHistoryEntry, +): ContinueRequest | null { + try { + const value = localStorage.getItem(key(entry)); + return value === null ? null : requestSchema.parse(JSON.parse(value)); + } catch { + throw storageError(); + } +} +function prepare( + entry: AssistantHistoryEntry, + recordRevision: number | null, +): ContinueRequest { + const saved = savedContinueRequest(entry); + if (saved) return saved; + if (recordRevision === null) + throw new Error("No readable Assistant record is available to continue."); + const request = requestSchema.parse({ + operationId: crypto.randomUUID(), + expectedRevision: entry.lifecycle.revision, + expectedRecordRevision: recordRevision, + }); + try { + localStorage.setItem(key(entry), JSON.stringify(request)); + if (savedContinueRequest(entry)?.operationId !== request.operationId) + throw storageError(); + } catch { + throw storageError(); + } + return request; +} +/** Serialize creation across tabs before any request can leave the browser. */ +export async function prepareContinueRequest( + entry: AssistantHistoryEntry, + recordRevision: number | null, +): Promise { + if (!navigator.locks) throw storageError(); + return navigator.locks.request(key(entry), () => + prepare(entry, recordRevision), + ); +} +export function completeContinueRequest( + entry: AssistantHistoryEntry, + operationId: string, +): void { + try { + if (savedContinueRequest(entry)?.operationId === operationId) + localStorage.removeItem(key(entry)); + } catch { + /* A retained receipt can safely reconcile again. */ + } +} + +export async function continueAssistantRequest( + entry: AssistantHistoryEntry, + request: ContinueRequest, + bootToken: string, + signal: AbortSignal, +) { + key(entry); + requestSchema.parse(request); + const value = await assistantActionRequest( + entry, + "continue", + request, + bootToken, + signal, + ); + const session = assistantSessionSchema.safeParse(value.session); + const attachment = value.attachment as Record | null; + const lifecycle = parseAssistantLifecycle(attachment?.lifecycle); + const continuation = await verifyAssistantContinuationView( + value.continuation, + typeof attachment?.conversationId === "string" + ? attachment.conversationId + : "", + ); + if ( + !session.success || + session.data.id === entry.harnessSessionId || + session.data.cwd !== entry.cwd || + !session.data.agentMapIdentity || + session.data.agentMapIdentity.sessionId !== session.data.id || + !lifecycle || + lifecycle.harnessSessionId !== session.data.id || + lifecycle.lifecycle !== "open" || + lifecycle.revision < 1 || + !z.string().uuid().safeParse(attachment?.lease).success || + !continuation || + continuation.operationId !== request.operationId || + continuation.sourceSessionId !== entry.harnessSessionId || + continuation.sourceRecordRevision !== request.expectedRecordRevision + ) + throw new Error( + "The prepared continuation could not be verified. Your original history is still available.", + ); + return { session: session.data, lifecycle, continuation }; +} diff --git a/packages/harness/web/src/lib/assistant-history-client.ts b/packages/harness/web/src/lib/assistant-history-client.ts index 0f8278db3..e96b73274 100644 --- a/packages/harness/web/src/lib/assistant-history-client.ts +++ b/packages/harness/web/src/lib/assistant-history-client.ts @@ -31,6 +31,11 @@ const entrySchema = z ) .optional(), recordRevision: count.positive().nullable(), + continuationScope: z + .string() + .regex(/^[a-f\d]{64}$/) + .optional() + .catch(undefined), }) .refine( (entry) => entry.harnessSessionId === entry.lifecycle.harnessSessionId, @@ -176,7 +181,7 @@ export async function readAssistantHistory( signal, ); const result = z.object({ entries: z.array(entrySchema) }).safeParse(value); - if (!result.success) + if (!result.success || result.data.entries.some((entry) => entry.cwd !== cwd)) throw new Error("Assistant history could not be verified."); const ids = new Set( result.data.entries.map((entry) => entry.harnessSessionId), diff --git a/packages/harness/web/src/lib/assistant-resume-client.ts b/packages/harness/web/src/lib/assistant-resume-client.ts index d8b5307bc..7401b111f 100644 --- a/packages/harness/web/src/lib/assistant-resume-client.ts +++ b/packages/harness/web/src/lib/assistant-resume-client.ts @@ -6,7 +6,7 @@ import { parseOpenCodeTransportFailure } from "../../../src/shared/opencode-erro import { parseAssistantHistoryEntry } from "./assistant-history-client"; const id = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/); -const sessionSchema = z.object({ +export const assistantSessionSchema = z.object({ id, cwd: z.string(), title: z.string(), @@ -35,7 +35,7 @@ const object = (value: unknown): Record => export async function assistantActionRequest( entry: AssistantHistoryEntry, - action: "inspect" | "resume", + action: "inspect" | "resume" | "continue", body: object, bootToken: string, signal: AbortSignal, @@ -100,7 +100,7 @@ export async function resumeAssistantRequest( bootToken, signal, ); - const session = sessionSchema.safeParse(value.session); + const session = assistantSessionSchema.safeParse(value.session); const attachment = object(value.attachment); const lifecycle = parseAssistantLifecycle(attachment.lifecycle); if ( diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 5c537efce..dc93e5a70 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -2,6 +2,7 @@ import { AssistantStateOrder, type AssistantProjection } from "./assistant-state import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history"; import { readAssistantHistory } from "./assistant-history-client"; import { resumeAssistantRequest } from "./assistant-resume-client"; +import { continueAssistantRequest, type ContinueRequest } from "./assistant-continuation-client"; import { parseAgentMapInitializationStatus, type AgentMapInitializationStatus } from "@shared/agent-map-initialization"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { @@ -142,8 +143,11 @@ export interface HarnessStateHook { assistant: AssistantProjection; assistantHistory: AssistantHistoryEntry[]; assistantHistoryAuthority: string | null; + assistantAuthorityCurrent: (authority: string | null) => boolean; assistantHistoryUnavailable: boolean; 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; assistantRevealBySession: Map; endingSessionIds: ReadonlySet; authRevision: number; @@ -1828,6 +1832,33 @@ export function useHarnessState(): HarnessStateHook { return currentSession ?? result.session; }, [assistantAuthorityKey, assistantOrder]); + const continueAssistant = useCallback(async (entry: AssistantHistoryEntry, request: ContinueRequest, signal: AbortSignal, authority: string, isCurrent: () => boolean): Promise => { + 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 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); + if (current && (current.cwd !== entry.cwd || current.agentMapIdentity?.projectId !== result.session.agentMapIdentity?.projectId)) return null; + setState((prev) => prev && !prev.sessions.some((row) => row.id === result.session.id) + ? { ...prev, sessions: [...prev.sessions, result.session] } : prev); + const revision = ++conversationRevealSequence.current; + setAssistantRevealBySession((prev) => new Map(prev).set(result.session.id, revision)); + return current ?? result.session; + }, [assistantAuthorityKey, assistantOrder]); + + const assistantHistoryEntry = useCallback(async (id: string, signal: AbortSignal): Promise => { + const session = sessionsRef.current.find((row) => row.id === id); + const authority = assistantAuthorityKey(); + if (!session || !authority || signal.aborted) return null; + const entries = await readAssistantHistory(session.cwd, getBootToken(), signal); + if (signal.aborted || authority !== assistantAuthorityKey() || sessionsRef.current.find((row) => row.id === id)?.cwd !== session.cwd) return null; + return entries.find((entry) => entry.harnessSessionId === id) ?? null; + }, [assistantAuthorityKey]); + const closeSession = useCallback( async (id: string): Promise => { if (endingSessions.current.has(id)) return; @@ -2527,8 +2558,11 @@ export function useHarnessState(): HarnessStateHook { assistant, assistantHistory: assistantHistory.authority === assistantAuthorityKey() ? assistantHistory.entries : [], assistantHistoryAuthority: assistantAuthorityKey(), + assistantAuthorityCurrent: (authority) => authority !== null && authority === assistantAuthorityKey(), assistantHistoryUnavailable: assistantHistory.authority === assistantAuthorityKey() && assistantHistory.unavailable, resumeAssistant, + continueAssistant, + assistantHistoryEntry, assistantRevealBySession, endingSessionIds, state,