feat(ui): coalesce back-to-back settled agent turns into one "Worked" row — coalesceSettledTurns final pass + buildMergedTurnSummary (summed per-run durations, re-derived counts); user/interaction/live-turn boundaries preserved

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:55 -07:00
1 parent 39e30b51ab
commit 354bb822e0
3 files changed
+258 -11

No files matched your search

+16 -5
View File
@@ -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<string, SettledTurnMergeMeta>();
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(
@@ -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<string, SettledTurnMergeMeta>([
["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<TaskChatItem, { kind: "turn" }>;
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<TaskChatItem, { kind: "turn" }>).animateFold).toBe(true);
});
});
@@ -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<string, SettledTurnMergeMeta>,
): TaskChatItem[] {
const out: TaskChatItem[] = [];
// Overlay for merged turns' accumulated parts (metaById stays untouched).
const mergedMeta = new Map<string, SettledTurnMergeMeta>();
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")