fix(web): recover plan comments without blocking empty chat (#3616)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 應用程式無法正常運作。"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -2122,8 +2122,7 @@
|
||||
"planCommentNotReadyToRun": "Ţĥĩś ƥĺàń ćōḿḿēńţ ĩś śţĩĺĺ ƀēĩńĝ śàvēď. Ţŕŷ Ŕũń àĝàĩń.",
|
||||
"planCommentsChangedRetry": "Ţĥē ƥĺàń ćōḿḿēńţś ćĥàńĝēď. Ŕēvĩēŵ ţĥēḿ àńď śēńď àĝàĩń.",
|
||||
"primarySessionChangedRetry": "Ţĥē ƥŕĩḿàŕŷ śēśśĩōń ćĥàńĝēď. Ţŕŷ Ŕũń àĝàĩń.",
|
||||
"restoringSavedPlanComments": "Ŕēśţōŕĩńĝ śàvēď ƥĺàń ćōḿḿēńţś...",
|
||||
"planCommentMigrationFailed": "Śōḿē śàvēď ƥĺàń ćōḿḿēńţś ćōũĺď ńōţ ƀē ŕēśţōŕēď.",
|
||||
"planCommentMigrationNeedsPlan": "Ćŕēàţē ōŕ ŕēśţōŕē à ƥĺàń ţō ŕēćōvēŕ śàvēď ćōḿḿēńţś.",
|
||||
"planCommentMigrationPending": "Śàvēď ƥĺàń ćōḿḿēńţś àŕē śţĩĺĺ ƀēĩńĝ ŕēśţōŕēď. Ŕēţŕŷ ƀēƒōŕē śēńďĩńĝ."
|
||||
"planCommentMigrationPending": "Śàvēď ƥĺàń ćōḿḿēńţś àŕē śţĩĺĺ ƀēĩńĝ ŕēśţōŕēď. Ŷōũŕ ḿēśśàĝē ĩś ķēƥţ."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -2122,8 +2122,7 @@
|
||||
"planCommentNotReadyToRun": "此计划评论仍在保存。请再次运行。",
|
||||
"planCommentsChangedRetry": "计划评论已更改。请检查后重新发送。",
|
||||
"primarySessionChangedRetry": "主会话已更改。请再次运行。",
|
||||
"restoringSavedPlanComments": "正在恢复已保存的计划评论...",
|
||||
"planCommentMigrationFailed": "无法恢复部分已保存的计划评论。",
|
||||
"planCommentMigrationNeedsPlan": "请创建或恢复计划,以恢复已保存的评论。",
|
||||
"planCommentMigrationPending": "仍在恢复已保存的计划评论。请重试后再发送。"
|
||||
"planCommentMigrationPending": "仍在恢复已保存的计划评论。您的消息会保留。"
|
||||
}
|
||||
|
||||
@@ -2122,8 +2122,7 @@
|
||||
"planCommentNotReadyToRun": "此計劃評論仍在儲存。請再次執行。",
|
||||
"planCommentsChangedRetry": "計劃評論已更改。請檢查後重新傳送。",
|
||||
"primarySessionChangedRetry": "主工作階段已更改。請再次執行。",
|
||||
"restoringSavedPlanComments": "正在恢復已儲存的計劃評論...",
|
||||
"planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。",
|
||||
"planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。",
|
||||
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。請重試後再傳送。"
|
||||
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。"
|
||||
}
|
||||
|
||||
@@ -2122,8 +2122,7 @@
|
||||
"planCommentNotReadyToRun": "此計劃評論仍在儲存。請再次執行。",
|
||||
"planCommentsChangedRetry": "計劃評論已更改。請檢查後重新傳送。",
|
||||
"primarySessionChangedRetry": "主工作階段已更改。請再次執行。",
|
||||
"restoringSavedPlanComments": "正在恢復已儲存的計劃評論...",
|
||||
"planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。",
|
||||
"planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。",
|
||||
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。請重試後再傳送。"
|
||||
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user