diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 14be27056e1..f5892597065 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -4,11 +4,13 @@ import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/tr import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; import { buildTurnSummary, + coalesceSettledTurns, deriveRunStatusLabel, isNestableLiveChild, isTerminalRunStatus, settledRunChildren, transcriptToTaskChatItems, + type SettledTurnMergeMeta, } from "@/components/task-chat/transcript-adapter"; import type { TaskChatInteractionItem, @@ -251,6 +253,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { // (live-line only), and thinking stays in the run log / classic // transcript — "Worked · N tools" expands to exactly the tool rows. const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = []; + // Raw summary inputs per turn id, so back-to-back same-agent runs can + // coalesce into one "Worked" row in the final pass (PAP-362). + const turnMergeMetaById = new Map(); for (const source of runs) { if (!isTerminalRunStatus(source.status)) continue; if (liveRun && source.id === liveRun.id) continue; @@ -270,6 +275,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { }); const children = settledRunChildren(parsed); if (children.length === 0) continue; + const failed = source.status !== "succeeded"; + turnMergeMetaById.set(`${source.id}:turn`, { + agentKey: meta?.agentId ?? "", + agentName: meta?.agentName, + parts: [{ entries, durationMs, failed }], + }); settledTurns.push({ turn: { id: `${source.id}:turn`, @@ -277,10 +288,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { settled: true, animateFold: liveSeenRef.current.has(source.id), items: children, - summary: buildTurnSummary(entries, { - durationMs, - failed: source.status !== "succeeded", - }), + summary: buildTurnSummary(entries, { durationMs, failed }), }, anchorCommentId: lastCommentIdByRun.get(source.id) ?? null, order: meta?.createdAt ? new Date(meta.createdAt).getTime() : 0, @@ -344,7 +352,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, }); } - return out; + // PAP-362: two runs replying back-to-back (same agent, nothing but the + // agent's own bubbles between) fold into ONE "Worked" row below the last + // reply; a user message, interaction, or the live turn keeps them apart. + return coalesceSettledTurns(out, turnMergeMetaById); }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]); const renderInteraction = useCallback( diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 6733e37a902..916a745d3ea 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; import type { TranscriptEntry } from "@/adapters"; import { + buildMergedTurnSummary, buildTurnSummary, + coalesceSettledTurns, deriveRunStatusLabel, flattenSelfTalk, isNestableLiveChild, settledRunChildren, toolDisplayName, transcriptToTaskChatItems, + type SettledTurnMergeMeta, } from "./transcript-adapter"; import type { TaskChatItem } from "./task-chat-model"; @@ -378,3 +381,134 @@ describe("settledRunChildren (PAP-361)", () => { expect(children.filter((c) => c.kind === "tool")).toHaveLength(summary.toolCount); }); }); + +describe("buildMergedTurnSummary (PAP-362)", () => { + const runA: TranscriptEntry[] = [ + { kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry, + { kind: "tool_call", ts: "2026-07-31T12:00:05.000Z", name: "Edit", toolUseId: "a-2" } as TranscriptEntry, + { kind: "diff", ts: "2026-07-31T12:00:06.000Z", changeType: "add", text: "+x" } as TranscriptEntry, + { kind: "result", ts: "2026-07-31T12:00:10.000Z", inputTokens: 500, outputTokens: 300 } as TranscriptEntry, + ]; + const runB: TranscriptEntry[] = [ + { kind: "tool_call", ts: "2026-07-31T12:30:00.000Z", name: "Bash", toolUseId: "b-1" } as TranscriptEntry, + { kind: "result", ts: "2026-07-31T12:30:05.000Z", inputTokens: 300, outputTokens: 100 } as TranscriptEntry, + ]; + + it("sums tool count, diffs, tokens, and duration across runs", () => { + const summary = buildMergedTurnSummary([ + { entries: runA, durationMs: 30_000 }, + { entries: runB, durationMs: 15_000 }, + ]); + expect(summary.toolCount).toBe(3); + expect(summary.added).toBe(1); + expect(summary.tokensLabel).toBe("1.2k tokens"); + expect(summary.durationLabel).toBe("45s"); + expect(summary.failed).toBeUndefined(); + }); + + it("sums per-run ts spans — never the idle gap between runs", () => { + // runA spans 10s, runB spans 5s, the gap between them is ~30min. + const summary = buildMergedTurnSummary([{ entries: runA }, { entries: runB }]); + expect(summary.durationLabel).toBe("15s"); + }); + + it("marks the merged turn failed when any run failed", () => { + const summary = buildMergedTurnSummary([ + { entries: runA, durationMs: 1000 }, + { entries: runB, durationMs: 1000, failed: true }, + ]); + expect(summary.failed).toBe(true); + }); +}); + +describe("coalesceSettledTurns (PAP-362)", () => { + const runA: TranscriptEntry[] = [ + { kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry, + { kind: "tool_call", ts: TS, name: "Edit", toolUseId: "a-2" } as TranscriptEntry, + ]; + const runB: TranscriptEntry[] = [ + { kind: "tool_call", ts: TS, name: "Bash", toolUseId: "b-1" } as TranscriptEntry, + ]; + + function agentBubble(id: string): TaskChatItem { + return { id, kind: "message", author: "agent", authorName: "CEO", text: "Done." }; + } + function turn(id: string, entries: TranscriptEntry[], durationMs: number): TaskChatItem { + return { + id, + kind: "turn", + settled: true, + items: entries.map((e, i) => ({ + id: `${id}:tool:${i}`, + kind: "tool" as const, + name: (e as { name: string }).name, + status: "completed" as const, + })), + summary: buildTurnSummary(entries, { durationMs }), + }; + } + const metaById = new Map([ + ["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }], + ["run-b:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runB, durationMs: 15_000 }] }], + ]); + + it("merges two back-to-back runs into ONE Worked row summing tools and duration", () => { + // Two terminal runs, same agent, consecutive comments, no user message + // between → the merged turn sits below the LAST bubble. + const items: TaskChatItem[] = [ + agentBubble("msg-a"), + turn("run-a:turn", runA, 30_000), + agentBubble("msg-b"), + turn("run-b:turn", runB, 15_000), + ]; + const out = coalesceSettledTurns(items, metaById); + expect(out.map((i) => i.kind)).toEqual(["message", "message", "turn"]); + const merged = out[2] as Extract; + expect(merged.id).toBe("run-a:turn"); // stable: keeps the first run's id + expect(merged.summary.toolCount).toBe(3); + expect(merged.summary.durationLabel).toBe("45s"); + expect(merged.items.map((c) => c.id)).toEqual([ + "run-a:turn:tool:0", + "run-a:turn:tool:1", + "run-b:turn:tool:0", + ]); + }); + + it("keeps two rows when a user message sits between the runs", () => { + const items: TaskChatItem[] = [ + agentBubble("msg-a"), + turn("run-a:turn", runA, 30_000), + { id: "msg-u", kind: "message", author: "human", text: "also do X" }, + agentBubble("msg-b"), + turn("run-b:turn", runB, 15_000), + ]; + const out = coalesceSettledTurns(items, metaById); + expect(out.filter((i) => i.kind === "turn")).toHaveLength(2); + expect(out.map((i) => i.id)).toEqual(["msg-a", "run-a:turn", "msg-u", "msg-b", "run-b:turn"]); + }); + + it("does not merge across an interaction or into another agent's turn", () => { + const withInteraction: TaskChatItem[] = [ + turn("run-a:turn", runA, 30_000), + { id: "int-1", kind: "interaction", interaction: {} } as TaskChatItem, + turn("run-b:turn", runB, 15_000), + ]; + expect(coalesceSettledTurns(withInteraction, metaById).filter((i) => i.kind === "turn")).toHaveLength(2); + + const otherAgent = new Map(metaById); + otherAgent.set("run-b:turn", { agentKey: "agent-2", agentName: "QA", parts: [{ entries: runB }] }); + const sameShape: TaskChatItem[] = [turn("run-a:turn", runA, 30_000), turn("run-b:turn", runB, 15_000)]; + expect(coalesceSettledTurns(sameShape, otherAgent).filter((i) => i.kind === "turn")).toHaveLength(2); + }); + + it("never merges the live (unsettled) turn and preserves animateFold", () => { + const live: TaskChatItem = { id: "run-b:turn", kind: "turn", settled: false, items: [], summary: buildTurnSummary([]) }; + const out = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), live], metaById); + expect(out).toHaveLength(2); + + const seenLive = { ...turn("run-b:turn", runB, 15_000), animateFold: true } as TaskChatItem; + const merged = coalesceSettledTurns([turn("run-a:turn", runA, 30_000), seenLive], metaById); + expect(merged).toHaveLength(1); + expect((merged[0] as Extract).animateFold).toBe(true); + }); +}); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 3ce6393dd98..cedd2788124 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -358,6 +358,15 @@ function formatTokensLabel(tokens: number): string | undefined { return `${label} tokens`; } +/** First→last ts span of a transcript, or undefined when unknowable. */ +function transcriptSpanMs(entries: readonly TranscriptEntry[]): number | undefined { + if (entries.length < 2) return undefined; + const first = Date.parse(entries[0].ts); + const last = Date.parse(entries[entries.length - 1].ts); + if (!Number.isFinite(first) || !Number.isFinite(last)) return undefined; + return Math.max(0, last - first); +} + /** * Aggregate a turn's transcript into the folded one-line summary * ("✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens"). Duration prefers the @@ -383,12 +392,7 @@ export function buildTurnSummary( tokens += (entry.inputTokens || 0) + (entry.outputTokens || 0); } } - let durationMs = opts.durationMs; - if (durationMs == null && entries.length >= 2) { - const first = Date.parse(entries[0].ts); - const last = Date.parse(entries[entries.length - 1].ts); - if (Number.isFinite(first) && Number.isFinite(last)) durationMs = last - first; - } + const durationMs = opts.durationMs ?? transcriptSpanMs(entries); return { durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined, toolCount: toolIds.size, @@ -399,6 +403,104 @@ export function buildTurnSummary( }; } +/** One settled run's raw summary inputs, kept so back-to-back runs can coalesce (PAP-362). */ +export interface TurnSummaryPart { + entries: readonly TranscriptEntry[]; + durationMs?: number; + failed?: boolean; +} + +/** + * Summary for a turn coalesced from several back-to-back runs (PAP-362). + * Tool/diff/token counts re-derive from the concatenated transcripts; duration + * is the SUM of the per-run durations (each with its own ts-span fallback) — + * never the wall span across the idle gap between runs. + */ +export function buildMergedTurnSummary( + parts: readonly TurnSummaryPart[], +): TaskChatTurnItem["summary"] { + const all: TranscriptEntry[] = []; + let durationMs: number | undefined; + let failed = false; + for (const part of parts) { + all.push(...part.entries); + if (part.failed) failed = true; + const d = part.durationMs ?? transcriptSpanMs(part.entries); + if (d != null && Number.isFinite(d)) durationMs = (durationMs ?? 0) + Math.max(0, d); + } + // durationMs: 0 suppresses the concatenated-span fallback (which would count + // the gap between runs); the summed label is applied over it below. + const counts = buildTurnSummary(all, { durationMs: 0, failed }); + return { + ...counts, + durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined, + }; +} + +/** Per-turn identity + raw summary inputs for coalescing (keyed by turn item id). */ +export interface SettledTurnMergeMeta { + /** Stable agent identity (agentId); empty/unknown turns never merge. */ + agentKey: string; + /** Display name, used to keep another agent's bubble from bridging a merge. */ + agentName?: string; + parts: TurnSummaryPart[]; +} + +/** + * Final assembly pass (PAP-362): merge runs of settled turns from the SAME + * agent that arrive back-to-back — two runs replying consecutively — into one + * "Worked" row. The agent's own reply bubbles do not break the run (the merged + * row lands below the last bubble, in the last run's slot); a human/system + * message, an interaction, a marker, or the live in-flight turn does. Child + * items concatenate in order, the summary re-derives via buildMergedTurnSummary, + * the merged turn keeps the FIRST run's id (stable across re-renders), and + * animateFold survives if any merged run was seen live. + */ +export function coalesceSettledTurns( + items: readonly TaskChatItem[], + metaById: ReadonlyMap, +): TaskChatItem[] { + const out: TaskChatItem[] = []; + // Overlay for merged turns' accumulated parts (metaById stays untouched). + const mergedMeta = new Map(); + const metaFor = (id: string) => mergedMeta.get(id) ?? metaById.get(id); + let heldIdx = -1; // index in `out` of the last mergeable settled turn + for (const item of items) { + if (item.kind === "turn" && item.settled) { + const meta = metaFor(item.id); + const held = heldIdx >= 0 ? (out[heldIdx] as TaskChatTurnItem) : null; + const heldMeta = held ? metaFor(held.id) : undefined; + if (held && meta && heldMeta && meta.agentKey && meta.agentKey === heldMeta.agentKey) { + out.splice(heldIdx, 1); + const parts = [...heldMeta.parts, ...meta.parts]; + out.push({ + ...held, + items: [...held.items, ...item.items], + animateFold: held.animateFold || item.animateFold || undefined, + summary: buildMergedTurnSummary(parts), + }); + mergedMeta.set(held.id, { ...heldMeta, parts }); + } else { + out.push(item); + } + heldIdx = meta?.agentKey ? out.length - 1 : -1; + continue; + } + out.push(item); + if (item.kind === "message" && item.author === "agent") { + // The same agent's reply bubble sits between its runs — keep merging + // across it. A different agent's bubble ends the run of turns. + const heldMeta = heldIdx >= 0 ? metaFor((out[heldIdx] as TaskChatTurnItem).id) : undefined; + if (heldMeta?.agentName && item.authorName && item.authorName !== heldMeta.agentName) { + heldIdx = -1; + } + } else { + heldIdx = -1; + } + } + return out; +} + /** * Human-readable label for the live status pill from the tail of a transcript. * A tail tool_call yields the taxonomy verb ("Searching", "Running a command")