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 (
+
+
void run()}
+ >
+ {busy
+ ? "Preparing continuation…"
+ : saved
+ ? "Retry Continue"
+ : "Continue in new Assistant"}
+
+
+ {!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,