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-ui-operation-retries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Preserve uncertain Assistant Resume requests and fence stale Terminal Resume and End acknowledgements.
6 changes: 6 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,9 @@ or confirmed removal, and other sessions keep their own process and binding.
Brief part selection keeps the latest useful Assistant text, tool results and
attachments even when blank or omitted control parts follow them. Omission counts
remain explicit without incorrectly labeling every removed part as earlier.

End and ordinary Terminal Resume acknowledgements yield to newer operations on
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.
122 changes: 122 additions & 0 deletions packages/harness/web/e2e/assistant-end.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ async function openStudio(page: Page) {
? { ok: true }
: { failure: { code: "cleanup_unconfirmed" } },
});
if (status === 200)
await expect
.poll(() =>
page.evaluate(
() => (window as any).__HARNESS_TEST__.killSessionCalls ?? [],
),
)
.toContain(
new URL(requests[0]!.request().url()).pathname.split("/").at(-1),
);
},
};
}
Expand Down Expand Up @@ -308,3 +318,115 @@ test("late End cannot remove or select a row after an auth barrier", async ({
await expect(page.locator(".harness-terminal")).toBeVisible();
expect(fixture.unexpected).toEqual([]);
});

async function controlledTerminalResume(page: Page) {
const requests: Route[] = [];
await page.route("**/api/sessions/*/resume", (route) => {
requests.push(route);
});
await page.evaluate(async () => {
const url = performance
.getEntriesByType("resource")
.find(
(entry) => new URL(entry.name).pathname === "/src/lib/api.ts",
)!.name;
const { MockApi } = await import(url);
MockApi.prototype.resumeSession = async function (id: string) {
const response = await fetch(
`/api/sessions/${encodeURIComponent(id)}/resume`,
{ method: "POST" },
);
const session = await response.json();
(window as any).__HARNESS_TEST__.terminalResumeResponse = session.title;
return session;
};
});
return requests;
}
const resumable = (title = "Resumed Terminal") =>
session("sess-boot", {
title,
agentSessionId: "8f2b1c6a-4d3e-4a11-9c2f-1a2b3c4d5e6f",
});
const terminalExited = (page: Page) =>
publish(page, {
type: "session.status",
session: { ...resumable(), status: "exited", ready: false },
});

test("late End ACK cannot overwrite a newer ordinary Terminal Resume without an Assistant bus transition", async ({
page,
}) => {
const fixture = await openStudio(page);
const resumes = await controlledTerminalResume(page);
await project(page, 1, [lifecycle("sess-boot")]);
await openEnd(page);
await page.getByTestId("end-session-confirm-btn").click();
await expect.poll(() => fixture.requests.length).toBe(1);
await terminalExited(page);
await page.getByTestId("dead-session-resume").click();
await expect.poll(() => resumes.length).toBe(1);
await resumes[0]!.fulfill({ json: resumable() });
await expect(page.locator(".harness-terminal")).toBeVisible();
await fixture.settle(200);
await expect(selected(page)).toHaveAttribute("data-session-id", "sess-boot");
await expect(page.getByTestId("session-tab-sess-boot")).toContainText(
"Resumed Terminal",
);
await expect(page.locator(".harness-terminal")).toBeVisible();
});

for (const barrier of ["End", "account", "newer bus"] as const) {
test(`an ordinary Terminal Resume response yields to ${barrier}`, async ({
page,
}) => {
const fixture = await openStudio(page);
const resumes = await controlledTerminalResume(page);
await project(page, 1, [lifecycle("sess-boot")]);
await terminalExited(page);
await page.getByTestId("dead-session-resume").click();
await expect.poll(() => resumes.length).toBe(1);
if (barrier === "End") {
await openEnd(page);
await page.getByTestId("end-session-confirm-btn").click();
await fixture.settle(200);
await expect(selected(page)).not.toHaveAttribute(
"data-session-id",
"sess-boot",
);
} else {
if (barrier === "account")
await publish(page, {
type: "auth.changed",
authenticated: true,
organizationName: "Account B",
});
await publish(page, {
type: "session.status",
session: resumable("Newer current Terminal"),
});
}
await resumes[0]!.fulfill({ json: resumable("Stale Terminal response") });
await expect
.poll(() =>
page.evaluate(
() => (window as any).__HARNESS_TEST__.terminalResumeResponse,
),
)
.toBe("Stale Terminal response");
await expect(
page.getByText("Stale Terminal response", { exact: true }),
).toHaveCount(0);
if (barrier === "End") {
await expect(selected(page)).not.toHaveAttribute(
"data-session-id",
"sess-boot",
);
await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(0);
} else {
await expect(page.getByTestId("session-tab-sess-boot")).toContainText(
"Newer current Terminal",
);
}
});
}
75 changes: 72 additions & 3 deletions packages/harness/web/e2e/assistant-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ const review = async (page: Page, id = "assistant-only") => {
const checkResume = (page: Page) =>
page.getByRole("button", { name: "Check Resume availability", exact: true });
const resumeButton = (page: Page) =>
page.getByRole("button", { name: "Resume Assistant", exact: true });
page.getByRole("button", { name: /^(Resume Assistant|Retry Resume)$/ });

test("Resume verifies availability, preserves identity and opens paused without submitting work", async ({
page,
Expand Down Expand Up @@ -516,7 +516,9 @@ test("uncertain Resume retries the same operation and accepts reconciled later r
.getByRole("button", { name: "Close", exact: true })
.click();
await page.getByRole("button", { name: "Go back" }).click();
await checkResume(page).click();
await expect(
page.getByRole("button", { name: "Retry Resume", exact: true }),
).toBeEnabled();
probe.failResume = false;
probe.resumedRevision = 4;
await resumeButton(page).click();
Expand Down Expand Up @@ -553,7 +555,7 @@ for (const barrier of ["navigation", "account", "lifecycle"] as const) {
},
});
await probe.resumes[0]!.fulfill({ json: probe.resumed("assistant-only") });
if (barrier === "lifecycle") await expect(checkResume(page)).toBeEnabled();
if (barrier === "lifecycle") await expect(resumeButton(page)).toBeEnabled();
else
await expect(page.getByTestId("assistant-history-pane")).toHaveCount(0);
await expect(
Expand Down Expand Up @@ -652,3 +654,70 @@ test("resumed Assistant yields to newer foreground Terminal work", async ({
).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(".harness-terminal")).toBeVisible();
});

for (const revisit of [false, true]) {
test(`Resume keeps the original revision after its bus commit and lost ACK${revisit ? " across Close and Back" : ""}`, async ({
page,
}) => {
const probe = await setup(page);
probe.holdResume = true;
await review(page);
await checkResume(page).click();
await resumeButton(page).click();
await expect.poll(() => probe.resumes.length).toBe(1);
const original = probe.resumes[0]!.request().postDataJSON();
await publish(page, {
type: "assistant.state",
snapshot: {
hostInstanceId: "history-host",
authorityRevision: "account-a",
revision: 2,
enabled: true,
sessions: [],
lifecycles: [probe.resumed("assistant-only").attachment.lifecycle],
},
});
await probe.resumes[0]!.fulfill({ status: 503, json: {} });
await expect(page.getByRole("alert")).toContainText(
"Retry to check the same operation",
);
if (revisit) {
await page
.getByTestId("assistant-history-pane")
.getByRole("button", { name: "Close", exact: true })
.click();
await page.getByRole("button", { name: "Go back" }).click();
}
await page
.getByRole("button", { name: "Retry Resume", exact: true })
.click();
await expect.poll(() => probe.resumes.length).toBe(2);
expect(probe.resumes[1]!.request().postDataJSON()).toEqual(original);
expect(original.expectedRevision).toBe(2);
expect(probe.inspections).toHaveLength(1);
probe.resumedRevision = 4;
await probe.resumes[1]!.fulfill({ json: probe.resumed("assistant-only") });
await expect(page.getByText("Paused", { exact: true })).toBeVisible();
});
}

test("a definite Resume conflict permits a fresh availability check without auto dispatch", async ({
page,
}) => {
const probe = await setup(page);
probe.holdResume = true;
await review(page);
await checkResume(page).click();
await resumeButton(page).click();
await expect.poll(() => probe.resumes.length).toBe(1);
await probe.resumes[0]!.fulfill({
status: 409,
json: { failure: openCodeTransportFailure("lifecycle_changed") },
});
await expect(checkResume(page)).toBeEnabled();
await expect(resumeButton(page)).toHaveCount(0);
expect(probe.resumes).toHaveLength(1);
await expect(page.getByTestId("assistant-transcript")).toContainText(
"Saved answer assistant-only",
);
});
4 changes: 2 additions & 2 deletions packages/harness/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ import { Terminal } from "./components/Terminal";
import { AssistantPane } from "./components/AssistantPane";
import { AssistantHistoryPane } from "./components/AssistantHistoryPane";
import type { AssistantHistoryEntry } from "../../src/shared/assistant-history";
import { AssistantHistoryActions } from "./components/AssistantHistoryActions";
import { AssistantHistoryActions, type AssistantResumeOperation } from "./components/AssistantHistoryActions";
import { AssistantContinueAction } from "./components/AssistantContinueAction";
import { DormantTerminalPane } from "./components/DormantTerminalPane";
import type { ChatDraftStore } from "./components/OpenCodeChat";
Expand Down Expand Up @@ -742,7 +742,7 @@ export const App = (): JSX.Element => {
const [assistantReview, setAssistantReview] = useState<{
entry: AssistantHistoryEntry; authority: string; navigation: number;
} | null>(null);
const assistantResumeOperations = useMemo(() => new Map<string, string>(), [harness.assistantHistoryAuthority, harness.bootToken]);
const assistantResumeOperations = useMemo(() => new Map<string, AssistantResumeOperation>(), [harness.assistantHistoryAuthority, harness.bootToken]);
const setReviewSummary = useCallback((summary: SessionSummary | null) => {
setAssistantReview(null);
setTerminalReviewSummary(summary);
Expand Down
70 changes: 52 additions & 18 deletions packages/harness/web/src/components/AssistantHistoryActions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { useEffect, useRef, useState } from "react";
import type { AssistantHistoryEntry } from "../../../src/shared/assistant-history";
import type { AssistantLifecycle } from "../../../src/shared/assistant-session";
import { inspectAssistant } from "../lib/assistant-resume-client";
import {
inspectAssistant,
isAssistantLifecycleConflict,
} from "../lib/assistant-resume-client";

export interface AssistantResumeOperation {
entry: AssistantHistoryEntry;
operationId: string;
}

export function AssistantHistoryActions({
entry,
Expand All @@ -13,7 +21,7 @@ export function AssistantHistoryActions({
entry: AssistantHistoryEntry;
lifecycle?: AssistantLifecycle;
bootToken: string;
operations: Map<string, string>;
operations: Map<string, AssistantResumeOperation>;
onResume: (
entry: AssistantHistoryEntry,
operationId: string,
Expand Down Expand Up @@ -48,12 +56,11 @@ export function AssistantHistoryActions({
busy === "resume"
)
return;
operations.delete(`${entry.harnessSessionId}:${observedRevision.current}`);
observedRevision.current = current.lifecycle.revision;
request.current?.abort();
request.current = null;
setInspected(null);
setFailure(null);
if (!operations.has(entry.harnessSessionId)) setFailure(null);
setBusy(null);
}, [entry.harnessSessionId, current.lifecycle.revision, busy, operations]);
const run = async (action: "inspect" | "resume") => {
Expand All @@ -66,30 +73,54 @@ export function AssistantHistoryActions({
if (action === "inspect") {
const next = await inspectAssistant(current, bootToken, abort.signal);
if (!abort.signal.aborted) setInspected(next);
} else if (inspected?.nativeResume === "available") {
const key = `${entry.harnessSessionId}:${inspected.lifecycle.revision}`;
const operationId = operations.get(key) ?? crypto.randomUUID();
operations.set(key, operationId);
const opened = await onResume(inspected, operationId, abort.signal);
if (!abort.signal.aborted && opened) operations.delete(key);
} else {
const key = entry.harnessSessionId;
const operation =
operations.get(key) ??
(inspected?.nativeResume === "available"
? { entry: inspected, operationId: crypto.randomUUID() }
: null);
if (!operation) return;
// Keep the original revision even when our own commit arrives over the
// bus before an HTTP failure. A retry reconciles that exact operation.
operations.set(key, operation);
const opened = await onResume(
operation.entry,
operation.operationId,
abort.signal,
);
if (
!abort.signal.aborted &&
opened &&
operations.get(key) === operation
)
operations.delete(key);
else if (!abort.signal.aborted)
setFailure("This session changed. Check Resume availability again.");
setFailure(
"Your selection or session changed. Return here to retry the original Resume request.",
);
}
} catch (error) {
if (!abort.signal.aborted)
if (!abort.signal.aborted) {
if (isAssistantLifecycleConflict(error)) {
operations.delete(entry.harnessSessionId);
setInspected(null);
}
setFailure(
error instanceof Error
? error.message
: "Assistant is unavailable. Check again.",
);
}
} finally {
if (request.current === abort) {
request.current = null;
setBusy(null);
}
}
};
const available = inspected?.nativeResume === "available";
const retry = operations.has(entry.harnessSessionId);
const available = retry || inspected?.nativeResume === "available";
return (
<div data-testid="assistant-history-actions">
{current.lifecycle.lifecycle === "ending" ? (
Expand All @@ -107,14 +138,17 @@ export function AssistantHistoryActions({
? "Checking Resume availability…"
: busy === "resume"
? "Resuming Assistant…"
: available
? "Resume Assistant"
: "Check Resume availability"}
: retry
? "Retry Resume"
: available
? "Resume Assistant"
: "Check Resume availability"}
</button>
{available && (
<p className="dead-session-resume-reason">
Restores the same conversation. Restarted Assistant stays paused
until you send a message.
{retry
? "Retry checks the original Resume request, including its saved revision."
: "Restores the same conversation. Restarted Assistant stays paused until you send a message."}
</p>
)}
{inspected && !available && (
Expand Down
Loading
Loading