mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
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:
1 parent
39e30b51ab
commit
354bb822e0
3 files changed
+258
-11
No files matched your search
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user