diff --git a/.changeset/assistant-continue-conflict-recovery.md b/.changeset/assistant-continue-conflict-recovery.md new file mode 100644 index 000000000..46f3c88a8 --- /dev/null +++ b/.changeset/assistant-continue-conflict-recovery.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Allow explicit review and a new continuation after a verified lifecycle conflict while preserving uncertain retries. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 81c7a81a7..1941395b6 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -195,3 +195,8 @@ the same Studio. An uncertain Assistant Resume keeps its original revision and operation ID across its own lifecycle event and Close/Back navigation; Retry Resume reconciles that request. Only a verified lifecycle conflict permits a new availability check. + +After a verified Continue lifecycle conflict, Review latest record fetches and +displays saved history without creating work. A separate Start a new continuation +action replaces the rejected request only if its saved tuple is still unchanged. +Uncertain failures and reloads continue to reconcile the original operation. diff --git a/packages/harness/web/e2e/assistant-continuation.spec.ts b/packages/harness/web/e2e/assistant-continuation.spec.ts index 4acd3e299..948834948 100644 --- a/packages/harness/web/e2e/assistant-continuation.spec.ts +++ b/packages/harness/web/e2e/assistant-continuation.spec.ts @@ -42,7 +42,7 @@ const source = ( }, continuationScope: scope, }); -const record = (revision: number) => ({ +const record = (revision: number, text = "Original saved task") => ({ schemaVersion: 1, reconstructed: true, revision, @@ -66,7 +66,7 @@ const record = (revision: number) => ({ { id: "part1", type: "text", - text: "Original saved task", + text, truncated: false, }, ], @@ -139,6 +139,7 @@ async function setup(page: Page) { hold: false, fail: false, missingRecord: false, + recordText: "Original saved task", missingNative: false, prompts: [] as Route[], messages: [] as any[], @@ -222,7 +223,7 @@ async function setup(page: Page) { route.fulfill( probe.missingRecord ? { status: 404, json: {} } - : { json: { record: record(probe.revision) } }, + : { json: { record: record(probe.revision, probe.recordText) } }, ), ); await page.route( @@ -238,7 +239,7 @@ async function setup(page: Page) { json: { error: openCodeTransportFailure("native_history_missing") }, }); const attachment = response( - probe.requests[0]?.request().postDataJSON() ?? { + probe.requests.at(-1)?.request().postDataJSON() ?? { operationId: "22222222-2222-4222-8222-222222222222", expectedRecordRevision: 1, }, @@ -911,3 +912,148 @@ for (const field of ["project", "user", "harness", "source-rebound"]) { }, ); } + +const reviewLatest = (page: Page) => + page.getByRole("button", { name: "Review latest record", exact: true }); +const startNew = (page: Page) => + page.getByRole("button", { name: "Start a new continuation", exact: true }); +async function rejectedContinuation(page: 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 original = probe.requests[0]!.request().postDataJSON(); + await probe.requests[0]!.fulfill({ + status: 409, + json: { failure: openCodeTransportFailure("lifecycle_changed") }, + }); + await expect(reviewLatest(page)).toBeEnabled(); + return Object.assign(probe, { original }); +} + +test("a definite Continue conflict requires refreshed displayed history and an explicit new operation", async ({ + page, +}, info) => { + const probe = await rejectedContinuation(page); + await expect(startNew(page)).toHaveCount(0); + await page.screenshot({ + path: info.outputPath("continue-conflict-before-review.png"), + }); + probe.revision = 2; + probe.recordText = "Latest recorded task after the source changed"; + await project(page, 2, "account-a", [ + { ...lifecycle(sourceId, 3), lifecycle: "ended" }, + ]); + await expect(reviewLatest(page)).toBeEnabled(); + await reviewLatest(page).click(); + await expect(page.getByTestId("assistant-transcript")).toContainText( + probe.recordText, + ); + await expect(startNew(page)).toBeEnabled(); + expect(probe.requests).toHaveLength(1); + await page.screenshot({ + path: info.outputPath("continue-conflict-reviewed.png"), + }); + await startNew(page).click(); + await expect.poll(() => probe.requests.length).toBe(2); + const current = probe.requests[1]!.request().postDataJSON(); + expect(current).toMatchObject({ + expectedRevision: 3, + expectedRecordRevision: 2, + }); + expect(current.operationId).not.toBe(probe.original.operationId); + await probe.requests[1]!.fulfill({ json: probe.response(current) }); + await expect(page.getByText("Paused", { exact: true })).toBeVisible(); + expect( + probe.calls.filter((url) => /prompt_async|final-response/.test(url)), + ).toEqual([]); +}); + +for (const mode of ["unknown409", "503", "unconfirmed409"] as const) { + test(`Continue keeps the original request after ${mode} without offering replacement`, 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 original = probe.requests[0]!.request().postDataJSON(); + await probe.requests[0]!.fulfill({ + status: mode === "503" ? 503 : 409, + json: { + failure: + mode === "unknown409" + ? { code: "lifecycle_changed" } + : openCodeTransportFailure( + mode === "503" + ? "lifecycle_changed" + : "continuation_unconfirmed", + ), + }, + }); + await expect(button(page)).toHaveText("Retry Continue"); + await expect(reviewLatest(page)).toHaveCount(0); + await expect(startNew(page)).toHaveCount(0); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(2); + expect(probe.requests[1]!.request().postDataJSON()).toEqual(original); + await probe.requests[1]!.fulfill({ status: 503, json: {} }); + }); +} + +test("new Continue cannot replace a receipt changed by another tab", async ({ + page, +}) => { + const probe = await rejectedContinuation(page); + await reviewLatest(page).click(); + await expect(startNew(page)).toBeEnabled(); + await page.evaluate(() => { + const key = Object.keys(localStorage).find((key) => + key.startsWith("studio.assistant-continue."), + )!; + localStorage.setItem( + key, + JSON.stringify({ + ...JSON.parse(localStorage.getItem(key)!), + operationId: crypto.randomUUID(), + }), + ); + }); + await startNew(page).click(); + await expect(page.getByRole("alert")).toContainText( + "saved continuation request changed", + ); + expect(probe.requests).toHaveLength(1); +}); + +test("a missing fresh record cannot authorize a new continuation", async ({ + page, +}) => { + const probe = await rejectedContinuation(page); + probe.missingRecord = true; + await reviewLatest(page).click(); + await expect( + page.getByText("No Assistant conversation was recorded for this session."), + ).toBeVisible(); + await expect(startNew(page)).toHaveCount(0); + expect(probe.requests).toHaveLength(1); + probe.missingRecord = false; + await reviewLatest(page).click(); + await expect(startNew(page)).toBeEnabled(); +}); + +test("reload after a definite Continue conflict still retries the original request first", async ({ + page, +}) => { + const probe = await rejectedContinuation(page); + await page.reload(); + await project(page); + await review(page); + await expect(reviewLatest(page)).toHaveCount(0); + await button(page).click(); + await expect.poll(() => probe.requests.length).toBe(2); + expect(probe.requests[1]!.request().postDataJSON()).toEqual(probe.original); + await probe.requests[1]!.fulfill({ status: 503, json: {} }); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index b55500fac..6d0182071 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -3403,7 +3403,7 @@ export const App = (): JSX.Element => { entry={activeAssistantReview.entry} bootToken={harness.bootToken} onClose={() => setAssistantReview(null)} - actions={(record) => <> <> row.harnessSessionId === activeAssistantReview.entry.harnessSessionId)} bootToken={harness.bootToken} @@ -3418,7 +3418,8 @@ export const App = (): JSX.Element => { }} /> row.harnessSessionId === activeAssistantReview.entry.harnessSessionId && row.revision >= activeAssistantReview.entry.lifecycle.revision) ?? activeAssistantReview.entry.lifecycle }} - recordRevision={record?.turns.length ? record.revision : null} + record={record} + onRefreshRecord={refreshRecord} bootToken={harness.bootToken} onContinue={async (entry, request, signal) => { const review = activeAssistantReview; diff --git a/packages/harness/web/src/components/AssistantContinueAction.tsx b/packages/harness/web/src/components/AssistantContinueAction.tsx index 6618f2e1d..ee6dd4a76 100644 --- a/packages/harness/web/src/components/AssistantContinueAction.tsx +++ b/packages/harness/web/src/components/AssistantContinueAction.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history"; +import type { ReadableAssistantRecord } from "../lib/assistant-history-client"; +import { isAssistantLifecycleConflict } from "../lib/assistant-resume-client"; import { completeContinueRequest, prepareContinueRequest, @@ -9,12 +11,14 @@ import { export function AssistantContinueAction({ entry, - recordRevision, + record, + onRefreshRecord, bootToken, onContinue, }: { entry: AssistantHistoryEntry; - recordRevision: number | null; + record: ReadableAssistantRecord | null; + onRefreshRecord: () => void; bootToken: string; onContinue: ( entry: AssistantHistoryEntry, @@ -22,6 +26,7 @@ export function AssistantContinueAction({ signal: AbortSignal, ) => Promise; }) { + const recordRevision = record?.turns.length ? record.revision : null; const [saved, setSaved] = useState(() => { try { return savedContinueRequest(entry); @@ -31,6 +36,11 @@ export function AssistantContinueAction({ }); const [failure, setFailure] = useState(null); const [busy, setBusy] = useState(false); + const [rejected, setRejected] = useState<{ + request: ContinueRequest; + record: ReadableAssistantRecord | null; + refreshRequested: boolean; + } | null>(null); const pending = useRef(null); useEffect(() => { setBusy(false); @@ -50,14 +60,27 @@ export function AssistantContinueAction({ bootToken, entry.continuationScope, ]); - const run = async () => { + useEffect( + () => setRejected(null), + [entry.harnessSessionId, bootToken, entry.continuationScope], + ); + const reviewed = + rejected?.refreshRequested && + record && + record !== rejected.record && + recordRevision !== null; + const run = async (startNew = false) => { if (pending.current) return; + if (startNew && !reviewed) return; + const replace = startNew ? rejected?.request : undefined; const abort = new AbortController(); pending.current = abort; setBusy(true); setFailure(null); + setRejected(null); + let request: ContinueRequest | null = null; try { - const request = await prepareContinueRequest(entry, recordRevision); + request = await prepareContinueRequest(entry, recordRevision, replace); if (abort.signal.aborted) return; setSaved(request); const opened = await onContinue(entry, request, abort.signal); @@ -68,12 +91,19 @@ export function AssistantContinueAction({ "Your selection or session changed. Return here to retry the saved continuation.", ); } catch (error) { - if (!abort.signal.aborted) + if (!abort.signal.aborted) { + if (request && isAssistantLifecycleConflict(error)) + setRejected({ + request, + record: record ?? null, + refreshRequested: false, + }); setFailure( error instanceof Error ? error.message : "Continuation is unavailable. Your original history is still available.", ); + } } finally { if (pending.current === abort) { pending.current = null; @@ -108,6 +138,31 @@ export function AssistantContinueAction({ ? "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."}

+ {rejected && onRefreshRecord && ( + <> + +

+ The previous continuation was rejected because the session changed. + Review the latest saved record before starting a new continuation. +

+ + )} {failure && (

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

)}
- {actions?.(state.record)} + {actions?.(state.record, () => setAttempt((value) => value + 1))} {state.loading ? (
Loading Assistant history… diff --git a/packages/harness/web/src/lib/assistant-continuation-client.ts b/packages/harness/web/src/lib/assistant-continuation-client.ts index d7cf11ab8..c378027b4 100644 --- a/packages/harness/web/src/lib/assistant-continuation-client.ts +++ b/packages/harness/web/src/lib/assistant-continuation-client.ts @@ -42,9 +42,20 @@ export function savedContinueRequest( function prepare( entry: AssistantHistoryEntry, recordRevision: number | null, + rejected?: ContinueRequest, ): ContinueRequest { const saved = savedContinueRequest(entry); - if (saved) return saved; + if ( + rejected && + (!saved || + saved.operationId !== rejected.operationId || + saved.expectedRevision !== rejected.expectedRevision || + saved.expectedRecordRevision !== rejected.expectedRecordRevision) + ) + throw new Error( + "The saved continuation request changed. Retry its current request before starting another.", + ); + if (saved && !rejected) return saved; if (recordRevision === null) throw new Error("No readable Assistant record is available to continue."); const request = requestSchema.parse({ @@ -65,10 +76,12 @@ function prepare( export async function prepareContinueRequest( entry: AssistantHistoryEntry, recordRevision: number | null, + /** Only an explicit new-operation action after a confirmed lifecycle conflict. */ + rejected?: ContinueRequest, ): Promise { if (!navigator.locks) throw storageError(); return navigator.locks.request(key(entry), () => - prepare(entry, recordRevision), + prepare(entry, recordRevision, rejected), ); } export function completeContinueRequest(