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)), }; }