diff --git a/dashboard/src/v2/hooks/use-cinematic-invocation-feedback.ts b/dashboard/src/v2/hooks/use-cinematic-invocation-feedback.ts new file mode 100644 index 0000000000..4d9275d2ca --- /dev/null +++ b/dashboard/src/v2/hooks/use-cinematic-invocation-feedback.ts @@ -0,0 +1,172 @@ +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import type { ExecutionInvocationRecord } from "../types.js"; +import { fetchInvocationMessages } from "../lib/invocation-api.js"; +import { + projectCinematicInvocationFeedback, + selectCinematicFeedbackInvocation, +} from "../lib/cinematic-invocation-feedback.js"; + +export interface UseCinematicInvocationFeedbackOptions { + invocations: readonly ExecutionInvocationRecord[]; + projectId: string | null | undefined; + projectManagerAgentPresetId: string | null | undefined; +} + +export interface UseCinematicInvocationFeedbackResult { + activeInvocation: ExecutionInvocationRecord | null; + message: string | null; + toolCount: number; + loading: boolean; + error: string | null; +} + +interface FeedbackSnapshot { + error: string | null; + invocationId: string | null; + loading: boolean; + message: string | null; + projectId: string | null; + toolCount: number; +} + +const EMPTY_FEEDBACK: FeedbackSnapshot = { + error: null, + invocationId: null, + loading: false, + message: null, + projectId: null, + toolCount: 0, +}; + +const errorMessage = (error: unknown): string => ( + error instanceof Error ? error.message : String(error) +); + +export const useCinematicInvocationFeedback = ( + options: UseCinematicInvocationFeedbackOptions, +): UseCinematicInvocationFeedbackResult => { + const { invocations, projectId, projectManagerAgentPresetId } = options; + const requestGenerationRef = useRef(0); + const [snapshot, setSnapshot] = useState(EMPTY_FEEDBACK); + + const activeInvocation = useMemo(() => { + if (!projectId) return null; + return selectCinematicFeedbackInvocation( + invocations.filter((invocation) => invocation.projectId === projectId), + projectManagerAgentPresetId, + ); + }, [invocations, projectId, projectManagerAgentPresetId]); + + const activeInvocationId = activeInvocation?.id ?? null; + const activeMessageCount = activeInvocation?.messageCount ?? null; + const activeLastMessageAt = activeInvocation?.lastMessageAt ?? null; + const activeUpdatedAt = activeInvocation?.updatedAt ?? null; + + useEffect(() => { + const requestGeneration = requestGenerationRef.current + 1; + requestGenerationRef.current = requestGeneration; + const abortController = new AbortController(); + + if (!projectId || !activeInvocationId) { + setSnapshot((current) => ( + current.invocationId === null + && current.projectId === null + && !current.loading + && current.message === null + && current.toolCount === 0 + && current.error === null + ? current + : EMPTY_FEEDBACK + )); + return () => { + abortController.abort(); + if (requestGenerationRef.current === requestGeneration) { + requestGenerationRef.current += 1; + } + }; + } + + setSnapshot((current) => { + const sameInvocation = current.projectId === projectId + && current.invocationId === activeInvocationId; + return { + error: null, + invocationId: activeInvocationId, + loading: true, + message: sameInvocation ? current.message : null, + projectId, + toolCount: sameInvocation ? current.toolCount : 0, + }; + }); + + void fetchInvocationMessages(activeInvocationId, { signal: abortController.signal }) + .then((messages) => { + if ( + abortController.signal.aborted + || requestGenerationRef.current !== requestGeneration + ) { + return; + } + + const feedback = projectCinematicInvocationFeedback(messages); + setSnapshot({ + error: null, + invocationId: activeInvocationId, + loading: false, + message: feedback.message, + projectId, + toolCount: feedback.toolCount, + }); + }) + .catch((error: unknown) => { + if ( + abortController.signal.aborted + || requestGenerationRef.current !== requestGeneration + ) { + return; + } + + setSnapshot((current) => { + if ( + current.projectId !== projectId + || current.invocationId !== activeInvocationId + ) { + return current; + } + return { + ...current, + error: errorMessage(error), + loading: false, + }; + }); + }); + + return () => { + abortController.abort(); + if (requestGenerationRef.current === requestGeneration) { + requestGenerationRef.current += 1; + } + }; + }, [ + activeInvocationId, + activeLastMessageAt, + activeMessageCount, + activeUpdatedAt, + projectId, + ]); + + const snapshotMatchesActiveInvocation = Boolean( + projectId + && activeInvocationId + && snapshot.projectId === projectId + && snapshot.invocationId === activeInvocationId, + ); + + return { + activeInvocation, + message: snapshotMatchesActiveInvocation ? snapshot.message : null, + toolCount: snapshotMatchesActiveInvocation ? snapshot.toolCount : 0, + loading: snapshotMatchesActiveInvocation ? snapshot.loading : Boolean(activeInvocation), + error: snapshotMatchesActiveInvocation ? snapshot.error : null, + }; +}; diff --git a/dashboard/src/v2/lib/cinematic-invocation-feedback.ts b/dashboard/src/v2/lib/cinematic-invocation-feedback.ts new file mode 100644 index 0000000000..6028f697ae --- /dev/null +++ b/dashboard/src/v2/lib/cinematic-invocation-feedback.ts @@ -0,0 +1,121 @@ +import type { + ExecutionInvocationMessageRecord, + ExecutionInvocationRecord, +} from "../types.js"; + +export type CinematicFeedbackInvocation = Pick< + ExecutionInvocationRecord, + | "agentPresetId" + | "id" + | "lastMessageAt" + | "messageCount" + | "startedAt" + | "status" + | "type" + | "updatedAt" +>; + +export interface CinematicInvocationTranscriptFeedback { + message: string | null; + toolCount: number; +} + +const REPLY_INVOCATION_TYPES = new Set(["dashboard_reply", "worker_reply"]); +const PROSE_MESSAGE_KINDS = new Set(["assistant"]); +const TOOL_MESSAGE_KINDS = new Set(["tool_call", "tool_result"]); + +const readMetadataString = ( + message: ExecutionInvocationMessageRecord, + key: string, +): string | null => { + const value = message.metadata?.[key]; + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized || null; +}; + +/** + * Selects only current Project Manager reply work. Invocation-pane selection + * and unrelated project activity deliberately have no bearing on this result. + */ +export const selectCinematicFeedbackInvocation = ( + invocations: readonly T[], + projectManagerAgentPresetId: string | null | undefined, +): T | null => { + if (!projectManagerAgentPresetId) return null; + + let selected: T | null = null; + for (const invocation of invocations) { + if ( + invocation.status !== "running" + || invocation.agentPresetId !== projectManagerAgentPresetId + || !REPLY_INVOCATION_TYPES.has(invocation.type) + ) { + continue; + } + + if ( + !selected + || invocation.startedAt > selected.startedAt + || (invocation.startedAt === selected.startedAt && invocation.id > selected.id) + ) { + selected = invocation; + } + } + + return selected; +}; + +const isAssistantProseMessage = ( + message: ExecutionInvocationMessageRecord, +): boolean => { + if (message.role !== "assistant" || !message.contentMarkdown.trim()) { + return false; + } + + const kind = readMetadataString(message, "kind"); + return kind === null || PROSE_MESSAGE_KINDS.has(kind); +}; + +/** Returns the latest safe assistant prose without exposing internal turns. */ +export const selectLatestCinematicAssistantMessage = ( + messages: readonly ExecutionInvocationMessageRecord[], +): string | null => { + let selected: ExecutionInvocationMessageRecord | null = null; + + for (const message of messages) { + if (!isAssistantProseMessage(message)) continue; + // The invocation API returns the persisted transcript in sequence order. + selected = message; + } + + return selected?.contentMarkdown.trim() || null; +}; + +/** + * Counts logical tool activity from normalized turn metadata. Calls and their + * paired results share one key; stable message ids cover providers without a + * call id and keep repeated transcript refreshes idempotent. + */ +export const countUniqueCinematicToolCalls = ( + messages: readonly ExecutionInvocationMessageRecord[], +): number => { + const toolKeys = new Set(); + + for (const message of messages) { + const kind = readMetadataString(message, "kind"); + if (!kind || !TOOL_MESSAGE_KINDS.has(kind)) continue; + + const toolCallId = readMetadataString(message, "toolCallId"); + toolKeys.add(toolCallId ? `call:${toolCallId}` : `message:${message.id}`); + } + + return toolKeys.size; +}; + +export const projectCinematicInvocationFeedback = ( + messages: readonly ExecutionInvocationMessageRecord[], +): CinematicInvocationTranscriptFeedback => ({ + message: selectLatestCinematicAssistantMessage(messages), + toolCount: countUniqueCinematicToolCalls(messages), +}); diff --git a/docs-web/architecture/execution-invocation-tracking.md b/docs-web/architecture/execution-invocation-tracking.md index 5ed6091ac4..191d9ac418 100644 --- a/docs-web/architecture/execution-invocation-tracking.md +++ b/docs-web/architecture/execution-invocation-tracking.md @@ -71,6 +71,10 @@ Jules remains outside this local CLI parser and watcher path. Its remote session Chat's Invocations rail is server-authoritative. It reads the paginated `GET /api/projects/:projectId/execution/invocations` projection; `project.execution.updated` and `snapshot_required` trigger REST refetches for the list and selected transcript instead of creating browser-only invocation rows. +The cinematic feedback model is separate from whichever invocation is selected in that rail. Only the latest running `dashboard_reply` or `worker_reply` for the resolved Project Manager preset is eligible, with `startedAt` and invocation id providing deterministic precedence. The model loads the persisted transcript through the existing invocation-message endpoint and exposes only non-empty normalized assistant prose. User/system turns, injected context, reasoning, tool arguments, and tool output are never promoted into stage copy. + +Logical tool activity is deduplicated by normalized `metadata.toolCallId`; a stable message id is the fallback only when no call id exists. The frontend refreshes this projection when the active invocation or its `messageCount`, `lastMessageAt`, or `updatedAt` changes, preserves same-invocation feedback during refresh, and aborts or generation-invalidates stale work after project/invocation changes. Terminal or missing invocations clear the feedback. A transcript request failure remains a local, non-fatal state and does not replace the normal chat transcript or make unrelated work foreground activity. + Startup recovery reconciles stale workflow and provider rows from durable task-run, sprint-run, dispatch, process, and Docker-container evidence. Preparation-only rows can fail without provider linkage, terminal provider rows are reconciled without extending their usage window, and a recovered completed provider attempt may continue from its preserved workspace without a duplicate provider run. ## Focused verification diff --git a/docs-web/content/docs/architecture-execution-invocation-tracking.mdx b/docs-web/content/docs/architecture-execution-invocation-tracking.mdx index 5ed6091ac4..191d9ac418 100644 --- a/docs-web/content/docs/architecture-execution-invocation-tracking.mdx +++ b/docs-web/content/docs/architecture-execution-invocation-tracking.mdx @@ -71,6 +71,10 @@ Jules remains outside this local CLI parser and watcher path. Its remote session Chat's Invocations rail is server-authoritative. It reads the paginated `GET /api/projects/:projectId/execution/invocations` projection; `project.execution.updated` and `snapshot_required` trigger REST refetches for the list and selected transcript instead of creating browser-only invocation rows. +The cinematic feedback model is separate from whichever invocation is selected in that rail. Only the latest running `dashboard_reply` or `worker_reply` for the resolved Project Manager preset is eligible, with `startedAt` and invocation id providing deterministic precedence. The model loads the persisted transcript through the existing invocation-message endpoint and exposes only non-empty normalized assistant prose. User/system turns, injected context, reasoning, tool arguments, and tool output are never promoted into stage copy. + +Logical tool activity is deduplicated by normalized `metadata.toolCallId`; a stable message id is the fallback only when no call id exists. The frontend refreshes this projection when the active invocation or its `messageCount`, `lastMessageAt`, or `updatedAt` changes, preserves same-invocation feedback during refresh, and aborts or generation-invalidates stale work after project/invocation changes. Terminal or missing invocations clear the feedback. A transcript request failure remains a local, non-fatal state and does not replace the normal chat transcript or make unrelated work foreground activity. + Startup recovery reconciles stale workflow and provider rows from durable task-run, sprint-run, dispatch, process, and Docker-container evidence. Preparation-only rows can fail without provider linkage, terminal provider rows are reconciled without extending their usage window, and a recovered completed provider attempt may continue from its preserved workspace without a duplicate provider run. ## Focused verification diff --git a/docs/architecture/execution-invocation-tracking.md b/docs/architecture/execution-invocation-tracking.md index 807df930c1..53ca056167 100644 --- a/docs/architecture/execution-invocation-tracking.md +++ b/docs/architecture/execution-invocation-tracking.md @@ -102,6 +102,10 @@ This provides a clear audit log of the agent's work and prompt history separate User-facing chat threads show up with `scope === "project"`, while agent background logs and execution runs appear with `scope === "connection"`. The dashboard Chat -> Invocations rail renders only `execution_invocations` returned by the paginated `GET /api/projects/:projectId/execution/invocations` server endpoint. Invocation rows are created by the backend when the routed operation starts or is persisted. Sending a chat message still updates the thread transcript from the returned conversation message immediately, but the invocation rail waits for the persisted backend invocation row instead of inserting a frontend-only optimistic invocation placeholder. `project.execution.updated` and `snapshot_required` are invalidation signals: Chat refetches the authoritative invocation page and selected transcript instead of accepting or manufacturing invocation rows from realtime payloads in the browser. +The 3D Chat cinematic feedback model is independent from the invocation selected in that rail. It considers only the latest running `dashboard_reply` or `worker_reply` owned by the resolved Project Manager preset, ordered by `startedAt` and then invocation id, and loads that invocation's persisted messages through the existing invocation-message endpoint. Its interim copy is restricted to non-empty normalized assistant prose; user/system turns, injected context, reasoning, tool arguments, and tool output never become stage prose. Tool activity is counted by normalized `metadata.toolCallId`, with the stable message id used only when no call id exists, so call/result pairs and repeated refreshes remain one logical count. + +`useCinematicInvocationFeedback` refreshes when the foreground invocation changes or its `messageCount`, `lastMessageAt`, or `updatedAt` summary changes. Same-invocation copy remains visible during a refresh, while project/invocation changes abort and generation-invalidate older requests. Terminal or missing invocations clear the projection immediately. Transcript fetch errors stay local and non-fatal; they do not replace the normal chat transcript or activate unrelated project work. + The Chat -> Invocations detail view exposes same-session recovery actions for failed or cancelled planning invocations. **Restart** preserves the original terminal transcript, creates a new invocation row, and resends the full planning prompt while passing the terminal provider row's native session id as `continueSessionId` (Claude Code uses `--resume `). **Continue** uses the same native-session resume path and asks the provider to finish the previous planning attempt, but the continuation prompt also embeds the original planning instructions so a provider fallback to a fresh session still has the full schema, sprint goal, and task-generation context. Docker-backed planning runs use a stable project/sprint snapshot workspace and preserve its paired provider runtime volume while the run is failed, cancelled, or incomplete. Restart and Continue reuse that workspace so provider-local session files remain available; fresh planning invocations in `REMOTE` git mode still refresh `origin` and build a new snapshot from `origin/`, using the explicit sprint feature branch when present or the effective runtime git default branch otherwise. Successful planning cleans up that workspace and paired runtime volume. The replacement invocation has its own provider usage trail; the terminal row remains immutable evidence of the quota/error/cancellation history. If Claude Code reports "No conversation found" during resume, Code UX retries once with a fresh Claude session and persists that fresh native session id rather than the rejected id. Running invocations can also be cancelled from the same detail header. Cancellation is available for every running invocation type, not just planning. The dashboard posts to `/api/execution/invocations/:invocationId/cancel`; the server requests any registered active dispatch to stop, finds Docker containers by the existing `code-ux.session-id` label from the linked provider/task runtime, kills those containers, marks the provider usage row `cancelled`, and appends a system cancellation message to the invocation transcript. Provider finalizers check the current invocation state before writing terminal status so a cancelled row is not overwritten by a late provider failure while the process unwinds. diff --git a/tests/dashboard/v2/cinematic-invocation-feedback.test.ts b/tests/dashboard/v2/cinematic-invocation-feedback.test.ts new file mode 100644 index 0000000000..e4c36029f3 --- /dev/null +++ b/tests/dashboard/v2/cinematic-invocation-feedback.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + countUniqueCinematicToolCalls, + projectCinematicInvocationFeedback, + selectCinematicFeedbackInvocation, + selectLatestCinematicAssistantMessage, +} from "../../../dashboard/src/v2/lib/cinematic-invocation-feedback.js"; +import type { + ExecutionInvocationMessageRecord, + ExecutionInvocationRecord, +} from "../../../dashboard/src/v2/types.js"; + +const invocation = ( + overrides: Partial = {}, +): ExecutionInvocationRecord => ({ + id: "invocation-1", + projectId: "project-1", + sprintId: null, + taskId: null, + sprintRunId: null, + dispatchId: null, + taskRunId: null, + attentionItemId: null, + providerInvocationId: "provider-1", + type: "dashboard_reply", + status: "running", + provider: "codex", + model: "test-model", + systemPrompt: null, + startedAt: "2026-07-13T10:00:00.000Z", + finishedAt: null, + errorMessage: null, + lastErrorCategory: null, + lastErrorMessage: null, + lastRetryAfterIso: null, + messageCount: 0, + lastMessageAt: null, + invocationSource: "internal", + agentPresetId: "pm-agent", + createdAt: "2026-07-13T10:00:00.000Z", + updatedAt: "2026-07-13T10:00:00.000Z", + ...overrides, +}); + +const message = ( + overrides: Partial = {}, +): ExecutionInvocationMessageRecord => ({ + id: "message-1", + invocationId: "invocation-1", + role: "assistant", + contentMarkdown: "Interim response", + toolCallsJson: null, + metadata: null, + createdAt: "2026-07-13T10:00:00.000Z", + ...overrides, +}); + +describe("cinematic invocation feedback", () => { + it("selects the latest matching running reply with deterministic id precedence", () => { + const latestByTime = invocation({ + id: "reply-latest-time", + startedAt: "2026-07-13T10:01:00.000Z", + type: "worker_reply", + }); + const latestById = invocation({ + id: "reply-z", + startedAt: latestByTime.startedAt, + }); + + expect(selectCinematicFeedbackInvocation([ + latestById, + invocation({ id: "completed", status: "completed", startedAt: "2026-07-13T11:00:00.000Z" }), + invocation({ id: "wrong-agent", agentPresetId: "worker-agent", startedAt: "2026-07-13T12:00:00.000Z" }), + invocation({ id: "wrong-type", type: "task_coding", startedAt: "2026-07-13T13:00:00.000Z" }), + latestByTime, + ], "pm-agent")?.id).toBe("reply-z"); + + expect(selectCinematicFeedbackInvocation([invocation()], null)).toBeNull(); + expect(selectCinematicFeedbackInvocation([ + invocation({ status: "failed" }), + invocation({ type: "planning" }), + ], "pm-agent")).toBeNull(); + }); + + it("returns no prose or tools for an empty transcript", () => { + expect(projectCinematicInvocationFeedback([])).toEqual({ + message: null, + toolCount: 0, + }); + }); + + it("selects only the latest non-empty normalized assistant prose", () => { + const messages = [ + message({ id: "assistant-old", contentMarkdown: " Earlier answer ", createdAt: "2026-07-13T10:01:00.000Z" }), + message({ id: "assistant-empty", contentMarkdown: " ", createdAt: "2026-07-13T10:09:00.000Z" }), + message({ id: "reasoning", contentMarkdown: "Private chain", metadata: { kind: "reasoning" }, createdAt: "2026-07-13T10:10:00.000Z" }), + message({ id: "context", role: "system", contentMarkdown: "Injected secret", metadata: { kind: "injected_context" }, createdAt: "2026-07-13T10:11:00.000Z" }), + message({ id: "tool-call", role: "tool", contentMarkdown: "Raw arguments", metadata: { kind: "tool_call" }, createdAt: "2026-07-13T10:12:00.000Z" }), + message({ id: "tool-result", role: "tool", contentMarkdown: "Raw output", metadata: { kind: "tool_result" }, createdAt: "2026-07-13T10:13:00.000Z" }), + message({ id: "user", role: "user", contentMarkdown: "Raw prompt", createdAt: "2026-07-13T10:14:00.000Z" }), + message({ id: "assistant-kind", contentMarkdown: " Current safe answer ", metadata: { kind: "assistant" }, createdAt: "2026-07-13T10:08:00.000Z" }), + message({ id: "unknown-internal", contentMarkdown: "Unknown internal data", metadata: { kind: "provider_debug" }, createdAt: "2026-07-13T10:15:00.000Z" }), + ]; + + expect(selectLatestCinematicAssistantMessage(messages)).toBe("Current safe answer"); + }); + + it("counts logical tool calls once across pairs and repeated message records", () => { + const messages = [ + message({ id: "call-1", role: "tool", metadata: { kind: "tool_call", toolCallId: "tool-a" } }), + message({ id: "result-1", role: "tool", metadata: { kind: "tool_result", toolCallId: "tool-a" } }), + message({ id: "call-2", role: "tool", metadata: { kind: "tool_call", toolCallId: "tool-b" } }), + message({ id: "call-without-id", role: "tool", metadata: { kind: "tool_call" } }), + message({ id: "call-without-id", role: "tool", metadata: { kind: "tool_call" } }), + message({ id: "plain-tool-role", role: "tool", metadata: null }), + message({ id: "assistant", metadata: { toolCallId: "not-a-tool-turn" } }), + ]; + + expect(countUniqueCinematicToolCalls(messages)).toBe(3); + }); +}); diff --git a/tests/dashboard/v2/use-cinematic-invocation-feedback.test.tsx b/tests/dashboard/v2/use-cinematic-invocation-feedback.test.tsx new file mode 100644 index 0000000000..e7b7f92bba --- /dev/null +++ b/tests/dashboard/v2/use-cinematic-invocation-feedback.test.tsx @@ -0,0 +1,285 @@ +/** @vitest-environment happy-dom */ +import { act, renderHook, waitFor } from "@testing-library/preact"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useCinematicInvocationFeedback } from "../../../dashboard/src/v2/hooks/use-cinematic-invocation-feedback.js"; +import { fetchInvocationMessages } from "../../../dashboard/src/v2/lib/invocation-api.js"; +import type { + ExecutionInvocationMessageRecord, + ExecutionInvocationRecord, +} from "../../../dashboard/src/v2/types.js"; + +vi.mock("../../../dashboard/src/v2/lib/invocation-api.js", () => ({ + fetchInvocationMessages: vi.fn(), +})); + +const invocation = ( + overrides: Partial = {}, +): ExecutionInvocationRecord => ({ + id: "invocation-1", + projectId: "project-1", + sprintId: null, + taskId: null, + sprintRunId: null, + dispatchId: null, + taskRunId: null, + attentionItemId: null, + providerInvocationId: "provider-1", + type: "dashboard_reply", + status: "running", + provider: "codex", + model: "test-model", + systemPrompt: null, + startedAt: "2026-07-13T10:00:00.000Z", + finishedAt: null, + errorMessage: null, + lastErrorCategory: null, + lastErrorMessage: null, + lastRetryAfterIso: null, + messageCount: 1, + lastMessageAt: "2026-07-13T10:00:00.000Z", + invocationSource: "internal", + agentPresetId: "pm-agent", + createdAt: "2026-07-13T10:00:00.000Z", + updatedAt: "2026-07-13T10:00:00.000Z", + ...overrides, +}); + +const message = ( + overrides: Partial = {}, +): ExecutionInvocationMessageRecord => ({ + id: "message-1", + invocationId: "invocation-1", + role: "assistant", + contentMarkdown: "Working on it.", + toolCallsJson: null, + metadata: null, + createdAt: "2026-07-13T10:00:00.000Z", + ...overrides, +}); + +interface Deferred { + promise: Promise; + reject: (error: unknown) => void; + resolve: (value: T) => void; +} + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +}; + +interface HookProps { + invocations: ExecutionInvocationRecord[]; + projectId: string | null; + projectManagerAgentPresetId: string | null; +} + +const initialProps = ( + overrides: Partial = {}, +): HookProps => ({ + invocations: [invocation()], + projectId: "project-1", + projectManagerAgentPresetId: "pm-agent", + ...overrides, +}); + +describe("useCinematicInvocationFeedback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads feedback and preserves it while all invocation refresh signals update", async () => { + const refresh = deferred(); + vi.mocked(fetchInvocationMessages) + .mockResolvedValueOnce([ + message({ contentMarkdown: "First safe update" }), + message({ id: "call", role: "tool", metadata: { kind: "tool_call", toolCallId: "call-1" } }), + message({ id: "result", role: "tool", metadata: { kind: "tool_result", toolCallId: "call-1" } }), + ]) + .mockImplementationOnce(() => refresh.promise) + .mockResolvedValue([]); + + const view = renderHook( + (props: HookProps) => useCinematicInvocationFeedback(props), + { initialProps: initialProps() }, + ); + + await waitFor(() => expect(view.result.current).toMatchObject({ + message: "First safe update", + toolCount: 1, + loading: false, + error: null, + })); + expect(fetchInvocationMessages).toHaveBeenCalledWith( + "invocation-1", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + + view.rerender(initialProps({ + invocations: [invocation({ messageCount: 3 })], + })); + + expect(view.result.current).toMatchObject({ + message: "First safe update", + toolCount: 1, + loading: true, + }); + + await act(async () => { + refresh.resolve([ + message({ id: "latest", contentMarkdown: "Fresh safe update", createdAt: "2026-07-13T10:01:00.000Z" }), + message({ id: "call", role: "tool", metadata: { kind: "tool_call", toolCallId: "call-1" } }), + message({ id: "call-2", role: "tool", metadata: { kind: "tool_call", toolCallId: "call-2" } }), + ]); + await refresh.promise; + }); + await waitFor(() => expect(view.result.current).toMatchObject({ + message: "Fresh safe update", + toolCount: 2, + loading: false, + })); + + view.rerender(initialProps({ + invocations: [invocation({ + messageCount: 3, + lastMessageAt: "2026-07-13T10:01:00.000Z", + })], + })); + await waitFor(() => expect(fetchInvocationMessages).toHaveBeenCalledTimes(3)); + + view.rerender(initialProps({ + invocations: [invocation({ + messageCount: 3, + lastMessageAt: "2026-07-13T10:01:00.000Z", + updatedAt: "2026-07-13T10:02:00.000Z", + })], + })); + await waitFor(() => expect(fetchInvocationMessages).toHaveBeenCalledTimes(4)); + }); + + it("keeps same-invocation content when a refresh fails and reports a non-fatal error", async () => { + vi.mocked(fetchInvocationMessages) + .mockResolvedValueOnce([message({ contentMarkdown: "Still valid" })]) + .mockRejectedValueOnce(new Error("Transcript temporarily unavailable")); + + const view = renderHook( + (props: HookProps) => useCinematicInvocationFeedback(props), + { initialProps: initialProps() }, + ); + await waitFor(() => expect(view.result.current.message).toBe("Still valid")); + + view.rerender(initialProps({ + invocations: [invocation({ updatedAt: "2026-07-13T10:01:00.000Z" })], + })); + + await waitFor(() => expect(view.result.current).toMatchObject({ + message: "Still valid", + toolCount: 0, + loading: false, + error: "Transcript temporarily unavailable", + })); + }); + + it("discards stale out-of-order responses after invocation and project changes", async () => { + const oldRequest = deferred(); + const newRequest = deferred(); + vi.mocked(fetchInvocationMessages) + .mockImplementationOnce(() => oldRequest.promise) + .mockImplementationOnce(() => newRequest.promise); + + const view = renderHook( + (props: HookProps) => useCinematicInvocationFeedback(props), + { initialProps: initialProps() }, + ); + const newInvocation = invocation({ + id: "invocation-2", + projectId: "project-2", + startedAt: "2026-07-13T11:00:00.000Z", + }); + + view.rerender(initialProps({ + invocations: [newInvocation], + projectId: "project-2", + })); + expect(view.result.current.message).toBeNull(); + + await act(async () => { + newRequest.resolve([message({ + id: "new-message", + invocationId: "invocation-2", + contentMarkdown: "New project update", + })]); + await newRequest.promise; + }); + await waitFor(() => expect(view.result.current.message).toBe("New project update")); + + await act(async () => { + oldRequest.resolve([message({ contentMarkdown: "Stale project update" })]); + await oldRequest.promise; + }); + expect(view.result.current).toMatchObject({ + activeInvocation: newInvocation, + message: "New project update", + error: null, + }); + }); + + it("clears feedback when the active invocation becomes terminal or disappears", async () => { + vi.mocked(fetchInvocationMessages).mockResolvedValue([message()]); + const view = renderHook( + (props: HookProps) => useCinematicInvocationFeedback(props), + { initialProps: initialProps() }, + ); + await waitFor(() => expect(view.result.current.message).toBe("Working on it.")); + + view.rerender(initialProps({ + invocations: [invocation({ status: "completed" })], + })); + expect(view.result.current).toEqual({ + activeInvocation: null, + message: null, + toolCount: 0, + loading: false, + error: null, + }); + + view.rerender(initialProps({ invocations: [] })); + expect(view.result.current.activeInvocation).toBeNull(); + expect(fetchInvocationMessages).toHaveBeenCalledTimes(1); + }); + + it("does not fetch unrelated activity and aborts an active request on unmount", async () => { + const request = deferred(); + vi.mocked(fetchInvocationMessages).mockImplementation(() => request.promise); + const unrelated = renderHook( + (props: HookProps) => useCinematicInvocationFeedback(props), + { initialProps: initialProps({ + invocations: [invocation({ agentPresetId: "worker-agent" })], + }) }, + ); + expect(unrelated.result.current.activeInvocation).toBeNull(); + expect(fetchInvocationMessages).not.toHaveBeenCalled(); + unrelated.unmount(); + + const active = renderHook( + (props: HookProps) => useCinematicInvocationFeedback(props), + { initialProps: initialProps() }, + ); + await waitFor(() => expect(fetchInvocationMessages).toHaveBeenCalledTimes(1)); + const requestInit = vi.mocked(fetchInvocationMessages).mock.calls[0]?.[1]; + expect(requestInit?.signal?.aborted).toBe(false); + + active.unmount(); + expect(requestInit?.signal?.aborted).toBe(true); + + await act(async () => { + request.resolve([message({ contentMarkdown: "Too late" })]); + await request.promise; + }); + }); +});