fix(web): recover plan comments without blocking empty chat (#3616)

This commit is contained in:
José Almeida
2026-09-13 14:14:26 +02:00
committed by GitHub
parent f718c50666
commit 0c2901a788
45 changed files with 3553 additions and 499 deletions
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { planCommentRecovery } from "@/lib/plan-comment-recovery";
const toastMock = vi.fn();
const handleSendMessageMock = vi.fn();
@@ -175,7 +176,15 @@ function panelState(overrides = {}) {
clearEphemeral: vi.fn(),
addContextFile: vi.fn(),
planModeEnabled: false,
planCommentMigration: { status: "complete", isReady: true, isBlocking: false, retry: vi.fn() },
planCommentMigration: {
status: "complete",
pendingCount: 0,
failure: null,
needsAttention: false,
isReady: true,
isBlocking: false,
retry: vi.fn(),
},
...overrides,
} as never;
}
@@ -381,31 +390,61 @@ describe("useSubmitHandler plan mode", () => {
});
describe("useSubmitHandler task plan comments", () => {
it("blocks delivery while legacy comments still need migration", async () => {
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
planCommentMigration: {
status: "failed",
isReady: false,
isBlocking: true,
retry: vi.fn(),
},
}),
),
);
it.each(["idle", "retrying", "failed"] as const)(
"accepts plain Send with no identified drafts during %s recovery",
async (status) => {
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
planCommentMigration: {
...planCommentRecovery({ status, pendingCount: 0, failure: "transient" }),
retry: vi.fn(),
},
}),
),
);
await act(async () => {
await expect(result.current.handleSubmit({ message: "Send my message" })).resolves.toBe(
true,
);
});
expect(handleSendMessageMock).toHaveBeenCalledWith({ message: "Send my message" });
expect(toastMock).not.toHaveBeenCalled();
},
);
it.each(["transient", "conflict", "rejected"] as const)(
"preserves blocked delivery without promising retries for %s recovery",
async (failure) => {
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
planCommentMigration: {
status: "failed",
pendingCount: 1,
failure,
needsAttention: true,
isReady: false,
isBlocking: true,
retry: vi.fn(),
},
}),
),
);
await act(async () => {
await expect(result.current.handleSubmit({ message: "Keep my draft" })).resolves.toBe(false);
});
await act(async () => {
await expect(result.current.handleSubmit({ message: "Keep my draft" })).resolves.toBe(
false,
);
});
expect(handleSendMessageMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith({
title: "Message not sent",
description: "Saved plan comments are still being restored. Retry before sending.",
variant: "error",
});
});
expect(handleSendMessageMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith({
title: "Message not sent",
description: "Saved plan comments are still being restored. Your message is kept.",
variant: "error",
});
},
);
it("submits displayed IDs and versions without clearing the shared snapshot locally", async () => {
const clearSessionPlanComments = vi.fn();
@@ -418,7 +457,16 @@ describe("useSubmitHandler task plan comments", () => {
selectedText: "Large step",
};
const { result } = renderHook(() =>
useSubmitHandler(panelState({ planComments: [comment], clearSessionPlanComments })),
useSubmitHandler(
panelState({
planComments: [comment],
clearSessionPlanComments,
planCommentMigration: {
...planCommentRecovery({ status: "failed", pendingCount: 0, failure: "transient" }),
retry: vi.fn(),
},
}),
),
);
await act(async () => {
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { DiffComment } from "@/lib/diff/types";
import type { TaskMentionData } from "@/hooks/use-inline-mention";
import { planCommentRecovery } from "@/lib/plan-comment-recovery";
import {
buildContextFilesMeta,
buildPassthroughFinalMessage,
@@ -88,7 +89,15 @@ function panelState(overrides: Record<string, unknown> = {}) {
walkthroughComments: [],
messageComments: [],
planModeEnabled: false,
planCommentMigration: { status: "complete", isReady: true, isBlocking: false, retry: vi.fn() },
planCommentMigration: {
status: "complete",
pendingCount: 0,
failure: null,
needsAttention: false,
isReady: true,
isBlocking: false,
retry: vi.fn(),
},
handleClearPRFeedback: vi.fn(),
clearSessionPlanComments: vi.fn(),
handleClearWalkthroughComments: vi.fn(),
@@ -286,20 +295,38 @@ describe("passthrough chat composer plan context", () => {
});
describe("passthrough chat composer cleanup", () => {
it("rejects delivery while legacy plan comments are not migrated", () => {
it("accepts plain delivery after background read failures without identified drafts", () => {
expect(() =>
assertPlanCommentMigrationReady(
panelState({
planCommentMigration: {
status: "failed",
isReady: false,
isBlocking: true,
...planCommentRecovery({ status: "failed", pendingCount: 0, failure: "transient" }),
retry: vi.fn(),
},
}),
),
).toThrow("Saved plan comments are still being restored. Retry before sending.");
).not.toThrow();
});
it.each(["transient", "conflict", "rejected"] as const)(
"preserves blocked delivery without promising retries for %s recovery",
(failure) => {
expect(() =>
assertPlanCommentMigrationReady(
panelState({
planCommentMigration: {
status: "failed",
pendingCount: 1,
failure,
needsAttention: true,
isReady: false,
isBlocking: true,
retry: vi.fn(),
},
}),
),
).toThrow("Saved plan comments are still being restored. Your message is kept.");
},
);
it("clears session context but leaves task plan comments to the backend snapshot", () => {
const state = panelState({
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { planCommentRecovery } from "@/lib/plan-comment-recovery";
import { PlanCommentMigrationNotice } from "./plan-comment-migration-notice";
afterEach(cleanup);
describe("plan comment recovery feedback", () => {
it.each(["idle", "running", "retrying", "complete", "failed", "waiting_for_plan"] as const)(
"stays quiet for empty %s recovery",
(status) => {
render(
<PlanCommentMigrationNotice
{...planCommentRecovery({ status, pendingCount: 0, failure: null })}
retry={vi.fn()}
/>,
);
expect(screen.queryByTestId("plan-comment-migration-notice")).toBeNull();
},
);
it("stays quiet while real drafts retry automatically", () => {
render(
<PlanCommentMigrationNotice
{...planCommentRecovery({ status: "retrying", pendingCount: 1, failure: "transient" })}
retry={vi.fn()}
/>,
);
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByTestId("plan-comment-migration-notice")).toBeNull();
});
it("offers explicit Retry for identified feedback needing attention", () => {
const retry = vi.fn();
render(
<PlanCommentMigrationNotice
{...planCommentRecovery({ status: "failed", pendingCount: 1, failure: "conflict" })}
retry={retry}
/>,
);
expect(screen.getByRole("alert")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(retry).toHaveBeenCalledOnce();
});
it("offers Retry with a status notice when saved feedback needs a plan", () => {
const retry = vi.fn();
render(
<PlanCommentMigrationNotice
{...planCommentRecovery({ status: "waiting_for_plan", pendingCount: 1, failure: null })}
retry={retry}
/>,
);
expect(screen.getByRole("status")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(retry).toHaveBeenCalledOnce();
});
});
@@ -1,48 +1,44 @@
"use client";
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import type { PlanCommentMigrationStatus } from "@/lib/state/slices/session/types";
type PlanCommentMigrationNoticeProps = {
status: PlanCommentMigrationStatus;
needsAttention: boolean;
retry: () => void;
};
export function PlanCommentMigrationNotice({ status, retry }: PlanCommentMigrationNoticeProps) {
export function PlanCommentMigrationNotice({
status,
needsAttention,
retry,
}: PlanCommentMigrationNoticeProps) {
const { t } = useTranslation("task");
if (status === "complete") return null;
if (!needsAttention) return null;
const isFailed = status === "failed";
const isWaitingForPlan = status === "waiting_for_plan";
let message = t("restoringSavedPlanComments");
if (isFailed) message = t("planCommentMigrationFailed");
else if (isWaitingForPlan) message = t("planCommentMigrationNeedsPlan");
const message = isFailed ? t("planCommentMigrationFailed") : t("planCommentMigrationNeedsPlan");
return (
<div
className="mx-1 mb-1 flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/50 px-2 py-1.5 text-xs text-muted-foreground [@media(pointer:coarse)]:min-h-11"
className="mx-1 mb-1 flex min-h-9 flex-wrap items-center gap-2 rounded-md border border-border bg-muted/50 px-2 py-1.5 text-xs text-muted-foreground [@media(pointer:coarse)]:min-h-11"
role={isFailed ? "alert" : "status"}
data-testid="plan-comment-migration-notice"
>
{isFailed || isWaitingForPlan ? (
<IconAlertTriangle className="h-4 w-4 shrink-0" />
) : (
<IconLoader2 className="h-4 w-4 shrink-0 animate-spin" />
)}
<IconAlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">{message}</span>
{isFailed && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 shrink-0 text-xs [@media(pointer:coarse)]:h-11"
onClick={retry}
>
{t("retry")}
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 shrink-0 text-xs [@media(pointer:coarse)]:h-11"
onClick={retry}
>
{t("retry")}
</Button>
</div>
);
}
+1 -6
View File
@@ -309,10 +309,8 @@ function removeCommentMark(editor: Editor | null, commentId: string) {
}
function planCommentRunDisabledReason(
migrationReady: boolean,
reason: ReturnType<typeof resolvePlanCommentRunAvailability>["reason"],
) {
if (!migrationReady) return t("task:planCommentMigrationPending");
if (reason === "no-primary-session") return t("task:noPrimarySessionForPlanComment");
if (reason === "primary-session-unavailable") {
return t("task:primarySessionUnavailableForPlanComment");
@@ -434,10 +432,7 @@ function PlanSelectionPopoverWrapper({
const runUnavailableReason = useAppStore(
(state) => resolvePlanCommentRunAvailability(state, taskId).reason,
);
const migrationReady = useAppStore((state) =>
taskId ? state.taskPlans.commentsMigrationStatusByTaskId[taskId] === "complete" : false,
);
const runDisabledReason = planCommentRunDisabledReason(migrationReady, runUnavailableReason);
const runDisabledReason = planCommentRunDisabledReason(runUnavailableReason);
const { handleAdd, handleAddAndRun, runError } = usePlanSelectionCommentActions({
textSelection,
commentState,
+5 -2
View File
@@ -654,9 +654,12 @@ export class SessionPage {
return this.page.getByTestId("reset-context-confirm");
}
/** "Resume session" button shown after agent crash. */
/** Observe the first visible recovery action, including while it is already resuming. */
recoveryResumeButton(): Locator {
return this.page.getByTestId("recovery-resume-button");
return this.activeChat()
.getByTestId("recovery-resume-button")
.filter({ visible: true })
.first();
}
/** Error returned by a manual session recovery action. */
@@ -3,6 +3,10 @@ import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { planScript } from "../../helpers/seed-session-messages";
import { SessionPage } from "../../pages/session-page";
import {
assertPlainSendDuringRecovery,
assertLegacyRecoveryPreservesDraft,
} from "./plan-comment-recovery-helpers";
const PLAN_CONTENT = "## Mobile shared plan\n\nReview this mobile implementation step";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
@@ -10,6 +14,29 @@ const LEGACY_PRIMARY_ID = "11111111-1111-4111-8111-111111111111";
const LEGACY_SECONDARY_ID = "22222222-2222-4222-8222-222222222222";
test.describe("mobile: task-owned plan comments", () => {
// @covers AC-TASKS-PLAN-COMMENTS-004.5, AC-TASKS-PLAN-COMMENTS-004.8
for (const withPlan of [false, true]) {
test(`plain Send survives failed comment reads ${withPlan ? "with" : "without"} a plan`, async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
await assertPlainSendDuringRecovery(
{ testPage, apiClient, seedData, mobile: true },
withPlan,
);
});
}
// @covers AC-TASKS-PLAN-COMMENTS-004.4, AC-TASKS-PLAN-COMMENTS-004.6, AC-TASKS-PLAN-COMMENTS-004.8
test("resumes feedback recovery and retains the mobile draft and focus", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
await assertLegacyRecoveryPreservesDraft({ testPage, apiClient, seedData, mobile: true });
});
// @covers AC-TASKS-PLAN-COMMENTS-001.7
// @covers AC-TASKS-PLAN-COMMENTS-003.2
// @covers AC-TASKS-PLAN-COMMENTS-004.1
@@ -0,0 +1,297 @@
import type { Page } from "@playwright/test";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { planScript } from "../../helpers/seed-session-messages";
import { SessionPage } from "../../pages/session-page";
type Frame = {
id?: string;
type?: string;
action?: string;
payload?: { task_id?: string; id?: string };
};
type RecoveryOptions = {
testPage: Page;
apiClient: ApiClient;
seedData: SeedData;
mobile: boolean;
};
const LEGACY_ID = "33333333-3333-4333-8333-333333333333";
const FEEDBACK = "Keep this recovered feedback";
function frame(value: string): Frame | null {
try {
return JSON.parse(value) as Frame;
} catch {
return null;
}
}
function matchesRequest(
parsed: Frame | null,
taskId: string,
actions: string[],
): parsed is Frame & { id: string } {
return (
parsed?.type === "request" &&
typeof parsed.id === "string" &&
parsed.payload?.task_id === taskId &&
actions.includes(parsed.action ?? "")
);
}
/** Reject only the selected task's correlated reads/uploads; keep chat and other frames live. */
async function interceptPlanRequests(
page: Page,
taskId: string,
actions: string[],
failedCommentId?: string,
) {
let blocked = true;
let rejected = 0;
let succeeded = 0;
await page.routeWebSocket(/\/ws$/, (ws) => {
const server = ws.connectToServer();
const requestIds = new Set<string>();
ws.onMessage((message) => {
if (typeof message !== "string") {
server.send(message);
return;
}
const forwarded: string[] = [];
for (const part of message.split("\n")) {
const parsed = frame(part);
const matches = matchesRequest(parsed, taskId, actions);
if (matches && blocked && (!failedCommentId || parsed.payload?.id === failedCommentId)) {
rejected++;
ws.send(
JSON.stringify({
type: "error",
id: parsed.id,
action: parsed.action,
payload: {
code: "INTERNAL_ERROR",
message: "Injected transient plan request failure",
},
}),
);
} else {
if (matches) requestIds.add(parsed.id!);
if (part) forwarded.push(part);
}
}
if (forwarded.length) server.send(forwarded.join("\n"));
});
server.onMessage((message) => {
if (typeof message === "string")
for (const part of message.split("\n")) {
const parsed = frame(part);
if (parsed?.type === "response" && parsed.id && requestIds.delete(parsed.id)) succeeded++;
}
ws.send(message);
});
});
return {
rejected: () => rejected,
succeeded: () => succeeded,
release: () => {
blocked = false;
},
};
}
async function seedRecoveryTask({ apiClient, seedData }: RecoveryOptions, withPlan: boolean) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Plan comment recovery",
seedData.agentProfileId,
{
description: withPlan
? planScript("## Recovery plan\n\nPreserve feedback")
: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("Expected a task session");
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return sessions.some(
(session) =>
session.id === task.session_id &&
["COMPLETED", "WAITING_FOR_INPUT"].includes(session.state),
);
},
{ timeout: 60_000 },
)
.toBe(true);
if (withPlan)
await expect.poll(() => apiClient.getTaskPlan(task.id), { timeout: 30_000 }).not.toBeNull();
return { ...task, session_id: task.session_id };
}
async function showComposer(page: Page, taskId: string) {
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
// Do not use the reload-capable readiness helpers while proving recovery.
await expect(session.activeChat()).toBeVisible({ timeout: 30_000 });
await expect(session.anyIdleInput()).toBeVisible({ timeout: 30_000 });
const editor = session
.activeChat()
.locator('.tiptap.ProseMirror[contenteditable="true"]')
.first();
await expect(editor).toBeEditable();
return { session, editor };
}
async function submit(session: SessionPage, mobile: boolean) {
if (mobile) await session.tapSubmitWhenReady();
else await session.clickSubmitWhenReady();
}
export async function assertPlainSendDuringRecovery(options: RecoveryOptions, withPlan: boolean) {
const { testPage, apiClient, mobile } = options;
const task = await seedRecoveryTask(options, withPlan);
const control = await interceptPlanRequests(testPage, task.id, [
"task.plan.get",
"task.plan.comments.list",
]);
const { session, editor } = await showComposer(testPage, task.id);
await expect.poll(control.rejected, { timeout: 15_000 }).toBeGreaterThan(0);
const notice = testPage.getByTestId("plan-comment-migration-notice");
await expect(notice).toHaveCount(0);
const message = "Plain message while comment reads are unavailable";
if (mobile) await editor.tap();
else await editor.click();
await editor.fill(message);
await testPage.screenshot({ path: test.info().outputPath("plan-comment-recovery-empty.png") });
await submit(session, mobile);
await expect
.poll(
async () =>
(await apiClient.listSessionMessages(task.session_id)).messages.filter(
(entry) => entry.author_type === "user" && entry.content.includes(message),
).length,
{ timeout: 30_000 },
)
.toBe(1);
await expect(editor).toHaveText("");
control.release();
await testPage.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect.poll(control.succeeded, { timeout: 15_000 }).toBeGreaterThan(0);
await expect(notice).toHaveCount(0);
if (mobile) await assertNoDocumentHorizontalOverflow(testPage, "empty plan comment recovery");
}
async function seedMixedFeedback(page: Page, sessionId: string) {
const legacy = {
sessionId,
source: "plan",
selectedText: "Preserve feedback",
from: 1,
to: 10,
createdAt: "2026-09-02T00:00:00Z",
status: "pending",
};
const diff = {
id: "legacy-diff",
sessionId,
source: "diff",
text: "Keep this diff feedback",
filePath: "src/app.ts",
startLine: 1,
endLine: 1,
side: "additions",
codeContent: "code",
createdAt: legacy.createdAt,
status: "pending",
};
const rows = [
{ ...legacy, id: "44444444-4444-4444-8444-444444444444", text: "Already recovered feedback" },
{ ...legacy, id: LEGACY_ID, text: FEEDBACK },
diff,
];
await page.addInitScript(
({ sessionId, rows }) => {
sessionStorage.setItem(`kandev.comments.${sessionId}`, JSON.stringify(rows));
},
{ sessionId, rows },
);
return diff;
}
export async function assertLegacyRecoveryPreservesDraft(options: RecoveryOptions) {
const { testPage, apiClient, mobile } = options;
const task = await seedRecoveryTask(options, true);
const control = await interceptPlanRequests(
testPage,
task.id,
["task.plan.comments.create"],
LEGACY_ID,
);
const diff = await seedMixedFeedback(testPage, task.session_id);
const { session, editor } = await showComposer(testPage, task.id);
await expect.poll(control.rejected, { timeout: 15_000 }).toBeGreaterThanOrEqual(3);
await expect(session.activeChat().getByText("1 plan comment", { exact: true })).toBeVisible();
const notice = session.activeChat().getByTestId("plan-comment-migration-notice");
await expect(notice).toBeVisible();
await testPage.screenshot({
path: test.info().outputPath("plan-comment-recovery-attention.png"),
});
if (mobile) {
const retrySize = await notice
.getByRole("button", { name: "Retry", exact: true })
.boundingBox();
expect(retrySize?.height).toBeGreaterThanOrEqual(44);
await assertNoDocumentHorizontalOverflow(testPage, "actionable plan comment recovery");
await editor.tap();
} else await editor.click();
const message = "Retain this draft until feedback recovers";
await editor.fill(message);
await submit(session, mobile);
await expect(testPage.getByText("Message not sent", { exact: true })).toBeVisible();
await expect(editor).toHaveText(message);
await editor.focus();
control.release();
// A browser resume is enough; no Retry, reload, or submit is invoked by recovery.
await testPage.evaluate(() => {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("pageshow"));
document.dispatchEvent(new Event("visibilitychange"));
});
await expect.poll(control.succeeded, { timeout: 15_000 }).toBe(2);
await expect(session.activeChat().getByText("2 plan comments", { exact: true })).toBeVisible();
await expect(notice).toHaveCount(0);
await expect(editor).toHaveText(message);
await expect(editor).toBeFocused();
expect(
(await apiClient.listSessionMessages(task.session_id)).messages.some((entry) =>
entry.content.includes(message),
),
).toBe(false);
expect(
await testPage.evaluate(
(sessionId) => JSON.parse(sessionStorage.getItem(`kandev.comments.${sessionId}`) ?? "[]"),
task.session_id,
),
).toEqual([diff]);
expect(control.succeeded()).toBe(2);
await submit(session, mobile);
await expect
.poll(
async () =>
(await apiClient.listSessionMessages(task.session_id)).messages.filter(
(entry) =>
entry.author_type === "user" &&
entry.content.includes(message) &&
entry.content.includes(FEEDBACK) &&
entry.content.includes(diff.text),
).length,
{ timeout: 30_000 },
)
.toBe(1);
}
@@ -0,0 +1,37 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
test("idle recovery ignores background controls and observes an in-flight resume", async ({
testPage,
}) => {
await testPage.setContent(`
<div data-testid="session-chat" hidden>
<button data-testid="recovery-resume-button">Background resume</button>
</div>
<div data-testid="session-chat">
<button id="primary" data-testid="recovery-resume-button" disabled>Resuming</button>
<button id="duplicate" data-testid="recovery-resume-button">Resume</button>
<div id="editor" class="tiptap ProseMirror" contenteditable="true"
data-placeholder="Continue working on the task..." hidden></div>
</div>
`);
await testPage.evaluate(() => {
document.getElementById("duplicate")!.addEventListener("click", () => {
document.body.dataset.duplicateClicked = "true";
});
document.getElementById("primary")!.addEventListener("click", () => {
document.getElementById("primary")!.remove();
document.getElementById("duplicate")!.remove();
document.getElementById("editor")!.hidden = false;
});
});
const session = new SessionPage(testPage);
await expect(session.recoveryResumeButton()).toHaveCount(1);
await expect(session.recoveryResumeButton()).toBeDisabled();
await testPage.locator("#primary").evaluate((button: HTMLButtonElement) => {
button.disabled = false;
});
await session.waitForChatIdle({ timeout: 5_000, requireEditable: true });
await expect(session.anyIdleInput()).toBeVisible();
await expect(testPage.locator("body")).not.toHaveAttribute("data-duplicate-clicked", "true");
});
@@ -5,6 +5,10 @@ import { waitForStableActiveSession } from "../../helpers/session-store";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import {
assertPlainSendDuringRecovery,
assertLegacyRecoveryPreservesDraft,
} from "./plan-comment-recovery-helpers";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
const PLAN_CONTENT = "## Shared plan\n\nReview this shared implementation step";
@@ -65,6 +69,29 @@ async function openPlanCommentComposer(page: Page, session: SessionPage) {
}
test.describe("task-owned plan comments", () => {
// @covers AC-TASKS-PLAN-COMMENTS-004.5, AC-TASKS-PLAN-COMMENTS-004.6
for (const withPlan of [false, true]) {
test(`plain Send survives failed comment reads ${withPlan ? "with" : "without"} a plan`, async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
await assertPlainSendDuringRecovery(
{ testPage, apiClient, seedData, mobile: false },
withPlan,
);
});
}
// @covers AC-TASKS-PLAN-COMMENTS-004.3, AC-TASKS-PLAN-COMMENTS-004.4, AC-TASKS-PLAN-COMMENTS-004.6
test("automatically restores real feedback without submitting or losing the draft", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
await assertLegacyRecoveryPreservesDraft({ testPage, apiClient, seedData, mobile: false });
});
// @covers AC-TASKS-PLAN-COMMENTS-001.2
// @covers AC-TASKS-PLAN-COMMENTS-002.2
// @covers AC-TASKS-PLAN-COMMENTS-002.5
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createAppStore } from "@/lib/state/store";
import type { TaskPlan, TaskPlanCommentSnapshot } from "@/lib/types/http";
import { planCommentLoaderFor } from "./plan-comment-loading";
const api = vi.hoisted(() => ({ getTaskPlan: vi.fn() }));
vi.mock("@/lib/api/domains/plan-api", () => api);
const comments = vi.hoisted(() => ({ getTaskPlanComments: vi.fn() }));
vi.mock("@/lib/api/domains/plan-comment-api", () => comments);
const releases: Array<() => void> = [];
const DATE = "2026-09-02T00:00:00Z";
const ERROR_MESSAGE = "Could not load comments";
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
});
afterEach(() => {
releases.splice(0).forEach((release) => release());
vi.restoreAllMocks();
vi.useRealTimers();
});
it("releases loading flags when plan hydration invalidates an in-flight read", async () => {
const taskId = "task-1";
const plan: TaskPlan = {
id: "plan-1",
task_id: taskId,
title: "Plan",
content: "Step",
created_by: "agent",
created_at: DATE,
updated_at: DATE,
};
const snapshot: TaskPlanCommentSnapshot = {
task_id: taskId,
plan_id: plan.id,
revision: 1,
comments: [],
};
const store = createAppStore();
store.getState().setTaskPlan(taskId, plan);
store.setState((state) => ({ taskPlans: { ...state.taskPlans, loadedByTaskId: {} } }));
store.getState().setConnectionStatus("connected");
const loader = planCommentLoaderFor(store, taskId, ERROR_MESSAGE);
releases.push(loader.attach());
let resolve!: (value: TaskPlanCommentSnapshot) => void;
comments.getTaskPlanComments.mockImplementationOnce(
() =>
new Promise((done) => {
resolve = done;
}),
);
const reading = loader.load(false);
await vi.advanceTimersByTimeAsync(0);
expect(store.getState().taskPlans.commentsLoadingByTaskId[taskId]).toBe(true);
store.getState().setTaskPlan(taskId, plan);
resolve(snapshot);
await reading;
expect(store.getState().taskPlans.commentsLoadingByTaskId[taskId]).toBe(false);
expect(store.getState().taskPlans.loadingByTaskId[taskId]).toBe(false);
expect(store.getState().taskPlans.commentsByTaskId[taskId]).toBeUndefined();
comments.getTaskPlanComments.mockResolvedValue(snapshot);
await loader.load(false);
expect(store.getState().taskPlans.commentsByTaskId[taskId]).toEqual(snapshot);
});
it("restarts the background retry delay when the plan is replaced", async () => {
const taskId = "task-1";
const plan: TaskPlan = {
id: "plan-1",
task_id: taskId,
title: "Plan",
content: "Step",
created_by: "agent",
created_at: DATE,
updated_at: DATE,
};
const store = createAppStore();
store.getState().setTaskPlan(taskId, plan);
store.getState().setConnectionStatus("connected");
api.getTaskPlan.mockResolvedValue(plan);
comments.getTaskPlanComments.mockRejectedValue(new Error("offline"));
const loader = planCommentLoaderFor(store, taskId, ERROR_MESSAGE);
releases.push(loader.attach());
await loader.load(false);
await vi.advanceTimersByTimeAsync(1000);
expect(comments.getTaskPlanComments).toHaveBeenCalledTimes(2);
const replacement = { ...plan, id: "plan-2" };
store.getState().setTaskPlan(taskId, replacement);
api.getTaskPlan.mockResolvedValue(replacement);
await loader.load(false);
expect(comments.getTaskPlanComments).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(999);
expect(comments.getTaskPlanComments).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(1);
expect(comments.getTaskPlanComments).toHaveBeenCalledTimes(4);
});
it("does not let a hidden wake suppress immediate visible recovery", async () => {
const store = createAppStore();
store.getState().setConnectionStatus("connected");
const loader = planCommentLoaderFor(store, "task-1", ERROR_MESSAGE);
releases.push(loader.attach());
api.getTaskPlan.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(null);
await loader.load(true);
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
await loader.wake();
visibility.mockReturnValue("visible");
await loader.wake();
expect(store.getState().taskPlans.commentsErrorByTaskId["task-1"]).toBeUndefined();
expect(api.getTaskPlan).toHaveBeenCalledTimes(2);
});
it("uses the current localized error when a retained loader fails again", async () => {
const store = createAppStore();
store.getState().setConnectionStatus("connected");
const loader = planCommentLoaderFor(store, "task-1", "Old localized error");
releases.push(loader.attach());
api.getTaskPlan.mockRejectedValue(new Error("offline"));
await loader.load(true);
const translated = planCommentLoaderFor(store, "task-1", "Current localized error");
expect(translated).toBe(loader);
await translated.load(true);
expect(store.getState().taskPlans.commentsErrorByTaskId["task-1"]).toBe(
"Current localized error",
);
});
it("resumes failed ordinary reads when a cached task surface remounts", async () => {
const store = createAppStore();
store.getState().setTaskPlan("task-1", null);
store.getState().setConnectionStatus("connected");
const loader = planCommentLoaderFor(store, "task-1", ERROR_MESSAGE);
api.getTaskPlan.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(null);
const detach = loader.attach();
await loader.load(true);
detach();
await vi.advanceTimersByTimeAsync(120000);
expect(api.getTaskPlan).toHaveBeenCalledOnce();
releases.push(loader.attach());
await vi.advanceTimersByTimeAsync(0);
expect(api.getTaskPlan).toHaveBeenCalledTimes(2);
expect(store.getState().taskPlans.commentsErrorByTaskId["task-1"]).toBeUndefined();
});
@@ -0,0 +1,192 @@
import type { StoreApi } from "zustand";
import { getTaskPlan } from "@/lib/api/domains/plan-api";
import { getTaskPlanComments } from "@/lib/api/domains/plan-comment-api";
import { planCommentRecoveryDelay } from "@/lib/plan-comment-recovery";
import type { AppState } from "@/lib/state/store";
// Keep one loader per visited task for the store's lifetime, including across plan resets.
// Last-detach releases timers and subscriptions; remounts retain the same retry owner.
const loaders = new WeakMap<StoreApi<AppState>, Map<string, PlanCommentLoader>>();
/** Ordinary reads retain the last snapshot and never participate in Send admission. */
class PlanCommentLoader {
private consumers = 0;
private generation = 0;
private planEpoch = 0;
private failures = 0;
private lastWakeAt = -Infinity;
private timer: ReturnType<typeof setTimeout> | undefined;
private inFlight: Promise<void> | undefined;
private activeReadEpoch = 0;
private needsReload = false;
private unsubscribe: (() => void) | undefined;
constructor(
private store: StoreApi<AppState>,
private taskId: string,
private errorMessage: string,
) {}
setErrorMessage(errorMessage: string) {
this.errorMessage = errorMessage;
}
attach() {
this.consumers++;
if (!this.unsubscribe) this.observe();
if (this.failures) void this.load(true);
return () => {
this.consumers--;
if (this.consumers) return;
this.generation++;
this.pause();
this.unsubscribe?.();
this.unsubscribe = undefined;
};
}
private pause() {
clearTimeout(this.timer);
this.timer = undefined;
}
private observe() {
const unsubscribe = this.store.subscribe((state, previous) => {
const planChanged =
state.taskPlans.byTaskId[this.taskId]?.id !== previous.taskPlans.byTaskId[this.taskId]?.id;
if (planChanged) this.failures = 0;
if (
planChanged ||
state.taskPlans.loadedByTaskId[this.taskId] !==
previous.taskPlans.loadedByTaskId[this.taskId]
)
this.planEpoch++;
if (state.connection.status !== "connected") this.pause();
});
const onHidden = () => {
if (document.visibilityState !== "visible") this.pause();
};
document.addEventListener("visibilitychange", onHidden);
this.unsubscribe = () => {
unsubscribe();
document.removeEventListener("visibilitychange", onHidden);
};
}
wake() {
if (!this.ready()) return this.inFlight ?? Promise.resolve();
if (Date.now() - this.lastWakeAt < 250) return this.inFlight ?? Promise.resolve();
this.lastWakeAt = Date.now();
return this.load(true);
}
private ready() {
return (
this.consumers > 0 &&
this.store.getState().connection.status === "connected" &&
document.visibilityState === "visible"
);
}
load(force: boolean): Promise<void> {
if (this.inFlight) {
if (this.planEpoch !== this.activeReadEpoch) this.needsReload = true;
return this.inFlight;
}
if (!this.ready()) return Promise.resolve();
clearTimeout(this.timer);
this.timer = undefined;
const generation = this.generation;
this.activeReadEpoch = this.planEpoch;
this.needsReload = false;
const promise = Promise.resolve()
.then(() => this.read(force, generation))
.finally(() => {
if (this.inFlight === promise) this.inFlight = undefined;
if (generation !== this.generation && this.ready()) void this.load(true);
else if (this.needsReload && this.ready()) void this.load(false);
});
this.inFlight = promise;
return promise;
}
private current(generation: number) {
return this.consumers > 0 && generation === this.generation;
}
private async resolvePlan(force: boolean, generation: number) {
const state = this.store.getState();
const cached = state.taskPlans.byTaskId[this.taskId];
if (!force && cached !== undefined) return cached;
const epoch = this.planEpoch;
state.setTaskPlanLoading(this.taskId, true);
const next = await getTaskPlan(this.taskId);
if (!this.current(generation)) return undefined;
const current = this.store.getState();
if (this.planEpoch !== epoch || current.taskPlans.byTaskId[this.taskId] !== cached)
return current.taskPlans.byTaskId[this.taskId];
current.setTaskPlan(this.taskId, next);
this.activeReadEpoch = this.planEpoch;
return next;
}
private async read(force: boolean, generation: number) {
if (!this.current(generation) || !this.ready()) return;
const state = this.store.getState();
let expectedEpoch = this.planEpoch;
state.setTaskPlanCommentsError(this.taskId);
try {
const plan = await this.resolvePlan(force, generation);
if (!this.current(generation)) return;
expectedEpoch = this.planEpoch;
this.activeReadEpoch = expectedEpoch;
this.needsReload = false;
if (plan && this.ready()) {
this.store.getState().setTaskPlanCommentsLoading(this.taskId, true);
const snapshot = await getTaskPlanComments(this.taskId);
if (!this.current(generation) || expectedEpoch !== this.planEpoch) return;
this.store.getState().setTaskPlanComments(this.taskId, snapshot);
}
this.failures = 0;
} catch {
this.failedRead(generation, expectedEpoch);
} finally {
this.finishRead();
}
}
private failedRead(generation: number, epoch: number) {
if (!this.current(generation) || epoch !== this.planEpoch) return;
this.store.getState().setTaskPlanCommentsError(this.taskId, this.errorMessage);
this.failures++;
if (!this.ready()) return;
this.timer = setTimeout(() => {
this.timer = undefined;
void this.load(true);
}, planCommentRecoveryDelay(this.failures));
}
private finishRead() {
const current = this.store.getState();
current.setTaskPlanLoading(this.taskId, false);
current.setTaskPlanCommentsLoading(this.taskId, false);
}
}
export function planCommentLoaderFor(
store: StoreApi<AppState>,
taskId: string,
errorMessage: string,
) {
let tasks = loaders.get(store);
if (!tasks) {
tasks = new Map();
loaders.set(store, tasks);
}
let loader = tasks.get(taskId);
if (!loader) {
loader = new PlanCommentLoader(store, taskId, errorMessage);
tasks.set(taskId, loader);
} else loader.setErrorMessage(errorMessage);
return loader;
}
@@ -0,0 +1,567 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createAppStore } from "@/lib/state/store";
import { COMMENTS_STORAGE_PREFIX } from "@/lib/state/slices/comments/persistence";
import type { PlanComment } from "@/lib/state/slices/comments";
import type { TaskPlan, TaskPlanCommentSnapshot } from "@/lib/types/http";
import { WebSocketRequestError, toWebSocketRequestError } from "@/lib/ws/request-error";
import { planCommentMigrationFor } from "./plan-comment-migration";
const api = vi.hoisted(() => ({
createTaskPlanComment: vi.fn(),
updateTaskPlanComment: vi.fn(),
}));
const plans = vi.hoisted(() => ({ getTaskPlan: vi.fn() }));
vi.mock("@/lib/api/domains/plan-comment-api", () => api);
vi.mock("@/lib/api/domains/plan-api", () => plans);
const TASK = "task-1";
const SESSION = "session-1";
const PLAN = "plan-1";
const DATE = "2026-09-02T00:00:00Z";
const EDITED_BODY = "Edited feedback";
const plan: TaskPlan = {
id: PLAN,
task_id: TASK,
title: "Plan",
content: "Step",
created_by: "agent",
created_at: DATE,
updated_at: DATE,
};
const comment: PlanComment = {
id: "legacy-1",
sessionId: SESSION,
source: "plan",
text: "Feedback",
selectedText: "Step",
from: 1,
to: 5,
createdAt: DATE,
status: "pending",
};
const snapshot: TaskPlanCommentSnapshot = {
task_id: TASK,
plan_id: PLAN,
revision: 1,
comments: [
{
id: comment.id,
task_id: TASK,
plan_id: PLAN,
body: comment.text,
selected_text: comment.selectedText,
anchor_from: 1,
anchor_to: 5,
version: 1,
created_at: DATE,
updated_at: DATE,
},
],
};
let releases: Array<() => void>;
function write(rows = [comment], sessionId = SESSION) {
window.sessionStorage.setItem(`${COMMENTS_STORAGE_PREFIX}${sessionId}`, JSON.stringify(rows));
}
function saved() {
return JSON.parse(window.sessionStorage.getItem(`${COMMENTS_STORAGE_PREFIX}${SESSION}`) ?? "[]");
}
function setup(connected = true) {
const store = createAppStore();
store.getState().setTaskPlan(TASK, plan);
store.getState().setConnectionStatus(connected ? "connected" : "disconnected");
const recovery = planCommentMigrationFor(store, TASK);
const detach = recovery.attach(vi.fn().mockResolvedValue(undefined));
releases.push(detach);
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
const state = () => store.getState().taskPlans.commentsMigrationByTaskId[TASK];
return { store, recovery, detach, state };
}
function settle() {
return vi.advanceTimersByTimeAsync(0);
}
function deferred<T = TaskPlanCommentSnapshot>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function editedSnapshot(body = EDITED_BODY, version = 2): TaskPlanCommentSnapshot {
return { ...snapshot, revision: version, comments: [{ ...snapshot.comments[0], body, version }] };
}
async function acknowledgeDuringLocalEdit(edited = { ...comment, text: EDITED_BODY }) {
write();
const pending = deferred();
api.createTaskPlanComment
.mockReturnValueOnce(pending.promise)
.mockRejectedValue(new WebSocketRequestError("UUID already used", "plan_comments_changed"));
const fixture = setup();
await settle();
write([edited]);
fixture.recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
pending.resolve(snapshot);
await settle();
return fixture;
}
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
window.sessionStorage.clear();
releases = [];
api.createTaskPlanComment.mockResolvedValue(snapshot);
plans.getTaskPlan.mockResolvedValue(plan);
});
afterEach(() => {
for (const release of releases) release();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("task-scoped plan comment recovery", () => {
it("resolves an unknown plan without relying on an ordinary comment loader", async () => {
write();
const store = createAppStore();
store.getState().setConnectionStatus("connected");
const recovery = planCommentMigrationFor(store, TASK);
releases.push(recovery.attach(vi.fn()));
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
await settle();
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK]).toMatchObject({
status: "complete",
pendingCount: 0,
});
expect(plans.getTaskPlan).toHaveBeenCalledOnce();
expect(saved()).toEqual([]);
});
// @covers AC-TASKS-PLAN-COMMENTS-004.2, AC-TASKS-PLAN-COMMENTS-004.6
it("uses three connected attempts then bounded background backoff with the original UUID", async () => {
write();
api.createTaskPlanComment.mockRejectedValue(new Error("offline"));
const { state } = setup();
await settle();
expect(state()).toMatchObject({ status: "retrying", pendingCount: 1 });
for (const [index, delay] of [1000, 2000, 30000, 60000, 120000, 120000].entries()) {
await vi.advanceTimersByTimeAsync(delay - 1);
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(index + 1);
await vi.advanceTimersByTimeAsync(1);
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(index + 2);
}
expect(state()).toMatchObject({ status: "failed", pendingCount: 1, failure: "transient" });
expect(
api.createTaskPlanComment.mock.calls.every(
([input]) => input.id === comment.id && input.body === comment.text,
),
).toBe(true);
expect(saved()).toEqual([comment]);
});
it("gives a replacement plan a fresh quiet retry burst", async () => {
write();
api.createTaskPlanComment.mockRejectedValue(new Error("offline"));
const { store, state } = setup();
await settle();
await vi.advanceTimersByTimeAsync(1000);
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(2);
store.getState().setTaskPlan(TASK, { ...plan, id: "plan-2" });
await settle();
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(3);
expect(api.createTaskPlanComment).toHaveBeenLastCalledWith(
expect.objectContaining({ planId: "plan-2", id: comment.id }),
);
expect(state()).toMatchObject({ status: "retrying", pendingCount: 1 });
await vi.advanceTimersByTimeAsync(1000);
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(4);
expect(state()?.status).toBe("retrying");
await vi.advanceTimersByTimeAsync(2000);
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(5);
expect(state()).toMatchObject({ status: "failed", pendingCount: 1, failure: "transient" });
expect(saved()).toEqual([comment]);
});
it("coalesces multiple surfaces and resume signals around one in-flight upload", async () => {
write();
const pending = deferred();
api.createTaskPlanComment.mockReturnValue(pending.promise);
const { store, recovery, state, detach } = setup();
const other = planCommentMigrationFor(store, TASK);
expect(other).toBe(recovery);
releases.push(other.attach(vi.fn()));
other.update({ sessionIds: [SESSION], complete: true, loading: false });
recovery.wake();
other.wake();
await settle();
detach();
pending.resolve(snapshot);
await settle();
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
});
it("spends no attempts while hidden or offline and resumes without a manual retry", async () => {
write();
const { store, recovery, state } = setup(false);
await vi.advanceTimersByTimeAsync(120000);
expect(api.createTaskPlanComment).not.toHaveBeenCalled();
expect(state()?.pendingCount).toBe(1);
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
store.getState().setConnectionStatus("connected");
await settle();
expect(api.createTaskPlanComment).not.toHaveBeenCalled();
visibility.mockReturnValue("visible");
await recovery.wake();
expect(state()?.status).toBe("complete");
});
});
describe("legacy recovery scope lifecycle", () => {
it("keeps an in-flight upload when a load confirms the same plan identity", async () => {
write();
const pending = deferred();
api.createTaskPlanComment.mockReturnValueOnce(pending.promise);
const { store, state } = setup(false);
store.setState((previous) => ({
taskPlans: {
...previous.taskPlans,
loadedByTaskId: { ...previous.taskPlans.loadedByTaskId, [TASK]: false },
},
}));
store.getState().setConnectionStatus("connected");
await settle();
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
store.getState().setTaskPlan(TASK, plan);
pending.resolve(snapshot);
await settle();
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
expect(saved()).toEqual([]);
});
it("stops timers when the last surface unmounts and resumes on remount", async () => {
write();
api.createTaskPlanComment.mockRejectedValueOnce(new Error("offline"));
const { recovery, detach, state } = setup();
await settle();
detach();
await vi.advanceTimersByTimeAsync(120000);
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
releases.push(recovery.attach(vi.fn()));
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
await settle();
expect(state()?.status).toBe("complete");
});
it("does not acknowledge a late result after task departure, including immediate remount", async () => {
write();
const old = deferred();
const next = deferred();
api.createTaskPlanComment.mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise);
const { recovery, detach, store } = setup();
await settle();
detach();
releases.push(recovery.attach(vi.fn()));
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
old.resolve(snapshot);
await settle();
expect(saved()).toEqual([comment]);
expect(store.getState().taskPlans.commentsByTaskId[TASK]).toBeUndefined();
next.resolve(snapshot);
await settle();
expect(saved()).toEqual([]);
});
});
describe("legacy recovery wire errors", () => {
// @covers AC-TASKS-PLAN-COMMENTS-004.6
it.each(["INTERNAL_ERROR", "internal_error"])("automatically retries %s", async (code) => {
write();
api.createTaskPlanComment.mockRejectedValueOnce(
toWebSocketRequestError({ code, message: "Temporary server failure" }),
);
const { state } = setup();
await settle();
expect(state()).toMatchObject({ status: "retrying", pendingCount: 1 });
expect(saved()).toEqual([comment]);
await vi.advanceTimersByTimeAsync(1000);
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(2);
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
expect(saved()).toEqual([]);
});
it.each([
["NOT_FOUND", "automatic"],
["not_found", "automatic"],
["NOT_FOUND", "manual"],
["not_found", "manual"],
])("refreshes a missing plan after %s through %s retry", async (code, trigger) => {
write();
const replacement = { ...plan, id: "plan-2" };
const recovered = {
...snapshot,
plan_id: replacement.id,
comments: snapshot.comments.map((row) => ({ ...row, plan_id: replacement.id })),
};
plans.getTaskPlan.mockResolvedValue(replacement);
api.createTaskPlanComment
.mockRejectedValueOnce(toWebSocketRequestError({ code, message: "Plan not found" }))
.mockResolvedValue(recovered);
const { store, recovery, state } = setup();
await settle();
expect(saved()).toEqual([comment]);
if (trigger === "manual") await recovery.retry();
else await vi.advanceTimersByTimeAsync(1000);
await settle();
expect(plans.getTaskPlan).toHaveBeenCalledOnce();
expect(api.createTaskPlanComment).toHaveBeenLastCalledWith(
expect.objectContaining({ planId: replacement.id, id: comment.id, body: comment.text }),
);
expect(store.getState().taskPlans.commentsByTaskId[TASK]).toEqual(recovered);
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
expect(saved()).toEqual([]);
});
});
describe("legacy recovery identity", () => {
it("ignores a late plan lookup after another reader confirms absence", async () => {
write();
const lookup = deferred<TaskPlan | null>();
plans.getTaskPlan.mockReturnValueOnce(lookup.promise);
const store = createAppStore();
store.getState().setConnectionStatus("connected");
const recovery = planCommentMigrationFor(store, TASK);
releases.push(recovery.attach(vi.fn()));
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
await settle();
expect(plans.getTaskPlan).toHaveBeenCalledOnce();
store.getState().setTaskPlan(TASK, null);
lookup.resolve(plan);
await settle();
expect(store.getState().taskPlans.byTaskId[TASK]).toBeNull();
expect(api.createTaskPlanComment).not.toHaveBeenCalled();
expect(saved()).toEqual([comment]);
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK]).toMatchObject({
status: "waiting_for_plan",
pendingCount: 1,
});
});
it("surfaces exhausted plan lookup failures for known drafts and later recovers", async () => {
write();
plans.getTaskPlan.mockRejectedValue(new Error("offline"));
const store = createAppStore();
store.getState().setConnectionStatus("connected");
const recovery = planCommentMigrationFor(store, TASK);
releases.push(recovery.attach(vi.fn()));
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
await vi.advanceTimersByTimeAsync(3000);
expect(plans.getTaskPlan).toHaveBeenCalledTimes(3);
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK]).toMatchObject({
status: "failed",
pendingCount: 1,
failure: "transient",
});
expect(saved()).toEqual([comment]);
plans.getTaskPlan.mockResolvedValue(plan);
await vi.advanceTimersByTimeAsync(30000);
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK]).toMatchObject({
status: "complete",
pendingCount: 0,
});
expect(saved()).toEqual([]);
});
it("does not let a hidden wake suppress the immediate visible retry", async () => {
write();
api.createTaskPlanComment.mockRejectedValueOnce(new Error("offline"));
const { recovery, state } = setup();
await settle();
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
await recovery.wake();
visibility.mockReturnValue("visible");
await recovery.wake();
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(2);
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
});
it("does not acknowledge a stale generation after a plan is deleted and restored", async () => {
write();
const old = deferred();
const next = deferred();
api.createTaskPlanComment.mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise);
const { store } = setup();
await settle();
store.getState().setTaskPlan(TASK, null);
store.getState().setTaskPlan(TASK, plan);
old.resolve(snapshot);
await settle();
expect(saved()).toEqual([comment]);
expect(store.getState().taskPlans.commentsByTaskId[TASK]).toBeUndefined();
next.resolve(snapshot);
await settle();
expect(saved()).toEqual([]);
});
});
describe("legacy edits during acknowledgement", () => {
it("updates an acknowledged body at its original UUID and exact version", async () => {
api.updateTaskPlanComment.mockResolvedValue(editedSnapshot());
const { store, state } = await acknowledgeDuringLocalEdit();
expect(saved()).toEqual([{ ...comment, text: EDITED_BODY }]);
await vi.advanceTimersByTimeAsync(1000);
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
expect(store.getState().taskPlans.commentsByTaskId[TASK]).toEqual(editedSnapshot());
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
expect(api.updateTaskPlanComment).toHaveBeenCalledWith({
taskId: TASK,
planId: PLAN,
id: comment.id,
expectedVersion: 1,
body: EDITED_BODY,
});
expect(saved()).toEqual([]);
});
it("retains local feedback without overwriting a conflicting server edit", async () => {
const remote = editedSnapshot("Another client's feedback", 3);
api.updateTaskPlanComment.mockRejectedValue(
new WebSocketRequestError("Changed version", "plan_comments_changed", { snapshot: remote }),
);
const { store, recovery, state } = await acknowledgeDuringLocalEdit();
await vi.advanceTimersByTimeAsync(121000);
expect(api.updateTaskPlanComment).toHaveBeenCalledOnce();
expect(state()).toMatchObject({ status: "failed", pendingCount: 1, failure: "conflict" });
expect(saved()).toEqual([{ ...comment, text: EDITED_BODY }]);
expect(store.getState().taskPlans.commentsByTaskId[TASK]).toEqual(remote);
await recovery.retry();
expect(api.updateTaskPlanComment).toHaveBeenCalledTimes(2);
expect(api.updateTaskPlanComment).toHaveBeenLastCalledWith({
taskId: TASK,
planId: PLAN,
id: comment.id,
expectedVersion: 1,
body: EDITED_BODY,
});
expect(state()).toMatchObject({ status: "failed", pendingCount: 1, failure: "conflict" });
expect(store.getState().taskPlans.commentsByTaskId[TASK]).toEqual(remote);
expect(saved()).toEqual([{ ...comment, text: EDITED_BODY }]);
const resolved = editedSnapshot(EDITED_BODY, 4);
api.updateTaskPlanComment.mockRejectedValueOnce(
new WebSocketRequestError("Changed version", "plan_comments_changed", { snapshot: resolved }),
);
await recovery.retry();
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
expect(store.getState().taskPlans.commentsByTaskId[TASK]).toEqual(resolved);
expect(saved()).toEqual([]);
});
it("reconciles an accepted update whose response was lost", async () => {
api.updateTaskPlanComment.mockRejectedValueOnce(new Error("response lost")).mockRejectedValue(
new WebSocketRequestError("Changed version", "plan_comments_changed", {
snapshot: editedSnapshot(),
}),
);
const { state } = await acknowledgeDuringLocalEdit();
await vi.advanceTimersByTimeAsync(3000);
expect(state()).toMatchObject({ status: "complete", pendingCount: 0 });
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
expect(api.updateTaskPlanComment).toHaveBeenCalledTimes(2);
expect(saved()).toEqual([]);
});
it("does not reinterpret a changed legacy selection as a body-only update", async () => {
const edited = { ...comment, text: EDITED_BODY, selectedText: "Other step" };
const { state } = await acknowledgeDuringLocalEdit(edited);
await vi.advanceTimersByTimeAsync(1000);
expect(state()).toMatchObject({ status: "failed", pendingCount: 1, failure: "conflict" });
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
expect(api.updateTaskPlanComment).not.toHaveBeenCalled();
expect(saved()).toEqual([edited]);
});
});
describe("legacy feedback retention", () => {
it("keeps a paused partial recovery quiet when a plan still exists", async () => {
write([comment, { ...comment, id: "legacy-2" }]);
const pending = deferred();
api.createTaskPlanComment.mockReturnValueOnce(pending.promise);
const { store, state } = setup();
await settle();
store.getState().setConnectionStatus("disconnected");
pending.resolve(snapshot);
await settle();
expect(state()).toMatchObject({ status: "idle", pendingCount: 1 });
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
});
it("retains identified drafts when later storage scans are empty or unavailable", async () => {
write();
const { recovery, state } = setup(false);
window.sessionStorage.clear();
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
expect(state()?.pendingCount).toBe(1);
const read = vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
throw new DOMException("denied", "SecurityError");
});
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
expect(state()?.pendingCount).toBe(1);
expect(read).toHaveBeenCalled();
});
it("retries refused cleanup without uploading the acknowledged feedback again", async () => {
write();
const removal = vi.spyOn(window.sessionStorage, "removeItem").mockImplementation(() => {
throw new DOMException("denied", "SecurityError");
});
const { state } = setup();
await settle();
expect(state()?.pendingCount).toBe(1);
expect(saved()).toEqual([comment]);
removal.mockRestore();
await vi.advanceTimersByTimeAsync(1000);
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
expect(saved()).toEqual([]);
expect(state()?.status).toBe("complete");
});
it.each(["validation_error", "unauthorized", "plan_comments_changed"])(
"requires explicit retry for %s instead of looping mutations",
async (code) => {
write();
api.createTaskPlanComment.mockRejectedValue(new WebSocketRequestError("rejected", code));
const { recovery, state } = setup();
await settle();
recovery.wake();
await vi.advanceTimersByTimeAsync(120000);
expect(api.createTaskPlanComment).toHaveBeenCalledOnce();
expect(state()).toMatchObject({ status: "failed", pendingCount: 1 });
api.createTaskPlanComment.mockResolvedValue(snapshot);
await recovery.retry();
expect(saved()).toEqual([]);
},
);
it("rescans newly discovered task sessions without touching another task's records", async () => {
write([comment], "foreign-session");
const { recovery, state } = setup();
await settle();
expect(state()?.status).toBe("complete");
expect(api.createTaskPlanComment).not.toHaveBeenCalled();
write();
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
await settle();
expect(state()?.pendingCount).toBe(0);
expect(
JSON.parse(
window.sessionStorage.getItem(`${COMMENTS_STORAGE_PREFIX}foreign-session`) ?? "[]",
),
).toEqual([comment]);
});
});
@@ -0,0 +1,403 @@
import type { StoreApi } from "zustand";
import { createTaskPlanComment, updateTaskPlanComment } from "@/lib/api/domains/plan-comment-api";
import { getTaskPlan } from "@/lib/api/domains/plan-api";
import { planCommentAdmissionConflict } from "@/lib/plan-comment-refs";
import { planCommentRecoveryDelay } from "@/lib/plan-comment-recovery";
import { useCommentsStore, type PlanComment } from "@/lib/state/slices/comments";
import {
readLegacyPlanComments,
removeAcknowledgedLegacyPlanComment,
type LegacyPlanCommentRecord,
} from "@/lib/state/slices/comments/persistence";
import type { PlanCommentMigrationState } from "@/lib/state/slices/session/types";
import type { AppState } from "@/lib/state/store";
import type { TaskPlanComment, TaskPlanCommentSnapshot } from "@/lib/types/http";
import { WebSocketRequestError } from "@/lib/ws/request-error";
type Failure = PlanCommentMigrationState["failure"];
type PendingRecord = { record: LegacyPlanCommentRecord; acknowledged?: TaskPlanComment };
type Discovery = { sessionIds: string[]; complete: boolean; loading: boolean };
// Keep one recovery per visited task for the store's lifetime, including across plan resets.
// Last-detach releases timers and subscriptions, retaining pending drafts and acknowledgements.
const recoveries = new WeakMap<StoreApi<AppState>, Map<string, PlanCommentMigration>>();
function legacyAnchor(comment: PlanComment) {
const anchorFrom = comment.from ?? 0;
return {
selectedText: comment.selectedText,
anchorFrom,
anchorTo: comment.to ?? anchorFrom + Math.max(1, comment.selectedText.length),
};
}
function sameAnchor(persisted: TaskPlanComment, comment: PlanComment) {
const anchor = legacyAnchor(comment);
return (
persisted.selected_text === anchor.selectedText &&
persisted.anchor_from === anchor.anchorFrom &&
persisted.anchor_to === anchor.anchorTo
);
}
function acknowledgeLegacyRecord({ sessionId, comment }: LegacyPlanCommentRecord): Failure {
if (!removeAcknowledgedLegacyPlanComment(sessionId, comment)) return "transient";
useCommentsStore.getState().forgetMigratedPlanComment(sessionId, comment.id);
return null;
}
function classifyFailure(error: unknown): Failure {
if (planCommentAdmissionConflict(error)) return "conflict";
if (
error instanceof WebSocketRequestError &&
error.code &&
!["internal_error", "internal", "timeout", "not_found"].includes(error.code.toLowerCase())
)
return "rejected";
return "transient";
}
/** One retry owner per task, independent of the number of mounted chat/plan surfaces. */
export class PlanCommentMigration {
private pending = new Map<string, PendingRecord>();
private consumers = new Map<symbol, () => Promise<void>>();
private discovery: Discovery = { sessionIds: [], complete: false, loading: false };
private storageAvailable = true;
private failures = 0;
private failure: Failure = null;
private dueAt = 0;
private lastWakeAt = -Infinity;
private generation = 0;
private refreshPlan = false;
private inFlight: Promise<void> | undefined;
private timer: ReturnType<typeof setTimeout> | undefined;
private unsubscribe: (() => void) | undefined;
constructor(
private store: StoreApi<AppState>,
private taskId: string,
) {}
/** Consumers of the same task must provide equivalent task-wide session discovery. */
attach(discover: () => Promise<void>) {
const consumer = Symbol();
this.consumers.set(consumer, discover);
if (!this.unsubscribe) this.observe();
return () => {
this.consumers.delete(consumer);
if (this.consumers.size) return;
this.generation++;
this.cancelTimer();
this.unsubscribe?.();
this.unsubscribe = undefined;
};
}
update(discovery: Discovery) {
this.discovery = discovery;
this.scan();
this.kick();
}
wake() {
if (!this.ready()) return this.inFlight;
if (Date.now() - this.lastWakeAt < 250) return this.inFlight;
this.lastWakeAt = Date.now();
this.dueAt = 0;
this.cancelTimer();
this.scan();
return this.kick();
}
retry() {
this.failures = 0;
this.failure = null;
this.dueAt = 0;
this.refreshPlan ||= this.pending.size > 0 && !this.plan();
this.cancelTimer();
this.scan();
return this.kick();
}
hasPendingComment(commentId: string) {
return [...this.pending.values()].some(({ record }) => record.comment.id === commentId);
}
private plan() {
return this.store.getState().taskPlans.byTaskId[this.taskId];
}
private ready() {
return (
this.consumers.size > 0 &&
this.store.getState().connection.status === "connected" &&
document.visibilityState === "visible"
);
}
private observe() {
const unsubscribe = this.store.subscribe((state, previous) => {
const plan = state.taskPlans.byTaskId[this.taskId];
const previousPlan = previous.taskPlans.byTaskId[this.taskId];
// Unknown and confirmed-absent plans are different recovery scopes.
const planChanged =
plan?.id !== previousPlan?.id || (plan === null) !== (previousPlan === null);
const loadedChanged =
state.taskPlans.loadedByTaskId[this.taskId] !==
previous.taskPlans.loadedByTaskId[this.taskId];
const connectionChanged = state.connection.status !== previous.connection.status;
if (!planChanged && !loadedChanged && !connectionChanged) return;
if (planChanged) {
this.generation++;
this.failure = null;
this.failures = 0;
this.refreshPlan = false;
}
this.dueAt = 0;
this.cancelTimer();
// Finish the store update before publishing derived recovery state.
void Promise.resolve().then(() => this.kick());
});
const onHidden = () => {
if (document.visibilityState !== "visible") this.cancelTimer();
};
document.addEventListener("visibilitychange", onHidden);
this.unsubscribe = () => {
unsubscribe();
document.removeEventListener("visibilitychange", onHidden);
};
}
private scan() {
const { records, available } = readLegacyPlanComments(this.discovery.sessionIds);
this.storageAvailable = available;
for (const record of records) {
const key = `${record.sessionId}:${record.comment.id}`;
const known = this.pending.get(key);
if (known) known.record = record;
else this.pending.set(key, { record });
}
}
private publish(status: PlanCommentMigrationState["status"]) {
const next = { status, pendingCount: this.pending.size, failure: this.failure };
const current = this.store.getState().taskPlans.commentsMigrationByTaskId[this.taskId];
if (
current?.status === next.status &&
current.pendingCount === next.pendingCount &&
current.failure === next.failure
)
return;
this.store.getState().setTaskPlanCommentMigrationState(this.taskId, next);
}
private cancelTimer() {
clearTimeout(this.timer);
this.timer = undefined;
}
private schedule() {
if (this.timer || !this.ready()) return;
this.timer = setTimeout(
() => {
this.timer = undefined;
void this.kick();
},
Math.max(0, this.dueAt - Date.now()),
);
}
private kick(): Promise<void> | undefined {
if (!this.consumers.size) return this.inFlight;
this.scan();
if (this.inFlight) {
const state = this.store.getState().taskPlans.commentsMigrationByTaskId[this.taskId];
this.publish(state?.status ?? "idle");
return this.inFlight;
}
if (this.failure === "conflict" || this.failure === "rejected") {
this.publish("failed");
return;
}
if (!this.pending.size && this.discovery.complete && this.storageAvailable) {
this.failure = null;
this.failures = 0;
this.publish("complete");
return;
}
if (this.pending.size && this.plan() === null && !this.refreshPlan) {
this.publish("waiting_for_plan");
return;
}
if (!this.ready()) {
this.publish("idle");
return;
}
if (this.dueAt > Date.now()) {
this.schedule();
return;
}
const generation = this.generation;
this.inFlight = Promise.resolve()
.then(() => this.run(generation))
.finally(() => {
this.inFlight = undefined;
if (generation !== this.generation) void this.kick();
else if (this.failure === "transient") this.schedule();
else if (!this.failure && this.pending.size && this.plan() && this.ready())
void this.kick();
});
return this.inFlight;
}
private current(generation: number) {
return this.consumers.size > 0 && generation === this.generation;
}
private async discover(generation: number) {
if (this.refreshPlan || (this.pending.size > 0 && this.plan() === undefined)) {
const next = await getTaskPlan(this.taskId);
if (!this.current(generation)) return;
this.refreshPlan = false;
this.store.getState().setTaskPlan(this.taskId, next);
}
if (!this.discovery.complete && !this.discovery.loading) {
// One live consumer performs the shared discovery for all mounted surfaces.
await this.consumers.values().next().value?.();
}
}
private async run(generation: number) {
if (!this.current(generation) || !this.ready()) return;
this.publish(this.pending.size ? "running" : "idle");
let failure: Failure = null;
try {
await this.discover(generation);
} catch {
failure = "transient";
}
if (!this.current(generation)) return;
failure = (await this.uploadPending(generation)) ?? failure;
if (!this.current(generation)) return;
this.scan();
if (!this.discovery.complete || !this.storageAvailable) failure ??= "transient";
this.finishRun(failure);
}
private async uploadPending(generation: number): Promise<Failure> {
const plan = this.plan();
if (!plan) return null;
let failure: Failure = null;
for (const [key, pending] of this.pending) {
if (!this.current(generation) || !this.ready()) return failure;
const outcome = await this.upload(pending, plan.id, generation);
if (!this.current(generation)) return failure;
if (!outcome) this.pending.delete(key);
else if (failure !== "conflict" && failure !== "rejected") failure = outcome;
}
return failure;
}
private finishRun(failure: Failure) {
this.failure = failure;
if (failure) {
this.failures++;
this.dueAt = Date.now() + planCommentRecoveryDelay(this.failures);
this.publish(failure !== "transient" || this.failures >= 3 ? "failed" : "retrying");
} else if (this.pending.size) {
this.publish(this.plan() === null ? "waiting_for_plan" : "idle");
} else {
this.failures = 0;
this.publish("complete");
}
}
private async upload(
pending: PendingRecord,
planId: string,
generation: number,
): Promise<Failure> {
const record = pending.record;
const { comment } = record;
try {
const acknowledged =
pending.acknowledged?.plan_id === planId ? pending.acknowledged : undefined;
if (acknowledged && !sameAnchor(acknowledged, comment)) return "conflict";
if (!acknowledged || acknowledged.body !== comment.text) {
const input = {
taskId: this.taskId,
planId,
id: comment.id,
body: comment.text,
};
const snapshot = acknowledged
? await updateTaskPlanComment({ ...input, expectedVersion: acknowledged.version })
: await createTaskPlanComment({ ...input, ...legacyAnchor(comment) });
if (!this.current(generation)) return "transient";
this.store.getState().setTaskPlanComments(this.taskId, snapshot);
if (!this.recordAcknowledgement(pending, planId, comment, snapshot)) return "conflict";
}
if (!this.current(generation)) return "transient";
return acknowledgeLegacyRecord(record);
} catch (error) {
if (!this.current(generation)) return "transient";
return this.handleUploadFailure(error, pending, planId, record);
}
}
private handleUploadFailure(
error: unknown,
pending: PendingRecord,
planId: string,
record: LegacyPlanCommentRecord,
): Failure {
const snapshot = planCommentAdmissionConflict(error)?.snapshot;
if (snapshot) this.store.getState().setTaskPlanComments(this.taskId, snapshot);
if (
snapshot &&
pending.acknowledged?.plan_id === planId &&
this.recordAcknowledgement(pending, planId, record.comment, snapshot)
) {
return acknowledgeLegacyRecord(record);
}
if (error instanceof WebSocketRequestError && error.code?.toLowerCase() === "not_found")
this.refreshPlan = true;
return classifyFailure(error);
}
private recordAcknowledgement(
pending: PendingRecord,
planId: string,
comment: PlanComment,
snapshot: TaskPlanCommentSnapshot,
) {
if (snapshot.task_id !== this.taskId || snapshot.plan_id !== planId) return false;
const persisted = snapshot.comments.find((row) => row.id === comment.id);
if (!persisted || persisted.body !== comment.text || !sameAnchor(persisted, comment))
return false;
pending.acknowledged = persisted;
return true;
}
}
export function planCommentMigrationFor(store: StoreApi<AppState>, taskId: string) {
let tasks = recoveries.get(store);
if (!tasks) {
tasks = new Map();
recoveries.set(store, tasks);
}
let recovery = tasks.get(taskId);
if (!recovery) {
recovery = new PlanCommentMigration(store, taskId);
tasks.set(taskId, recovery);
}
return recovery;
}
export function hasPendingPlanCommentMigration(
store: StoreApi<AppState>,
taskId: string,
commentId: string,
) {
return recoveries.get(store)?.get(taskId)?.hasPendingComment(commentId) ?? false;
}
@@ -1,14 +1,16 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { useCommentsStore } from "@/lib/state/slices/comments";
import { COMMENTS_STORAGE_PREFIX } from "@/lib/state/slices/comments/persistence";
import type { DiffComment, PlanComment } from "@/lib/state/slices/comments";
import type { TaskPlan, TaskPlanComment, TaskPlanCommentSnapshot } from "@/lib/types/http";
import { sessionId as toSessionId, taskId as toTaskId } from "@/lib/types/http";
import { WebSocketRequestError } from "@/lib/ws/request-error";
const api = vi.hoisted(() => ({
createTaskPlanComment: vi.fn(),
updateTaskPlanComment: vi.fn(),
getTaskPlanComments: vi.fn(),
}));
const sessionsHook = vi.hoisted(() => ({
@@ -28,6 +30,7 @@ import { usePlanCommentMigration } from "./use-plan-comment-migration";
const TASK_ID = "task-1";
const PLAN_ID = "plan-1";
const FOREIGN_SESSION = "foreign-session";
const PLAN_TIMESTAMP = "2026-09-02T00:00:00Z";
const taskPlan: TaskPlan = {
@@ -78,7 +81,7 @@ function serverComment(comment: PlanComment, version = 1): TaskPlanComment {
body: comment.text,
selected_text: comment.selectedText,
anchor_from: comment.from ?? 0,
anchor_to: comment.to ?? comment.selectedText.length,
anchor_to: comment.to ?? (comment.from ?? 0) + Math.max(1, comment.selectedText.length),
version,
created_at: comment.createdAt,
updated_at: comment.createdAt,
@@ -103,7 +106,13 @@ function readSession(sessionId: string): unknown[] {
}
function wrapper({ children }: { children: React.ReactNode }) {
return <StateProvider>{children}</StateProvider>;
return (
<StateProvider
initialState={{ connection: { status: "connected", error: null, issueSeverity: "none" } }}
>
{children}
</StateProvider>
);
}
function useHarness() {
@@ -115,7 +124,8 @@ function useHarness() {
// eslint-disable-next-line max-lines-per-function -- Migration recovery cases share browser-storage and store fixtures.
describe("usePlanCommentMigration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
sessionsHook.sessions = [{ id: "session-1" }, { id: "session-2" }];
sessionsHook.isLoaded = true;
sessionsHook.error = null;
sessionsHook.loadSessions.mockResolvedValue(undefined);
@@ -128,6 +138,43 @@ describe("usePlanCommentMigration", () => {
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("does not request a migration refresh or block Send when storage is empty", async () => {
api.getTaskPlanComments.mockRejectedValue(new Error("offline"));
const { result } = renderHook(useHarness, { wrapper });
expect(result.current.migration.isBlocking).toBe(false);
act(() => result.current.store.getState().setTaskPlan(TASK_ID, taskPlan));
await waitFor(() => expect(result.current.migration.status).toBe("complete"));
expect(api.getTaskPlanComments).not.toHaveBeenCalled();
expect(api.createTaskPlanComment).not.toHaveBeenCalled();
expect(result.current.migration.isBlocking).toBe(false);
});
it("automatically retries a transient legacy upload without manual Retry", async () => {
vi.useFakeTimers();
const comment = legacyPlanComment("comment-1", "session-1");
writeSession("session-1", [comment]);
api.createTaskPlanComment
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(snapshot([serverComment(comment)]));
api.getTaskPlanComments.mockResolvedValue(snapshot([serverComment(comment)]));
const { result } = renderHook(useHarness, { wrapper });
await act(async () => result.current.store.getState().setTaskPlan(TASK_ID, taskPlan));
expect(readSession("session-1")).toEqual([comment]);
expect(result.current.migration.isBlocking).toBe(true);
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(result.current.migration.status).toBe("complete");
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(2);
expect(readSession("session-1")).toEqual([]);
});
it("migrates every known session row by UUID and preserves non-plan records", async () => {
const first = legacyPlanComment("comment-1", "session-1");
const second = legacyPlanComment("comment-2", "session-2");
@@ -166,7 +213,8 @@ describe("usePlanCommentMigration", () => {
it("preserves failed rows and retries only what remains", async () => {
const first = legacyPlanComment("comment-1", "session-1");
const second = legacyPlanComment("comment-2", "session-2");
writeSession("session-1", [first]);
const diff = diffComment();
writeSession("session-1", [first, diff]);
writeSession("session-2", [second]);
api.createTaskPlanComment
.mockResolvedValueOnce(snapshot([serverComment(first)], 1))
@@ -175,9 +223,9 @@ describe("usePlanCommentMigration", () => {
const { result } = renderHook(useHarness, { wrapper });
act(() => result.current.store.getState().setTaskPlan(TASK_ID, taskPlan));
await waitFor(() => expect(result.current.migration.status).toBe("failed"));
await waitFor(() => expect(result.current.migration.status).toBe("retrying"));
expect(readSession("session-1")).toEqual([]);
expect(readSession("session-1")).toEqual([diff]);
expect(readSession("session-2")).toEqual([second]);
const finalSnapshot = snapshot([serverComment(first), serverComment(second)], 2);
@@ -243,32 +291,70 @@ describe("usePlanCommentMigration", () => {
);
});
it("surfaces session discovery failure and force reloads on retry", async () => {
it("does not block plain Send when session discovery fails without identified drafts", async () => {
sessionsHook.isLoaded = false;
sessionsHook.error = "offline";
const { result, rerender } = renderHook(useHarness, { wrapper });
act(() => result.current.store.getState().setTaskPlan(TASK_ID, taskPlan));
await waitFor(() => expect(result.current.migration.status).toBe("failed"));
expect(result.current.migration.isBlocking).toBe(false);
sessionsHook.error = null;
sessionsHook.isLoaded = true;
await act(async () => result.current.migration.retry());
rerender();
expect(sessionsHook.loadSessions).toHaveBeenCalledWith(true);
await waitFor(() => expect(result.current.migration.status).toBe("complete"));
});
it("surfaces current-plan load failure and clears it on retry", async () => {
it("protects already-hydrated task drafts when the session list is unavailable", async () => {
sessionsHook.sessions = [];
sessionsHook.isLoaded = false;
sessionsHook.error = "offline";
const legacy = legacyPlanComment("comment-1", "session-1");
const unrelated = legacyPlanComment("comment-2", FOREIGN_SESSION);
writeSession("session-1", [legacy]);
writeSession(FOREIGN_SESSION, [unrelated]);
const { result } = renderHook(useHarness, { wrapper });
act(() => {
result.current.store.getState().setConnectionStatus("disconnected");
for (const [id, task_id] of [
["session-1", TASK_ID],
[FOREIGN_SESSION, "other-task"],
]) {
result.current.store.getState().setTaskSession({
id: toSessionId(id),
task_id: toTaskId(task_id),
state: "WAITING_FOR_INPUT",
started_at: PLAN_TIMESTAMP,
updated_at: PLAN_TIMESTAMP,
});
}
});
expect(
result.current.store.getState().taskSessionsByTask.itemsByTaskId[TASK_ID],
).toBeUndefined();
expect(result.current.migration.pendingCount).toBe(1);
expect(result.current.migration.isBlocking).toBe(true);
expect(readSession("session-1")).toEqual([legacy]);
expect(readSession(FOREIGN_SESSION)).toEqual([unrelated]);
});
it("failed plan discovery does not block plain Send before or after a null plan", async () => {
const { result } = renderHook(useHarness, { wrapper });
act(() => result.current.store.getState().setTaskPlanCommentsError(TASK_ID, "offline"));
await waitFor(() => expect(result.current.migration.status).toBe("failed"));
await act(async () => result.current.migration.retry());
expect(result.current.migration.isBlocking).toBe(false);
act(() => result.current.store.getState().setTaskPlan(TASK_ID, null));
expect(result.current.migration.isBlocking).toBe(false);
});
expect(
result.current.store.getState().taskPlans.commentsErrorByTaskId[TASK_ID],
).toBeUndefined();
expect(sessionsHook.loadSessions).toHaveBeenCalledWith(true);
it("successful empty snapshot recovery leaves no obsolete migration restriction", async () => {
const { result } = renderHook(useHarness, { wrapper });
act(() => result.current.store.getState().setTaskPlanCommentsError(TASK_ID, "offline"));
act(() => {
result.current.store.getState().setTaskPlan(TASK_ID, taskPlan);
result.current.store.getState().setTaskPlanComments(TASK_ID, snapshot([]));
});
await waitFor(() => expect(result.current.migration.isBlocking).toBe(false));
expect(api.createTaskPlanComment).not.toHaveBeenCalled();
});
});
@@ -1,201 +1,45 @@
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { createTaskPlanComment, getTaskPlanComments } from "@/lib/api/domains/plan-comment-api";
import { useCommentsStore } from "@/lib/state/slices/comments";
import {
listLegacyPlanComments,
removeAcknowledgedLegacyPlanComment,
} from "@/lib/state/slices/comments/persistence";
import type { AppState } from "@/lib/state/store";
import type { TaskPlan } from "@/lib/types/http";
import { planCommentAdmissionConflict } from "@/lib/plan-comment-refs";
import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import { planCommentRecovery } from "@/lib/plan-comment-recovery";
import { planCommentMigrationFor } from "./plan-comment-migration";
type AppStore = ReturnType<typeof useAppStoreApi>;
const migrationRunsByStore = new WeakMap<AppStore, Map<string, Promise<void>>>();
function migrationRunsFor(store: AppStore) {
const existing = migrationRunsByStore.get(store);
if (existing) return existing;
const runs = new Map<string, Promise<void>>();
migrationRunsByStore.set(store, runs);
return runs;
}
function planIdentityMatches(state: AppState, taskId: string, plan: TaskPlan | null) {
return (state.taskPlans.byTaskId[taskId]?.id ?? null) === (plan?.id ?? null);
}
function setStatusIfCurrent(
store: AppStore,
taskId: string,
plan: TaskPlan | null,
status: "running" | "complete" | "waiting_for_plan" | "failed",
) {
const state = store.getState();
if (planIdentityMatches(state, taskId, plan)) {
state.setTaskPlanCommentMigrationStatus(taskId, status);
}
}
type LegacyPlanCommentRecord = ReturnType<typeof listLegacyPlanComments>[number];
function acknowledgeLegacyComment({ sessionId, comment }: LegacyPlanCommentRecord) {
const removed = removeAcknowledgedLegacyPlanComment(sessionId, comment);
const sameIdStillStored = listLegacyPlanComments([sessionId]).some(
(record) => record.comment.id === comment.id,
);
if (!removed && sameIdStillStored) return false;
useCommentsStore.getState().forgetMigratedPlanComment(sessionId, comment.id);
return true;
}
async function migrateLegacyComment(
taskId: string,
plan: TaskPlan,
record: LegacyPlanCommentRecord,
store: AppStore,
) {
const { comment } = record;
try {
const anchorFrom = comment.from ?? 0;
const snapshot = await createTaskPlanComment({
taskId,
planId: plan.id,
id: comment.id,
body: comment.text,
selectedText: comment.selectedText,
anchorFrom,
anchorTo: comment.to ?? anchorFrom + Math.max(1, comment.selectedText.length),
});
store.getState().setTaskPlanComments(taskId, snapshot);
return acknowledgeLegacyComment(record);
} catch (error) {
const snapshot = planCommentAdmissionConflict(error)?.snapshot;
if (snapshot) store.getState().setTaskPlanComments(taskId, snapshot);
// i18n-exempt: developer diagnostic; the migration notice is localized.
console.error("Failed to migrate legacy plan comment:", error);
return false;
}
}
async function refreshMigratedComments(taskId: string, store: AppStore) {
try {
const snapshot = await getTaskPlanComments(taskId);
store.getState().setTaskPlanComments(taskId, snapshot);
return true;
} catch (error) {
// i18n-exempt: developer diagnostic; the migration notice is localized.
console.error("Failed to refresh task plan comments after migration:", error);
return false;
}
}
async function migrateLegacyComments(
taskId: string,
plan: TaskPlan | null,
sessionIds: string[],
store: AppStore,
) {
const records = listLegacyPlanComments(sessionIds);
if (!plan) {
setStatusIfCurrent(store, taskId, plan, records.length === 0 ? "complete" : "waiting_for_plan");
return;
}
setStatusIfCurrent(store, taskId, plan, "running");
let failed = false;
for (const record of records) {
if (!planIdentityMatches(store.getState(), taskId, plan)) return;
if (!(await migrateLegacyComment(taskId, plan, record, store))) failed = true;
}
if (!(await refreshMigratedComments(taskId, store))) failed = true;
setStatusIfCurrent(store, taskId, plan, failed ? "failed" : "complete");
}
function startMigration(
taskId: string,
plan: TaskPlan | null,
sessionIds: string[],
store: AppStore,
) {
const migrationRuns = migrationRunsFor(store);
const runKey = `${taskId}:${plan?.id ?? "none"}`;
const existing = migrationRuns.get(runKey);
if (existing) return existing;
const request = migrateLegacyComments(taskId, plan, sessionIds, store).finally(() => {
if (migrationRuns.get(runKey) === request) migrationRuns.delete(runKey);
});
migrationRuns.set(runKey, request);
return request;
}
/** Losslessly promotes sessionStorage plan drafts into the task-owned collection. */
/** Losslessly promotes identified browser drafts, independently of ordinary comment reads. */
export function usePlanCommentMigration(taskId: string | null | undefined) {
const store = useAppStoreApi();
const {
sessions,
isLoaded: sessionsLoaded,
error: sessionsError,
loadSessions,
} = useTaskSessions(taskId ?? null);
const plan = useAppStore((state) => (taskId ? state.taskPlans.byTaskId[taskId] : undefined));
const planLoaded = useAppStore((state) =>
taskId ? (state.taskPlans.loadedByTaskId[taskId] ?? false) : true,
const { sessions, isLoaded, isLoading, error, loadSessions } = useTaskSessions(taskId ?? null);
const knownSessionIds = useAppStore(
useShallow((state) =>
Object.values(state.taskSessions.items)
.filter((session) => session.task_id === taskId)
.map((session) => session.id),
),
);
const storedStatus = useAppStore((state) =>
taskId ? state.taskPlans.commentsMigrationStatusByTaskId[taskId] : "complete",
const connected = useAppStore((state) => state.connection.status === "connected");
const state = useAppStore((state) =>
taskId ? state.taskPlans.commentsMigrationByTaskId[taskId] : undefined,
);
const planLoadError = useAppStore((state) =>
taskId ? (state.taskPlans.commentsErrorByTaskId[taskId] ?? null) : null,
const recovery = useMemo(
() => (taskId ? planCommentMigrationFor(store, taskId) : null),
[store, taskId],
);
const status = storedStatus ?? "idle";
const sessionIds = useMemo(() => sessions.map((session) => session.id), [sessions]);
const discover = useRef(loadSessions);
useEffect(() => {
if (
taskId &&
status !== "complete" &&
status !== "running" &&
(sessionsError || planLoadError)
) {
store.getState().setTaskPlanCommentMigrationStatus(taskId, "failed");
return;
}
if (!taskId || !sessionsLoaded || !planLoaded || plan === undefined) return;
if (status === "running" || status === "complete" || status === "failed") return;
if (status === "waiting_for_plan" && plan === null) return;
void startMigration(taskId, plan, sessionIds, store);
}, [
plan,
planLoadError,
planLoaded,
sessionIds,
sessionsError,
sessionsLoaded,
status,
store,
taskId,
]);
discover.current = loadSessions;
}, [loadSessions]);
useEffect(() => recovery?.attach(() => discover.current(true)), [recovery]);
useEffect(() => {
recovery?.update({
sessionIds: [...new Set([...sessions.map((session) => session.id), ...knownSessionIds])],
complete: isLoaded && !error,
loading: isLoading,
});
}, [recovery, sessions, knownSessionIds, isLoaded, isLoading, error]);
useForegroundRefresh(() => recovery?.wake(), Boolean(taskId) && connected, recovery);
const retry = useCallback(async () => {
if (!taskId) return;
const state = store.getState();
state.setTaskPlanCommentMigrationStatus(taskId, "running");
state.setTaskPlanCommentsError(taskId);
await loadSessions(true);
const next = store.getState();
next.setTaskPlanCommentMigrationStatus(
taskId,
next.taskSessionsByTask.errorByTaskId?.[taskId] ? "failed" : "idle",
);
}, [loadSessions, store, taskId]);
return {
status,
isReady: status === "complete",
isBlocking: Boolean(taskId) && status !== "complete",
retry,
};
await recovery?.retry();
}, [recovery]);
return { ...planCommentRecovery(state), retry };
}
@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { TaskPlan, TaskPlanCommentSnapshot } from "@/lib/types/http";
@@ -70,10 +70,88 @@ function setUpApis() {
api.getTaskPlanComments.mockResolvedValue(snapshot());
}
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// eslint-disable-next-line max-lines-per-function -- Loading and reconnect cases share one task-plan store fixture.
describe("usePlanComments loading", () => {
beforeEach(setUpApis);
it("loads a replacement plan after an older in-flight read settles", async () => {
let finishOld!: (value: TaskPlanCommentSnapshot) => void;
const replacement = { ...snapshot(2), plan_id: "plan-2", comments: [] };
api.getTaskPlanComments
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOld = resolve;
}),
)
.mockResolvedValueOnce(replacement);
const { result } = renderHook(useTwoTaskCommentConsumers, { wrapper });
await act(async () => {
result.current.store.getState().setTaskPlan(TASK_ID, taskPlan);
result.current.store.getState().setConnectionStatus("connected");
});
await act(async () =>
result.current.store.getState().setTaskPlan(TASK_ID, { ...taskPlan, id: "plan-2" }),
);
await act(async () => finishOld(snapshot()));
expect(result.current.first.snapshot).toEqual(replacement);
});
it("ignores an old response after deletion and restoration of the same plan", async () => {
let rejectOld!: (error: Error) => void;
api.getTaskPlanComments.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectOld = reject;
}),
);
const { result } = renderHook(useTwoTaskCommentConsumers, { wrapper });
await act(async () => {
result.current.store.getState().setTaskPlan(TASK_ID, taskPlan);
result.current.store.getState().setConnectionStatus("connected");
});
await act(async () => {
result.current.store.getState().setTaskPlan(TASK_ID, null);
result.current.store.getState().setTaskPlan(TASK_ID, taskPlan);
result.current.store.getState().setTaskPlanComments(TASK_ID, snapshot(5));
rejectOld(new Error("old generation failed"));
});
expect(result.current.first.loadError).toBeNull();
expect(result.current.first.comments[0]?.version).toBe(5);
});
it("retries failed background discovery without losing displayed context", async () => {
vi.useFakeTimers();
planApi.getTaskPlan.mockRejectedValueOnce(new Error("offline")).mockResolvedValue(taskPlan);
const { result } = renderHook(useTwoTaskCommentConsumers, { wrapper });
await act(async () => {
result.current.store.getState().setTaskPlan(TASK_ID, taskPlan);
result.current.store.getState().setTaskPlanComments(TASK_ID, snapshot());
result.current.store.getState().setConnectionStatus("connected");
});
expect(result.current.first.comments[0]?.version).toBe(1);
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(planApi.getTaskPlan).toHaveBeenCalledTimes(2);
expect(result.current.first.loadError).toBeNull();
expect(result.current.first.comments[0]?.version).toBe(1);
});
it("does not request plan comments on foreground events while disconnected", async () => {
const { result } = renderHook(useTwoTaskCommentConsumers, { wrapper });
await act(async () => result.current.store.getState().setTaskPlan(TASK_ID, taskPlan));
await act(async () => window.dispatchEvent(new Event("focus")));
expect(planApi.getTaskPlan).not.toHaveBeenCalled();
expect(api.getTaskPlanComments).not.toHaveBeenCalled();
await act(async () => result.current.store.getState().setConnectionStatus("connected"));
await act(async () => window.dispatchEvent(new Event("focus")));
await waitFor(() => expect(api.getTaskPlanComments).toHaveBeenCalled());
});
it("loads the current plan before comments when only a task composer is mounted", async () => {
const { result } = renderHook(
() => {
@@ -201,35 +279,32 @@ describe("usePlanComments loading", () => {
it("does not let a stale plan fetch clear the replacement plan loading state", async () => {
let rejectOld!: (error: Error) => void;
api.getTaskPlanComments.mockImplementationOnce(
() => new Promise((_resolve, reject) => (rejectOld = reject)),
);
const { result } = renderHook(
() => {
const store = useAppStoreApi();
return { store, comments: usePlanComments(TASK_ID) };
},
{ wrapper },
);
act(() => {
let finishReplacement!: (value: TaskPlanCommentSnapshot) => void;
const replacement = { ...snapshot(2), plan_id: "plan-2", comments: [] };
api.getTaskPlanComments
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectOld = reject)))
.mockImplementationOnce(() => new Promise((resolve) => (finishReplacement = resolve)));
const { result } = renderHook(useTwoTaskCommentConsumers, { wrapper });
await act(async () => {
result.current.store.getState().setTaskPlan(TASK_ID, taskPlan);
result.current.store.setState({
connection: { status: "connected", error: null, issueSeverity: "none" },
});
result.current.store.getState().setConnectionStatus("connected");
});
await waitFor(() => expect(result.current.comments.isLoading).toBe(true));
act(() => {
expect(result.current.first.isLoading).toBe(true);
await act(async () => {
result.current.store.getState().setTaskPlan(TASK_ID, { ...taskPlan, id: "plan-2" });
result.current.store.getState().setTaskPlanCommentsLoading(TASK_ID, true);
rejectOld(new Error("old plan failed"));
});
await waitFor(() => {
expect(result.current.store.getState().taskPlans.commentsLoadingByTaskId[TASK_ID]).toBe(true);
expect(
result.current.store.getState().taskPlans.commentsErrorByTaskId[TASK_ID],
).toBeUndefined();
});
expect(api.getTaskPlanComments).toHaveBeenCalledOnce();
await act(async () => rejectOld(new Error("old plan failed")));
expect(api.getTaskPlanComments).toHaveBeenCalledTimes(2);
expect(result.current.first.isLoading).toBe(true);
expect(result.current.second.isLoading).toBe(true);
expect(result.current.first.loadError).toBeNull();
expect(result.current.second.loadError).toBeNull();
await act(async () => finishReplacement(replacement));
expect(result.current.first.isLoading).toBe(false);
expect(result.current.second.isLoading).toBe(false);
expect(result.current.first.snapshot).toEqual(replacement);
expect(result.current.second.snapshot).toEqual(replacement);
});
});
@@ -13,10 +13,10 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import {
createTaskPlanComment,
deleteTaskPlanComment,
getTaskPlanComments,
updateTaskPlanComment,
} from "@/lib/api/domains/plan-comment-api";
import { getTaskPlan } from "@/lib/api/domains/plan-api";
import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import { planCommentLoaderFor } from "./plan-comment-loading";
import type { PlanComment } from "@/lib/state/slices/comments";
import type { AppState } from "@/lib/state/store";
import type { TaskPlan, TaskPlanComment } from "@/lib/types/http";
@@ -24,24 +24,8 @@ import { generateUUID } from "@/lib/utils";
import { planCommentAdmissionConflict } from "@/lib/plan-comment-refs";
const EMPTY_COMMENTS: PlanComment[] = [];
interface CommentLoad {
planId: string;
promise: Promise<void>;
}
type AppStore = ReturnType<typeof useAppStoreApi>;
const commentLoadsByStore = new WeakMap<AppStore, Map<string, CommentLoad>>();
const planLoadsByStore = new WeakMap<AppStore, Map<string, Promise<TaskPlan | null>>>();
function loadsFor<T>(registry: WeakMap<AppStore, Map<string, T>>, store: AppStore) {
const existing = registry.get(store);
if (existing) return existing;
const loads = new Map<string, T>();
registry.set(store, loads);
return loads;
}
function projectPlanComment(comment: TaskPlanComment): PlanComment {
return {
id: comment.id,
@@ -60,73 +44,6 @@ function projectPlanComment(comment: TaskPlanComment): PlanComment {
};
}
function loadComments(
taskId: string,
planId: string,
store: AppStore,
errorMessage: string,
): Promise<void> {
const commentLoads = loadsFor(commentLoadsByStore, store);
const existing = commentLoads.get(taskId);
if (existing?.planId === planId) return existing.promise;
const state = store.getState();
state.setTaskPlanCommentsLoading(taskId, true);
state.setTaskPlanCommentsError(taskId);
const request = getTaskPlanComments(taskId)
.then((snapshot) => store.getState().setTaskPlanComments(taskId, snapshot))
.catch((error) => {
// i18n-exempt: developer diagnostic; localized copy is stored in state.
console.error("Failed to load task plan comments:", error);
const current = store.getState();
if (current.taskPlans.byTaskId[taskId]?.id === planId) {
current.setTaskPlanCommentsError(taskId, errorMessage);
}
})
.finally(() => {
setTimeout(() => {
const active = commentLoads.get(taskId);
if (active?.promise === request) commentLoads.delete(taskId);
}, 0);
const current = store.getState();
if (current.taskPlans.byTaskId[taskId]?.id === planId) {
current.setTaskPlanCommentsLoading(taskId, false);
}
});
commentLoads.set(taskId, { planId, promise: request });
return request;
}
function loadPlan(
taskId: string,
store: AppStore,
errorMessage: string,
force: boolean,
): Promise<TaskPlan | null> {
const planLoads = loadsFor(planLoadsByStore, store);
const existing = planLoads.get(taskId);
if (existing) return existing;
const state = store.getState();
const cached = state.taskPlans.byTaskId[taskId];
if (!force && cached !== undefined) return Promise.resolve(cached);
state.setTaskPlanLoading(taskId, true);
state.setTaskPlanCommentsError(taskId);
const request = getTaskPlan(taskId)
.then((next) => {
store.getState().setTaskPlan(taskId, next);
return next;
})
.catch((error) => {
// i18n-exempt: developer diagnostic; localized copy is stored below.
console.error("Failed to load task plan for comments:", error);
store.getState().setTaskPlanCommentsError(taskId, errorMessage);
store.getState().setTaskPlanLoading(taskId, false);
return null;
})
.finally(() => planLoads.delete(taskId));
planLoads.set(taskId, request);
return request;
}
function applyMutationFailure(error: unknown, taskId: string, state: AppState) {
const snapshot = planCommentAdmissionConflict(error)?.snapshot;
if (snapshot) state.setTaskPlanComments(taskId, snapshot);
@@ -369,21 +286,6 @@ function usePlanCommentReconnectRefresh(connectionStatus: string, refetch: () =>
}, [connectionStatus, refetch]);
}
function usePlanCommentForegroundRefresh(refetch: () => Promise<void>) {
useEffect(() => {
const refreshOnFocus = () => void refetch();
const refreshWhenVisible = () => {
if (document.visibilityState === "visible") void refetch();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshWhenVisible);
return () => {
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshWhenVisible);
};
}, [refetch]);
}
/** Task-owned current-plan comments shared by every session surface. */
export function usePlanComments(taskId: string | null | undefined) {
const { t } = useTranslation("task");
@@ -405,16 +307,13 @@ export function usePlanComments(taskId: string | null | undefined) {
const isPlanLoading = useAppStore((state) =>
taskId ? (state.taskPlans.loadingByTaskId[taskId] ?? false) : false,
);
const refresh = useCallback(
async (forcePlan: boolean) => {
if (!taskId) return;
const resolvedPlan = await loadPlan(taskId, store, t("failedToLoadPlanComments"), forcePlan);
if (!resolvedPlan) return;
await loadComments(taskId, resolvedPlan.id, store, t("failedToLoadPlanComments"));
},
const loader = useMemo(
() => (taskId ? planCommentLoaderFor(store, taskId, t("failedToLoadPlanComments")) : null),
[store, t, taskId],
);
const refetch = useCallback(() => refresh(true), [refresh]);
useEffect(() => loader?.attach(), [loader]);
const refetch = useCallback(() => loader?.load(true) ?? Promise.resolve(), [loader]);
const wake = useCallback(() => loader?.wake() ?? Promise.resolve(), [loader]);
useEffect(() => {
if (
@@ -427,10 +326,10 @@ export function usePlanComments(taskId: string | null | undefined) {
) {
return;
}
void refresh(false);
}, [connectionStatus, isLoaded, isLoading, isPlanLoading, loadError, refresh, taskId]);
usePlanCommentReconnectRefresh(connectionStatus, refetch);
usePlanCommentForegroundRefresh(refetch);
void loader?.load(false);
}, [connectionStatus, isLoaded, isLoading, isPlanLoading, loadError, loader, taskId]);
usePlanCommentReconnectRefresh(connectionStatus, wake);
useForegroundRefresh(wake, Boolean(taskId) && connectionStatus === "connected", loader);
const comments = useMemo(() => {
if (!snapshot || snapshot.comments.length === 0) return EMPTY_COMMENTS;
@@ -0,0 +1,191 @@
import { cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createAppStore } from "@/lib/state/store";
import type { PlanComment } from "@/lib/state/slices/comments";
import { COMMENTS_STORAGE_PREFIX } from "@/lib/state/slices/comments/persistence";
import type { TaskPlan, TaskPlanCommentSnapshot, TaskSessionState } from "@/lib/types/http";
import { sessionId, taskId } from "@/lib/types/ids";
import { planCommentMigrationFor } from "./plan-comment-migration";
import { useRunComment } from "./use-run-comment";
const api = vi.hoisted(() => ({
createTaskPlanComment: vi.fn(),
updateTaskPlanComment: vi.fn(),
getTaskPlanComments: vi.fn(),
sendMessageRequest: vi.fn(),
queueMessage: vi.fn(),
}));
vi.mock("@/components/state-provider", () => ({ useAppStoreApi: () => mockStore }));
vi.mock("@/lib/api/domains/plan-comment-api", () => api);
vi.mock("@/hooks/use-message-handler", () => api);
vi.mock("@/lib/api/domains/queue-api", () => ({ ...api, appendToQueue: vi.fn() }));
const TASK = taskId("task-legacy-run");
const SESSION = sessionId("session-legacy-run");
const PLAN = "plan-legacy-run";
const DATE = "2026-09-02T00:00:00Z";
const KEY = `${COMMENTS_STORAGE_PREFIX}${SESSION}`;
const legacy: PlanComment = {
id: "legacy-selected",
source: "plan",
sessionId: SESSION,
text: "Keep this feedback",
selectedText: "Step",
from: 1,
to: 5,
status: "pending",
createdAt: DATE,
};
const persisted: PlanComment = {
...legacy,
taskId: TASK,
planId: PLAN,
version: 1,
};
const plan: TaskPlan = {
id: PLAN,
task_id: TASK,
title: "Plan",
content: "Step",
created_by: "agent",
created_at: DATE,
updated_at: DATE,
};
let mockStore: ReturnType<typeof createAppStore>;
let detachments: Array<() => void>;
let admitted: boolean;
let serverHasComment: boolean;
function serverSnapshot(): TaskPlanCommentSnapshot {
return {
task_id: TASK,
plan_id: PLAN,
revision: serverHasComment ? 1 : 2,
comments: serverHasComment
? [
{
id: legacy.id,
task_id: TASK,
plan_id: PLAN,
body: legacy.text,
selected_text: legacy.selectedText,
anchor_from: 1,
anchor_to: 5,
version: 1,
created_at: DATE,
updated_at: DATE,
},
]
: [],
};
}
async function mountRecovery(state: TaskSessionState = "WAITING_FOR_INPUT") {
mockStore = createAppStore();
mockStore.getState().setTaskPlan(TASK, plan);
mockStore.getState().setTaskSession({
id: SESSION,
task_id: TASK,
state,
is_primary: true,
queue_incarnation_id: "inc-legacy-run",
started_at: DATE,
updated_at: DATE,
});
mockStore.getState().setConnectionStatus("connected");
const recovery = planCommentMigrationFor(mockStore, TASK);
const detach = recovery.attach(vi.fn());
detachments.push(detach);
recovery.update({ sessionIds: [SESSION], complete: true, loading: false });
await vi.advanceTimersByTimeAsync(0);
const hook = renderHook(() => useRunComment({ sessionId: SESSION, taskId: TASK }));
return { ...hook, detach, store: mockStore };
}
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
window.sessionStorage.clear();
window.sessionStorage.setItem(KEY, JSON.stringify([legacy]));
detachments = [];
admitted = false;
serverHasComment = false;
vi.spyOn(console, "error").mockImplementation(() => undefined);
api.createTaskPlanComment.mockImplementation(async () => {
// Replayed admission returns the current snapshot, never recreating a consumed UUID.
if (!admitted) serverHasComment = true;
admitted = true;
return serverSnapshot();
});
api.getTaskPlanComments.mockImplementation(async () => serverSnapshot());
const consume = async () => {
serverHasComment = false;
};
api.sendMessageRequest.mockImplementation(consume);
api.queueMessage.mockImplementation(consume);
});
afterEach(() => {
cleanup();
for (const detach of detachments) detach();
vi.restoreAllMocks();
vi.useRealTimers();
window.sessionStorage.clear();
});
describe("Run during selected legacy recovery", () => {
// @covers AC-TASKS-PLAN-COMMENTS-004.3, AC-TASKS-PLAN-COMMENTS-004.7
it.each(["WAITING_FOR_INPUT", "RUNNING"] as const)(
"waits for selected cleanup before Run and reload in %s",
async (state) => {
const removal = vi.spyOn(window.sessionStorage, "removeItem").mockImplementation(() => {
throw new DOMException("denied", "SecurityError");
});
const first = await mountRecovery(state);
expect(first.store.getState().taskPlans.commentsByTaskId[TASK]?.comments).toHaveLength(1);
await expect(first.result.current.runComment(persisted)).rejects.toMatchObject({
code: "plan-comment-not-persisted",
});
expect(api.sendMessageRequest).not.toHaveBeenCalled();
expect(api.queueMessage).not.toHaveBeenCalled();
expect(JSON.parse(window.sessionStorage.getItem(KEY)!)).toEqual([legacy]);
first.unmount();
first.detach();
removal.mockRestore();
const reloaded = await mountRecovery(state);
expect(window.sessionStorage.getItem(KEY)).toBeNull();
await expect(reloaded.result.current.runComment(persisted)).resolves.toEqual({
queued: state === "RUNNING",
});
expect(api.sendMessageRequest.mock.calls.length + api.queueMessage.mock.calls.length).toBe(1);
expect(serverHasComment).toBe(false);
reloaded.unmount();
reloaded.detach();
const afterRun = await mountRecovery(state);
expect(api.createTaskPlanComment).toHaveBeenCalledTimes(2);
expect(afterRun.store.getState().taskPlans.commentsMigrationByTaskId[TASK]).toMatchObject({
status: "complete",
pendingCount: 0,
});
expect(window.sessionStorage.getItem(KEY)).toBeNull();
},
);
it("allows Run once its own row is cleaned while another legacy row is pending", async () => {
const other = { ...legacy, id: "legacy-other" };
window.sessionStorage.setItem(KEY, JSON.stringify([legacy, other]));
const create = api.createTaskPlanComment.getMockImplementation()!;
api.createTaskPlanComment.mockImplementation(async (input: { id: string }) => {
if (input.id === other.id) throw new Error("temporarily unavailable");
return create(input);
});
const { store, result } = await mountRecovery();
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK]?.pendingCount).toBe(1);
await expect(result.current.runComment(persisted)).resolves.toEqual({ queued: false });
expect(api.sendMessageRequest).toHaveBeenCalledOnce();
expect(JSON.parse(window.sessionStorage.getItem(KEY)!)).toEqual([other]);
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK]?.pendingCount).toBe(1);
});
});
@@ -122,7 +122,11 @@ function makeStoreState() {
taskSessionsByTask: {
itemsByTaskId: { [TASK_ID]: [primary, selected] } as Record<string, MockSession[]>,
},
taskPlans: { commentsMigrationStatusByTaskId: { [TASK_ID]: "complete" } },
taskPlans: {
commentsMigrationByTaskId: {
[TASK_ID]: { status: "complete", pendingCount: 0, failure: null },
},
},
queue: { metaBySessionId: {} },
chatInput: { planModeBySessionId: {} },
addMessage: mockAddMessage,
@@ -175,6 +179,23 @@ function setup() {
describe("useRunComment primary-session recovery", () => {
beforeEach(setup);
it("runs a persisted comment despite unrelated legacy recovery", async () => {
mockStoreState.taskPlans.commentsMigrationByTaskId[TASK_ID] = {
status: "failed",
pendingCount: 1,
failure: null,
};
const { result } = renderCommentHook();
await result.current.runComment(makePlanComment());
expect(mockSendMessageRequest).toHaveBeenCalledWith(
expect.objectContaining({
resolvedSessionId: PRIMARY_SESSION_ID,
planCommentRefs: [{ id: "comment-1", version: 2 }],
}),
);
expect(mockQueueMessage).not.toHaveBeenCalled();
});
it("applies refreshed primary details before retry", async () => {
const replacement = makeSession(NEW_PRIMARY_ID, "STARTING", false, "inc-new-primary");
mockStoreState.taskSessions.items[NEW_PRIMARY_ID] = replacement;
@@ -142,9 +142,6 @@ function makeStoreState(sessionState: string, planMode = false, foregroundActivi
chatInput: {
planModeBySessionId: { "sess-1": planMode },
},
taskPlans: {
commentsMigrationStatusByTaskId: { "task-1": "complete" },
},
queue: { metaBySessionId: {} as Record<string, { count: number }> },
addMessage: mockAddMessage,
setTaskPlanComments: mockSetTaskPlanComments,
@@ -487,19 +484,6 @@ describe("useRunComment — plan routing", () => {
);
});
it("rejects Run until legacy plan comments finish migrating", async () => {
const state = makeStoreState("WAITING_FOR_INPUT");
state.taskPlans.commentsMigrationStatusByTaskId["task-1"] = "failed";
mockStoreState = state;
const { result } = renderCommentHook();
await expect(result.current.runComment(makePlanComment())).rejects.toMatchObject({
code: "plan-comment-migration-pending",
});
expect(mockSendMessageRequest).not.toHaveBeenCalled();
expect(mockQueueMessage).not.toHaveBeenCalled();
});
it("queues a busy primary as a distinct idempotent entry instead of appending", async () => {
const state = makeStoreState("WAITING_FOR_INPUT");
(state.taskSessions.items["primary-session"] as { state: string }).state = "RUNNING";
@@ -30,6 +30,7 @@ import { getTaskPlanComments } from "@/lib/api/domains/plan-comment-api";
import { listTaskSessions } from "@/lib/api/domains/session-api";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import { captureTaskSessionActivityEpochs } from "@/lib/state/slices/session/activity-epochs";
import { hasPendingPlanCommentMigration } from "./plan-comment-migration";
/**
* Format a single comment into markdown suitable for sending to the agent.
@@ -211,7 +212,6 @@ export class PlanCommentRunError extends Error {
readonly code:
| PlanCommentRunUnavailableReason
| "plan-comment-not-persisted"
| "plan-comment-migration-pending"
| "plan-comments-changed"
| "primary-session-changed"
| "delivery-failed",
@@ -227,8 +227,6 @@ function planCommentRunErrorMessage(code: PlanCommentRunError["code"]): string {
return t("task:noPrimarySessionForPlanComment");
case "primary-session-unavailable":
return t("task:primarySessionUnavailableForPlanComment");
case "plan-comment-migration-pending":
return t("task:planCommentMigrationPending");
case "plan-comment-not-persisted":
return t("task:planCommentNotReadyToRun");
case "plan-comments-changed":
@@ -246,10 +244,11 @@ async function runTaskPlanComment(
storeApi: ReturnType<typeof useAppStoreApi>,
clientAdmissionId: string,
): Promise<{ queued: boolean }> {
if (storeApi.getState().taskPlans.commentsMigrationStatusByTaskId[taskId] !== "complete") {
throw new PlanCommentRunError("plan-comment-migration-pending");
}
if (!comment.version || comment.version < 1) {
if (
!comment.version ||
comment.version < 1 ||
hasPendingPlanCommentMigration(storeApi, taskId, comment.id)
) {
throw new PlanCommentRunError("plan-comment-not-persisted");
}
const availability = resolvePlanCommentRunAvailability(storeApi.getState(), taskId);
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { planCommentRecovery, planCommentRecoveryDelay } from "./plan-comment-recovery";
describe("plan comment recovery admission", () => {
it.each(["idle", "running", "retrying", "complete", "waiting_for_plan", "failed"] as const)(
"does not block empty %s state",
(status) => {
expect(planCommentRecovery({ status, pendingCount: 0, failure: "transient" })).toMatchObject({
isBlocking: false,
needsAttention: false,
});
},
);
it("blocks mixed feedback until the remaining identified row is acknowledged", () => {
expect(
planCommentRecovery({ status: "retrying", pendingCount: 1, failure: "transient" }),
).toMatchObject({ isBlocking: true, needsAttention: false });
});
it.each(["failed", "waiting_for_plan"] as const)(
"requests attention for identified feedback in %s state",
(status) => {
expect(planCommentRecovery({ status, pendingCount: 1, failure: null })).toMatchObject({
isBlocking: true,
needsAttention: true,
});
},
);
it("caps retries after the three-attempt burst", () => {
expect([1, 2, 3, 4, 5, 6, 99].map(planCommentRecoveryDelay)).toEqual([
1000, 2000, 30000, 60000, 120000, 120000, 120000,
]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { PlanCommentMigrationState } from "@/lib/state/slices/session/types";
export const EMPTY_PLAN_COMMENT_MIGRATION: PlanCommentMigrationState = {
status: "idle",
pendingCount: 0,
failure: null,
};
/** Only identified, unacknowledged feedback can be omitted by composer Send. */
export function planCommentRecovery(state = EMPTY_PLAN_COMMENT_MIGRATION) {
const isBlocking = state.pendingCount > 0;
return {
...state,
isBlocking,
isReady: !isBlocking,
needsAttention:
isBlocking && (state.status === "failed" || state.status === "waiting_for_plan"),
};
}
/** Three connected attempts, then a capped background backoff. */
export function planCommentRecoveryDelay(failures: number) {
if (failures < 3) return failures * 1000;
return Math.min(30_000 * 2 ** (failures - 3), 120_000);
}
+2 -2
View File
@@ -518,9 +518,9 @@ export type AppState = KanbanSlice & {
setTaskPlanComments: (taskId: string, snapshot: TaskPlanCommentSnapshot) => void;
setTaskPlanCommentsLoading: (taskId: string, loading: boolean) => void;
setTaskPlanCommentsError: (taskId: string, error?: string) => void;
setTaskPlanCommentMigrationStatus: (
setTaskPlanCommentMigrationState: (
taskId: string,
status: import("./slices/session/types").PlanCommentMigrationStatus,
state: import("./slices/session/types").PlanCommentMigrationState,
) => void;
clearTaskPlan: (taskId: string) => void;
markTaskPlanSeen: (taskId: string) => void;
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiffComment, PlanComment } from "./types";
import {
COMMENTS_STORAGE_PREFIX,
@@ -46,6 +46,21 @@ function stored(): unknown[] {
describe("legacy plan comment persistence", () => {
beforeEach(() => window.sessionStorage.clear());
afterEach(() => vi.restoreAllMocks());
it("does not acknowledge a row when browser storage refuses cleanup", () => {
const comment = planComment("plan-1");
window.sessionStorage.setItem(
`${COMMENTS_STORAGE_PREFIX}${SESSION_ID}`,
JSON.stringify([comment]),
);
vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => {
throw new DOMException("Storage unavailable", "SecurityError");
});
expect(removeAcknowledgedLegacyPlanComment(SESSION_ID, comment)).toBe(false);
expect(stored()).toEqual([comment]);
});
it("lists only readable pending plan rows for the requested sessions", () => {
window.sessionStorage.setItem(
@@ -40,20 +40,33 @@ function isLegacyPlanComment(value: unknown): value is PlanComment {
);
}
function rawSessionComments(sessionId: string): unknown[] {
const stored = getSessionStorage(`${STORAGE_PREFIX}${sessionId}`, [] as Comment[]) as unknown;
return Array.isArray(stored) ? stored : [];
function rawSessionComments(sessionId: string): unknown[] | null {
try {
const raw = window.sessionStorage.getItem(`${STORAGE_PREFIX}${sessionId}`);
const stored: unknown = raw === null ? [] : JSON.parse(raw);
return Array.isArray(stored) ? stored : null;
} catch {
return null;
}
}
/** Discover only valid pending legacy plan rows for known sessions. */
export function listLegacyPlanComments(sessionIds: string[]): LegacyPlanCommentRecord[] {
const result: LegacyPlanCommentRecord[] = [];
return readLegacyPlanComments(sessionIds).records;
}
/** An unavailable scan cannot prove that previously identified drafts disappeared. */
export function readLegacyPlanComments(sessionIds: string[]) {
const records: LegacyPlanCommentRecord[] = [];
let available = true;
for (const sessionId of sessionIds) {
for (const value of rawSessionComments(sessionId)) {
if (isLegacyPlanComment(value)) result.push({ sessionId, comment: value });
const values = rawSessionComments(sessionId);
if (values === null) available = false;
for (const value of values ?? []) {
if (isLegacyPlanComment(value)) records.push({ sessionId, comment: value });
}
}
return result;
return { records, available };
}
function sameLegacyPlanComment(value: unknown, expected: PlanComment): boolean {
@@ -76,12 +89,21 @@ export function removeAcknowledgedLegacyPlanComment(
expected: PlanComment,
): boolean {
const values = rawSessionComments(sessionId);
if (values === null) return false;
const index = values.findIndex((value) => sameLegacyPlanComment(value, expected));
if (index < 0) return false;
values.splice(index, 1);
if (values.length === 0) removeSessionStorage(`${STORAGE_PREFIX}${sessionId}`);
else setSessionStorage(`${STORAGE_PREFIX}${sessionId}`, values as Comment[]);
return true;
if (index < 0) return !values.some((value) => isRecord(value) && value.id === expected.id);
try {
values.splice(index, 1);
if (values.length === 0) window.sessionStorage.removeItem(`${STORAGE_PREFIX}${sessionId}`);
else window.sessionStorage.setItem(`${STORAGE_PREFIX}${sessionId}`, JSON.stringify(values));
// Readback confirms cleanup; a completed write alone cannot acknowledge the draft's removal.
const remaining = rawSessionComments(sessionId);
return (
remaining !== null && !remaining.some((value) => isRecord(value) && value.id === expected.id)
);
} catch {
return false;
}
}
export const COMMENTS_STORAGE_PREFIX = STORAGE_PREFIX;
@@ -220,7 +220,7 @@ export const defaultSessionState: SessionSliceState = {
commentsLoadingByTaskId: {},
commentsLoadedByTaskId: {},
commentsErrorByTaskId: {},
commentsMigrationStatusByTaskId: {},
commentsMigrationByTaskId: {},
revisionsByTaskId: {},
revisionsLoadingByTaskId: {},
revisionsLoadedByTaskId: {},
@@ -378,7 +378,8 @@ function reconcilePlanCommentIdentity(
const previousPlanId = previousPlan?.id ?? null;
if (previousPlan !== undefined && previousPlanId === nextPlanId) return;
if (previousPlanId !== nextPlanId) {
taskPlans.commentsMigrationStatusByTaskId[taskId] = "idle";
const pendingCount = taskPlans.commentsMigrationByTaskId[taskId]?.pendingCount ?? 0;
taskPlans.commentsMigrationByTaskId[taskId] = { status: "idle", pendingCount, failure: null };
}
const snapshot = taskPlans.commentsByTaskId[taskId];
if (nextPlanId !== null && snapshot?.plan_id === nextPlanId) return;
@@ -414,12 +415,12 @@ function buildTaskPlanCommentActions(set: ImmerSet) {
if (error) draft.taskPlans.commentsErrorByTaskId[taskId] = error;
else delete draft.taskPlans.commentsErrorByTaskId[taskId];
}),
setTaskPlanCommentMigrationStatus: (
setTaskPlanCommentMigrationState: (
taskId: string,
status: Parameters<SessionSlice["setTaskPlanCommentMigrationStatus"]>[1],
state: Parameters<SessionSlice["setTaskPlanCommentMigrationState"]>[1],
) =>
set((draft) => {
draft.taskPlans.commentsMigrationStatusByTaskId[taskId] = status;
draft.taskPlans.commentsMigrationByTaskId[taskId] = state;
}),
};
}
@@ -468,7 +469,7 @@ function buildTaskPlanActions(set: ImmerSet, get: ImmerGet) {
delete draft.taskPlans.commentsLoadingByTaskId[taskId];
delete draft.taskPlans.commentsLoadedByTaskId[taskId];
delete draft.taskPlans.commentsErrorByTaskId[taskId];
delete draft.taskPlans.commentsMigrationStatusByTaskId[taskId];
delete draft.taskPlans.commentsMigrationByTaskId[taskId];
delete draft.taskPlans.revisionsByTaskId[taskId];
delete draft.taskPlans.revisionsLoadingByTaskId[taskId];
delete draft.taskPlans.revisionsLoadedByTaskId[taskId];
@@ -56,27 +56,43 @@ describe("task plan comment state", () => {
const store = createAppStore();
store.getState().setTaskPlan(TASK_ID, plan());
store.getState().setTaskPlanComments(TASK_ID, snapshot(2));
store.getState().setTaskPlanCommentMigrationStatus(TASK_ID, "complete");
store.getState().setTaskPlanCommentMigrationState(TASK_ID, {
status: "failed",
pendingCount: 2,
failure: "conflict",
});
store.getState().setTaskPlanComments(TASK_ID, snapshot(99, "old-plan"));
expect(store.getState().taskPlans.commentsByTaskId[TASK_ID]).toEqual(snapshot(2));
store.getState().setTaskPlan(TASK_ID, plan("plan-2"));
expect(store.getState().taskPlans.commentsByTaskId[TASK_ID]).toBeUndefined();
expect(store.getState().taskPlans.commentsLoadedByTaskId[TASK_ID]).toBe(false);
expect(store.getState().taskPlans.commentsMigrationStatusByTaskId[TASK_ID]).toBe("idle");
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK_ID]).toEqual({
status: "idle",
pendingCount: 2,
failure: null,
});
});
it("clears the task snapshot when the plan is deleted", () => {
const store = createAppStore();
store.getState().setTaskPlan(TASK_ID, plan());
store.getState().setTaskPlanComments(TASK_ID, snapshot(1));
store.getState().setTaskPlanCommentMigrationStatus(TASK_ID, "complete");
store.getState().setTaskPlanCommentMigrationState(TASK_ID, {
status: "failed",
pendingCount: 2,
failure: "conflict",
});
store.getState().setTaskPlan(TASK_ID, null);
expect(store.getState().taskPlans.commentsByTaskId[TASK_ID]).toBeUndefined();
expect(store.getState().taskPlans.commentsLoadedByTaskId[TASK_ID]).toBe(true);
expect(store.getState().taskPlans.commentsMigrationStatusByTaskId[TASK_ID]).toBe("idle");
expect(store.getState().taskPlans.commentsMigrationByTaskId[TASK_ID]).toEqual({
status: "idle",
pendingCount: 2,
failure: null,
});
});
it("keeps comment load state when the same plan is refreshed", () => {
+9 -2
View File
@@ -129,10 +129,17 @@ export type ComparePair = [string | null, string | null];
export type PlanCommentMigrationStatus =
| "idle"
| "running"
| "retrying"
| "complete"
| "waiting_for_plan"
| "failed";
export type PlanCommentMigrationState = {
status: PlanCommentMigrationStatus;
pendingCount: number;
failure: "transient" | "conflict" | "rejected" | null;
};
export type TaskPlansState = {
byTaskId: Record<string, TaskPlan | null>;
loadingByTaskId: Record<string, boolean>;
@@ -142,7 +149,7 @@ export type TaskPlansState = {
commentsLoadingByTaskId: Record<string, boolean>;
commentsLoadedByTaskId: Record<string, boolean>;
commentsErrorByTaskId: Record<string, string | undefined>;
commentsMigrationStatusByTaskId: Record<string, PlanCommentMigrationStatus | undefined>;
commentsMigrationByTaskId: Record<string, PlanCommentMigrationState | undefined>;
revisionsByTaskId: Record<string, TaskPlanRevision[]>;
revisionsLoadingByTaskId: Record<string, boolean>;
revisionsLoadedByTaskId: Record<string, boolean>;
@@ -402,7 +409,7 @@ export type SessionSliceActions = {
setTaskPlanComments: (taskId: string, snapshot: TaskPlanCommentSnapshot) => void;
setTaskPlanCommentsLoading: (taskId: string, loading: boolean) => void;
setTaskPlanCommentsError: (taskId: string, error?: string) => void;
setTaskPlanCommentMigrationStatus: (taskId: string, status: PlanCommentMigrationStatus) => void;
setTaskPlanCommentMigrationState: (taskId: string, state: PlanCommentMigrationState) => void;
clearTaskPlan: (taskId: string) => void;
markTaskPlanSeen: (taskId: string) => void;
// Revision actions
@@ -19,6 +19,7 @@
"withManagedWorkspaceCredentialsKandevConfigures": "使用受管理的工作區認證資訊時,Kandev 會在新啟動的任務處理程序中,將 agentctl 設為 Git 的認證輔助程式。當 Git 要求取得已附加 GitHub HTTPS 儲存庫的認證資訊時,輔助程式會兌換僅適用於該任務與儲存庫的 broker 租約。認證資訊會在需要時傳回 Git,且不會寫入儲存庫或 Git 設定。gh 命令會使用 Kandev 支援 broker 的轉接程式。繼承執行器設定則會略過這兩者,改用所選執行器可見的認證資訊。變更會套用至新啟動的任務及下次繼續執行,不會影響已在執行的處理程序。"
},
"task": {
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。",
"proxy": "代理伺服器",
"pathBasedProxyWorksForApis": "路徑型代理伺服器。適用於 API,但可能導致預期由 / 提供服務的 Web 應用程式無法正常運作。"
},
+1 -2
View File
@@ -2122,8 +2122,7 @@
"planCommentNotReadyToRun": "This plan comment is still being saved. Try Run again.",
"planCommentsChangedRetry": "The plan comments changed. Review them and send again.",
"primarySessionChangedRetry": "The primary session changed. Try Run again.",
"restoringSavedPlanComments": "Restoring saved plan comments...",
"planCommentMigrationFailed": "Some saved plan comments could not be restored.",
"planCommentMigrationNeedsPlan": "Create or restore a plan to recover saved comments.",
"planCommentMigrationPending": "Saved plan comments are still being restored. Retry before sending."
"planCommentMigrationPending": "Saved plan comments are still being restored. Your message is kept."
}
+1 -2
View File
@@ -2122,8 +2122,7 @@
"planCommentNotReadyToRun": "Ţĥĩś ƥĺàń ćōḿḿēńţ ĩś śţĩĺĺ ƀēĩńĝ śàvēď. Ţŕŷ Ŕũń àĝàĩń.",
"planCommentsChangedRetry": "Ţĥē ƥĺàń ćōḿḿēńţś ćĥàńĝēď. Ŕēvĩēŵ ţĥēḿ àńď śēńď àĝàĩń.",
"primarySessionChangedRetry": "Ţĥē ƥŕĩḿàŕŷ śēśśĩōń ćĥàńĝēď. Ţŕŷ Ŕũń àĝàĩń.",
"restoringSavedPlanComments": "Ŕēśţōŕĩńĝ śàvēď ƥĺàń ćōḿḿēńţś...",
"planCommentMigrationFailed": "Śōḿē śàvēď ƥĺàń ćōḿḿēńţś ćōũĺď ńōţ ƀē ŕēśţōŕēď.",
"planCommentMigrationNeedsPlan": "Ćŕēàţē ōŕ ŕēśţōŕē à ƥĺàń ţō ŕēćōvēŕ śàvēď ćōḿḿēńţś.",
"planCommentMigrationPending": "Śàvēď ƥĺàń ćōḿḿēńţś àŕē śţĩĺĺ ƀēĩńĝ ŕēśţōŕēď. Ŕēţŕŷ ƀēƒōŕē śēńďĩńĝ."
"planCommentMigrationPending": "Śàvēď ƥĺàń ćōḿḿēńţś àŕē śţĩĺĺ ƀēĩńĝ ŕēśţōŕēď. Ŷōũŕ ḿēśśàĝē ĩś ķēƥţ."
}
+1 -2
View File
@@ -2122,8 +2122,7 @@
"planCommentNotReadyToRun": "Este comentário do plano ainda está a ser guardado. Tente executar novamente.",
"planCommentsChangedRetry": "Os comentários do plano foram alterados. Reveja-os e envie novamente.",
"primarySessionChangedRetry": "A sessão principal foi alterada. Tente executar novamente.",
"restoringSavedPlanComments": "A restaurar comentários do plano guardados...",
"planCommentMigrationFailed": "Não foi possível restaurar alguns comentários do plano guardados.",
"planCommentMigrationNeedsPlan": "Crie ou restaure um plano para recuperar os comentários guardados.",
"planCommentMigrationPending": "Os comentários do plano guardados ainda estão a ser restaurados. Tente novamente antes de enviar."
"planCommentMigrationPending": "Os comentários do plano guardados ainda estão a ser restaurados. A sua mensagem é mantida."
}
+1 -2
View File
@@ -2122,8 +2122,7 @@
"planCommentNotReadyToRun": "此计划评论仍在保存。请再次运行。",
"planCommentsChangedRetry": "计划评论已更改。请检查后重新发送。",
"primarySessionChangedRetry": "主会话已更改。请再次运行。",
"restoringSavedPlanComments": "正在恢复已保存的计划评论...",
"planCommentMigrationFailed": "无法恢复部分已保存的计划评论。",
"planCommentMigrationNeedsPlan": "请创建或恢复计划,以恢复已保存的评论。",
"planCommentMigrationPending": "仍在恢复已保存的计划评论。请重试后再发送。"
"planCommentMigrationPending": "仍在恢复已保存的计划评论。您的消息会保留。"
}
+1 -2
View File
@@ -2122,8 +2122,7 @@
"planCommentNotReadyToRun": "此計劃評論仍在儲存。請再次執行。",
"planCommentsChangedRetry": "計劃評論已更改。請檢查後重新傳送。",
"primarySessionChangedRetry": "主工作階段已更改。請再次執行。",
"restoringSavedPlanComments": "正在恢復已儲存的計劃評論...",
"planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。",
"planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。",
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。請重試後再傳送。"
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。"
}
+1 -2
View File
@@ -2122,8 +2122,7 @@
"planCommentNotReadyToRun": "此計劃評論仍在儲存。請再次執行。",
"planCommentsChangedRetry": "計劃評論已更改。請檢查後重新傳送。",
"primarySessionChangedRetry": "主工作階段已更改。請再次執行。",
"restoringSavedPlanComments": "正在恢復已儲存的計劃評論...",
"planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。",
"planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。",
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。請重試後再傳送。"
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。"
}