diff --git a/apps/web/components/task/workflow-step-disclosure.tsx b/apps/web/components/task/workflow-step-disclosure.tsx index cfbfe7e2dd..46739a751c 100644 --- a/apps/web/components/task/workflow-step-disclosure.tsx +++ b/apps/web/components/task/workflow-step-disclosure.tsx @@ -169,10 +169,10 @@ const CompactWorkflowTrigger = forwardRef { test("shows the same prediction and details inside the touch drawer", async ({ @@ -33,62 +39,136 @@ test.describe("mobile: workflow move preview", () => { const kanban = new KanbanPage(tabletTestPage); await kanban.goto(); + const { settings } = await apiClient.getUserSettings(); + const previousPreviewOnClick = settings.enable_preview_on_click === true; await apiClient.saveUserSettings({ enable_preview_on_click: true }); - await kanban.goto(); - const card = kanban.taskCardByTitle("Mobile Move Preview Task"); - await expect(card).toBeVisible({ timeout: 10_000 }); - await card.tap(); + try { + await kanban.goto(); + const card = kanban.taskCardByTitle("Mobile Move Preview Task"); + await expect(card).toBeVisible({ timeout: 10_000 }); + await card.tap(); - const previewPanel = tabletTestPage.getByTestId("task-preview-panel"); - await expect(previewPanel).toBeVisible({ timeout: 10_000 }); - const trigger = previewPanel.getByTestId("workflow-stepper-minimal"); - await expect(trigger).toBeVisible({ timeout: 15_000 }); - await trigger.tap(); + const previewPanel = tabletTestPage.getByTestId("task-preview-panel"); + await expect(previewPanel).toBeVisible({ timeout: 10_000 }); + const trigger = previewPanel.getByTestId("workflow-stepper-minimal"); + await expect(trigger).toBeVisible({ timeout: 15_000 }); - const drawer = tabletTestPage.locator('[data-slot="drawer-content"][data-state="open"]'); - await expect(drawer).toBeVisible(); - const row = drawer.getByTestId(`workflow-step-disclosure-row-${targetStep.id}`); - await expect(row).toBeVisible(); - const preview = row.getByTestId("workflow-move-preview"); - await expect(preview).toBeVisible({ timeout: 15_000 }); - await expect(preview).toContainText("Reuse current session"); - await expect(preview).toContainText("mock-fast"); + const isMovePreviewRequest = movePreviewRequestPredicate(task.id, targetStep.id); + let requestCount = 0; + const requestListener = (request: Request) => { + if (isMovePreviewRequest(request)) requestCount += 1; + }; + tabletTestPage.on("request", requestListener); - const move = row.getByTestId(`workflow-step-disclosure-move-${targetStep.id}`); - await expect(move).toBeVisible(); - await expect(row.getByTestId("workflow-move-preview-details")).toHaveCount(0); - const labelBox = await row.getByText(targetStep.name, { exact: true }).boundingBox(); - const moveBox = await move.boundingBox(); - expect(labelBox).not.toBeNull(); - expect(moveBox).not.toBeNull(); - expect( - Math.abs(labelBox!.y + labelBox!.height / 2 - moveBox!.y - moveBox!.height / 2), - ).toBeLessThan(2); - await expect(preview).toHaveCSS("text-align", "left"); - const detailsToggle = row.getByTestId(`workflow-step-disclosure-options-${targetStep.id}`); - const toggleBox = await detailsToggle.boundingBox(); - expect(toggleBox).not.toBeNull(); - if (!toggleBox) return; - expect(toggleBox.height).toBeGreaterThanOrEqual(44); - expect(toggleBox.width).toBeGreaterThanOrEqual(44); - await detailsToggle.tap(); - await expect(row.getByTestId("workflow-move-preview-details")).toBeVisible(); + try { + await trigger.tap(); - for (const testId of [ - "workflow-step-disclosure-options-" + targetStep.id, - "workflow-step-disclosure-move-" + targetStep.id, - ]) { - const box = await drawer.getByTestId(testId).boundingBox(); - expect(box).not.toBeNull(); - if (!box) return; - expect(box.height).toBeGreaterThanOrEqual(44); + const drawer = tabletTestPage.locator('[data-slot="drawer-content"][data-state="open"]'); + await expect(drawer).toBeVisible(); + const row = drawer.getByTestId(`workflow-step-disclosure-row-${targetStep.id}`); + await expect(row).toBeVisible(); + const preview = row.getByTestId("workflow-move-preview"); + await expect(preview).toBeVisible({ timeout: 15_000 }); + await expect(preview).toContainText("Reuse current session"); + await expect(preview).toContainText("mock-fast"); + expect(requestCount).toBe(1); + + const move = row.getByTestId(`workflow-step-disclosure-move-${targetStep.id}`); + await expect(move).toBeVisible(); + await expect(row.getByTestId("workflow-move-preview-details")).toHaveCount(0); + const labelBox = await row.getByText(targetStep.name, { exact: true }).boundingBox(); + const moveBox = await move.boundingBox(); + expect(labelBox).not.toBeNull(); + expect(moveBox).not.toBeNull(); + expect( + Math.abs(labelBox!.y + labelBox!.height / 2 - moveBox!.y - moveBox!.height / 2), + ).toBeLessThan(3); + await expect(preview).toHaveCSS("text-align", "left"); + const detailsToggle = row.getByTestId(`workflow-step-disclosure-options-${targetStep.id}`); + const toggleBox = await detailsToggle.boundingBox(); + expect(toggleBox).not.toBeNull(); + if (!toggleBox) return; + expect(toggleBox.height).toBeGreaterThanOrEqual(44); + expect(toggleBox.width).toBeGreaterThanOrEqual(44); + await detailsToggle.tap(); + await expect(row.getByTestId("workflow-move-preview-details")).toBeVisible(); + + let unexpectedRequest = false; + void tabletTestPage + .waitForRequest(isMovePreviewRequest) + .then(() => { + unexpectedRequest = true; + }) + .catch(() => undefined); + for (const revision of [1, 2, 3]) { + await applyHarmlessPreviewUpdate(tabletTestPage, task.id, revision); + } + await dwell( + tabletTestPage, + 500, + "negative-assertion", + "observe no move-preview refresh after harmless touch updates", + ); + expect(unexpectedRequest).toBe(false); + expect(requestCount).toBe(1); + await expect(row.getByTestId("workflow-move-preview-details")).toBeVisible(); + await expect(row.getByTestId("workflow-move-preview-loading")).toHaveCount(0); + + const movePreviewUrl = `**/api/v1/tasks/${task.id}/move-preview`; + let releaseHeldRequest: (() => void) | undefined; + let resolveHeldRequest!: () => void; + const heldRequest = new Promise((resolve) => { + resolveHeldRequest = resolve; + }); + let held = false; + await tabletTestPage.route(movePreviewUrl, async (route) => { + if (isMovePreviewRequest(route.request()) && !held) { + held = true; + resolveHeldRequest(); + await new Promise((resolve) => { + releaseHeldRequest = resolve; + }); + } + await route.continue(); + }); + try { + const currentTask = await apiClient.getTask(task.id); + const modelUpdate = apiClient.setSessionModel( + currentTask.primary_session_id, + "mock-slow", + ); + await heldRequest; + expect(requestCount).toBe(2); + await expect(row.getByTestId("workflow-move-preview-loading")).toBeVisible(); + releaseHeldRequest?.(); + await modelUpdate; + await expect(preview).toContainText("mock-slow"); + } finally { + releaseHeldRequest?.(); + await tabletTestPage.unroute(movePreviewUrl); + } + + for (const testId of [ + "workflow-step-disclosure-options-" + targetStep.id, + "workflow-step-disclosure-move-" + targetStep.id, + ]) { + const box = await drawer.getByTestId(testId).boundingBox(); + expect(box).not.toBeNull(); + if (!box) return; + expect(box.height).toBeGreaterThanOrEqual(44); + } + await assertNoDocumentHorizontalOverflow(tabletTestPage); + + await tabletTestPage.keyboard.press("Escape"); + await expect( + tabletTestPage.locator('[data-slot="drawer-content"][data-state="open"]'), + ).toHaveCount(0); + await expect(trigger).toBeFocused(); + } finally { + tabletTestPage.off("request", requestListener); + } + } finally { + await apiClient.saveUserSettings({ enable_preview_on_click: previousPreviewOnClick }); } - await assertNoDocumentHorizontalOverflow(tabletTestPage); - - await tabletTestPage.keyboard.press("Escape"); - await expect( - tabletTestPage.locator('[data-slot="drawer-content"][data-state="open"]'), - ).toHaveCount(0); - await expect(trigger).toBeFocused(); }); }); diff --git a/apps/web/e2e/tests/workflow/workflow-move-preview-stability-helpers.ts b/apps/web/e2e/tests/workflow/workflow-move-preview-stability-helpers.ts new file mode 100644 index 0000000000..62c03512e2 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-move-preview-stability-helpers.ts @@ -0,0 +1,99 @@ +import type { Page } from "@playwright/test"; + +type PreviewTask = { + id: string; + workflowId?: string | null; + workflowStepId?: string | null; + state?: string | null; + primarySessionId?: string | null; + primarySessionState?: string | null; + description?: string; + updatedAt?: string; + statusSummary?: Record | null; +}; + +type PreviewSession = { + id: string; + task_id?: string; + state?: string; + is_primary?: boolean; + agent_profile_id?: string; + started_at?: string; + metadata?: Record | null; + last_read_message_id?: string; + command_count?: number; + updated_at?: string; +}; + +type PreviewStoreState = { + connection: { status: string }; + workspaceContextGeneration: number; + kanban: { tasks: PreviewTask[] }; + taskSessions: { items: Record }; + taskSessionsByTask: { + itemsByTaskId: Record; + loadedByTaskId: Record; + }; + agentProfiles: { version: number }; +}; + +type PreviewStoreWindow = Window & { + __KANDEV_E2E_STORE__?: { + getState: () => PreviewStoreState; + setState: (updater: (state: PreviewStoreState) => void) => void; + }; +}; + +/** Apply the bookkeeping updates that previously caused an open preview to reload. */ +export async function applyHarmlessPreviewUpdate( + page: Page, + taskId: string, + revision: number, +): Promise { + await page.waitForFunction(() => Boolean((window as PreviewStoreWindow).__KANDEV_E2E_STORE__)); + await page.evaluate( + ({ taskId: currentTaskId, revision: currentRevision }) => { + const store = (window as PreviewStoreWindow).__KANDEV_E2E_STORE__; + if (!store) throw new Error("E2E store bridge is unavailable"); + store.setState((state) => { + const task = state.kanban.tasks.find(({ id }) => id === currentTaskId); + if (!task) throw new Error(`Task ${currentTaskId} is not in the Kanban store`); + task.description = `Bookkeeping update ${currentRevision}`; + task.updatedAt = `2026-01-01T00:00:0${currentRevision}.000Z`; + task.statusSummary = { + ...(task.statusSummary ?? {}), + bookkeeping_revision: currentRevision, + }; + + const sessions = state.taskSessionsByTask.itemsByTaskId[currentTaskId] ?? []; + for (const session of sessions) { + session.last_read_message_id = `bookkeeping-${currentRevision}`; + session.command_count = currentRevision; + session.updated_at = `2026-01-01T00:00:0${currentRevision}.000Z`; + const indexed = state.taskSessions.items[session.id]; + if (indexed && indexed !== session) { + indexed.last_read_message_id = session.last_read_message_id; + indexed.command_count = session.command_count; + indexed.updated_at = session.updated_at; + } + } + state.agentProfiles.version += 1; + }); + }, + { taskId, revision }, + ); +} + +export function movePreviewRequestPredicate(taskId: string, workflowStepId?: string) { + return (request: { method(): string; url(): string; postDataJSON(): unknown }) => { + if ( + request.method() !== "POST" || + new URL(request.url()).pathname !== `/api/v1/tasks/${taskId}/move-preview` + ) { + return false; + } + if (!workflowStepId) return true; + const payload = request.postDataJSON() as { workflow_step_id?: unknown } | null; + return payload?.workflow_step_id === workflowStepId; + }; +} diff --git a/apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts b/apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts index 8770022264..149643f09b 100644 --- a/apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts @@ -1,12 +1,17 @@ import { expectPreviewFooter } from "./workflow-move-preview-assertions"; -import type { Page } from "@playwright/test"; +import type { Page, Request } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; +import { dwell } from "../../helpers/causal-waits"; import { SessionPage } from "../../pages/session-page"; import { createWorkflowAgentProfiles, waitForWorkflowProfileSession, } from "./workflow-agent-switch-helpers"; import { seedMoveOverrideFixture } from "./workflow-step-move-overrides-helpers"; +import { + applyHarmlessPreviewUpdate, + movePreviewRequestPredicate, +} from "./workflow-move-preview-stability-helpers"; function waitForPreviewResponse(page: Page, taskId: string) { return page.waitForResponse( @@ -64,6 +69,84 @@ test.describe("Workflow move preview", () => { .toBe(fixture.primarySessionId); }); + test("keeps harmless updates stable and refreshes for a model change", async ({ + testPage, + apiClient, + seedData, + }) => { + const fixture = await seedMoveOverrideFixture( + testPage, + apiClient, + seedData, + "Desktop Move Preview Stability", + ); + const isMovePreviewRequest = movePreviewRequestPredicate(fixture.taskId, fixture.targetStepId); + let requestCount = 0; + const requestListener = (request: Request) => { + if (isMovePreviewRequest(request)) requestCount += 1; + }; + testPage.on("request", requestListener); + + try { + const { popover } = await openStepPreview(testPage, fixture.taskId, "Verify"); + expect(requestCount).toBe(1); + + let unexpectedRequest = false; + void testPage + .waitForRequest(isMovePreviewRequest) + .then(() => { + unexpectedRequest = true; + }) + .catch(() => undefined); + for (const revision of [1, 2, 3]) { + await applyHarmlessPreviewUpdate(testPage, fixture.taskId, revision); + } + await dwell( + testPage, + 500, + "negative-assertion", + "observe no move-preview refresh after harmless task updates", + ); + expect(unexpectedRequest).toBe(false); + expect(requestCount).toBe(1); + await expect(popover.getByTestId("workflow-move-preview")).toBeVisible(); + await expect(popover.getByTestId("workflow-move-preview-loading")).toHaveCount(0); + + const movePreviewUrl = `**/api/v1/tasks/${fixture.taskId}/move-preview`; + let releaseHeldRequest: (() => void) | undefined; + let resolveHeldRequest!: () => void; + const heldRequest = new Promise((resolve) => { + resolveHeldRequest = resolve; + }); + let held = false; + await testPage.route(movePreviewUrl, async (route) => { + if (route.request().method() === "POST" && !held) { + held = true; + resolveHeldRequest(); + await new Promise((resolve) => { + releaseHeldRequest = resolve; + }); + } + await route.continue(); + }); + + try { + const modelUpdate = apiClient.setSessionModel(fixture.primarySessionId, "mock-slow"); + await heldRequest; + expect(requestCount).toBe(2); + await expect(popover.getByTestId("workflow-move-preview-loading")).toBeVisible(); + releaseHeldRequest?.(); + await modelUpdate; + await expect(popover.getByTestId("workflow-move-preview")).toContainText("mock-slow"); + } finally { + releaseHeldRequest?.(); + await testPage.unroute(movePreviewUrl); + } + } finally { + testPage.off("request", requestListener); + } + }); + test("matches other-session reuse and fresh-session creation", async ({ testPage, apiClient, diff --git a/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts index 6839d8402e..c9999b3316 100644 --- a/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts +++ b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts @@ -1,5 +1,7 @@ import { renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import { registerAgentsHandlers } from "@/lib/ws/handlers/agents"; import type { AppState } from "@/lib/state/store"; import { getWorkflowMovePreviewRevision, @@ -9,6 +11,9 @@ import { const TASK_ID = "task-1"; const WORKFLOW_ID = "workflow-1"; const SESSION_ID = "session-1"; +const INITIAL_PROFILE_ID = "profile-initial"; +const BASE_TIMESTAMP = "2026-09-15T00:00:00Z"; +const BOOKKEEPING_TIMESTAMP = "2026-09-15T00:01:00Z"; type RevisionState = Pick< AppState, @@ -20,6 +25,7 @@ type RevisionState = Pick< | "taskSessions" | "taskSessionsByTask" | "agentProfiles" + | "settingsAgents" | "sessionModels" >; @@ -42,6 +48,7 @@ function resetState() { color: "blue", position: 1, allow_manual_move: true, + agent_profile_id: "profile-1", profile_session_start_policy: "reuse", events: { on_enter: [{ type: "auto_start_agent" }] }, }, @@ -62,7 +69,7 @@ function resetState() { position: 0, primarySessionId: SESSION_ID, primarySessionState: "WAITING_FOR_INPUT", - updatedAt: "2026-09-15T00:00:00Z", + updatedAt: BASE_TIMESTAMP, }, ], isLoading: false, @@ -86,8 +93,8 @@ function resetState() { id: SESSION_ID, task_id: TASK_ID, state: "WAITING_FOR_INPUT", - started_at: "2026-09-15T00:00:00Z", - updated_at: "2026-09-15T00:00:00Z", + started_at: BASE_TIMESTAMP, + updated_at: BASE_TIMESTAMP, is_primary: true, agent_profile_id: "profile-1", }, @@ -98,6 +105,7 @@ function resetState() { errorByTaskId: {}, }, agentProfiles: { items: [], version: 1 }, + settingsAgents: { items: [] }, sessionModels: { bySessionId: { [SESSION_ID]: { @@ -110,78 +118,441 @@ function resetState() { } as unknown as RevisionState; } -describe("workflow move preview revisions", () => { - it("changes for connection, session, task, step, and profile updates", () => { - resetState(); - const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); +function eventStore(): StoreApi { + const setState: StoreApi["setState"] = (update) => { + const next = typeof update === "function" ? update(state as AppState) : update; + state = { ...state, ...next } as RevisionState; + }; + return { + getState: () => state as AppState, + setState, + } as unknown as StoreApi; +} - state.connection.status = "reconnecting"; - expect( - getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), - ).not.toBe(first); +function settingsProfile(id: string, overrides: Record = {}) { + return { + id, + name: id, + agentId: "agent-1", + agentDisplayName: "Luna", + model: "mock-fast", + mode: "default", + configOptions: { reasoning_effort: "low" }, + allowIndexing: false, + autoApprove: false, + cliFlags: [], + createdAt: BASE_TIMESTAMP, + updatedAt: BASE_TIMESTAMP, + ...overrides, + } as never; +} - state.connection.status = "connected"; - state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0].state = "RUNNING"; - const afterSession = getWorkflowMovePreviewRevision( - state as AppState, - TASK_ID, - WORKFLOW_ID, - "step-2", - ); - expect(afterSession).not.toBe(first); +function profileEvent(id: string, overrides: Record = {}) { + return { + id, + agent_id: "agent-1", + name: id, + model: "mock-fast", + mode: "plan", + config_options: { reasoning_effort: "high" }, + enabled: true, + created_at: "2026-09-15T00:00:00Z", + updated_at: BOOKKEEPING_TIMESTAMP, + ...overrides, + }; +} - state.kanban.steps[0]!.profile_session_start_policy = "new"; - const afterStep = getWorkflowMovePreviewRevision( - state as AppState, - TASK_ID, - WORKFLOW_ID, - "step-2", - ); - expect(afterStep).not.toBe(afterSession); +function dispatchProfileUpdate(id: string, overrides: Record = {}) { + const handler = registerAgentsHandlers(eventStore())["agent.profile.updated"]; + if (!handler) throw new Error("agent.profile.updated handler is not registered"); + handler({ + id: `profile-update-${id}`, + type: "notification", + action: "agent.profile.updated", + timestamp: BOOKKEEPING_TIMESTAMP, + payload: { profile: profileEvent(id, overrides) }, + } as never); +} - state.kanban.steps[1]!.profile_session_end_policy = "complete"; - const afterSourceStep = getWorkflowMovePreviewRevision( - state as AppState, - TASK_ID, - WORKFLOW_ID, - "step-2", - ); - expect(afterSourceStep).not.toBe(afterStep); +it("changes for connection, session, task, step, and profile updates", () => { + resetState(); + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); - state.agentProfiles.items = [ - { - id: "profile-1", - label: "Luna", - agent_id: "agent-1", - agent_name: "luna", - cli_passthrough: false, - model: "gpt-5.6-luna", - }, - ]; - expect( - getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), - ).not.toBe(afterSourceStep); + state.connection.status = "reconnecting"; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(first); - const beforeModel = getWorkflowMovePreviewRevision( - state as AppState, - TASK_ID, - WORKFLOW_ID, - "step-2", - ); - state.sessionModels.bySessionId[SESSION_ID]!.currentModelId = "gpt-5.6-astra"; - expect( - getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), - ).not.toBe(beforeModel); - }); + state.connection.status = "connected"; + state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0].state = "RUNNING"; + const afterSession = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterSession).not.toBe(first); - it("exposes the authoritative revision through the hook", () => { - resetState(); - const { result, rerender } = renderHook(() => - useWorkflowMovePreviewRevision(TASK_ID, WORKFLOW_ID, "step-2"), - ); - const first = result.current; - state.connection.status = "disconnected"; - rerender(); - expect(result.current).not.toBe(first); - }); + state.kanban.steps[0]!.profile_session_start_policy = "new"; + const afterStep = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterStep).not.toBe(afterSession); + + state.kanban.steps[1]!.profile_session_end_policy = "complete"; + const afterSourceStep = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterSourceStep).not.toBe(afterStep); + + state.agentProfiles.items = [ + { + id: "profile-1", + label: "Luna", + agent_id: "agent-1", + agent_name: "luna", + cli_passthrough: false, + model: "gpt-5.6-luna", + }, + ]; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(afterSourceStep); + + const beforeModel = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + state.sessionModels.bySessionId[SESSION_ID]!.currentModelId = "gpt-5.6-astra"; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(beforeModel); +}); + +it("ignores non-predictive task, session, and profile bookkeeping updates", () => { + resetState(); + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + + const task = state.kanban.tasks[0]!; + task.description = "A newer description"; + task.updatedAt = BOOKKEEPING_TIMESTAMP; + task.statusSummary = { + updated_at: BOOKKEEPING_TIMESTAMP, + status: "working", + } as never; + + const session = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0]!; + session.last_read_message_id = "message-2"; + session.command_count = 12; + session.updated_at = BOOKKEEPING_TIMESTAMP; + state.agentProfiles.version = 2; + + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + first, + ); +}); + +it("normalizes equivalent projected map key order", () => { + resetState(); + const session = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0]!; + session.metadata = { + runtime_config: { + config_options: { reasoning_effort: "high", verbosity: "short" }, + }, + }; + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + + session.metadata = { + runtime_config: { + config_options: { verbosity: "short", reasoning_effort: "high" }, + }, + }; + + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + first, + ); +}); + +it("tracks profile mode and options through profile events without global version churn", () => { + resetState(); + state.agentProfiles.items = [ + { + id: "profile-1", + label: "Luna", + agent_id: "agent-1", + agent_name: "luna", + model: "mock-fast", + updatedAt: BASE_TIMESTAMP, + }, + { + id: "profile-2", + label: "Other", + agent_id: "agent-1", + agent_name: "luna", + model: "mock-fast", + updatedAt: BASE_TIMESTAMP, + }, + ] as never; + state.settingsAgents.items = [ + { + id: "agent-1", + name: "luna", + profiles: [settingsProfile("profile-1"), settingsProfile("profile-2")], + }, + ] as never; + + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + dispatchProfileUpdate("profile-1", { config_options: { reasoning_effort: "low" } }); + const afterModeUpdate = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterModeUpdate).not.toBe(first); + + dispatchProfileUpdate("profile-1", { updated_at: "2026-09-15T00:02:00Z" }); + const afterOptionsUpdate = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterOptionsUpdate).not.toBe(afterModeUpdate); + + dispatchProfileUpdate("profile-2"); + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + afterOptionsUpdate, + ); + + state.agentProfiles.version += 1; + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + afterOptionsUpdate, + ); +}); + +it("tracks the initial-target profile after the original session is missing", () => { + resetState(); + state.kanban.tasks[0]!.metadata = { + workflow_initial_session: { + session_id: "deleted-initial-session", + agent_profile_id: INITIAL_PROFILE_ID, + }, + }; + state.agentProfiles.items = [ + { + id: "profile-1", + label: "Luna", + agent_id: "agent-1", + agent_name: "luna", + model: "mock-fast", + updatedAt: BASE_TIMESTAMP, + }, + { + id: INITIAL_PROFILE_ID, + label: "Initial Luna", + agent_id: "agent-1", + agent_name: "luna", + model: "mock-fast", + updatedAt: BASE_TIMESTAMP, + }, + ] as never; + state.settingsAgents.items = [ + { + id: "agent-1", + name: "luna", + profiles: [settingsProfile("profile-1"), settingsProfile(INITIAL_PROFILE_ID)], + }, + ] as never; + + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + const parsed = JSON.parse(first) as { + original_sessions: Array<{ stores: Array<{ status: string }> }>; + profiles: Array<{ id: string }>; + }; + expect(parsed.original_sessions[0]?.stores.every(({ status }) => status === "missing")).toBe( + true, + ); + expect(parsed.profiles.some(({ id }) => id === INITIAL_PROFILE_ID)).toBe(true); + + dispatchProfileUpdate(INITIAL_PROFILE_ID, { mode: "plan" }); + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(first); +}); + +it("deduplicates logical sessions across task and indexed stores", () => { + resetState(); + const source = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0]!; + source.metadata = { origin: "task_initial" }; + const candidateB = { + ...source, + id: "candidate-b" as never, + metadata: undefined, + is_primary: false, + started_at: "2026-09-15T00:00:01Z", + updated_at: "2026-09-15T00:00:10Z", + }; + state.taskSessionsByTask.itemsByTaskId[TASK_ID]!.push(candidateB); + state.taskSessions.items = { + [source.id]: { ...source }, + [candidateB.id]: { ...candidateB }, + }; + + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + const parsed = JSON.parse(first) as { + original_sessions: Array<{ + stores: Array<{ status: string }>; + }>; + reusable_candidate_order: Array<{ + profiles: Array<{ sessions: Array<{ id: string }> }>; + }>; + }; + expect(parsed.original_sessions[0]?.stores.every(({ status }) => status === "selected")).toBe( + true, + ); + expect( + parsed.reusable_candidate_order.every(({ profiles }) => + profiles.every(({ sessions }) => sessions.length === 1 && sessions[0]?.id === candidateB.id), + ), + ).toBe(true); + + const taskCandidate = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![1]!; + taskCandidate.updated_at = "2026-09-15T00:00:20Z"; + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + first, + ); + state.taskSessions.items[candidateB.id]!.updated_at = "2026-09-15T00:00:20Z"; + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + first, + ); + + const candidateC = { + ...candidateB, + id: "candidate-c" as never, + started_at: "2026-09-15T00:00:02Z", + updated_at: "2026-09-15T00:00:15Z", + }; + state.taskSessionsByTask.itemsByTaskId[TASK_ID]!.push(candidateC); + state.taskSessions.items[candidateC.id] = { ...candidateC }; + const withTwoCandidates = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + taskCandidate.updated_at = "2026-09-15T00:00:09Z"; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(withTwoCandidates); +}); + +it("tracks candidate order changes without raw timestamp churn", () => { + resetState(); + const source = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0]!; + source.agent_profile_id = "profile-1" as never; + source.is_primary = true; + state.kanban.tasks[0]!.primarySessionId = source.id; + state.taskSessionsByTask.itemsByTaskId[TASK_ID]!.push( + { + ...source, + id: "candidate-a" as never, + agent_profile_id: "profile-1" as never, + is_primary: false, + started_at: "2026-09-15T00:00:01Z", + updated_at: "2026-09-15T00:00:10Z", + }, + { + ...source, + id: "candidate-b" as never, + agent_profile_id: "profile-1" as never, + is_primary: false, + started_at: "2026-09-15T00:00:02Z", + updated_at: "2026-09-15T00:00:11Z", + }, + ); + + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + source.updated_at = BOOKKEEPING_TIMESTAMP; + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + first, + ); + + state.taskSessionsByTask.itemsByTaskId[TASK_ID]![2]!.updated_at = "2026-09-15T00:00:12Z"; + expect(getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2")).toBe( + first, + ); + + state.taskSessionsByTask.itemsByTaskId[TASK_ID]![2]!.updated_at = "2026-09-15T00:00:09Z"; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(first); +}); + +it("tracks terminal and completion-follow-up eligibility changes", () => { + resetState(); + const source = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![0]!; + source.agent_profile_id = "profile-source" as never; + state.taskSessionsByTask.itemsByTaskId[TASK_ID]!.push( + { + ...source, + id: "eligible" as never, + agent_profile_id: "profile-1" as never, + is_primary: false, + updated_at: "2026-09-15T00:00:10Z", + }, + { + ...source, + id: "terminal" as never, + agent_profile_id: "profile-1" as never, + state: "COMPLETED", + is_primary: false, + updated_at: "2026-09-15T00:00:20Z", + }, + { + ...source, + id: "follow-up" as never, + agent_profile_id: "profile-1" as never, + is_primary: false, + metadata: { completion_follow_up: true }, + updated_at: "2026-09-15T00:00:30Z", + }, + ); + + const first = getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"); + const terminal = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![2]!; + terminal.state = "RUNNING"; + const afterTerminal = getWorkflowMovePreviewRevision( + state as AppState, + TASK_ID, + WORKFLOW_ID, + "step-2", + ); + expect(afterTerminal).not.toBe(first); + + terminal.state = "COMPLETED"; + const followUp = state.taskSessionsByTask.itemsByTaskId[TASK_ID]![3]!; + followUp.metadata = { completion_follow_up: false }; + expect( + getWorkflowMovePreviewRevision(state as AppState, TASK_ID, WORKFLOW_ID, "step-2"), + ).not.toBe(afterTerminal); +}); + +it("exposes the authoritative revision through the hook", () => { + resetState(); + const { result, rerender } = renderHook(() => + useWorkflowMovePreviewRevision(TASK_ID, WORKFLOW_ID, "step-2"), + ); + const first = result.current; + state.connection.status = "disconnected"; + rerender(); + expect(result.current).not.toBe(first); }); diff --git a/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts index 179de5aec7..1253d829d2 100644 --- a/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts +++ b/apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts @@ -3,11 +3,320 @@ import { useCallback } from "react"; import { useAppStore } from "@/components/state-provider"; import type { AppState } from "@/lib/state/store"; +import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; type PreviewRevisionTask = AppState["kanban"]["tasks"][number]; type PreviewRevisionStep = AppState["kanban"]["steps"][number]; type PreviewRevisionSession = AppState["taskSessions"]["items"][string]; +const TASK_METADATA_KEYS = [ + "agent_profile_id", + "workflow_initial_session", + "workflow_session_route", + "initial_session_runtime_config", + "initial_session_runtime_config_profile_id", +] as const; + +const SESSION_METADATA_KEYS = [ + "created_by", + "completion_follow_up", + "origin", + "runtime_config", + "runtime_config_overrides", + "original_effective_config", + "acp_config_baseline", + "session_mode", +] as const; + +const PROFILE_SNAPSHOT_KEYS = [ + "name", + "agent_name", + "agent_id", + "model", + "mode", + "config_options", + "configOptions", +] as const; + +type SessionReference = { source: string; id: string }; +type SessionEntry = { source: string; session: PreviewRevisionSession }; +type SessionSourceGroup = { source: string; entries: SessionEntry[] }; + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, stableValue(child)]), + ); +} + +function serializeRevision(value: unknown): string { + return JSON.stringify(stableValue(value)); +} + +function projectRecord( + record: Record | null | undefined, + keys: readonly string[], +): Record | undefined { + if (!record) return undefined; + const projection: Record = {}; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(record, key)) projection[key] = record[key]; + } + return Object.keys(projection).length > 0 ? projection : undefined; +} + +function projectTask(task: PreviewRevisionTask) { + return { + id: task.id, + workflow_id: task.workflowId, + workflow_step_id: task.workflowStepId, + state: task.state, + primary_session_id: task.primarySessionId, + primary_session_state: task.primarySessionState, + archived: task.isArchived === true, + metadata: projectRecord(task.metadata, TASK_METADATA_KEYS), + }; +} + +function projectStep(step: PreviewRevisionStep) { + return { + id: step.id, + position: step.position, + agent_profile_id: step.agent_profile_id, + profile_session_start_policy: step.profile_session_start_policy, + profile_session_end_policy: step.profile_session_end_policy, + session_target: step.session_target, + on_enter: step.events?.on_enter?.map((action) => ({ + type: action.type, + config: action.config, + })), + }; +} + +function projectSession(session: PreviewRevisionSession, source: string) { + return { + source, + session: { + id: session.id, + task_id: session.task_id, + name: session.name, + agent_profile_id: session.agent_profile_id, + state: session.state, + is_primary: session.is_primary, + is_passthrough: session.is_passthrough, + metadata: projectRecord(session.metadata, SESSION_METADATA_KEYS), + agent_profile_snapshot: projectRecord(session.agent_profile_snapshot, PROFILE_SNAPSHOT_KEYS), + }, + }; +} + +function sessionReference(entry: SessionEntry): SessionReference { + return { source: entry.source, id: entry.session.id }; +} + +function compareTimestamps(left: string | undefined, right: string | undefined): number { + const leftValue = left ? parseTurnTimestamp(left) : null; + const rightValue = right ? parseTurnTimestamp(right) : null; + if (leftValue === null) return rightValue === null ? 0 : -1; + if (rightValue === null) return 1; + if (leftValue === rightValue) return 0; + return leftValue > rightValue ? 1 : -1; +} + +function isTerminalSession(session: PreviewRevisionSession): boolean { + return ( + session.state === "COMPLETED" || session.state === "FAILED" || session.state === "CANCELLED" + ); +} + +function isPreviewActiveSession(session: PreviewRevisionSession): boolean { + return ( + session.state === "CREATED" || + session.state === "STARTING" || + session.state === "RUNNING" || + session.state === "WAITING_FOR_INPUT" + ); +} + +function hasCompletionFollowUp(session: PreviewRevisionSession): boolean { + return session.metadata?.completion_follow_up === true; +} + +function currentSessionReference(entries: SessionEntry[]): SessionReference | null { + const active = entries.filter(({ session }) => isPreviewActiveSession(session)); + const primary = active.find(({ session }) => session.is_primary); + if (primary) return sessionReference(primary); + const latest = active.reduce((best, candidate) => { + if (!best) return candidate; + const started = compareTimestamps(candidate.session.started_at, best.session.started_at); + if (started !== 0) return started > 0 ? candidate : best; + return candidate.session.id > best.session.id ? candidate : best; + }, undefined); + return latest ? sessionReference(latest) : null; +} + +function originalSessionReference( + task: PreviewRevisionTask | undefined, + entries: SessionEntry[], +): { status: "selected" | "missing" | "ambiguous"; session: SessionReference | null } { + const initial = task?.metadata?.workflow_initial_session; + if (initial && typeof initial === "object" && "session_id" in initial) { + const sessionId = (initial as { session_id?: unknown }).session_id; + if (typeof sessionId === "string" && sessionId !== "") { + const match = entries.find(({ session }) => session.id === sessionId); + return { + status: match ? "selected" : "missing", + session: match ? sessionReference(match) : null, + }; + } + } + + const marked = entries.filter(({ session }) => session.metadata?.origin === "task_initial"); + if (marked.length > 1) return { status: "ambiguous", session: null }; + if (marked.length === 1) return { status: "selected", session: sessionReference(marked[0]!) }; + + const legacy = entries.filter( + ({ session }) => session.metadata?.created_by !== "workflow_switch", + ); + legacy.sort((left, right) => { + const started = compareTimestamps(left.session.started_at, right.session.started_at); + return started !== 0 ? started : left.session.id.localeCompare(right.session.id); + }); + if (legacy.length === 0) return { status: "missing", session: null }; + if ( + legacy.length > 1 && + compareTimestamps(legacy[0]!.session.started_at, legacy[1]!.session.started_at) === 0 + ) { + return { status: "ambiguous", session: null }; + } + return { status: "selected", session: sessionReference(legacy[0]!) }; +} + +function reusableCandidateOrder(entries: SessionEntry[], excludedSessionId: string | null) { + const candidatesByProfile = new Map(); + for (const entry of entries) { + const { session } = entry; + if ( + session.id === excludedSessionId || + !session.agent_profile_id || + isTerminalSession(session) || + hasCompletionFollowUp(session) + ) { + continue; + } + const candidates = candidatesByProfile.get(session.agent_profile_id) ?? []; + candidates.push(entry); + candidatesByProfile.set(session.agent_profile_id, candidates); + } + return [...candidatesByProfile.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([profileId, candidates]) => ({ + profile_id: profileId, + sessions: candidates + .map((entry, index) => ({ entry, index })) + .sort((left, right) => { + const updated = compareTimestamps( + right.entry.session.updated_at, + left.entry.session.updated_at, + ); + if (updated !== 0) return updated; + const started = compareTimestamps( + right.entry.session.started_at, + left.entry.session.started_at, + ); + return started !== 0 ? started : left.index - right.index; + }) + .map(({ entry }) => sessionReference(entry)), + })); +} + +function profileIdsForRevision( + tasks: Array<{ task: PreviewRevisionTask }>, + steps: Array<{ step: PreviewRevisionStep }>, + workflows: Array<{ id: string; agent_profile_id?: string }>, + sessions: SessionEntry[], +): Set { + const profileIds = new Set(); + for (const { task } of tasks) { + const profileId = task.metadata?.agent_profile_id; + if (typeof profileId === "string" && profileId !== "") profileIds.add(profileId); + const initialProfileId = profileIdFromMetadata(task.metadata?.workflow_initial_session); + if (initialProfileId) profileIds.add(initialProfileId); + } + for (const { step } of steps) { + if (step.agent_profile_id) profileIds.add(step.agent_profile_id); + } + for (const workflow of workflows) { + if (workflow.agent_profile_id) profileIds.add(workflow.agent_profile_id); + } + for (const { session } of sessions) { + if (session.agent_profile_id) profileIds.add(session.agent_profile_id); + } + return profileIds; +} + +function profileIdFromMetadata(value: unknown): string | undefined { + if (!value || typeof value !== "object") return undefined; + const profileId = (value as { agent_profile_id?: unknown }).agent_profile_id; + return typeof profileId === "string" && profileId !== "" ? profileId : undefined; +} + +function projectProfile(state: AppState, profile: AppState["agentProfiles"]["items"][number]) { + const settingsProfile = profileFromSettings(state, profile.id); + const hasSettingsConfiguration = + settingsProfile?.mode !== undefined || settingsProfile?.configOptions !== undefined; + return { + id: profile.id, + label: profile.label, + agent_id: profile.agent_id, + agent_name: profile.agent_name, + kind: profile.kind, + cli_passthrough: profile.cli_passthrough, + inference_capable: profile.inference_capable, + model: profile.model, + fallback_model: profile.fallback_model, + auto_fallback: profile.auto_fallback, + require_exact_model: profile.require_exact_model, + enabled: profile.enabled, + capability_status: profile.capability_status, + updated_at: hasSettingsConfiguration ? undefined : profile.updatedAt, + mode: settingsProfile?.mode, + config_options: settingsProfile?.configOptions, + }; +} + +function profileFromSettings( + state: AppState, + profileId: string, +): AppState["settingsAgents"]["items"][number]["profiles"][number] | undefined { + for (const agent of state.settingsAgents.items) { + const profile = agent.profiles.find(({ id }) => id === profileId); + if (profile) return profile; + } + return undefined; +} + +function projectSessionModels(state: AppState, sessionId: string) { + const models = state.sessionModels?.bySessionId?.[sessionId]; + if (!models) return null; + return { + current_model_id: models.currentModelId, + models: models.models + .map(({ modelId, name, usageMultiplier }) => ({ + model_id: modelId, + name, + usage_multiplier: usageMultiplier, + })) + .sort((left, right) => left.model_id.localeCompare(right.model_id)), + config_options: models.configOptions + .map(({ type, id, currentValue }) => ({ type, id, current_value: currentValue })) + .sort((left, right) => left.id.localeCompare(right.id)), + }; +} + function matchingTasks(state: AppState, taskId: string | null | undefined) { if (!taskId) return []; const tasks: Array<{ source: string; task: PreviewRevisionTask }> = []; @@ -45,19 +354,56 @@ function matchingSteps(state: AppState, workflowIds: Set, workflowStepId function matchingSessions(state: AppState, taskId: string | null | undefined) { if (!taskId) return []; - const sessions: Array<{ source: string; session: PreviewRevisionSession }> = []; + // Keep both store projections observable. Semantic selectors group them by + // source so one logical session cannot become two candidates. + const sessions: SessionEntry[] = []; for (const session of state.taskSessionsByTask.itemsByTaskId[taskId] ?? []) { sessions.push({ source: "task", session }); } for (const session of Object.values(state.taskSessions.items)) { if (session.task_id === taskId) sessions.push({ source: "session", session }); } - return sessions - .sort( - (left, right) => - left.session.id.localeCompare(right.session.id) || left.source.localeCompare(right.source), - ) - .map(({ source, session }) => ({ source, session })); + return sessions; +} + +function sessionSourceGroups(entries: SessionEntry[]): SessionSourceGroup[] { + const bySource = new Map(); + for (const entry of entries) { + const sourceEntries = bySource.get(entry.source) ?? []; + sourceEntries.push(entry); + bySource.set(entry.source, sourceEntries); + } + return [...bySource.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([source, sourceEntries]) => ({ source, entries: sourceEntries })); +} + +function currentSessionBySource(entries: SessionEntry[]) { + return sessionSourceGroups(entries).map(({ source, entries: sourceEntries }) => ({ + source, + session_id: currentSessionReference(sourceEntries)?.id ?? null, + })); +} + +function originalSessionsBySource(task: PreviewRevisionTask | undefined, entries: SessionEntry[]) { + return sessionSourceGroups(entries).map(({ source, entries: sourceEntries }) => { + const original = originalSessionReference(task, sourceEntries); + return { + source, + status: original.status, + session_id: original.session?.id ?? null, + }; + }); +} + +function reusableCandidateOrderBySource(entries: SessionEntry[]) { + return sessionSourceGroups(entries).map(({ source, entries: sourceEntries }) => ({ + source, + profiles: reusableCandidateOrder( + sourceEntries, + currentSessionReference(sourceEntries)?.id ?? null, + ), + })); } /** @@ -81,34 +427,60 @@ export function getWorkflowMovePreviewRevision( if (task.workflowId) workflowIds.add(task.workflowId); if (task.workflowStepId) workflowStepIds.add(task.workflowStepId); } + + const initialSteps = matchingSteps(state, workflowIds, workflowStepIds); + for (const { step } of initialSteps) { + if (step.session_target?.kind === "step" && step.session_target.step_id) { + workflowStepIds.add(step.session_target.step_id); + } + } + + const steps = matchingSteps(state, workflowIds, workflowStepIds).sort( + (left, right) => + left.source.localeCompare(right.source) || left.step.id.localeCompare(right.step.id), + ); const workflows = state.workflows.items - .filter((workflow) => workflow.id === workflowId) - .map((workflow) => ({ ...workflow })); - const profiles = state.agentProfiles.items - .slice() + .filter((workflow) => workflowIds.has(workflow.id)) .sort((left, right) => left.id.localeCompare(right.id)) - .map((profile) => ({ ...profile })); + .map((workflow) => ({ + id: workflow.id, + agent_profile_id: workflow.agent_profile_id, + })); const sessions = matchingSessions(state, taskId); + const currentSessions = currentSessionBySource(sessions); + const projectedSessions = [...sessions].sort( + (left, right) => + left.source.localeCompare(right.source) || left.session.id.localeCompare(right.session.id), + ); + const profileIds = profileIdsForRevision(tasks, steps, workflows, sessions); + const profiles = state.agentProfiles.items + .filter((profile) => profileIds.has(profile.id)) + .sort((left, right) => left.id.localeCompare(right.id)) + .map((profile) => projectProfile(state, profile)); const sessionModels = Array.from(new Set(sessions.map(({ session }) => session.id))) .sort() .map((sessionId) => ({ session_id: sessionId, - models: state.sessionModels?.bySessionId?.[sessionId] ?? null, + models: projectSessionModels(state, sessionId), })); - return JSON.stringify({ + return serializeRevision({ connection: state.connection.status, workspace_context_generation: state.workspaceContextGeneration, - active_workflow_id: state.kanban.workflowId, workflow_id: workflowId ?? "", workflow_step_id: workflowStepId ?? "", workflows, - steps: matchingSteps(state, workflowIds, workflowStepIds), - tasks, + steps: steps.map(({ source, step }) => ({ source, step: projectStep(step) })), + tasks: tasks.map(({ source, task }) => ({ source, task: projectTask(task) })), sessions_loaded: taskId ? state.taskSessionsByTask.loadedByTaskId[taskId] === true : false, - sessions, + sessions: projectedSessions.map(({ source, session }) => projectSession(session, source)), + current_sessions: currentSessions, + original_sessions: tasks.map(({ source, task }) => ({ + source, + stores: originalSessionsBySource(task, sessions), + })), + reusable_candidate_order: reusableCandidateOrderBySource(sessions), session_models: sessionModels, - agent_profiles_version: state.agentProfiles.version, profiles, }); } diff --git a/apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts b/apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts index e019b8abef..cf6a4289b6 100644 --- a/apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts +++ b/apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts @@ -1,6 +1,8 @@ import { act, cleanup, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkflowMovePreviewResponse } from "@/lib/api"; +import type { AppState } from "@/lib/state/store"; +import { getWorkflowMovePreviewRevision } from "./use-workflow-move-preview-revision"; import { MAX_PREVIEW_CONCURRENT_REQUESTS, useWorkflowMovePreview, @@ -37,6 +39,38 @@ function makePreview(stepId: string): WorkflowMovePreviewResponse { }; } +function makeRevisionState() { + return { + connection: { status: "connected" }, + workspaceContextGeneration: 1, + kanban: { + workflowId: WORKFLOW_ID, + steps: [{ id: FIRST_STEP_ID, title: "Implement", position: 0 }], + tasks: [ + { + id: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: FIRST_STEP_ID, + title: "Task", + description: "Initial description", + position: 0, + }, + ], + }, + kanbanMulti: { snapshots: {} }, + workflows: { items: [{ id: WORKFLOW_ID }], activeId: WORKFLOW_ID }, + taskSessions: { items: {} }, + taskSessionsByTask: { + itemsByTaskId: {}, + loadingByTaskId: {}, + loadedByTaskId: {}, + errorByTaskId: {}, + }, + agentProfiles: { items: [], version: 1 }, + sessionModels: { bySessionId: {} }, + } as unknown as AppState; +} + function useThreePreviews() { const first = useWorkflowMovePreview({ taskId: TASK_ID, @@ -201,6 +235,55 @@ describe("useWorkflowMovePreview invalidation", () => { }); }); +describe("useWorkflowMovePreview stability", () => { + it("keeps success and request count stable across harmless store updates", async () => { + previewWorkflowMoveMock.mockResolvedValueOnce(makePreview(FIRST_STEP_ID)); + const revisionState = makeRevisionState(); + const { result, rerender } = renderHook( + ({ revision }: { revision: string }) => + useWorkflowMovePreview({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + workflowStepId: FIRST_STEP_ID, + invalidationKey: revision, + enabled: true, + }), + { + initialProps: { + revision: getWorkflowMovePreviewRevision( + revisionState, + TASK_ID, + WORKFLOW_ID, + FIRST_STEP_ID, + ), + }, + }, + ); + + await act(async () => { + vi.advanceTimersByTime(150); + await Promise.resolve(); + }); + expect(result.current.status).toBe("success"); + + (revisionState.kanban.tasks[0] as { description?: string }).description = "Updated copy"; + const nextRevision = getWorkflowMovePreviewRevision( + revisionState, + TASK_ID, + WORKFLOW_ID, + FIRST_STEP_ID, + ); + rerender({ revision: nextRevision }); + + expect(nextRevision).toBe( + getWorkflowMovePreviewRevision(revisionState, TASK_ID, WORKFLOW_ID, FIRST_STEP_ID), + ); + expect(result.current.status).toBe("success"); + expect(result.current.preview?.workflow_step_id).toBe(FIRST_STEP_ID); + expect(previewWorkflowMoveMock).toHaveBeenCalledOnce(); + }); +}); + describe("useWorkflowMovePreview request queue", () => { it("queues disclosed destinations while keeping only two requests active", async () => { const resolvers = new Map void>(); diff --git a/docs/plans/workflow-move-preview-stability/plan.md b/docs/plans/workflow-move-preview-stability/plan.md new file mode 100644 index 0000000000..2f08dab8c0 --- /dev/null +++ b/docs/plans/workflow-move-preview-stability/plan.md @@ -0,0 +1,215 @@ +--- +created: 2026-09-17 +status: complete +requirements: + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001 + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002 +system_design: + - ../../specs/tasks/system-design/workflow-move-preview.md +legacy_specs: [] +--- + +# Implementation Plan: Stable workflow move previews + +## Overview + +Keep an open move preview stable when background updates cannot change its +prediction. Deliver one sequential work order covering semantic invalidation +and its desktop and touch regression evidence. + +The tasks system owns this repair because it owns move prediction and recipient +selection. The existing requirement/design pair remains authoritative. This +package adds the missing stability criterion, AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.8, +and refines the design without changing routing or execution policy. + +Implementation followed the approved package in this work order. Existing .001.7 +and .002.3 require real invalidation and prohibit stale predictions appearing +current. The correction retains results only when prediction inputs remain equal. + +## Evidence and reproduction + +`getWorkflowMovePreviewRevision` currently serializes complete task, session, +workflow, step, model, and profile objects. Any serialized metadata change +changes the request key. `useWorkflowMovePreview` then clears success, sets +loading, and requests again after 150 ms. This is an update-driven cycle; +the hook has no periodic polling timer. + +The existing test `clears a successful result and fetches again when the +invalidation key changes` proves the reset path. The two focused hook suites +passed all eight tests on 2026-09-17. They lack a harmless-update regression. +No live trace established which event triggered the user's particular hover. + +Smallest deterministic regression: resolve a preview, change only the task +description or session read cursor in the real revision input, and rerender. +Advance fake timers beyond 150 ms. Require unchanged success and one request. +Current code changes the revision, clears success, and issues a second request. + +## Scope + +### In scope + +- Explicit semantic projections in the shared revision hook. +- Harmless-update stability in active and background task/session projections. +- Positive invalidation for routing, model, options, connection, and context changes. +- Session candidate-order semantics, mixed eligible/terminal inventories, and ties. +- Existing hover, keyboard, touch drawer, and next-step footer consumers. + +### Out of scope + +- Backend routing changes, new APIs, persistence, or TTL caches. +- Keeping known-stale results visible as current during a genuine refresh. +- Layout redesign, new copy, provider probes, and new phone navigation. +- Diagnosing unrelated timers or changing task/session store ownership. + +## Technical approach + +Follow [Semantic revision inputs](../../specs/tasks/system-design/workflow-move-preview.md#semantic-revision-inputs). +Replace whole-object serialization in +`apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts` with typed +projections and deterministic serialization. Reuse existing configuration +helpers where available; do not create a generic cache framework. + +Read `workflow_move_preview.go`, `workflow_move_preview_settings.go`, and the +session target helpers to verify each selected input. Session `updated_at` +cannot simply be dropped: it ranks reusable candidates. Represent meaningful +ordering without key churn when the winner/order remains unchanged. Preserve +backend tie behavior and original-session provenance. + +Keep the 150 ms debounce, two-request limit, in-flight sharing, abort/cleanup, +retry, close/reopen, and late-generation guards in `use-workflow-move-preview.ts`. +Change that hook only if the composed regression identifies an additional +lifecycle defect. No stale-while-refreshing state or renderer rewrite is needed. + +All existing profile records may remain candidates for projection to avoid +missing dynamic dependencies, but project only fields used by prediction. +Do not include `agentProfiles.version` as an independent refresh trigger. +Track source, destination, and an explicit referenced target step when available. + +## ASCII UI preview + +UI-01: Desktop hover/focus disclosure, resolved result plus harmless update. +AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.8 and -002.1, .3. + +```text +Current cycle: result -> Checking session... -> result +After: result -----------------------> same result + ++-----------------------------+ +| Move here | +| Options | +| [step capabilities] | +|-----------------------------| +| Reuse current session | +| Astra (i) | ++-----------------------------+ +``` + +UI-02: Coarse-pointer drawer, opened by tapping the existing step trigger. +AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.8 and -002.2, .3. + +```text ++--------------------------------+ +| Move to x | fixed header +|--------------------------------| +| Implement | +| [Options] [Move here] | +| Reuse current session | existing row alignment +| Astra | stable during harmless updates +| [expanded details if selected] | +|________________________________| safe-area clearance +``` + +Existing compact rows keep their shipped alignment; this repair does not apply +the centered desktop footer layout to drawer rows. Drawer content retains one +scroll owner and 44px touch actions. Nearest shipped surface is +`CompactWorkflowStepDisclosure`; the curated MobilePickerSheet pattern supplies +the temporary-choice drawer baseline. Full phone task routes keep their +existing composition; no desktop stepper is added to `SessionMobileLayout`. + +UI-03: First open or meaningful input change still shows `Checking session...`. +A failed refresh shows `Preview unavailable` with `Retry`. Neither state alone +disables Move. Closing clears the result; reopening requests a fresh one. +These states also apply to the next-step options footer and touch rows. + +Control order and stable feedback are structural requirements; names and +spacing are illustrative. Existing localization and accessibility remain intact. + +## Tests + +Add these named regressions to the existing suites; use deferred responses and +fake timers to assert both request count and status transitions. + +| Criteria | File and planned tests | +| --- | --- | +| 001.8 | `use-workflow-move-preview-revision.test.ts`: `ignores non-predictive task and session updates`; `normalizes equivalent map key order`; cover each store source and missing initial-target profile provenance | +| 001.7, .8 | Same file: `tracks relevant configuration and routing changes`; `tracks candidate order without timestamp churn`; include ties, terminal and completion-follow-up candidates, primary fallback, and original provenance | +| 001.7, .8; 002.3 | `use-workflow-move-preview.test.ts`: `keeps success and request count stable across harmless store updates`; compose the real revision function with the request hook | +| 001.7; 002.3 | Same file: `refreshes after a predictive change amid harmless updates`; delayed stale response, reconnect, close/reopen, retry, two-request queue | +| 002.1-.4 | Existing renderer, stepper, and proceed-button component suites; retain loading/error/details and shared footer behavior | + +Negative fixtures change task descriptions/titles, status-summary timestamps, +command counts, read cursors, unrelated metadata, and global profile version. +Positive fixtures change model/options, session name/profile/state/membership, +source end policy, destination start policy/target/actions, workflow default +profile, task profile fallback, reconnect, and workspace generation. Pair every +removed broad input with evidence that its meaningful fields remain observed. + +## E2E tests + +Extend `apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts` in `chromium`: +hold one hover open after success, apply repeated harmless updates through the +existing `__KANDEV_E2E_STORE__` bridge, and assert no new POST or loading flash. +Observe the result during the updates, not only after it settles. Use the shared +negative-assertion dwell observer for the no-refresh window. Count requests by +task AND destination. Then change a real model with `apiClient.setSessionModel`, +hold the preview response, assert loading, and release it to verify the new model. +Do not mock the revision hook or add a production diagnostic endpoint. + +Extend `mobile-workflow-move-preview.spec.ts` in `mobile-chrome` with the same +stability assertion in its existing `tabletTestPage` drawer. This is the shipped +coarse-pointer surface; the full Pixel 5 task route has no desktop stepper. +Keep Options/details open during updates and preserve the existing focus-return, +44px target, and overflow checks. Restore shared user settings in cleanup. +The shared hook regression covers the phone next-step footer without introducing +a new phone navigation surface. Preserve existing move-execution scenarios. + +## Work orders + +- [x] [Task 01: Stabilize preview invalidation](task-01-stabilize-invalidation.md) + +One work order, sequential. No implementation subagents are authorized. +The [original delivery plan](../workflow-move-preview/plan.md) retains its +historical completion results; this repair owns its new tests and results. + +## Verification results + +Implementation completed on 2026-09-18. The focused unit suite passed 74 tests +across five files, including 9 revision tests covering the review remediation. +Typecheck and changed-file ESLint passed. The final desktop +Chromium run passed 3 tests, and the final mobile-chrome run passed 1 test. +Both browser flows counted one initial preview request, kept the resolved result +and selected details visible across three harmless updates without a loading +state, then observed request two and loading for a genuine model change before +the refreshed model appeared. The browser checks also passed existing move, +focus-return, 44px target, row-alignment, and overflow assertions. + +`pnpm run build:e2e` passed after the final source changes. Package validation +passed on 2026-09-18: + +- `python3 scripts/list-docs.py validate`: 286 decisions and 998 specifications. +- `python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- `python3 scripts/lint-spec-files.py --all`: passed. +- Package links, acceptance references, and frontend command paths: passed. +- `git diff --check`: passed; scoped status confirms both new package files and + the updated requirements, design, and delivery-plan documents. + +No backend policy, API, cache, or layout changes were made. + +## Risks + +- Over-narrow projection can hide a model, metadata, or recipient change. +- Raw timestamps cause churn; dropping selection order can leave stale recipients. +- Reordered rule arrays are meaningful even when object key order is not. +- Active/background stores can update separately; retain both invalidation paths. +- Live updates may legitimately cause checks. The fix promises stability only + when prediction inputs remain equivalent. diff --git a/docs/plans/workflow-move-preview-stability/task-01-stabilize-invalidation.md b/docs/plans/workflow-move-preview-stability/task-01-stabilize-invalidation.md new file mode 100644 index 0000000000..3d0354f17b --- /dev/null +++ b/docs/plans/workflow-move-preview-stability/task-01-stabilize-invalidation.md @@ -0,0 +1,189 @@ +--- +id: "01-stabilize-invalidation" +title: "Stabilize preview invalidation" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-001 + - REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002 +acceptance_criteria: + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.7 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.8 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.1 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.2 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.3 + - AC-TASKS-WORKFLOW-MOVE-PREVIEW-002.4 +system_design: + - ../../specs/tasks/system-design/workflow-move-preview.md +--- + +# Task 01: Stabilize preview invalidation + +## Summary + +Replace broad record invalidation with prediction-specific revisions. Prove +that harmless updates preserve visible success while real changes still refresh. + +## In scope + +- Typed revision projections, semantic candidate ordering, and map normalization. +- All regression cases in the [plan test matrix](plan.md#tests). +- Desktop and existing touch-drawer browser evidence, preserving move scenarios. +- Update this work order and plan with actual verification results. + +## Out of scope + +Backend policy/API changes, persistent caching, stale-result rendering, new copy, +layout changes, production instance mutation, and new navigation surfaces. + +## Acceptance + +1. The composed hook regression fails before the fix because a harmless update + clears success or issues another request. After the fix it retains success + and the request count across repeated updates in every existing store source. +2. Positive regressions prove relevant changes still invalidate, including + candidate-order changes and mixed eligible/terminal/completion-follow-up + inventories. Preserve cancellation, retry, queue, and generation guards. +3. Desktop and touch E2E keep the disclosure and selected details stable, then + prove a real model update refreshes. Existing move, focus, target-size, and + overflow assertions pass against freshly built assets. + +## Implementation sequence + +Use `/tdd`. First add the negative revision test and the composed request test +named in the plan; record the expected behavioral failures. Add positive field +and timestamp-order cases before removing broad inputs. Implement explicit +projections in the existing revision module, extracting a small adjacent helper +only if source-size limits require it. Follow the design's metadata and ordering +rules; use backend selectors as read-only references, not targets for edits. + +Extend existing browser suites. The unit regression is the RED gate if the +browser fixture cannot reliably hold the pre-fix lifecycle; record that reason. +Run all commands below and record actual counts. Inspect desktop and touch +screenshots against UI-01/UI-02 without changing existing composition. + +## ASCII UI preview + +UI-01 and UI-02 excerpts; [full views and states](plan.md#ascii-ui-preview). +AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.8 and -002.1 through .3. + +```text +Desktop hover Touch drawer ++-------------------------+ +------------------------------+ +| Move here | | Move to x | +| Options | | Implement | +| [step capabilities] | | [Options] [Move here] | +|-------------------------| | Reuse current session | +| Reuse current session | | Astra | +| Astra (i) | | [details stay open] | ++-------------------------+ +------------------------------+ +Harmless update: both previews retain their current result. +Real input change: Checking session... -> fresh result or Retry. +``` + +Retain the existing drawer scroll owner, safe-area clearance, 44px touch targets, +and row alignment. Text is illustrative data or existing localized copy. + +## Verification + +Run from repository root. Install dependencies once if this worktree has no +workspace install. Run browser commands sequentially; managed runners rebuild. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run hooks/domains/kanban/use-workflow-move-preview-revision.test.ts hooks/domains/kanban/use-workflow-move-preview.test.ts components/task/workflow-move-preview.test.tsx components/task/workflow-stepper.test.tsx components/task/workflow-move-proceed-button.test.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint hooks/domains/kanban/use-workflow-move-preview-revision.ts hooks/domains/kanban/use-workflow-move-preview-revision.test.ts hooks/domains/kanban/use-workflow-move-preview.ts hooks/domains/kanban/use-workflow-move-preview.test.ts) +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-move-preview.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-move-preview.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Add any extracted helper and changed suite to the lint/test commands before +completion. No new user-facing text is planned; if needed, localize it and run +the repository i18n checks. Record request counts, visible status, and screenshot +comparison. No live instance may be used for browser mutations. + +## Files likely touched + +- `apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.ts` +- `apps/web/hooks/domains/kanban/use-workflow-move-preview-revision.test.ts` +- `apps/web/hooks/domains/kanban/use-workflow-move-preview.test.ts` +- `apps/web/hooks/domains/kanban/use-workflow-move-preview.ts` only if composed evidence requires it +- `apps/web/e2e/tests/workflow/workflow-move-preview.spec.ts` +- `apps/web/e2e/tests/workflow/mobile-workflow-move-preview.spec.ts` +- An adjacent shared E2E helper if both suites need the same stimulus/capture code +- This plan and work order for results + +## Dependencies + +None. Existing preview endpoint and consumers are already implemented. + +## Risks + +See [plan risks](plan.md#risks). Do not remove timestamp sensitivity without +proving candidate-order changes remain observable. Do not silence genuine +invalidations with a longer debounce or a persistent cache. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/workflow-move-preview.md). +- [Design](../../specs/tasks/system-design/workflow-move-preview.md#semantic-revision-inputs). +- Existing revision and request-hook suites, renderer/stepper/proceed-button suites. +- `workflow_move_preview.go`, `workflow_move_preview_settings.go`, and + `workflow_session_target.go` under `apps/backend/internal/orchestrator/`. +- Existing E2E specs and `apps/web/e2e/helpers/session-store.ts` for store access. +- `/tdd`, `/mobile-parity`, `/e2e`, and scoped frontend guidance. + +## Results + +Completed on 2026-09-18. + +- TDD RED was confirmed for the new harmless-update and composed-hook cases. +- GREEN focused Vitest passed 74 tests across the revision, request-hook, + renderer, stepper, and proceed-button suites. +- The revision now projects prediction inputs, normalizes configuration maps, + and encodes current/original session identity plus reusable candidate order. + Task descriptions, status-summary bookkeeping, read cursors, command counts, + profile version churn, and timestamp changes that preserve candidate order do + not invalidate the preview. Routing, model, options, connection, context, + eligibility, and candidate-order changes still invalidate it. +- Touch disclosure hover and focus handlers are disabled while the existing + coarse-pointer drawer is active. This keeps the drawer and its details open + through harmless store rerenders while preserving explicit tap and Escape + behavior. Fine-pointer hover and focus behavior is unchanged. +- Desktop Chromium workflow move-preview E2E passed 3 tests. Mobile-chrome + workflow move-preview E2E passed 1 test. Each stability flow observed one + initial request, no request or loading state across three harmless updates, + then a second request and visible loading for a model change before the new + model appeared. +- Typecheck, changed-file ESLint, `pnpm run build:e2e`, `git diff --check`, + `python3 scripts/list-docs.py validate`, `python3 scripts/lint-spec-files.test.py` + (36 tests), and `python3 scripts/lint-spec-files.py --all` passed. +- Review remediation: task-session and indexed-session projections now calculate + current, original, and candidate-order dependencies per source. A duplicate + logical session is not counted twice, while timestamp changes in either source + remain observable when they change a real candidate order. The regression + covers separate timestamp updates, convergence, marked-original provenance, + and a genuine two-candidate reorder. +- Review remediation: profile revisions now include full settings-profile mode + and config options, with the normalized profile `updatedAt` as a scoped + fallback. The regression dispatches the real `agent.profile.updated` handler + for a mode/config-options-only edit, then proves an unrelated profile event + and global version bump do not invalidate the referenced preview. +- Review remediation: an initial-target profile from the durable + `workflow_initial_session` snapshot remains a revision dependency after the + historical session is deleted. The regression proves the missing-session + state and a real profile event invalidate the open preview. +- Review remediation: desktop and touch negative assertions use the shared + `dwell(..., "negative-assertion", ...)` observer window instead of treating a + request timeout as proof that no late refresh occurred. diff --git a/docs/plans/workflow-move-preview/plan.md b/docs/plans/workflow-move-preview/plan.md index 557d254077..b5977575a3 100644 --- a/docs/plans/workflow-move-preview/plan.md +++ b/docs/plans/workflow-move-preview/plan.md @@ -256,6 +256,10 @@ Final post-remediation verification passed on 2026-09-15: ## Risks +Follow-up: [Preview stability repair](../workflow-move-preview-stability/plan.md) +narrows live invalidation. The results above describe the original delivery; +the follow-up package is complete and owns its implementation and verification. + - Selection helpers can write provenance or warnings; preview must isolate pure decisions. - Live provider rejection and deferred moves cannot be guaranteed by a hover snapshot. - Runtime configuration can differ from mutable profile rows. diff --git a/docs/specs/tasks/requirements/workflow-move-preview.md b/docs/specs/tasks/requirements/workflow-move-preview.md index 18646dd703..7f64361e50 100644 --- a/docs/specs/tasks/requirements/workflow-move-preview.md +++ b/docs/specs/tasks/requirements/workflow-move-preview.md @@ -66,6 +66,12 @@ with a Luna profile can retain a later Astra model selection when reused. options, known routing/configuration state, or the connection state shall invalidate the preview. Late responses shall not overwrite a newer selection. Actual moves shall retain execution-time validation, including deferred moves. +- **AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.8:** While a disclosure remains open, + updates that do not affect its prediction shall preserve the displayed result + without another preview request. Examples include task descriptions, read + cursors, command counts, and activity-summary bookkeeping. Timestamp changes + that leave session selection unchanged shall also preserve the result. + Changes that alter candidate eligibility or selection shall still invalidate it. ### REQ-TASKS-WORKFLOW-MOVE-PREVIEW-002: Compact accessible disclosure @@ -101,6 +107,7 @@ with a Luna profile can retain a later Astra model selection when reused. - [Conditional settings](workflow-session-settings.md) - [Design](../system-design/workflow-move-preview.md) - [Implementation plan](../../../plans/workflow-move-preview/plan.md) +- [Preview stability repair](../../../plans/workflow-move-preview-stability/plan.md) ## Out of scope diff --git a/docs/specs/tasks/system-design/workflow-move-preview.md b/docs/specs/tasks/system-design/workflow-move-preview.md index ac6d87c1d2..05d1e7497e 100644 --- a/docs/specs/tasks/system-design/workflow-move-preview.md +++ b/docs/specs/tasks/system-design/workflow-move-preview.md @@ -126,6 +126,68 @@ updates, profile updates, workflow-step updates, reconnect, and draft changes. Clear on close and fetch fresh on reopen. Debounce draft edits; typing instruction text need not refresh unless its presence changes prompt dispatch semantics. +### Semantic revision inputs + +`getWorkflowMovePreviewRevision` in `use-workflow-move-preview-revision.ts` +shall serialize explicit prediction inputs, not complete store records. This +implements AC-TASKS-WORKFLOW-MOVE-PREVIEW-001.8 while preserving .7. The existing +request hook remains responsible for loading, cancellation, retry, and generation +checks. An irrelevant update retains the same key and successful result. A real +input change clears that result before requesting another prediction. Do not +retain a stale result as current, add polling, or cache completed requests. + +Use these projection boundaries for active and background workflow snapshots: + +| Input | Included meaning | Excluded bookkeeping | +| --- | --- | --- | +| Task | Identity, placement, primary session/state, archive eligibility, task profile fallback, initial-session and routing metadata | Title, description, position, unrelated metadata, status-summary counters and timestamps | +| Sessions belonging to the task | Membership, identity/name, profile identities, state, primary/passthrough flags, configuration and original-session provenance | Read cursor, command counts, error prose, activity counters, workspace paths | +| Configuration | Runtime configuration, overrides, original effective snapshot, profile snapshot fields used by preview, model/capability/config-option data | Unrelated session metadata and capability-fetch bookkeeping | +| Workflow and steps | Default profile; source end policy; destination profile, target, start policy, entry actions; referenced target-step inputs | Workflow/step colors and unrelated presentation fields | +| Profiles | Prediction-relevant identity, display name, family, model, mode, config options, passthrough and dynamic-routing configuration; scoped profile revision when the normalized option lacks a full field | Global profile version counters and capability error prose without a projected change | +| Context | Connection status, workspace generation, requested task/workflow/step, normalized move options | Unrelated store activity | + +Keep both session stores and both task projections observable. Do not choose a +new authority or collapse conflicting snapshots as part of this repair. +Project metadata keys explicitly, including `agent_profile_id`, +`workflow_initial_session`, `workflow_session_route`, `runtime_config`, +`runtime_config_overrides`, `original_effective_config`, and +`completion_follow_up` where the backend reads them. Trace the preview's helper +calls when maintaining this list; arbitrary metadata is not an invalidation key. + +Timestamps require semantic treatment. `selectReusableWorkflowSession` uses +`UpdatedAt` to rank eligible same-profile candidates, while `previewCurrentSession` +uses primary flags and then `StartedAt` with an ID tie-break. Encode candidate +ordering or selected identity dependencies instead of raw changing timestamps. +Preserve exclusion and tie behavior, including terminal and completion-follow-up +sessions. A timestamp update that changes selection must change the revision; +one that leaves candidate order unchanged must not. Task-session and indexed +session stores can contain two projections of the same logical session. Keep +their raw projections and sensitivity, but calculate current-session, +original-session, and candidate-order dependencies independently per store so +the duplicate is never treated as two candidates or two marked originals. +This is a freshness signal, not a frontend replacement for the server's move +decision. + +Profile events update the full settings profile and a normalized picker option. +The revision must read `mode` and `configOptions` from the full settings +profile, and include the normalized profile's `updatedAt` as a scoped fallback +when those fields are unavailable. An unrelated profile event or global profile +version bump must not invalidate a preview that does not reference that profile. +When an initial-target snapshot names an `agent_profile_id`, include that profile +even if the historical session named by the snapshot is no longer present. + +Normalize object key order in projected maps. Sort collections only when their +order is not meaningful; preserve rule order, selection order, and ties. +Retain explicit empty and missing configuration distinctions where the backend +distinguishes them. Never log revision contents. + +The correction covers `StepMoveControls`, `StepDisclosureMoveControls`, and +`WorkflowMovePreviewFooter` through their shared hooks. It changes no drawer +geometry, controls, copy, or endpoint contract. The +[stability repair plan](../../../plans/workflow-move-preview-stability/plan.md) +defines the implementation and regression matrix. + Keep two-line loading/error states compact. Retry is explicit and does not move the task. Preview failure does not introduce a new move barrier. Show predictions as planned, not guaranteed; details explain that execution rechecks current state.