Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/assistant-continue-conflict-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Allow explicit review and a new continuation after a verified lifecycle conflict while preserving uncertain retries.
5 changes: 5 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
154 changes: 150 additions & 4 deletions packages/harness/web/e2e/assistant-continuation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -66,7 +66,7 @@ const record = (revision: number) => ({
{
id: "part1",
type: "text",
text: "Original saved task",
text,
truncated: false,
},
],
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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(
Expand All @@ -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,
},
Expand Down Expand Up @@ -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: {} });
});
5 changes: 3 additions & 2 deletions packages/harness/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3403,7 +3403,7 @@ export const App = (): JSX.Element => {
entry={activeAssistantReview.entry}
bootToken={harness.bootToken}
onClose={() => setAssistantReview(null)}
actions={(record) => <><AssistantHistoryActions
actions={(record, refreshRecord) => <><AssistantHistoryActions
entry={activeAssistantReview.entry}
lifecycle={assistantLifecycles.find((row) => row.harnessSessionId === activeAssistantReview.entry.harnessSessionId)}
bootToken={harness.bootToken}
Expand All @@ -3418,7 +3418,8 @@ export const App = (): JSX.Element => {
}}
/><AssistantContinueAction
entry={{ ...activeAssistantReview.entry, lifecycle: assistantLifecycles.find((row) => 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;
Expand Down
65 changes: 60 additions & 5 deletions packages/harness/web/src/components/AssistantContinueAction.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,19 +11,22 @@ 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,
request: ContinueRequest,
signal: AbortSignal,
) => Promise<boolean>;
}) {
const recordRevision = record?.turns.length ? record.revision : null;
const [saved, setSaved] = useState<ContinueRequest | null>(() => {
try {
return savedContinueRequest(entry);
Expand All @@ -31,6 +36,11 @@ export function AssistantContinueAction({
});
const [failure, setFailure] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [rejected, setRejected] = useState<{
request: ContinueRequest;
record: ReadableAssistantRecord | null;
refreshRequested: boolean;
} | null>(null);
const pending = useRef<AbortController | null>(null);
useEffect(() => {
setBusy(false);
Expand All @@ -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);
Expand All @@ -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,
});
Comment on lines +95 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Lifecycle events suppress conflict recovery

When a lifecycle event precedes the 409 response, pending aborts and setRejected never records the conflict. Review latest record stays hidden, leaving the rejected operation on the retry-only path.

Learn more

The component aborts its pending request whenever entry.lifecycle.revision changes in the lifecycle-reset effect. A lifecycle transition that causes the server's 409 can also publish the new revision before the POST response reaches the browser. The cleanup then aborts the fetch, and the catch block skips all handling because abort.signal.aborted is true. The confirmed conflict therefore never enters the new recovery state.

Example: Continue starts at revision 2. End advances the source to revision 3 and its lifecycle event renders first. The effect aborts the POST before its lifecycle_changed response is processed, so the user sees only Retry Continue instead of Review latest record.

Recommended fix: Preserve enough operation state to classify the matching 409 despite a source lifecycle render, or stop aborting an in-flight Continue solely because its displayed lifecycle revision changed. Keep navigation, authority, scope, and boot-token abort fences intact, and add a test that publishes the conflicting lifecycle before fulfilling the 409 response.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

setFailure(
error instanceof Error
? error.message
: "Continuation is unavailable. Your original history is still available.",
);
}
} finally {
if (pending.current === abort) {
pending.current = null;
Expand Down Expand Up @@ -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."}
</p>
{rejected && onRefreshRecord && (
<>
<button
className={reviewed ? "btn-primary" : "btn-ghost"}
disabled={busy || entry.lifecycle.lifecycle === "ending"}
onClick={() => {
if (reviewed) void run(true);
else {
setRejected({
...rejected,
record: record ?? null,
refreshRequested: true,
});
onRefreshRecord();
}
}}
>
{reviewed ? "Start a new continuation" : "Review latest record"}
</button>
<p className="dead-session-resume-reason">
The previous continuation was rejected because the session changed.
Review the latest saved record before starting a new continuation.
</p>
</>
)}
{failure && (
<p className="dead-session-resume-reason" role="alert">
{failure}
Expand Down
7 changes: 5 additions & 2 deletions packages/harness/web/src/components/AssistantHistoryPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,7 +92,7 @@ export function AssistantHistoryPane({
</p>
)}
<div className="past-session-body">
{actions?.(state.record)}
{actions?.(state.record, () => setAttempt((value) => value + 1))}
{state.loading ? (
<div className="past-session-status" role="status">
Loading Assistant history…
Expand Down
Loading
Loading