From 8ee851a0d832420888d340d968e84fd17f6d7c6e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:45:47 -0500 Subject: [PATCH] fix(ui): keep runner turns stable through startup and completion (#16989) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task chat shows each agent turn from message submission through completion. > - Run admission can publish adapter metadata before native runtime metadata. > - The UI changed layouts during that gap and removed the agent identity. > - Completion reports can also arrive before the provider writes its final reply. > - The event reader treated those reports as final replies and then replaced them. > - This pull request keeps the runner layout stable and delays report fallbacks until the final history fetch completes. > - Readers see one provider reply, while report-only runs keep a useful answer. ## Linked Issues or Issue Description **What happened?** Sending a task message briefly replaced the runner avatar, name, and Thinking row with a generic Working row. At completion, a full report summary appeared as the answer. A shorter provider reply then replaced it while streaming. **Expected behavior** Keep the runner identity and header spacing stable during admission. Stream the provider's final reply once. Use a report summary as a fallback only after the run ends and its final history fetch completes without a provider reply. **Steps to reproduce** 1. Send a message to a native Paperclip Runner agent. 2. Observe the initial run record before native runtime metadata arrives. 3. Let the agent call its completion tool before writing its final response. 4. Observe the report summary, final response stream, and saved reply handoff. **Paperclip version or commit** Reproduced from source at `6b804bfeba`. The event-reader behavior was also present in earlier revisions. **Deployment mode** Self-hosted server. Local component tests and browser fixtures reproduce the UI transitions. Related work: Refs #12617, which added native runner turns to task chat. The duplicate search found no open PR for this specific defect. ## What Changed - Keep Paperclip Runner turns in the runner layout while runtime metadata resolves. Use the same header spacing for queued and running states. - Keep structured reports as report entries. Do not create synthetic provider final messages from them. - Track successful event fetches for each run status. Permit a terminal report fallback after the final fetch, or when the transcript contains its terminal receipt. - Preserve report-only answers and saved reply deduplication. - Add desktop and mobile regressions for streamed replies, late final events, report-only runs, pagination, and fetch failures. Delay missing-answer notices until terminal history is ready. Document the presentation rules in `DESIGN.md`. ## Verification - Passed all 8,750 UI tests across 769 files. This includes 456 focused tests for the thread, runner turn, native projection, native transcript hook, transcript adapter, and boundary fixtures. - The six completion regressions fail against the old native event reader and pass with the fix. Three empty-history regressions fail before the review fix and pass afterward. The startup regressions also fail against the old layout condition. - Passed UI TypeScript checking with `node node_modules/typescript/bin/tsc -b ui/tsconfig.json`. - Passed the UI production build with `cd ui && node node_modules/vite/bin/vite.js build`. - Passed `pnpm check:token-gates`, `git diff --check`, and the Storybook catalog generator. - Browser check: mounted the production turn with native event snapshots at desktop and 390-pixel mobile widths. The report stayed hidden while running. The actual reply stayed unchanged at completion. The report-only fallback appeared after completion. The identity row kept the same position and size. No browser errors or horizontal overflow occurred. The temporary fixture was removed. - Passed `pnpm -r typecheck` and `pnpm build`. Initial attempts failed with `COPILOT_INSTALLATION_INVALID`. Reinstalling the generated Copilot assets resolved that local packaging failure without source changes. - The full repository test run was attempted locally and stopped after repeated embedded PostgreSQL bootstrap failures in server suites. The complete UI suite passes independently. In CI, serialized shard 5/9 had one 15-second timeout in the unchanged `agent-live-run-routes.test.ts` history-limit case; its other 387 tests passed. All five history-limit variants pass locally. The single CI job retry passed. The original timeout cause is not established. ## Risks - Report-only answers appear later because the UI waits for terminal history. Failed event fetches keep that fallback pending until a retry succeeds; provider replies remain visible. - Historical replies and accepted response-wake behavior retain their existing resolution rules. The focused tests cover these paths. - The change does not alter provider execution, completion authority, database schema, or run timing. ## Model Used OpenAI GPT-6 through Codex. The exact serving model ID and context window are not exposed in this session. Used reasoning, code execution, and browser inspection. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- DESIGN.md | 10 ++ ui/src/components/TaskChatThread.test.tsx | 162 +++++++++++++++++- ui/src/components/TaskChatThread.tsx | 17 +- .../task-chat/TaskChatRunnerTurn.tsx | 9 +- .../transcript/native-run-events.test.ts | 12 +- .../transcript/native-run-events.ts | 20 --- .../useNativeRunTranscripts.test.tsx | 32 ++++ .../transcript/useNativeRunTranscripts.ts | 12 +- 8 files changed, 232 insertions(+), 42 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cce5386f620..f212e8cfad9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -76,6 +76,16 @@ that relayed a message from another channel. ## Contextual feedback +Keep the runner turn's avatar, name, Working header and current-activity slot +in place from message submission through queued admission and native startup. +Runtime discovery must not replace the turn with a generic spinner or a +"run started" receipt. Queued and running headers use the same spacing. + +Stream the provider's final reply as the conversational answer. Completion-tool +reports remain inspectable run data while work continues. Use their summary as +an answer fallback only after the run ends and its final transcript fetch +completes without a provider reply. Preserve the reply through persistence. + Task chat shows execution errors and waits only while they remain relevant. Completing or cancelling a task hides its old execution notices. A newer attempt by the same agent or an explicit successor supersedes earlier run notices; an diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index d8915ae79cc..2ddc29a6bf8 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -30,6 +30,7 @@ const nativeTranscriptState = vi.hoisted(() => ({ transcriptByRun: new Map(), errorsByRun: new Map(), hydratedRunIds: undefined as Set | undefined, + terminalHydratedRunIds: undefined as Set | undefined, })); const transcriptHookRuns = vi.hoisted(() => ({ legacy: [] as unknown[][], @@ -59,12 +60,14 @@ vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({ }, })); vi.mock("@/components/transcript/useNativeRunTranscripts", () => ({ - useNativeRunTranscripts: (runs: unknown[]) => { + useNativeRunTranscripts: (runs: { id: string; status: string }[]) => { transcriptHookRuns.native.push(runs); return { transcriptByRun: new Map(nativeTranscriptState.transcriptByRun), errorsByRun: new Map(nativeTranscriptState.errorsByRun), hydratedRunIds: nativeTranscriptState.hydratedRunIds, + terminalHydratedRunIds: nativeTranscriptState.terminalHydratedRunIds ?? + new Set(runs.filter((run) => run.status !== "running" && run.status !== "queued").map((run) => run.id)), }; }, })); @@ -120,6 +123,7 @@ beforeEach(() => { nativeTranscriptState.transcriptByRun.clear(); nativeTranscriptState.errorsByRun.clear(); nativeTranscriptState.hydratedRunIds = undefined; + nativeTranscriptState.terminalHydratedRunIds = undefined; transcriptHookRuns.legacy.length = 0; transcriptHookRuns.native.length = 0; sidebarState.isMobile = false; @@ -1020,7 +1024,158 @@ describe("TaskChatThread runtime transcript selection", () => { expect(nativeRuns.map((run) => run.id)).toEqual(["native-run"]); }); - it("uses runner-only controls only for an actual native Paperclip Runner run", () => { + it.each([false, true])("keeps the runner identity and activity mounted through startup (mobile=%s)", (mobile) => { + sidebarState.isMobile = mobile; + const run = nativeRunningRun(); + const comment = { + id: "startup-message", companyId: "company", issueId: "issue", + authorType: "user" as const, authorUserId: "board", authorAgentId: null, + body: "Check the sandbox configuration.", + presentation: null, metadata: null, + createdAt: new Date(), updatedAt: new Date(), + }; + const props = { + comments: [comment], onAdd: async () => {}, issueStatus: "in_progress", + currentUserId: "board", issueAssigneeAgentId: "agent-1", + agentMap: new Map([["agent-1", { id: "agent-1", name: "Runner", adapterType: "paperclip_runner", icon: "code" } as Agent]]), + }; + renderThread({ ...props, comments: [{ ...comment, clientStatus: "pending" }] }); + const identity = container.querySelector('[data-testid="task-chat-agent-identity"]'); + const header = container.querySelector('[data-testid="task-chat-runner-identity-row"]'); + const activity = container.querySelector('[data-testid="task-chat-current-activity-label"]'); + expect(identity?.textContent).toBe("Runner"); + expect(identity?.querySelector("svg, img")).not.toBeNull(); + expect(activity?.textContent).toBe("Thinking"); + + for (const startup of [ + { ...run, runtimeMode: undefined, status: "queued", startedAt: null }, + { ...run, runtimeMode: "legacy" as const, currentStatusMessage: "run started" }, + run, + ]) { + renderThread({ ...props, activeRun: startup }); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + expect(container.querySelector('[data-testid="task-chat-runner-identity-row"]')).toBe(header); + expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')).toBe(activity); + expect(activity?.textContent).toBe("Thinking"); + expect(container.querySelector('[data-testid="task-chat-live-run-pill"]')).toBeNull(); + expect(container.textContent).not.toContain("run started"); + } + + nativeTranscriptState.transcriptByRun.set(run.id, [ + { kind: "thinking", ts: "2026-08-25T18:00:01.000Z", text: "", lifecycle: "started", itemId: "reasoning-1" }, + ]); + renderThread({ ...props, activeRun: run }); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + expect(container.querySelector('[data-testid="task-chat-runner-identity-row"]')).toBe(header); + expect(container.textContent).toContain("Thinking"); + }); + + it.each([false, true].flatMap(mobile => ["streamed", "late", "absent"].map(reply => ({ mobile, reply }))))( + "keeps report fallbacks behind complete terminal history ($reply, mobile=$mobile)", + ({ mobile, reply }) => { + sidebarState.isMobile = mobile; + const run = nativeRunningRun(); + const summary = "The report contains a longer account of the completed work."; + const finalText = "The final reply is complete."; + const result = { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary, + completionClaim: { objectiveSatisfied: true, remainingWork: [] }, + }; + const events: HeartbeatRunEvent[] = []; + const append = (eventType: string, payload: Record) => { + const seq = events.length + 1; + events.push({ + id: seq, runId: run.id, seq, eventType, + companyId: "company", agentId: run.agentId, stream: "system", level: "info", color: null, message: null, + createdAt: new Date(`2026-08-25T18:00:0${seq}.000Z`), + payload: { prpEvent: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, + runId: run.id, eventType, sourceEventId: `event-${seq}`, + itemId: "final-reply", payload, + } }, + }); + nativeTranscriptState.transcriptByRun.set(run.id, nativeRunEventsToTranscript(events)); + }; + const props = { comments: [], onAdd: async () => {}, issueStatus: "in_progress" }; + nativeTranscriptState.terminalHydratedRunIds = new Set(); + append("run.result.proposed", result); + renderThread({ ...props, activeRun: run }); + const identity = container.querySelector('[data-testid="task-chat-agent-identity"]'); + expect(container.textContent).not.toContain(summary); + + if (reply === "streamed") { + append("item.delta", { kind: "agentMessage", channel: "final", text: "The final reply" }); + renderThread({ ...props, activeRun: run }); + expect(container.textContent).toContain("The final reply"); + expect(container.textContent).not.toContain(summary); + expect(container.querySelector('[data-testid="task-chat-agent-identity"]')).toBe(identity); + append("item.completed", { kind: "agentMessage", channel: "final", text: finalText }); + renderThread({ ...props, activeRun: run }); + expect(container.textContent?.split(finalText)).toHaveLength(2); + } + + const linkedRun = { + ...run, runId: run.id, status: "succeeded", finishedAt: "2026-08-25T18:00:10.000Z", + resultJson: { nativeResult: result }, + }; + renderThread({ ...props, linkedRuns: [linkedRun] }); + expect(container.textContent).not.toContain(summary); + + if (reply === "late") { + append("item.completed", { kind: "agentMessage", channel: "final", text: finalText }); + } + nativeTranscriptState.terminalHydratedRunIds = new Set([run.id]); + renderThread({ ...props, linkedRuns: [linkedRun] }); + const displayed = reply === "absent" ? summary : finalText; + expect(container.textContent?.split(displayed)).toHaveLength(2); + if (reply !== "absent") expect(container.textContent).not.toContain(summary); + + renderThread({ + ...props, linkedRuns: [linkedRun], comments: [{ + id: "saved-response", companyId: "company", issueId: "issue", runId: run.id, + authorType: "agent", authorAgentId: run.agentId, authorUserId: null, + body: displayed, presentation: null, metadata: null, + createdAt: new Date("2026-08-25T18:00:10.000Z"), updatedAt: new Date("2026-08-25T18:00:10.000Z"), + }], + }); + expect(container.textContent?.split(displayed)).toHaveLength(2); + if (reply !== "absent") expect(container.textContent).not.toContain(summary); + }, + ); + + it.each(["summary", "provider", "empty"])("keeps empty terminal history pending before resolving its %s response", (response) => { + const run = nativeRunningRun(); + const summary = "The accepted report is ready."; + const finalText = "The provider's actual final reply."; + const props = { comments: [], onAdd: async () => {}, issueStatus: "in_progress" }; + nativeTranscriptState.terminalHydratedRunIds = new Set(); + nativeTranscriptState.transcriptByRun.set(run.id, []); + renderThread({ ...props, activeRun: run }); + const linkedRun = { + ...run, runId: run.id, status: "succeeded", finishedAt: "2026-08-25T18:00:10.000Z", + resultJson: response === "empty" ? null : { + nativeResult: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary }, + }, + }; + renderThread({ ...props, linkedRuns: [linkedRun] }); + expect(container.textContent).not.toContain(summary); + expect(container.textContent).not.toContain("The runner returned no user-facing response."); + + if (response === "provider") { + nativeTranscriptState.transcriptByRun.set(run.id, [assistantEntry("2026-08-25T18:00:05.000Z", finalText, "final")]); + } + nativeTranscriptState.terminalHydratedRunIds = new Set([run.id]); + renderThread({ ...props, linkedRuns: [linkedRun] }); + if (response === "empty") { + expect(container.textContent).toContain("The runner returned no user-facing response."); + } else { + expect(container.textContent?.split(response === "summary" ? summary : finalText)).toHaveLength(2); + expect(container.textContent).not.toContain("The runner returned no user-facing response."); + if (response === "provider") expect(container.textContent).not.toContain(summary); + } + }); + + it("keeps runner layout during admission and native transcripts scoped to native runs", () => { nativeTranscriptState.transcriptByRun.set("native-run", [ assistantEntry("2026-08-25T18:00:01.000Z", "Checking the task.", "progress"), assistantEntry("2026-08-25T18:00:02.000Z", "The task is ready.", "final"), @@ -1050,7 +1205,8 @@ describe("TaskChatThread runtime transcript selection", () => { expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), - ).toBeNull(); + ).not.toBeNull(); + expect(transcriptHookRuns.native.at(-1)).toEqual([]); renderThread({ comments: [], diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index e4ce52bf096..2e600931ca0 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -934,6 +934,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { errorsByRun: nativeTranscriptErrorsByRun, isInitialHydrating: nativeEventsAreInitiallyHydrating, hydratedRunIds: hydratedNativeRunIds, + terminalHydratedRunIds, retry: retryNativeEvents, } = useNativeRunTranscripts(nativeRuns); const logRuns = useMemo(() => runs.filter((run) => @@ -1600,6 +1601,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { )) && !sourceAcceptedResponseWake; const sourceIsPaperclipRunner = isNativePaperclipRunnerRun(source); + const sourceAllowSummaryFallback = !sourceIsPaperclipRunner || + terminalHydratedRunIds.has(source.id) || + entries.some((entry) => entry.kind === "run_terminal"); const decidedCommentId = presentationDecisionCommentId(meta?.resultJson); const progressCommentIds = semanticProgressCommentIds(meta?.resultJson); const sourcePresentationCommentId = @@ -1792,8 +1796,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { settledRunIds.add(source.id); continue; } + if (sourceIsPaperclipRunner && !sourceAllowSummaryFallback) continue; if ( isNativePaperclipRunnerRun(source) && + sourceAllowSummaryFallback && acceptedSummary && !sourceHasPresentationComment ) { @@ -1868,6 +1874,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { : undefined; if ( source.status === "succeeded" && + sourceAllowSummaryFallback && !sourceHasPresentationComment && resolvedWithoutUserFacingResponse(meta?.resultJson) && !sourceHasNativeResponse @@ -1928,6 +1935,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { runId: source.id, agentName: meta?.agentName, fallbackSummary: acceptedSummary, + allowFallback: sourceAllowSummaryFallback, })?.text) : undefined; const timelineItemsBySegment = sourceIsPaperclipRunner @@ -1966,6 +1974,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { segmentIndex === lastPopulatedSegmentIndex ? acceptedSummary : undefined, + allowFallback: sourceAllowSummaryFallback, }) : undefined; const children = settledRunChildren( @@ -2170,6 +2179,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { runs, liveRun, transcriptByRun, + terminalHydratedRunIds, linkedRunMetaById, lastCommentIdByRun, comments, @@ -3094,7 +3104,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { <> {tailRunId || optimisticRunnerStartup ? (
- {paperclipRunnerTail || + {tailRunSource?.adapterType === "paperclip_runner" || optimisticRunnerStartup ? ( entry.kind === "run_terminal")} status={ optimisticRunnerStartup ? "queued" diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 1e48943080f..59d7b2e5556 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -125,6 +125,7 @@ export function TaskChatRunnerTurn({ startedAtMs, finishedAtMs, activityUnavailable = false, + allowSummaryFallback = true, suppressFinal = false, continuedAfterSteering = false, onRuntimeRequestDecision, @@ -140,6 +141,7 @@ export function TaskChatRunnerTurn({ startedAtMs: number | null; finishedAtMs?: number | null; activityUnavailable?: boolean; + allowSummaryFallback?: boolean; /** Accepted wait/interaction authority overrides an early provider final. */ suppressFinal?: boolean; /** The visible tail resumes the same native run after an accepted steer. */ @@ -160,7 +162,7 @@ export function TaskChatRunnerTurn({ const observedFinal = suppressFinal ? undefined : paperclipRunnerFinalResponse(items, { - allowFallback: terminal, + allowFallback: terminal && allowSummaryFallback, }); const observedProviderText = Boolean( observedFinal && @@ -210,10 +212,7 @@ export function TaskChatRunnerTurn({ data-phase={status === "queued" ? "startup" : undefined} >
{agentName ? ( diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 7fb01b5b371..d49b4f598d6 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -700,7 +700,7 @@ describe("nativeRunEventsToTranscript", () => { ]); }); - it("uses the structured run summary when no agent message was emitted", () => { + it("retains a report without manufacturing a provider final message", () => { expect(nativeRunEventsToTranscript([ event(1, "run.result.proposed", runResult("Recovered final reply.")), ])).toEqual([ @@ -708,11 +708,6 @@ describe("nativeRunEventsToTranscript", () => { kind: "run_result", summary: "Recovered final reply.", }), - expect.objectContaining({ - kind: "assistant", - text: "Recovered final reply.", - channel: "final", - }), ]); }); @@ -782,11 +777,6 @@ describe("nativeRunEventsToTranscript", () => { kind: "run_result", summary: "The implementation is ready.", }), - expect.objectContaining({ - kind: "assistant", - text: "The implementation is ready.", - channel: "final", - }), ]); }); diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 85f5426a16a..b9d253cb1c7 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -586,7 +586,6 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] const entries: TranscriptEntry[] = []; const startedToolIds = new Set(); const completedToolIds = new Set(); - let hasFinalAssistantMessage = false; let usageSummary: { ts: string; inputTokens: number; @@ -601,7 +600,6 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] cachedTokens: number; costUsd: number; } | null = null; - let runResultFallback: { ts: string; text: string } | null = null; const seenSourceEventIds = new Set(); const orderedEvents = [...events] .sort((a, b) => a.seq - b.seq) @@ -739,7 +737,6 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] // Before that point the deltas still provide the live streaming view. if (completedAgentMessageIds.has(itemId)) continue; const channel = itemIdentity.assistantChannel; - if (channel !== "progress") hasFinalAssistantMessage = true; entries.push({ kind: "assistant", ts, text: value, delta: true, channel, itemId }); continue; } @@ -748,7 +745,6 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] const value = normalizedItemText(payload); if (!value) continue; const channel = itemIdentity.assistantChannel; - if (channel !== "progress") hasFinalAssistantMessage = true; entries.push({ kind: "assistant", ts, text: value, channel, ...(itemId ? { itemId } : {}) }); continue; } @@ -1023,10 +1019,6 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] } hasRunResult = true; } - const summary = text(result.summary); - if (!hasFinalAssistantMessage && summary && !runResultFallback) { - runResultFallback = { ts, text: summary }; - } continue; } } @@ -1057,18 +1049,6 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] } } - // A structured result can be proposed before its originating final item is - // durably completed. Delay the fallback until every event has been examined - // so the explicit assistant reply wins regardless of source ordering. - if (!hasFinalAssistantMessage && runResultFallback) { - entries.push({ - kind: "assistant", - ts: runResultFallback.ts, - text: runResultFallback.text, - channel: "final", - }); - } - if (usageSummary) { entries.push({ kind: "result", diff --git a/ui/src/components/transcript/useNativeRunTranscripts.test.tsx b/ui/src/components/transcript/useNativeRunTranscripts.test.tsx index 4666e242cf3..b602370ec46 100644 --- a/ui/src/components/transcript/useNativeRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useNativeRunTranscripts.test.tsx @@ -136,6 +136,38 @@ describe("native history readiness and stable projection", () => { expect(latest.transcriptByRun.get("one")).toBe(rows); }); + it("waits for every page of a successful terminal refresh before allowing a fallback", async () => { + eventsMock.mockResolvedValueOnce([{ seq: 1, payload: {}, eventType: "log" }]); + await act(async () => { root.render(); }); + expect(latest.terminalHydratedRunIds.has("one")).toBe(false); + + let resolvePage!: (rows: unknown[]) => void; + eventsMock.mockImplementationOnce(() => new Promise(resolve => { resolvePage = resolve; })); + await act(async () => { root.render(); }); + expect(latest.terminalHydratedRunIds.has("one")).toBe(false); + + let resolveLastPage!: (rows: unknown[]) => void; + eventsMock.mockImplementationOnce(() => new Promise(resolve => { resolveLastPage = resolve; })); + await act(async () => { + resolvePage(Array.from({ length: 1_000 }, (_, index) => ({ seq: index + 2, payload: {}, eventType: "log" }))); + }); + expect(latest.terminalHydratedRunIds.has("one")).toBe(false); + expect(eventsMock.mock.calls.at(-1)?.[1]).toBe(1_001); + await act(async () => { resolveLastPage([]); }); + expect(latest.terminalHydratedRunIds.has("one")).toBe(true); + }); + + it("keeps the fallback pending after a failed terminal refresh until retry succeeds", async () => { + eventsMock.mockResolvedValueOnce([]); + await act(async () => { root.render(); }); + eventsMock.mockRejectedValueOnce(new Error("offline")); + await act(async () => { root.render(); }); + expect(latest.terminalHydratedRunIds.has("one")).toBe(false); + eventsMock.mockResolvedValueOnce([]); + await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); + expect(latest.terminalHydratedRunIds.has("one")).toBe(true); + }); + it("times out stalled history, aborts its request, and ignores a late response before retry", async () => { let resolveLate!: (rows: never[]) => void; eventsMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLate = resolve; })); diff --git a/ui/src/components/transcript/useNativeRunTranscripts.ts b/ui/src/components/transcript/useNativeRunTranscripts.ts index a8dd3553e54..f2443303838 100644 --- a/ui/src/components/transcript/useNativeRunTranscripts.ts +++ b/ui/src/components/transcript/useNativeRunTranscripts.ts @@ -38,6 +38,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource const [eventsByRun, setEventsByRun] = useState>(new Map()); const [errorsByRun, setErrorsByRun] = useState>(new Map()); const [hydratedRunIds, setHydratedRunIds] = useState>(new Set()); + const [hydratedStatusByRun, setHydratedStatusByRun] = useState(new Map()); const [retryGeneration, setRetryGeneration] = useState(0); const retry = useCallback(() => setRetryGeneration((value) => value + 1), []); const projectionCacheRef = useRef(new Map()); @@ -54,6 +55,7 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource }; setEventsByRun(retainMap); setErrorsByRun(retainMap); + setHydratedStatusByRun(retainMap); setHydratedRunIds((previous) => { const next = new Set([...previous].filter((id) => retainedIds.has(id))); return next.size === previous.size ? previous : next; @@ -96,6 +98,10 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource next.delete(run.id); return next; }); + setHydratedStatusByRun((previous) => { + if (previous.get(run.id) === run.status) return previous; + return new Map(previous).set(run.id, run.status); + }); } catch (error) { if (cancelled) return; failed = true; @@ -145,8 +151,12 @@ export function useNativeRunTranscripts(runs: readonly NativeRunTranscriptSource return transcripts; }, [eventsByRun, nativeRuns]); + const terminalHydratedRunIds = useMemo(() => new Set(nativeRuns + .filter((run) => !isLive(run.status) && hydratedStatusByRun.get(run.id) === run.status) + .map((run) => run.id)), [nativeRuns, hydratedStatusByRun]); + return { - transcriptByRun, errorsByRun, hydratedRunIds, retry, + transcriptByRun, errorsByRun, hydratedRunIds, terminalHydratedRunIds, retry, isInitialHydrating: nativeRuns.some((run) => !hydratedRunIds.has(run.id)), }; }