mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
A run that posted a question card is waiting on that card. Once the card was answered, the turn became an accepted response wake and the wait summary replaced the agent's reply. Any non-suppressed card from the run now keeps it out of the response-wake path. Wait stubs in the backward scan are skipped, so a stub echoed after the authored reply no longer hides it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2478 lines
87 KiB
TypeScript
2478 lines
87 KiB
TypeScript
/**
|
||
* Live adapter: map a run's streaming TranscriptEntry[] (from
|
||
* useLiveRunTranscripts — the same source the current thread consumes) into the
|
||
* redesign's TaskChatItem[]. This is what lets a real task stream
|
||
* thinking → tool → diff → responding while a run is in flight, rather than
|
||
* only showing a response after the turn settles.
|
||
*/
|
||
import { isFatalProviderError, isStatusNotice, isStatusNoticePayload, statusNoticeCategory } from "./status-notice-presentation";
|
||
import type { TranscriptEntry } from "@/adapters";
|
||
import { turnChangesFromEditRows } from "./turn-file-changes";
|
||
import type {
|
||
TaskChatDiff,
|
||
TaskChatActivityPhaseItem,
|
||
TaskChatItem,
|
||
TaskChatToolItem,
|
||
TaskChatTurnChildItem,
|
||
TaskChatTurnItem,
|
||
TaskChatProviderActivityItem,
|
||
TaskChatProtocolDetail,
|
||
TaskChatProtocolItem,
|
||
TaskChatProtocolStep,
|
||
TaskChatRunResultItem,
|
||
TaskChatMessageItem,
|
||
TaskChatPlanDocumentItem,
|
||
TaskChatRuntimeRequestItem,
|
||
TaskChatSubagent,
|
||
TaskChatSubagentGroupItem,
|
||
} from "./task-chat-model";
|
||
import {
|
||
humanizeToolName,
|
||
isGenericToolName,
|
||
toolActivityPresentation,
|
||
toolTaxonomy,
|
||
} from "./tool-taxonomy";
|
||
|
||
const TERMINAL_STATUSES = new Set([
|
||
"failed",
|
||
"timed_out",
|
||
"cancelled",
|
||
"interrupted",
|
||
"succeeded",
|
||
]);
|
||
|
||
export function isTerminalRunStatus(
|
||
status: string | undefined | null,
|
||
): boolean {
|
||
return status != null && TERMINAL_STATUSES.has(status);
|
||
}
|
||
|
||
export interface TranscriptTimeSegment {
|
||
startMs: number;
|
||
endMs: number;
|
||
entries: TranscriptEntry[];
|
||
}
|
||
|
||
/**
|
||
* Split one provider run at durable in-turn input timestamps. Each boundary
|
||
* belongs to the segment after it, so a steering acknowledgement can stay in
|
||
* the thread between the work produced before and after the steer. Live and
|
||
* settled renderers share this projector to avoid re-sorting at handoff.
|
||
*/
|
||
export function splitTranscriptAtAnchors(
|
||
entries: readonly TranscriptEntry[],
|
||
startMs: number,
|
||
rawAnchors: readonly number[],
|
||
): TranscriptTimeSegment[] {
|
||
const anchors = [...new Set(rawAnchors.filter(Number.isFinite))].sort(
|
||
(a, b) => a - b,
|
||
);
|
||
if (anchors.length === 0) {
|
||
return [
|
||
{
|
||
startMs,
|
||
endMs: Number.POSITIVE_INFINITY,
|
||
entries: [...entries],
|
||
},
|
||
];
|
||
}
|
||
return [startMs, ...anchors].map((segmentStartMs, index) => {
|
||
const segmentEndMs = anchors[index] ?? Number.POSITIVE_INFINITY;
|
||
return {
|
||
startMs: segmentStartMs,
|
||
endMs: segmentEndMs,
|
||
entries: entries.filter((entry) => {
|
||
const parsed = Date.parse(entry.ts);
|
||
const entryMs = Number.isFinite(parsed) ? parsed : 0;
|
||
const afterStart = index === 0 || entryMs >= segmentStartMs;
|
||
return afterStart && entryMs < segmentEndMs;
|
||
}),
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The live parent row's nesting rule (PAP-354, narrowed by PAP-361): only tool
|
||
* calls, provider-supplied reasoning summaries, and usage readouts nest inside
|
||
* the expandable live turn. The classic parent-row surface may flatten a live
|
||
* interstitial into its status line; the Paperclip Runner task surface instead
|
||
* projects commentary as durable chronological phase boundaries. The run's
|
||
* final reply is resolved separately into the turn's durable response slot or
|
||
* its posted comment bubble. Markers, statuses and interaction cards stay in
|
||
* the thread outside.
|
||
*/
|
||
export function isNestableLiveChild(
|
||
item: TaskChatItem,
|
||
): item is TaskChatTurnChildItem {
|
||
return (
|
||
item.kind === "tool" ||
|
||
item.kind === "thinking" ||
|
||
item.kind === "usage" ||
|
||
item.kind === "activity_phase" ||
|
||
item.kind === "plan_document" ||
|
||
item.kind === "protocol"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Flatten markdown-ish interstitial text to one plain line for the live parent
|
||
* row. Stream-safe: markers are stripped without requiring pairs.
|
||
*/
|
||
export function flattenSelfTalk(text: string): string {
|
||
return text
|
||
.replace(/```[a-z]*\n?/gi, " ")
|
||
.replace(/`/g, "")
|
||
.replace(/^#{1,6}\s+/gm, "")
|
||
.replace(/^\s*[-*+]\s+/gm, "")
|
||
.replace(/^\s*>\s?/gm, "")
|
||
.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1")
|
||
.replace(/\*+/g, "")
|
||
.replace(/__/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function diffKind(changeType: string): "add" | "remove" | "context" | "hunk" {
|
||
if (changeType === "add") return "add";
|
||
if (changeType === "remove") return "remove";
|
||
if (changeType === "hunk") return "hunk";
|
||
return "context";
|
||
}
|
||
|
||
/** Param keys probed (in order) for a tool call's one-line mono "target". */
|
||
const TARGET_KEYS = [
|
||
"file_path",
|
||
"path",
|
||
"notebook_path",
|
||
"command",
|
||
"pattern",
|
||
"query",
|
||
"url",
|
||
"prompt",
|
||
"description",
|
||
"skill",
|
||
"subject",
|
||
] as const;
|
||
|
||
const TARGET_MAX = 96;
|
||
|
||
function clip(text: string, max: number): string {
|
||
const oneLine = text.replace(/\s+/g, " ").trim();
|
||
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
||
}
|
||
|
||
/**
|
||
* Summarize a tool call's input into the v7 toolrow target: the most
|
||
* identifying parameter when the input is a keyed object, else a clipped
|
||
* rendering of the raw input. Returns undefined when there is nothing useful.
|
||
*/
|
||
export function summarizeToolInput(input: unknown): string | undefined {
|
||
if (input == null) return undefined;
|
||
if (typeof input === "string")
|
||
return input.trim() ? clip(input, TARGET_MAX) : undefined;
|
||
if (typeof input !== "object") return clip(String(input), TARGET_MAX);
|
||
const record = input as Record<string, unknown>;
|
||
for (const key of TARGET_KEYS) {
|
||
const value = record[key];
|
||
if (typeof value === "string" && value.trim())
|
||
return clip(value, TARGET_MAX);
|
||
}
|
||
// The acpx log parser synthesizes { text, status } onto tool inputs from the
|
||
// event's summary line — presentation noise, not call parameters.
|
||
const entries = Object.entries(record).filter(
|
||
([k, v]) =>
|
||
(typeof v === "string" ||
|
||
typeof v === "number" ||
|
||
typeof v === "boolean") &&
|
||
k !== "text" &&
|
||
k !== "status",
|
||
);
|
||
if (entries.length === 0) return undefined;
|
||
return clip(
|
||
entries.map(([k, v]) => `${k}: ${String(v)}`).join(", "),
|
||
TARGET_MAX,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Display name for a tool call. Live acpx `tool_call` events carry real names
|
||
* ("Read", "Bash", mcp__server__tool); legacy stored logs may not — those fall
|
||
* back to a generic "Tool" row. MCP names collapse to their tool segment.
|
||
*/
|
||
export function toolDisplayName(name: string | undefined | null): string {
|
||
const raw = (name ?? "").trim();
|
||
return isGenericToolName(raw) ? "Unnamed tool" : humanizeToolName(raw);
|
||
}
|
||
|
||
/** "Thought for Ns" once a coalesced thinking group spans ≥1s. */
|
||
function thoughtDurationLabel(
|
||
startTs: string | undefined,
|
||
endTs: string,
|
||
): string | undefined {
|
||
if (!startTs) return undefined;
|
||
const start = Date.parse(startTs);
|
||
const end = Date.parse(endTs);
|
||
if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
|
||
const secs = Math.round((end - start) / 1000);
|
||
if (secs < 1) return undefined;
|
||
return secs < 60
|
||
? `Thought for ${secs}s`
|
||
: `Thought for ${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||
}
|
||
|
||
/** Append token deltas onto the open logical line while preserving real newlines. */
|
||
function appendThinkingText(
|
||
lines: string[],
|
||
text: string,
|
||
delta: boolean | undefined,
|
||
) {
|
||
const fragments = text.split("\n");
|
||
if (!delta || lines.length === 0) {
|
||
lines.push(...fragments);
|
||
return;
|
||
}
|
||
lines[lines.length - 1] =
|
||
`${lines[lines.length - 1] ?? ""}${fragments[0] ?? ""}`;
|
||
lines.push(...fragments.slice(1));
|
||
}
|
||
|
||
const DETAIL_MAX = 600;
|
||
const PROTOCOL_OUTPUT_MAX = 8 * 1024;
|
||
|
||
function objectRecord(value: unknown): Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
}
|
||
|
||
function stringValue(value: unknown): string | undefined {
|
||
return typeof value === "string" && value.trim().length > 0
|
||
? value.trim()
|
||
: undefined;
|
||
}
|
||
|
||
function scalarValue(value: unknown): string | undefined {
|
||
if (typeof value === "string" && value.trim()) return value.trim();
|
||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||
return undefined;
|
||
}
|
||
|
||
function titleCaseKey(value: string): string {
|
||
return value
|
||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||
.replaceAll("_", " ")
|
||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||
}
|
||
|
||
function safeHttpHref(value: unknown): string | null {
|
||
const href = stringValue(value);
|
||
if (!href) return null;
|
||
try {
|
||
const url = new URL(href);
|
||
return url.protocol === "http:" || url.protocol === "https:" ? href : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const PROVIDER_DETAIL_KEYS: Record<
|
||
TaskChatProviderActivityItem["family"],
|
||
readonly string[]
|
||
> = {
|
||
plan: ["revision", "syncStatus", "documentRevision", "complete"],
|
||
tool_execution: [
|
||
"transport",
|
||
"operation",
|
||
"name",
|
||
"target",
|
||
"namespace",
|
||
"readOnly",
|
||
"status",
|
||
"progress",
|
||
"durationMs",
|
||
"exitCode",
|
||
"outputBytes",
|
||
],
|
||
research: ["action", "status", "query", "pattern", "url"],
|
||
delegation: ["action", "status"],
|
||
model_identity: [
|
||
"provider",
|
||
"requestedModel",
|
||
"fromModel",
|
||
"effectiveModel",
|
||
"reason",
|
||
"status",
|
||
"buffering",
|
||
"summary",
|
||
],
|
||
context: ["reason", "preTokens", "postTokens", "sameSession"],
|
||
artifact: [
|
||
"status",
|
||
"reference",
|
||
"mediaType",
|
||
"title",
|
||
"registered",
|
||
"transparentBackground",
|
||
"failure",
|
||
],
|
||
review: ["state", "scope"],
|
||
hook: ["event", "scope", "status", "blocking", "durationMs", "summary"],
|
||
memory: ["label", "available", "reference"],
|
||
safety: ["status", "decision", "targetExecutionId", "summary"],
|
||
terminal: ["origin", "inputClass", "byteCount"],
|
||
wait: ["reason", "status", "plannedDurationMs", "elapsedDurationMs"],
|
||
verification: ["status", "detail", "reasonCode"],
|
||
provider_notice: [
|
||
"severity",
|
||
"category",
|
||
"scope",
|
||
"recoverable",
|
||
"userActionable",
|
||
"summary",
|
||
],
|
||
};
|
||
|
||
function providerActivityKey(
|
||
entry: Extract<TranscriptEntry, { kind: "provider_activity" }>,
|
||
): string {
|
||
const idKeys = [
|
||
"planId",
|
||
"executionId",
|
||
"researchId",
|
||
"delegationId",
|
||
"routeId",
|
||
"verificationId",
|
||
"compactionId",
|
||
"artifactId",
|
||
"reviewId",
|
||
"hookId",
|
||
"citationId",
|
||
"waitId",
|
||
"noticeId",
|
||
];
|
||
for (const key of idKeys) {
|
||
const value = stringValue(entry.payload[key]);
|
||
if (value) return `${entry.family}:${value}`;
|
||
}
|
||
return `${entry.family}:${entry.eventType}:${entry.ts}`;
|
||
}
|
||
|
||
function providerActivityItem(
|
||
entry: Extract<TranscriptEntry, { kind: "provider_activity" }>,
|
||
runId: string,
|
||
index: number,
|
||
): TaskChatProviderActivityItem {
|
||
const details: TaskChatProtocolDetail[] = [];
|
||
for (const key of PROVIDER_DETAIL_KEYS[entry.family]) {
|
||
const value = scalarValue(entry.payload[key]);
|
||
if (!value) continue;
|
||
if (
|
||
entry.family === "tool_execution" &&
|
||
key === "name" &&
|
||
isGenericToolName(value)
|
||
)
|
||
continue;
|
||
if (
|
||
entry.family === "tool_execution" &&
|
||
key === "progress" &&
|
||
!meaningfulProviderToolSummary(value)
|
||
)
|
||
continue;
|
||
details.push({
|
||
label: titleCaseKey(key),
|
||
value: clip(
|
||
value,
|
||
(entry.family === "provider_notice" && (key === "summary" || key === "message")) ||
|
||
(entry.family === "verification" && key === "detail")
|
||
? 4000
|
||
: key === "message" || key === "summary" || key === "reason" ? 320 : 160,
|
||
),
|
||
mono: /(?:id|model|target|reference|url|code|bytes)$/i.test(key),
|
||
});
|
||
}
|
||
|
||
if (entry.family === "provider_notice" && Array.isArray(entry.payload.details)) {
|
||
for (const raw of entry.payload.details.slice(0, 64)) {
|
||
const detail = objectRecord(raw);
|
||
if (typeof detail.name === "string" && typeof detail.value === "string") {
|
||
details.push({ label: clip(detail.name, 160), value: clip(detail.value, 4000), mono: false });
|
||
}
|
||
}
|
||
}
|
||
|
||
const steps =
|
||
entry.family === "plan" && Array.isArray(entry.payload.steps)
|
||
? entry.payload.steps
|
||
.map(objectRecord)
|
||
.slice(0, 256)
|
||
.map((step, stepIndex) => {
|
||
const rawStatus = stringValue(step.status);
|
||
const status: TaskChatProtocolStep["status"] =
|
||
rawStatus === "in_progress" ||
|
||
rawStatus === "completed" ||
|
||
rawStatus === "blocked" ||
|
||
rawStatus === "failed"
|
||
? rawStatus
|
||
: "pending";
|
||
return {
|
||
id: stringValue(step.stepId) ?? `${runId}:plan-step:${stepIndex}`,
|
||
label: stringValue(step.body) ?? "Plan step",
|
||
status,
|
||
};
|
||
})
|
||
: [];
|
||
|
||
const links =
|
||
entry.family === "research" && Array.isArray(entry.payload.sources)
|
||
? entry.payload.sources
|
||
.map(objectRecord)
|
||
.slice(0, 64)
|
||
.flatMap((source) => {
|
||
const href = safeHttpHref(source.url);
|
||
return href
|
||
? [
|
||
{
|
||
label: stringValue(source.title) ?? href,
|
||
href,
|
||
description: stringValue(source.snippet),
|
||
},
|
||
]
|
||
: [];
|
||
})
|
||
: [];
|
||
|
||
const children =
|
||
entry.family === "delegation" && Array.isArray(entry.payload.children)
|
||
? entry.payload.children
|
||
.map(objectRecord)
|
||
.slice(0, 64)
|
||
.map((child, childIndex) => ({
|
||
id:
|
||
stringValue(child.childId) ?? `${runId}:delegation:${childIndex}`,
|
||
title: stringValue(child.role) ?? "Subagent",
|
||
status: stringValue(child.status) ?? "unknown",
|
||
metadata:
|
||
[stringValue(child.model), stringValue(child.activitySummary)]
|
||
.filter(Boolean)
|
||
.join(" · ") || undefined,
|
||
summary: stringValue(child.summary),
|
||
role: stringValue(child.role),
|
||
model: stringValue(child.model),
|
||
progress: stringValue(child.activitySummary),
|
||
prompt: stringValue(child.prompt),
|
||
result: stringValue(child.result),
|
||
}))
|
||
: [];
|
||
|
||
const rawOutput =
|
||
entry.family === "tool_execution"
|
||
? stringValue(entry.payload.output)
|
||
: undefined;
|
||
const outputTruncated =
|
||
entry.payload.outputTruncated === true ||
|
||
Boolean(rawOutput && rawOutput.length > PROTOCOL_OUTPUT_MAX);
|
||
const summary =
|
||
entry.summary && entry.summary !== entry.eventType
|
||
? entry.summary
|
||
: (stringValue(entry.payload.explanation) ??
|
||
stringValue(entry.payload.progress) ??
|
||
stringValue(entry.payload.message) ??
|
||
stringValue(entry.payload.summary));
|
||
|
||
return {
|
||
id: `${runId}:provider:${providerActivityKey(entry)}:${index}`,
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: entry.family,
|
||
eventType: entry.eventType,
|
||
status: entry.status,
|
||
title: entry.title,
|
||
summary,
|
||
details,
|
||
steps,
|
||
links,
|
||
children,
|
||
output: rawOutput ? rawOutput.slice(0, PROTOCOL_OUTPUT_MAX) : undefined,
|
||
outputTruncated,
|
||
transcriptIndex: index,
|
||
startedAt: entry.ts,
|
||
...(entry.family === "tool_execution"
|
||
? {
|
||
activityId: stringValue(entry.payload.executionId),
|
||
parentToolUseId: stringValue(entry.payload.parentItemId),
|
||
}
|
||
: {}),
|
||
...(entry.family === "delegation"
|
||
? (() => {
|
||
const atMs = Date.parse(entry.ts);
|
||
return {
|
||
activityId: stringValue(entry.payload.delegationId),
|
||
...(Number.isFinite(atMs) ? { startedAtMs: atMs, updatedAtMs: atMs } : {}),
|
||
};
|
||
})()
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
function providerItemDetail(
|
||
item: TaskChatProviderActivityItem,
|
||
label: string,
|
||
): string | undefined {
|
||
return item.details.find((detail) => detail.label === label)?.value;
|
||
}
|
||
|
||
function meaningfulProviderToolSummary(value: string | undefined): boolean {
|
||
return Boolean(
|
||
value && !isGenericToolName(value) && !/^tool(?:\s+|_)call\b/i.test(value),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Provider lifecycle records are deltas. A terminal update owns status and
|
||
* output, while earlier identity/progress fields survive when later frames
|
||
* omit them or carry ACPX's literal `tool call` placeholder.
|
||
*/
|
||
function mergeProviderActivityItem(
|
||
previous: TaskChatProviderActivityItem,
|
||
incoming: TaskChatProviderActivityItem,
|
||
): TaskChatProviderActivityItem {
|
||
if (
|
||
previous.family !== "tool_execution" ||
|
||
incoming.family !== "tool_execution"
|
||
) {
|
||
return {
|
||
...incoming,
|
||
id: previous.id,
|
||
startedAt: previous.startedAt ?? incoming.startedAt,
|
||
...(previous.startedAtMs != null ? { startedAtMs: previous.startedAtMs } : {}),
|
||
};
|
||
}
|
||
const details = new Map(
|
||
previous.details.map((detail) => [detail.label, detail]),
|
||
);
|
||
const incomingName = providerItemDetail(incoming, "Name");
|
||
const genericIncomingIdentity = isGenericToolName(incomingName);
|
||
const identityLabels = new Set([
|
||
"Name",
|
||
"Transport",
|
||
"Namespace",
|
||
"Operation",
|
||
"Target",
|
||
]);
|
||
for (const detail of incoming.details) {
|
||
if (
|
||
identityLabels.has(detail.label) &&
|
||
genericIncomingIdentity &&
|
||
details.has(detail.label)
|
||
) {
|
||
continue;
|
||
}
|
||
if (
|
||
detail.label === "Progress" &&
|
||
!meaningfulProviderToolSummary(detail.value) &&
|
||
details.has("Progress")
|
||
) {
|
||
continue;
|
||
}
|
||
details.set(detail.label, detail);
|
||
}
|
||
return {
|
||
...incoming,
|
||
id: previous.id,
|
||
startedAt: previous.startedAt ?? incoming.startedAt,
|
||
summary: meaningfulProviderToolSummary(incoming.summary)
|
||
? incoming.summary
|
||
: previous.summary,
|
||
details: [...details.values()],
|
||
parentToolUseId: incoming.parentToolUseId ?? previous.parentToolUseId,
|
||
};
|
||
}
|
||
|
||
/** Result content → the expandable mono detail block (clipped, trimmed). */
|
||
function formatToolResultDetail(content: unknown): string | undefined {
|
||
if (content == null) return undefined;
|
||
const text = typeof content === "string" ? content : String(content);
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return undefined;
|
||
return trimmed.length > DETAIL_MAX
|
||
? `${trimmed.slice(0, DETAIL_MAX)}\n…`
|
||
: trimmed;
|
||
}
|
||
|
||
interface TranscriptAdapterOptions {
|
||
runId: string;
|
||
agentName?: string;
|
||
/** True while the run is still in flight (drives streaming cursors). */
|
||
running: boolean;
|
||
}
|
||
|
||
/** Request authority follows the whole run, even when steering splits its display. */
|
||
export function runtimeRequestSegmentContext(
|
||
entries: readonly TranscriptEntry[],
|
||
options: TranscriptAdapterOptions,
|
||
) {
|
||
const requests = entries.filter(entry => entry.kind === "runtime_request");
|
||
const origins = new Map<string, TranscriptEntry>();
|
||
for (const entry of requests) {
|
||
if (!origins.has(entry.requestId)) origins.set(entry.requestId, entry);
|
||
}
|
||
const context = new Map<string, { item: TaskChatRuntimeRequestItem; origin: TranscriptEntry }>();
|
||
for (const item of transcriptToTaskChatItems(requests, options)) {
|
||
if (item.kind === "protocol" && item.surface === "runtime_request") {
|
||
context.set(item.id, { item, origin: origins.get(item.requestId)! });
|
||
}
|
||
}
|
||
return context;
|
||
}
|
||
|
||
/** Carry live requests to the tail; retain closed cards at their original position. */
|
||
export function reconcileSegmentRuntimeRequests(
|
||
items: readonly TaskChatItem[],
|
||
entries: readonly TranscriptEntry[],
|
||
context: ReturnType<typeof runtimeRequestSegmentContext>,
|
||
carryPending = false,
|
||
): TaskChatItem[] {
|
||
const owned = new Set(entries);
|
||
const retained = new Set<string>();
|
||
const result = items.flatMap<TaskChatItem>(item => {
|
||
if (item.kind !== "protocol" || item.surface !== "runtime_request") return [item];
|
||
const state = context.get(item.id);
|
||
if (!state || (state.item.status === "pending" ? !carryPending : !owned.has(state.origin))) return [];
|
||
retained.add(item.id);
|
||
return [state.item];
|
||
});
|
||
const carried: TaskChatItem[] = [];
|
||
if (carryPending) {
|
||
for (const { item } of context.values()) {
|
||
if (item.status === "pending" && !retained.has(item.id)) carried.push(item);
|
||
}
|
||
}
|
||
// A carried request predates this section. Keep newer requests last so the
|
||
// composer selects the latest input instead of reviving an older one.
|
||
return [...carried, ...result];
|
||
}
|
||
|
||
/**
|
||
* Reduce a transcript into ordered items, coalescing consecutive thinking and
|
||
* assistant deltas and threading tool_result/diff onto their tool_call. Mirrors
|
||
* buildAssistantPartsFromTranscript's grouping, emitting our presentation model.
|
||
*/
|
||
export function transcriptToTaskChatItems(
|
||
entries: readonly TranscriptEntry[],
|
||
{ runId, agentName, running }: TranscriptAdapterOptions,
|
||
): TaskChatItem[] {
|
||
const items: TaskChatItem[] = [];
|
||
const toolIndexById = new Map<string, number>();
|
||
const protocolIndexByKey = new Map<string, number>();
|
||
const thinkingStartTs = new Map<number, string>();
|
||
let lastToolIndex = -1;
|
||
let thinkingIndex = -1;
|
||
let thinkingChannel: "summary" | "detail" | "unknown" | undefined;
|
||
let thinkingItemId: string | undefined;
|
||
let messageIndex = -1;
|
||
let messageChannel: "progress" | "final" | "unknown" | undefined;
|
||
let messageItemId: string | undefined;
|
||
|
||
const finishThinking = () => {
|
||
if (thinkingIndex >= 0) {
|
||
const item = items[thinkingIndex];
|
||
if (item?.kind === "thinking") item.streaming = false;
|
||
}
|
||
thinkingIndex = -1;
|
||
thinkingChannel = undefined;
|
||
thinkingItemId = undefined;
|
||
};
|
||
|
||
const resetInline = () => {
|
||
finishThinking();
|
||
messageIndex = -1;
|
||
messageChannel = undefined;
|
||
messageItemId = undefined;
|
||
};
|
||
|
||
for (const [i, entry] of entries.entries()) {
|
||
switch (entry.kind) {
|
||
case "thinking": {
|
||
const lifecycleOnly = !entry.text;
|
||
const openThinking =
|
||
thinkingIndex >= 0 ? items[thinkingIndex] : undefined;
|
||
const continuesAnonymousLifecycle =
|
||
entry.itemId === undefined &&
|
||
thinkingItemId === undefined &&
|
||
lifecycleOnly &&
|
||
entry.lifecycle === "completed" &&
|
||
openThinking?.kind === "thinking" &&
|
||
openThinking.lifecycleOnly === true;
|
||
const sameThinkingItem =
|
||
thinkingIndex >= 0 &&
|
||
thinkingChannel === entry.channel &&
|
||
((entry.itemId !== undefined && entry.itemId === thinkingItemId) ||
|
||
(entry.itemId === undefined &&
|
||
thinkingItemId === undefined &&
|
||
entry.delta === true) ||
|
||
continuesAnonymousLifecycle);
|
||
if (sameThinkingItem) {
|
||
const it = items[thinkingIndex];
|
||
if (it.kind === "thinking") {
|
||
if (entry.text) {
|
||
appendThinkingText(it.lines, entry.text, entry.delta);
|
||
it.lifecycleOnly = false;
|
||
}
|
||
it.transcriptIndex = i;
|
||
const startTs = thinkingStartTs.get(thinkingIndex);
|
||
const label = thoughtDurationLabel(startTs, entry.ts);
|
||
if (label) it.summaryLabel = label;
|
||
if (entry.lifecycle === "completed") it.streaming = false;
|
||
}
|
||
} else {
|
||
// A reasoning item is active only until the provider moves on to a
|
||
// different reasoning channel or another transcript surface. ACPX
|
||
// does not always send an explicit completed lifecycle frame, so
|
||
// waiting for one leaves every earlier brain blue and shimmering.
|
||
finishThinking();
|
||
items.push({
|
||
id: `${runId}:think:${i}`,
|
||
kind: "thinking",
|
||
lines: entry.text ? entry.text.split("\n") : [],
|
||
streaming: running && entry.lifecycle !== "completed",
|
||
// Settled history folds its thinking behind the header (v7);
|
||
// the in-flight run streams it expanded.
|
||
collapsed: !running,
|
||
channel: entry.channel,
|
||
lifecycleOnly,
|
||
transcriptIndex: i,
|
||
});
|
||
thinkingIndex = items.length - 1;
|
||
thinkingChannel = entry.channel;
|
||
thinkingItemId = entry.itemId;
|
||
thinkingStartTs.set(thinkingIndex, entry.ts);
|
||
messageIndex = -1;
|
||
messageChannel = undefined;
|
||
messageItemId = undefined;
|
||
}
|
||
if (entry.lifecycle === "completed") {
|
||
finishThinking();
|
||
}
|
||
break;
|
||
}
|
||
case "assistant": {
|
||
if (!entry.text) break;
|
||
finishThinking();
|
||
const channel = entry.channel;
|
||
const sameMessage =
|
||
messageIndex >= 0 &&
|
||
messageChannel === channel &&
|
||
((entry.itemId !== undefined && entry.itemId === messageItemId) ||
|
||
(entry.itemId === undefined &&
|
||
messageItemId === undefined &&
|
||
entry.delta === true));
|
||
if (sameMessage) {
|
||
const it = items[messageIndex];
|
||
if (it.kind === "message") {
|
||
it.text += entry.text;
|
||
it.transcriptIndex = i;
|
||
}
|
||
} else {
|
||
finishThinking();
|
||
const atMs = Date.parse(entry.ts);
|
||
items.push({
|
||
id: `${runId}:msg:${i}`,
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: agentName,
|
||
text: entry.text,
|
||
channel,
|
||
streaming: running,
|
||
// Explicit final-answer items stream durably. Unclassified text
|
||
// remains transient for backwards compatibility and fail-closed
|
||
// handling of a delta whose start metadata was missed.
|
||
interstitial: channel !== "final",
|
||
atMs: Number.isFinite(atMs) ? atMs : undefined,
|
||
transcriptIndex: i,
|
||
});
|
||
messageIndex = items.length - 1;
|
||
messageChannel = channel;
|
||
messageItemId = entry.itemId;
|
||
}
|
||
break;
|
||
}
|
||
case "tool_call": {
|
||
const toolCallId = entry.toolUseId || `tool-${i}`;
|
||
const existingIndex = toolIndexById.get(toolCallId);
|
||
const existing =
|
||
existingIndex != null ? items[existingIndex] : undefined;
|
||
if (existing?.kind === "tool") {
|
||
// tool_call_update for a call already in the list: merge. Updates
|
||
// often omit the title (acpx fills in a literal "tool call"), so a
|
||
// generic name must never displace the initial call's real one. ACP
|
||
// also retitles a call to its invocation once known ("Terminal" →
|
||
// "ls -la"); that is detail for the target slot, not a new identity.
|
||
if (!isGenericToolName(entry.name)) {
|
||
if (isGenericToolName(existing.rawName)) {
|
||
existing.name = toolDisplayName(entry.name);
|
||
existing.rawName = entry.name ?? undefined;
|
||
} else if (!existing.target && entry.name !== existing.rawName) {
|
||
existing.target = clip(entry.name!, TARGET_MAX);
|
||
}
|
||
}
|
||
lastToolIndex = existingIndex!;
|
||
} else {
|
||
items.push({
|
||
id: `${runId}:tool:${toolCallId}`,
|
||
kind: "tool",
|
||
name: toolDisplayName(entry.name),
|
||
rawName: entry.name ?? undefined,
|
||
target: summarizeToolInput(entry.input),
|
||
status: "in_progress",
|
||
...(entry.parentToolUseId ? { parentToolUseId: entry.parentToolUseId } : {}),
|
||
});
|
||
toolIndexById.set(toolCallId, items.length - 1);
|
||
lastToolIndex = items.length - 1;
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "tool_result": {
|
||
const toolCallId = entry.toolUseId || `tool-result-${i}`;
|
||
const idx = toolIndexById.get(toolCallId);
|
||
if (idx != null) {
|
||
const existing = items[idx];
|
||
if (existing.kind === "tool") {
|
||
existing.status = entry.delta
|
||
? "in_progress"
|
||
: entry.isError
|
||
? "failed"
|
||
: "completed";
|
||
if (
|
||
isGenericToolName(existing.rawName) &&
|
||
!isGenericToolName(entry.toolName)
|
||
) {
|
||
existing.name = toolDisplayName(entry.toolName);
|
||
existing.rawName = entry.toolName;
|
||
}
|
||
const detail = formatToolResultDetail(entry.content);
|
||
if (detail) {
|
||
existing.detail =
|
||
entry.delta && existing.detail
|
||
? `${existing.detail}${detail}`
|
||
: detail;
|
||
}
|
||
}
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "diff": {
|
||
// Diff lines addressed to a tool call attach to that row; legacy
|
||
// unaddressed lines attach to the most recent tool.
|
||
const addressedIndex = entry.toolUseId ? toolIndexById.get(entry.toolUseId) : undefined;
|
||
const targetIndex = addressedIndex ?? lastToolIndex;
|
||
const target =
|
||
targetIndex >= 0 && items[targetIndex]?.kind === "tool"
|
||
? (items[targetIndex] as TaskChatToolItem)
|
||
: null;
|
||
if (entry.changeType === "file_header") {
|
||
if (target) {
|
||
if (target.diff?.lines?.length || target.diff?.files?.length) {
|
||
target.diff.lines = target.diff.lines ?? [];
|
||
target.diff.lines.push({ kind: "file", text: entry.text });
|
||
} else {
|
||
target.diff = target.diff ?? { added: 0, removed: 0, lines: [] };
|
||
target.diff.path = entry.text;
|
||
}
|
||
if (entry.additions != null && entry.deletions != null) {
|
||
const diff = target.diff!;
|
||
diff.files = diff.files ?? [];
|
||
diff.files.push({ path: entry.text, additions: entry.additions, deletions: entry.deletions, truncated: false });
|
||
diff.added = diff.files.reduce((sum, file) => sum + file.additions, 0);
|
||
diff.removed = diff.files.reduce((sum, file) => sum + file.deletions, 0);
|
||
}
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
if (entry.changeType === "truncation") {
|
||
if (target?.diff) {
|
||
target.diff.truncated = true;
|
||
const file = target.diff.files?.at(-1);
|
||
if (file) file.truncated = true;
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
const line = {
|
||
kind: diffKind(entry.changeType),
|
||
text: entry.text ?? "",
|
||
};
|
||
if (target) {
|
||
const diff: TaskChatDiff = target.diff ?? {
|
||
added: 0,
|
||
removed: 0,
|
||
lines: [],
|
||
};
|
||
diff.lines = diff.lines ?? [];
|
||
diff.lines.push(line);
|
||
// Producer counts cover the whole patch; count lines only without them.
|
||
if (!diff.files?.length) {
|
||
if (line.kind === "add") diff.added += 1;
|
||
if (line.kind === "remove") diff.removed += 1;
|
||
}
|
||
target.diff = diff;
|
||
} else {
|
||
items.push({
|
||
id: `${runId}:diff:${i}`,
|
||
kind: "tool",
|
||
name: "Edit",
|
||
status: "completed",
|
||
diff: {
|
||
added: line.kind === "add" ? 1 : 0,
|
||
removed: line.kind === "remove" ? 1 : 0,
|
||
lines: [line],
|
||
},
|
||
});
|
||
lastToolIndex = items.length - 1;
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "provider_activity": {
|
||
// Use producer classification, including the legacy generic Codex
|
||
// categories, never message-text matching. The run transcript still
|
||
// retains these diagnostics.
|
||
if (entry.family === "provider_notice" && !isStatusNoticePayload(entry.payload) &&
|
||
(entry.payload.userActionable !== true ||
|
||
["error", "warning", "guardianWarning", "configWarning", "deprecationNotice", "windows_worldWritableWarning"]
|
||
.includes(String(entry.payload.category)))) break;
|
||
// Each retry keeps its own position, so work between two retries in
|
||
// one turn cannot make the later retry look recovered.
|
||
const key = entry.family === "provider_notice" && entry.payload.category === "provider_retry"
|
||
? `provider:${providerActivityKey(entry)}:${i}`
|
||
: `provider:${providerActivityKey(entry)}`;
|
||
const item = providerActivityItem(entry, runId, i);
|
||
const existingIndex = protocolIndexByKey.get(key);
|
||
if (existingIndex == null) {
|
||
items.push(item);
|
||
protocolIndexByKey.set(key, items.length - 1);
|
||
} else {
|
||
const existing = items[existingIndex];
|
||
items[existingIndex] =
|
||
existing.kind === "protocol" &&
|
||
existing.surface === "provider_activity"
|
||
? mergeProviderActivityItem(existing, item)
|
||
: item;
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "workspace_change": {
|
||
const key = `workspace:${entry.changeSetId}`;
|
||
const item: TaskChatProtocolItem = {
|
||
id: `${runId}:workspace:${entry.changeSetId}`,
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: entry.changeSetId,
|
||
revision: entry.revision,
|
||
source: entry.source,
|
||
complete: entry.complete,
|
||
files: entry.files.map((file) => {
|
||
if (file.diff == null || file.diff.length <= PROTOCOL_OUTPUT_MAX) return { ...file };
|
||
// Keep whole lines so the preview still parses as a patch.
|
||
const cut = file.diff.lastIndexOf("\n", PROTOCOL_OUTPUT_MAX);
|
||
return { ...file, diff: file.diff.slice(0, cut > 0 ? cut + 1 : PROTOCOL_OUTPUT_MAX), diffTruncated: true };
|
||
}),
|
||
totals: entry.totals,
|
||
patchArtifactRef: entry.patchArtifactRef,
|
||
};
|
||
const existingIndex = protocolIndexByKey.get(key);
|
||
if (existingIndex == null) {
|
||
items.push(item);
|
||
protocolIndexByKey.set(key, items.length - 1);
|
||
} else {
|
||
items[existingIndex] = { ...item, id: items[existingIndex].id };
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "workspace_file_reference": {
|
||
const key = `workspace-file:${entry.referenceId}`;
|
||
const item: TaskChatProtocolItem = {
|
||
id: `${runId}:workspace-file:${entry.referenceId}`,
|
||
kind: "protocol",
|
||
surface: "workspace_file",
|
||
referenceId: entry.referenceId,
|
||
source: entry.source,
|
||
path: entry.path,
|
||
displayName: entry.displayName,
|
||
mediaType: entry.mediaType,
|
||
presentation: entry.presentation,
|
||
line: entry.line,
|
||
preview:
|
||
entry.preview == null
|
||
? null
|
||
: entry.preview.slice(0, PROTOCOL_OUTPUT_MAX),
|
||
previewTruncated:
|
||
entry.previewTruncated ||
|
||
Boolean(
|
||
entry.preview && entry.preview.length > PROTOCOL_OUTPUT_MAX,
|
||
),
|
||
};
|
||
const existingIndex = protocolIndexByKey.get(key);
|
||
if (existingIndex == null) {
|
||
items.push(item);
|
||
protocolIndexByKey.set(key, items.length - 1);
|
||
} else {
|
||
items[existingIndex] = { ...item, id: items[existingIndex].id };
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "runtime_request": {
|
||
const key = `runtime-request:${entry.requestId}`;
|
||
const item: TaskChatProtocolItem = {
|
||
id: `${runId}:runtime-request:${entry.requestId}`,
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId,
|
||
requestId: entry.requestId,
|
||
requestKind: entry.requestKind,
|
||
turnId: entry.turnId,
|
||
requestType: entry.requestType,
|
||
status: entry.status,
|
||
prompt: entry.prompt,
|
||
choices: entry.choices,
|
||
fields: entry.fields,
|
||
questionSet: entry.questionSet,
|
||
resolvedAction: entry.resolvedAction,
|
||
response: entry.response,
|
||
};
|
||
const existingIndex = protocolIndexByKey.get(key);
|
||
if (existingIndex == null) {
|
||
items.push(item);
|
||
protocolIndexByKey.set(key, items.length - 1);
|
||
} else {
|
||
items[existingIndex] = { ...item, id: items[existingIndex].id };
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "run_result": {
|
||
items.push({
|
||
id: `${runId}:result:${i}`,
|
||
kind: "protocol",
|
||
surface: "run_result",
|
||
disposition: entry.disposition,
|
||
summary: entry.summary,
|
||
objectiveSatisfied: entry.objectiveSatisfied,
|
||
verification: entry.verification,
|
||
remainingWork: entry.remainingWork,
|
||
blocker: entry.blocker,
|
||
artifacts: entry.artifacts,
|
||
...(entry.acceptedResponseWake
|
||
? { acceptedResponseWake: entry.acceptedResponseWake }
|
||
: {}),
|
||
});
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "run_terminal": {
|
||
const key = "run-terminal";
|
||
const item: TaskChatProtocolItem = {
|
||
id: `${runId}:terminal`,
|
||
kind: "protocol",
|
||
surface: "run_terminal",
|
||
turnState: entry.turnState,
|
||
runState: entry.runState,
|
||
disposition: entry.disposition,
|
||
stopReason: entry.stopReason,
|
||
};
|
||
const existingIndex = protocolIndexByKey.get(key);
|
||
if (existingIndex == null) {
|
||
items.push(item);
|
||
protocolIndexByKey.set(key, items.length - 1);
|
||
} else {
|
||
items[existingIndex] = item;
|
||
}
|
||
resetInline();
|
||
break;
|
||
}
|
||
case "result": {
|
||
if (
|
||
entry.subtype !== "paperclip_runner_usage" &&
|
||
entry.subtype !== "paperclip_runner_session_usage"
|
||
)
|
||
break;
|
||
const inputTokens = entry.inputTokens || 0;
|
||
const outputTokens = entry.outputTokens || 0;
|
||
items.push({
|
||
id: `${runId}:usage:${i}`,
|
||
kind: "usage",
|
||
...(entry.subtype === "paperclip_runner_session_usage"
|
||
? {
|
||
label: "Provider session total",
|
||
detail:
|
||
entry.text ||
|
||
"This cumulative usage can include earlier runs in the resumed provider session.",
|
||
}
|
||
: {}),
|
||
usage: {
|
||
used: inputTokens + outputTokens + (entry.cachedTokens || 0),
|
||
size: 0,
|
||
inputTokens,
|
||
outputTokens,
|
||
...(entry.costUsd > 0 ? { costUsd: entry.costUsd } : {}),
|
||
},
|
||
});
|
||
resetInline();
|
||
break;
|
||
}
|
||
// init / stderr / stdout / system / user and non-runner result entries
|
||
// carry no thread-visible content (status is rendered separately).
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Only the message still open at the transcript tail is streaming; earlier
|
||
// provider commentary is complete but remains a durable phase boundary.
|
||
if (running) {
|
||
for (const [idx, it] of items.entries()) {
|
||
if (it.kind === "message" && idx !== messageIndex) it.streaming = false;
|
||
}
|
||
} else {
|
||
// A terminal transcript cannot retain live spinners forever when a
|
||
// provider disconnects or the operator cancels while a tool is active.
|
||
// Preserve the unfinished lifecycle honestly instead of inventing a
|
||
// success/failure result the provider never emitted.
|
||
for (const item of items) {
|
||
if (
|
||
item.kind === "tool" &&
|
||
(item.status === "pending" || item.status === "in_progress")
|
||
) {
|
||
item.status = "interrupted";
|
||
item.detail = item.detail
|
||
? `${item.detail}\nInterrupted before the provider reported completion.`
|
||
: "Interrupted before the provider reported completion.";
|
||
} else if (
|
||
item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
item.status === "running"
|
||
) {
|
||
item.status = "interrupted";
|
||
item.summary = item.summary
|
||
? `${item.summary} · Interrupted before completion.`
|
||
: "Interrupted before completion.";
|
||
} else if (
|
||
item.kind === "protocol" &&
|
||
item.surface === "runtime_request" &&
|
||
item.status === "pending"
|
||
) {
|
||
item.status = "cancelled";
|
||
} else if (item.kind === "protocol" && item.surface === "workspace_change") {
|
||
// A harness-reported change set is final once its run has ended.
|
||
item.complete = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Every turn that edited files ends with its changed files, just before its
|
||
// final answer. A harness that reports only per-edit patches gets the card
|
||
// assembled from its edit rows.
|
||
const changeIndex = items.findIndex((item) => item.kind === "protocol" && item.surface === "workspace_change");
|
||
const changes = changeIndex >= 0
|
||
? items.splice(changeIndex, 1)[0]
|
||
: turnChangesFromEditRows(items, runId ?? "run", !running);
|
||
if (changes) {
|
||
const last = items.at(-1);
|
||
const beforeFinal = last?.kind === "message" && last.channel === "final";
|
||
items.splice(beforeFinal ? items.length - 1 : items.length, 0, changes);
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* A settled run's chronological children. Commentary remains attached to the
|
||
* phase it introduced, while terminal runtime-request receipts keep the slot
|
||
* where the request first appeared. Final text is excluded because the turn's
|
||
* dedicated response slot or posted comment owns it.
|
||
*/
|
||
export function settledRunChildren(
|
||
parsed: readonly TaskChatItem[],
|
||
): TaskChatTurnChildItem[] {
|
||
return buildTurnTimelineRows(parsed, false);
|
||
}
|
||
|
||
/**
|
||
* A provider can emit the same user-facing text first as progress and then as
|
||
* its durable response. Keep the chronological progress item while the answer
|
||
* is still unknown, but once that response exists let its dedicated bubble own
|
||
* the text. Only the last exact match is removed so intentional earlier
|
||
* repetition remains visible.
|
||
*/
|
||
export function omitProgressRepeatedByResponse(
|
||
items: readonly TaskChatItem[],
|
||
responseText: string | null | undefined,
|
||
): readonly TaskChatItem[] {
|
||
return (
|
||
omitProgressRepeatedByResponseAcrossSegments([items], responseText)[0] ??
|
||
items
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Run-wide form of omitProgressRepeatedByResponse for steered transcripts.
|
||
* Segmentation is a presentation concern, so it must not turn one dedupe into
|
||
* one removal per segment. Preserve all intentional earlier repetitions and
|
||
* remove only the final progress item matching the durable response.
|
||
*/
|
||
export function omitProgressRepeatedByResponseAcrossSegments(
|
||
segments: readonly (readonly TaskChatItem[])[],
|
||
responseText: string | null | undefined,
|
||
): readonly (readonly TaskChatItem[])[] {
|
||
const normalizedResponse = responseText?.trim();
|
||
if (!normalizedResponse) return segments;
|
||
let redundantSegmentIndex = -1;
|
||
let redundantIndex = -1;
|
||
findRedundant: for (
|
||
let segmentIndex = segments.length - 1;
|
||
segmentIndex >= 0;
|
||
segmentIndex -= 1
|
||
) {
|
||
const items = segments[segmentIndex];
|
||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||
const item = items[index];
|
||
if (
|
||
item.kind === "message" &&
|
||
item.interstitial &&
|
||
item.channel === "progress" &&
|
||
item.text.trim() === normalizedResponse
|
||
) {
|
||
redundantSegmentIndex = segmentIndex;
|
||
redundantIndex = index;
|
||
break findRedundant;
|
||
}
|
||
}
|
||
}
|
||
if (redundantSegmentIndex < 0) return segments;
|
||
return segments.map((items, segmentIndex) =>
|
||
segmentIndex === redundantSegmentIndex
|
||
? items.filter((_, index) => index !== redundantIndex)
|
||
: items,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Keep the paperclip runner's expanded activity history focused on work the
|
||
* user can act on. The normalized transcript remains lossless; this is only a
|
||
* presentation filter for the new-runner turn surface. Legacy adapters keep
|
||
* their existing lifecycle and usage rows.
|
||
*/
|
||
export function paperclipRunnerHistoryItems(
|
||
parsed: readonly TaskChatItem[],
|
||
): TaskChatItem[] {
|
||
return parsed.filter((item) => {
|
||
if (item.kind === "usage") return false;
|
||
if (item.kind !== "marker") return true;
|
||
if (item.variant === "session_start") return false;
|
||
return (
|
||
item.variant !== "turn_boundary" ||
|
||
(item.label !== "Turn started" && item.label !== "Turn completed")
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Semantic rows for the Paperclip runner's Codex-style activity disclosure.
|
||
*
|
||
* This is intentionally narrower than the stored transcript and the runner
|
||
* inspector: it keeps work a person can understand or act on while excluding
|
||
* transport/lifecycle bookkeeping, terminal authority, and the final
|
||
* response. Logical provider/tool lifecycles have already been coalesced by
|
||
* `transcriptToTaskChatItems`, so each returned item is one visible "thing".
|
||
*/
|
||
export function paperclipRunnerActivityItems(
|
||
parsed: readonly TaskChatItem[],
|
||
): TaskChatItem[] {
|
||
const hasAggregateWorkspaceChange = parsed.some(
|
||
(item) => item.kind === "protocol" && item.surface === "workspace_change",
|
||
);
|
||
return parsed.filter((item) => {
|
||
switch (item.kind) {
|
||
case "message":
|
||
return Boolean(item.interstitial && item.text.trim());
|
||
case "thinking":
|
||
// A textless reasoning lifecycle drives the live "Thinking…" label,
|
||
// but repeating it as an empty child row adds no information.
|
||
return item.lines.some((line) => line.trim().length > 0);
|
||
case "tool": {
|
||
const normalizedName = (item.rawName ?? item.name)
|
||
.replaceAll("-", "_")
|
||
.toLowerCase();
|
||
if (
|
||
hasAggregateWorkspaceChange &&
|
||
(normalizedName === "file_change" || normalizedName === "filechange")
|
||
)
|
||
return false;
|
||
return normalizedName !== "paperclip_finish";
|
||
}
|
||
case "marker":
|
||
return item.variant === "interrupted";
|
||
case "protocol":
|
||
// Completion is already represented by task state and the final answer.
|
||
// Keep its event in the inspector, but omit it from feed rows and counts.
|
||
if (
|
||
item.surface === "provider_activity" &&
|
||
item.family === "tool_execution" &&
|
||
providerItemDetail(item, "Name") === "paperclip_finish"
|
||
) return false;
|
||
if (
|
||
hasAggregateWorkspaceChange &&
|
||
item.surface === "provider_activity" &&
|
||
item.family === "tool_execution"
|
||
) {
|
||
const presentation = toolActivityPresentation({
|
||
name: providerItemDetail(item, "Name"),
|
||
transport: providerItemDetail(item, "Transport"),
|
||
namespace: providerItemDetail(item, "Namespace"),
|
||
operation: providerItemDetail(item, "Operation"),
|
||
target: providerItemDetail(item, "Target"),
|
||
progress: providerItemDetail(item, "Progress"),
|
||
});
|
||
if (presentation.summaryGroup.key === "file_change") return false;
|
||
}
|
||
return (
|
||
item.surface === "provider_activity" ||
|
||
item.surface === "workspace_change" ||
|
||
item.surface === "workspace_file" ||
|
||
item.surface === "resource"
|
||
);
|
||
case "usage":
|
||
return true;
|
||
case "status":
|
||
case "activity_phase":
|
||
case "interaction":
|
||
case "turn":
|
||
case "brief":
|
||
case "plan_document":
|
||
return false;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ordered input for the Paperclip Runner task-turn timeline. This keeps the
|
||
* semantic activity filter above, but retains runtime-request lifecycles so
|
||
* the shared projector can use each request as a phase boundary and place its
|
||
* terminal receipt at the request's first-seen position.
|
||
*/
|
||
export function paperclipRunnerTimelineItems(
|
||
allParsed: readonly TaskChatItem[],
|
||
): TaskChatItem[] {
|
||
// Decide retry recovery on the full transcript: the final answer that
|
||
// proves recovery is filtered out of the display rows below.
|
||
const parsed = allParsed.map((item, index) =>
|
||
item.kind === "protocol" && item.surface === "provider_activity" &&
|
||
statusNoticeCategory(item) === "provider_retry" && !item.resolved &&
|
||
allParsed.slice(index + 1).some(statusNoticeResolvesRetry)
|
||
? { ...item, resolved: true }
|
||
: item,
|
||
);
|
||
const activityIds = new Set(
|
||
paperclipRunnerActivityItems(parsed).map((item) => item.id),
|
||
);
|
||
return parsed.filter(
|
||
(item) =>
|
||
activityIds.has(item.id) ||
|
||
(item.kind === "thinking" && Boolean(item.streaming)) ||
|
||
item.kind === "plan_document" ||
|
||
(item.kind === "protocol" && item.surface === "runtime_request"),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A response-wake can answer now while deliberately leaving the task open.
|
||
* Its marker is derived by the native event projector, never from final prose.
|
||
*/
|
||
export function paperclipRunnerAcceptedResponseWake(
|
||
parsed: readonly TaskChatItem[],
|
||
runId: string | undefined,
|
||
): TaskChatRunResultItem | undefined {
|
||
if (!runId) return undefined;
|
||
const results = parsed.filter(
|
||
(item): item is TaskChatRunResultItem =>
|
||
item.kind === "protocol" && item.surface === "run_result",
|
||
);
|
||
if (results.length !== 1) return undefined;
|
||
const result = results[0];
|
||
if (
|
||
result.disposition !== "yielded" ||
|
||
result.acceptedResponseWake?.runId !== runId ||
|
||
!result.acceptedResponseWake.sourceEventId.trim() ||
|
||
!result.summary.trim()
|
||
) return undefined;
|
||
if (parsed.some((item) =>
|
||
item.kind === "protocol" &&
|
||
item.surface === "runtime_request" && item.status === "pending",
|
||
)) return undefined;
|
||
return parsed.some((item) =>
|
||
item.kind === "protocol" && item.surface === "run_terminal" &&
|
||
item.id === `${runId}:terminal` && item.runState === "succeeded" &&
|
||
item.turnState === "completed" && item.disposition === "yielded",
|
||
) ? result : undefined;
|
||
}
|
||
|
||
function normalizedWaitText(value: string) {
|
||
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* A yielded turn's control-plane wait label ("Waiting for <card>.") that some
|
||
* providers echo as final prose. It duplicates the card, so it is not a reply.
|
||
*/
|
||
export function isYieldedWaitStub(
|
||
text: string,
|
||
waitSummaries: readonly (string | null | undefined)[] = [],
|
||
): boolean {
|
||
const normalized = normalizedWaitText(text);
|
||
if (!normalized) return true;
|
||
if (waitSummaries.some((summary) => summary && normalizedWaitText(summary) === normalized)) return true;
|
||
return !text.trim().includes("\n") && /^waiting for\b/.test(normalized);
|
||
}
|
||
|
||
/** Resolve a terminal reply without presenting ordinary yielded waits as answers. */
|
||
export function paperclipRunnerFinalResponse(
|
||
parsed: readonly TaskChatItem[],
|
||
options?: {
|
||
runId?: string;
|
||
agentName?: string;
|
||
fallbackSummary?: string | null;
|
||
/** Structured summaries are terminal fallbacks, never live partial replies. */
|
||
allowFallback?: boolean;
|
||
/** Run metadata established a yielded wait even if the transcript lacks its result. */
|
||
yielded?: boolean;
|
||
/** The yielded result's wait summary, when known outside the transcript. */
|
||
waitSummary?: string | null;
|
||
},
|
||
): TaskChatMessageItem | undefined {
|
||
// A yielded result is a control-plane wait boundary, not an assistant reply.
|
||
// Some providers historically labeled their "waiting for input" prose as a
|
||
// final message, so disposition must take precedence over message channel.
|
||
const yieldedResults = parsed.filter(
|
||
(item): item is TaskChatRunResultItem =>
|
||
item.kind === "protocol" &&
|
||
item.surface === "run_result" &&
|
||
item.disposition === "yielded",
|
||
);
|
||
if (yieldedResults.length > 0 || options?.yielded) {
|
||
const accepted =
|
||
options?.allowFallback === false
|
||
? undefined
|
||
: paperclipRunnerAcceptedResponseWake(parsed, options?.runId);
|
||
if (accepted) {
|
||
return {
|
||
id: `${accepted.id}:final-response`,
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: options?.agentName,
|
||
text: accepted.summary.trim(),
|
||
channel: "final",
|
||
streaming: false,
|
||
};
|
||
}
|
||
// The wait itself is not an answer, but prose the agent wrote for the user
|
||
// before yielding (e.g. explaining the question card it just posted) is.
|
||
// It streamed into view while the turn was live; dropping it on settle
|
||
// makes the conversation flash and lose that reply. Wait stubs are
|
||
// skipped, not terminal: a provider may echo one after its reply.
|
||
const waitSummaries = [
|
||
options?.waitSummary,
|
||
...yieldedResults.map((result) => result.summary),
|
||
];
|
||
for (let index = parsed.length - 1; index >= 0; index -= 1) {
|
||
const item = parsed[index];
|
||
if (item.kind !== "message" || item.channel !== "final") continue;
|
||
if (isYieldedWaitStub(item.text, waitSummaries)) continue;
|
||
return item;
|
||
}
|
||
return undefined;
|
||
}
|
||
for (let index = parsed.length - 1; index >= 0; index -= 1) {
|
||
const item = parsed[index];
|
||
if (
|
||
item.kind === "message" &&
|
||
item.channel === "final" &&
|
||
item.text.trim()
|
||
) {
|
||
return item;
|
||
}
|
||
}
|
||
if (options?.allowFallback === false) return undefined;
|
||
// Historical runner transcripts may contain a complete assistant reply from
|
||
// before message channels were persisted. Keep that user-authored text as
|
||
// the terminal response and do not replace it with a structured result
|
||
// summary. Explicit progress text is intentionally excluded.
|
||
for (let index = parsed.length - 1; index >= 0; index -= 1) {
|
||
const item = parsed[index];
|
||
if (
|
||
item.kind === "message" &&
|
||
item.channel !== "progress" &&
|
||
item.text.trim()
|
||
) {
|
||
return {
|
||
...item,
|
||
channel: "final",
|
||
interstitial: false,
|
||
streaming: false,
|
||
};
|
||
}
|
||
}
|
||
const runResult = [...parsed]
|
||
.reverse()
|
||
.find(
|
||
(item): item is TaskChatRunResultItem =>
|
||
item.kind === "protocol" &&
|
||
item.surface === "run_result" &&
|
||
Boolean(item.summary?.trim()),
|
||
);
|
||
const text = runResult?.summary?.trim() ?? options?.fallbackSummary?.trim();
|
||
if (!text) return undefined;
|
||
return {
|
||
id: runResult
|
||
? `${runResult.id}:final-response`
|
||
: `${options?.runId ?? "run"}:result-summary:final-response`,
|
||
kind: "message",
|
||
author: "agent",
|
||
authorName: options?.agentName,
|
||
text,
|
||
channel: "final",
|
||
streaming: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Materialize a saved plan at the tool boundary that created its revision.
|
||
* The document query and transcript stream update independently, so folding
|
||
* the document into the parsed turn gives live, settling, and replay the same
|
||
* stable owner and DOM position. If that boundary is temporarily unavailable,
|
||
* retain an explicitly marked end-of-turn fallback instead of dropping the
|
||
* canonical document.
|
||
*/
|
||
export function embedPlanDocumentAtWriteBoundary(
|
||
parsed: readonly TaskChatItem[],
|
||
plan: TaskChatPlanDocumentItem,
|
||
): TaskChatItem[] {
|
||
const withoutPlan = parsed.filter((item) => item.id !== plan.id);
|
||
let writeIndex = -1;
|
||
for (let index = 0; index < withoutPlan.length; index += 1) {
|
||
const item = withoutPlan[index];
|
||
const name =
|
||
item.kind === "tool"
|
||
? (item.rawName ?? item.name)
|
||
: item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
item.family === "tool_execution"
|
||
? providerItemDetail(item, "Name")
|
||
: null;
|
||
if (!name) continue;
|
||
const normalizedName = name.replaceAll("-", "_").toLowerCase();
|
||
if (normalizedName === "write_document") writeIndex = index;
|
||
}
|
||
const placedPlan: TaskChatPlanDocumentItem = {
|
||
...plan,
|
||
placement: writeIndex < 0 ? "fallback" : "write_boundary",
|
||
};
|
||
if (writeIndex < 0) return [...withoutPlan, placedPlan];
|
||
return [
|
||
...withoutPlan.slice(0, writeIndex + 1),
|
||
placedPlan,
|
||
...withoutPlan.slice(writeIndex + 1),
|
||
];
|
||
}
|
||
|
||
function phaseSummary(
|
||
items: ReadonlyArray<TaskChatActivityPhaseItem["items"][number]>,
|
||
): string {
|
||
const counts = new Map<string, number>();
|
||
const providerCounts = new Map<
|
||
TaskChatProviderActivityItem["family"],
|
||
number
|
||
>();
|
||
const providerToolGroups = new Map<string, number>();
|
||
let providerToolActions = 0;
|
||
let generic = 0;
|
||
let workspaceFiles = 0;
|
||
for (const item of items) {
|
||
if (item.kind === "protocol") {
|
||
if (item.surface === "provider_activity") {
|
||
providerCounts.set(
|
||
item.family,
|
||
(providerCounts.get(item.family) ?? 0) + 1,
|
||
);
|
||
if (item.family === "tool_execution") {
|
||
const presentation = toolActivityPresentation({
|
||
name: providerItemDetail(item, "Name"),
|
||
transport: providerItemDetail(item, "Transport"),
|
||
namespace: providerItemDetail(item, "Namespace"),
|
||
operation: providerItemDetail(item, "Operation"),
|
||
target: providerItemDetail(item, "Target"),
|
||
progress: providerItemDetail(item, "Progress"),
|
||
});
|
||
const summaryGroup = presentation.summaryGroup;
|
||
providerToolGroups.set(
|
||
summaryGroup.key,
|
||
(providerToolGroups.get(summaryGroup.key) ?? 0) + 1,
|
||
);
|
||
providerToolActions += 1;
|
||
}
|
||
} else if (item.surface === "workspace_change") {
|
||
workspaceFiles += item.totals.files || item.files.length;
|
||
} else if (item.surface === "workspace_file") {
|
||
workspaceFiles += 1;
|
||
}
|
||
continue;
|
||
}
|
||
if (item.kind !== "tool") continue;
|
||
if (isGenericToolName(item.rawName ?? item.name)) {
|
||
generic += 1;
|
||
continue;
|
||
}
|
||
const family = toolTaxonomy(item.rawName ?? item.name).family;
|
||
counts.set(family, (counts.get(family) ?? 0) + 1);
|
||
}
|
||
const phrases: Array<{ text: string; count: number }> = [];
|
||
const add = (
|
||
family: string,
|
||
singular: string,
|
||
plural: (count: number) => string,
|
||
) => {
|
||
const count = counts.get(family) ?? 0;
|
||
if (count)
|
||
phrases.push({ text: count === 1 ? singular : plural(count), count });
|
||
};
|
||
add("read", "Read a file", (count) => `Read ${count} files`);
|
||
add("edit", "Edited a file", (count) => `Edited ${count} files`);
|
||
add("terminal", "Ran a command", (count) => `Ran ${count} commands`);
|
||
const searched = (counts.get("grep") ?? 0) + (counts.get("search") ?? 0);
|
||
if (searched)
|
||
phrases.push({
|
||
text: searched === 1 ? "Searched once" : `Searched ${searched} times`,
|
||
count: searched,
|
||
});
|
||
const known = new Set(["read", "edit", "terminal", "grep", "search"]);
|
||
const other =
|
||
[...counts].reduce(
|
||
(n, [family, count]) => n + (known.has(family) ? 0 : count),
|
||
0,
|
||
) + generic;
|
||
if (other)
|
||
phrases.push({
|
||
text: other === 1 ? "Used a tool" : `Used ${other} tools`,
|
||
count: other,
|
||
});
|
||
const providerCount = (family: TaskChatProviderActivityItem["family"]) =>
|
||
providerCounts.get(family) ?? 0;
|
||
const addProvider = (
|
||
family: TaskChatProviderActivityItem["family"],
|
||
singular: string,
|
||
plural: (count: number) => string,
|
||
) => {
|
||
const count = providerCount(family);
|
||
if (count)
|
||
phrases.push({ text: count === 1 ? singular : plural(count), count });
|
||
};
|
||
addProvider("plan", "Updated the plan", (count) => `Updated ${count} plans`);
|
||
addProvider(
|
||
"research",
|
||
"Searched once",
|
||
(count) => `Searched ${count} times`,
|
||
);
|
||
addProvider(
|
||
"delegation",
|
||
"Used a subagent",
|
||
(count) => `Used ${count} subagents`,
|
||
);
|
||
addProvider(
|
||
"model_identity",
|
||
"Updated the model",
|
||
(count) => `Updated the model ${count} times`,
|
||
);
|
||
addProvider(
|
||
"context",
|
||
"Compacted context",
|
||
(count) => `Compacted context ${count} times`,
|
||
);
|
||
addProvider(
|
||
"artifact",
|
||
"Handled an artifact",
|
||
(count) => `Handled ${count} artifacts`,
|
||
);
|
||
addProvider(
|
||
"review",
|
||
"Changed review mode",
|
||
(count) => `Changed review mode ${count} times`,
|
||
);
|
||
addProvider("hook", "Ran a hook", (count) => `Ran ${count} hooks`);
|
||
addProvider(
|
||
"memory",
|
||
"Referenced memory",
|
||
(count) => `Referenced memory ${count} times`,
|
||
);
|
||
addProvider(
|
||
"safety",
|
||
"Ran a safety review",
|
||
(count) => `Ran ${count} safety reviews`,
|
||
);
|
||
addProvider(
|
||
"terminal",
|
||
"Sent terminal input",
|
||
(count) => `Sent terminal input ${count} times`,
|
||
);
|
||
addProvider("wait", "Waited", (count) => `Waited ${count} times`);
|
||
addProvider(
|
||
"provider_notice",
|
||
"Received a provider notice",
|
||
(count) => `Received ${count} provider notices`,
|
||
);
|
||
// A canonical tool-execution row can be the only tool representation for a
|
||
// provider. Avoid double-counting when the adapter also produced native
|
||
// TaskChatToolItems for the same executions.
|
||
const nativeToolCount = [...counts.values()].reduce(
|
||
(total, count) => total + count,
|
||
generic,
|
||
);
|
||
if (nativeToolCount === 0 && providerToolActions > 0) {
|
||
for (const [key, count] of providerToolGroups) {
|
||
let text: string;
|
||
switch (key) {
|
||
case "command":
|
||
text = count === 1 ? "Ran a command" : `Ran ${count} commands`;
|
||
break;
|
||
case "read":
|
||
text = count === 1 ? "Read a file" : `Read ${count} files`;
|
||
break;
|
||
case "search":
|
||
text = count === 1 ? "Searched once" : `Searched ${count} times`;
|
||
break;
|
||
case "file_change":
|
||
text = count === 1 ? "Edited a file" : `Edited ${count} files`;
|
||
break;
|
||
case "delegation":
|
||
text = count === 1 ? "Used a subagent" : `Used ${count} subagents`;
|
||
break;
|
||
case "wait":
|
||
text = count === 1 ? "Waited" : `Waited ${count} times`;
|
||
break;
|
||
case "tool_search":
|
||
text =
|
||
count === 1
|
||
? "Searched available tools"
|
||
: `Searched available tools ${count} times`;
|
||
break;
|
||
case "paperclip_read":
|
||
text =
|
||
count === 1
|
||
? "Read from Paperclip"
|
||
: `Read from Paperclip ${count} times`;
|
||
break;
|
||
case "task_operation":
|
||
text =
|
||
count === 1 ? "Used Paperclip" : `Used Paperclip ${count} times`;
|
||
break;
|
||
default:
|
||
text = count === 1 ? "Used a tool" : `Used ${count} tools`;
|
||
}
|
||
phrases.push({
|
||
text,
|
||
count,
|
||
});
|
||
}
|
||
}
|
||
if (workspaceFiles > 0)
|
||
phrases.push({
|
||
text:
|
||
workspaceFiles === 1
|
||
? "Changed a file"
|
||
: `Changed ${workspaceFiles} files`,
|
||
count: workspaceFiles,
|
||
});
|
||
if (phrases.length > 0) {
|
||
const visible = phrases.slice(0, 3);
|
||
const hidden = phrases
|
||
.slice(3)
|
||
.reduce((total, phrase) => total + phrase.count, 0);
|
||
const summary = visible
|
||
.map((phrase, index) =>
|
||
index === 0
|
||
? phrase.text
|
||
: phrase.text.charAt(0).toLowerCase() + phrase.text.slice(1),
|
||
)
|
||
.join(", ");
|
||
return `${summary}${hidden > 0 ? `, +${hidden} more` : ""}`;
|
||
}
|
||
const protocolCount = items.filter((item) => item.kind === "protocol").length;
|
||
if (protocolCount > 0)
|
||
return protocolCount === 1
|
||
? "Runner activity"
|
||
: `${protocolCount} runner updates`;
|
||
if (items.some((item) => item.kind === "thinking")) return "Reasoning";
|
||
const interrupted = items.find(
|
||
(item) => item.kind === "marker" && item.variant === "interrupted",
|
||
);
|
||
return interrupted?.kind === "marker"
|
||
? interrupted.label
|
||
: "No tool activity";
|
||
}
|
||
|
||
/**
|
||
* Project one turn into its durable chronological rows.
|
||
*
|
||
* Commentary starts a sticky phase. Consecutive diagnostic activity belongs
|
||
* to that phase until the next commentary or runtime request. Request lifecycle
|
||
* updates are coalesced to their latest state but emitted at the first request
|
||
* slot. Pending requests keep a compact feed entry so they remain reachable
|
||
* after their composer form is dismissed.
|
||
*/
|
||
function delegationItem(
|
||
item: TaskChatItem,
|
||
): TaskChatProviderActivityItem | null {
|
||
return (
|
||
item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
item.family === "delegation" &&
|
||
item.children.length > 0
|
||
) ? item : null;
|
||
}
|
||
|
||
function subagentStatus(
|
||
raw: string,
|
||
running: boolean,
|
||
): TaskChatSubagent["status"] {
|
||
if (raw === "completed" || raw === "closed") return "completed";
|
||
if (raw === "failed") return "failed";
|
||
if (raw === "interrupted" || raw === "cancelled") return "cancelled";
|
||
// A child still running after its run settled was cut off.
|
||
return running ? "running" : "cancelled";
|
||
}
|
||
|
||
/** One subagent per delegation child; the first child owns nested tools. */
|
||
function subagentsFromDelegation(
|
||
item: TaskChatProviderActivityItem,
|
||
running: boolean,
|
||
): TaskChatSubagent[] {
|
||
return item.children.map((child) => {
|
||
const status = subagentStatus(child.status, running);
|
||
return {
|
||
id: `${item.id}:${child.id}`,
|
||
agentType: child.role ?? child.title ?? "Subagent",
|
||
description: child.summary ?? child.role ?? "Subagent",
|
||
prompt: child.prompt,
|
||
model: child.model,
|
||
status,
|
||
progressSummary: status === "running" ? child.progress : undefined,
|
||
startedAtMs: item.startedAtMs ?? null,
|
||
finishedAtMs: status === "running" ? null : (item.updatedAtMs ?? null),
|
||
activity: [],
|
||
// Providers without a dedicated result (Cursor) keep their activity
|
||
// text in `activitySummary`; keep it readable once the child settles.
|
||
result: child.result ?? (status === "running" ? undefined : child.progress),
|
||
};
|
||
});
|
||
}
|
||
|
||
export function buildTurnTimelineRows(
|
||
parsed: readonly TaskChatItem[],
|
||
running: boolean,
|
||
): TaskChatTurnChildItem[] {
|
||
const rows: TaskChatTurnChildItem[] = [];
|
||
let current: TaskChatActivityPhaseItem | null = null;
|
||
// Subagents dispatched back to back share one tree; their own tool calls
|
||
// nest inside them instead of breaking the group.
|
||
let subagentGroup: TaskChatSubagentGroupItem | null = null;
|
||
const subagentByExecutionId = new Map<string, TaskChatSubagent>();
|
||
// Some transports also report the spawning call as a tool; the card owns it.
|
||
const delegationIds = new Set(
|
||
parsed.flatMap((item) =>
|
||
delegationItem(item)?.activityId ? [delegationItem(item)!.activityId!] : [],
|
||
),
|
||
);
|
||
const latestRequestByKey = new Map<string, TaskChatProtocolItem>();
|
||
for (const item of parsed) {
|
||
if (item.kind !== "protocol" || item.surface !== "runtime_request")
|
||
continue;
|
||
latestRequestByKey.set(`${item.runId}:${item.requestId}`, item);
|
||
}
|
||
const seenRequests = new Set<string>();
|
||
const ensureOpening = (seed: string) => {
|
||
if (!current) {
|
||
current = {
|
||
id: `${seed}:phase:opening`,
|
||
kind: "activity_phase",
|
||
items: [],
|
||
summary: "",
|
||
active: false,
|
||
};
|
||
rows.push(current);
|
||
}
|
||
return current;
|
||
};
|
||
const legacyReplyBoundary = [...parsed].reverse().find((item) => {
|
||
if (item.kind === "usage" || item.kind === "status") return false;
|
||
if (item.kind === "marker") {
|
||
return item.variant === "interrupted";
|
||
}
|
||
if (item.kind === "protocol") {
|
||
return (
|
||
item.surface === "provider_activity" ||
|
||
item.surface === "runtime_request"
|
||
);
|
||
}
|
||
return true;
|
||
});
|
||
for (const item of parsed) {
|
||
const delegation = delegationItem(item);
|
||
if (delegation) {
|
||
const subagents = subagentsFromDelegation(delegation, running);
|
||
if (delegation.activityId && subagents[0])
|
||
subagentByExecutionId.set(delegation.activityId, subagents[0]);
|
||
if (!subagentGroup || rows.at(-1) !== subagentGroup) {
|
||
subagentGroup = { id: `${delegation.id}:subagents`, kind: "subagent_group", subagents: [] };
|
||
rows.push(subagentGroup);
|
||
}
|
||
subagentGroup.subagents.push(...subagents);
|
||
current = null;
|
||
continue;
|
||
}
|
||
if (item.kind === "tool" && item.parentToolUseId) {
|
||
const parent = subagentByExecutionId.get(item.parentToolUseId);
|
||
if (parent) {
|
||
parent.activity.push(
|
||
parent.status === "running" || item.status !== "in_progress"
|
||
? item
|
||
: { ...item, status: parent.status === "failed" ? "failed" : "completed" },
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
if (
|
||
item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
item.family === "tool_execution"
|
||
) {
|
||
if (item.activityId && delegationIds.has(item.activityId)) continue;
|
||
const parent = item.parentToolUseId
|
||
? subagentByExecutionId.get(item.parentToolUseId)
|
||
: undefined;
|
||
if (parent) {
|
||
parent.activity.push(
|
||
parent.status === "running" || item.status !== "running"
|
||
? item
|
||
: { ...item, status: parent.status === "failed" ? "failed" : "completed" },
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
if (item.kind === "message") {
|
||
// Explicit final-channel replies remain canonical even when a later
|
||
// usage row makes them non-tail. Channel-less legacy transcripts retain
|
||
// the last-visible fallback until the posted reply lands.
|
||
const explicitFinal =
|
||
item.channel === "final" || item.interstitial === false;
|
||
const legacyTrailingReply =
|
||
(item.channel == null || item.channel === "unknown") &&
|
||
item === legacyReplyBoundary;
|
||
if (!running && (explicitFinal || legacyTrailingReply)) continue;
|
||
current = {
|
||
id: `${item.id}:phase`,
|
||
kind: "activity_phase",
|
||
interstitial: item,
|
||
items: [],
|
||
summary: "",
|
||
active: false,
|
||
};
|
||
rows.push(current);
|
||
} else if (item.kind === "protocol" && item.surface === "runtime_request") {
|
||
const key = `${item.runId}:${item.requestId}`;
|
||
if (seenRequests.has(key)) continue;
|
||
seenRequests.add(key);
|
||
current = null;
|
||
const latest = latestRequestByKey.get(key);
|
||
if (latest?.surface === "runtime_request") {
|
||
rows.push(latest.id === item.id ? latest : { ...latest, id: item.id });
|
||
}
|
||
} else if (
|
||
item.kind === "protocol" &&
|
||
item.surface === "workspace_change"
|
||
) {
|
||
current = null;
|
||
rows.push(item);
|
||
} else if (item.kind === "plan_document") {
|
||
current = null;
|
||
rows.push(item);
|
||
} else if (
|
||
item.kind === "protocol" &&
|
||
item.surface === "provider_activity" &&
|
||
isStatusNotice(item)
|
||
) {
|
||
// Harness status notices sit between activity as their own chips, so
|
||
// a retry or compaction is visible without opening a phase.
|
||
const later = parsed.slice(parsed.indexOf(item) + 1);
|
||
const retry = statusNoticeCategory(item) === "provider_retry";
|
||
// A fatal error that ends the retries already tells the whole story.
|
||
if (retry && later.some((next) => next.kind === "protocol" && next.surface === "provider_activity" && isFatalProviderError(next))) continue;
|
||
// Consecutive retries with no work between them show only the latest.
|
||
const nextSignal = later.find((next) => statusNoticeResolvesRetry(next) ||
|
||
(next.kind === "protocol" && next.surface === "provider_activity" && statusNoticeCategory(next) === "provider_retry"));
|
||
if (retry && nextSignal?.kind === "protocol" && nextSignal.surface === "provider_activity" &&
|
||
statusNoticeCategory(nextSignal) === "provider_retry") continue;
|
||
// A completed compaction with a canonical context.compacted divider
|
||
// after it is shown by that divider alone.
|
||
if (statusNoticeCategory(item) === "context_compaction" &&
|
||
item.details.find((detail) => detail.label === "Phase")?.value === "completed" &&
|
||
later.some(isCompactionDivider)) continue;
|
||
current = null;
|
||
// A compaction still "running" when the turn has ended was cut short.
|
||
const stoppedCompaction = !running && statusNoticeCategory(item) === "context_compaction" &&
|
||
item.details.find((detail) => detail.label === "Phase")?.value === "running";
|
||
const resolved = retry && (item.resolved === true || later.some(statusNoticeResolvesRetry));
|
||
// A retry with no later work in a turn that has ended never recovered.
|
||
const stoppedRetry = retry && !resolved && !running;
|
||
rows.push(resolved
|
||
? { ...item, resolved: true }
|
||
: stoppedCompaction || stoppedRetry ? { ...item, status: "interrupted" } : item);
|
||
} else if (
|
||
item.kind === "tool" ||
|
||
item.kind === "usage" ||
|
||
item.kind === "thinking" ||
|
||
item.kind === "marker" ||
|
||
item.kind === "protocol"
|
||
) {
|
||
ensureOpening(item.id).items.push(item);
|
||
}
|
||
}
|
||
const meaningful = rows.filter(
|
||
(row) =>
|
||
row.kind !== "activity_phase" || row.interstitial || row.items.length > 0,
|
||
);
|
||
for (const row of meaningful) {
|
||
if (row.kind === "activity_phase") row.summary = phaseSummary(row.items);
|
||
}
|
||
if (running && current && meaningful.includes(current)) current.active = true;
|
||
return meaningful;
|
||
}
|
||
|
||
/**
|
||
* A canonical context.compacted item: a context provider activity, or the
|
||
* context-meter divider marker (variant compared as text so this works
|
||
* before and after that variant joins the marker union).
|
||
*/
|
||
function isCompactionDivider(item: TaskChatItem): boolean {
|
||
if (item.kind === "protocol") return item.surface === "provider_activity" && item.family === "context";
|
||
return item.kind === "marker" && (item.variant as string) === "context_compacted";
|
||
}
|
||
|
||
/** Real provider progress after a retry notice shows the retry succeeded. */
|
||
function statusNoticeResolvesRetry(item: TaskChatItem): boolean {
|
||
// Only new provider work proves recovery. Run results, terminal records,
|
||
// markers and usage also appear when a user stops a reconnecting run.
|
||
switch (item.kind) {
|
||
case "message":
|
||
return item.author === "agent" && item.text.trim().length > 0;
|
||
case "thinking":
|
||
return item.lines.some((line) => line.trim().length > 0);
|
||
case "tool":
|
||
return true;
|
||
case "protocol":
|
||
// Workspace cards are re-positioned to the end of the turn, so their
|
||
// place says nothing about when the work happened.
|
||
return item.surface === "provider_activity" && !isStatusNotice(item);
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Segment parsed transcript rows at assistant/request boundaries. */
|
||
export function buildActivityPhases(
|
||
parsed: readonly TaskChatItem[],
|
||
running: boolean,
|
||
): TaskChatActivityPhaseItem[] {
|
||
return buildTurnTimelineRows(parsed, running).filter(
|
||
(item): item is TaskChatActivityPhaseItem => item.kind === "activity_phase",
|
||
);
|
||
}
|
||
|
||
function formatDurationLabel(ms: number): string | undefined {
|
||
if (!Number.isFinite(ms) || ms <= 0) return undefined;
|
||
const totalSec = Math.round(ms / 1000);
|
||
if (totalSec < 1) return "1s";
|
||
if (totalSec < 60) return `${totalSec}s`;
|
||
const min = Math.floor(totalSec / 60);
|
||
const sec = totalSec % 60;
|
||
return sec === 0 ? `${min}m` : `${min}m ${sec}s`;
|
||
}
|
||
|
||
function formatTokensLabel(tokens: number): string | undefined {
|
||
if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
|
||
const label = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;
|
||
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
|
||
* caller-supplied run duration and falls back to the transcript's ts span.
|
||
*/
|
||
export function buildTurnSummary(
|
||
entries: readonly TranscriptEntry[],
|
||
opts: { durationMs?: number; failed?: boolean } = {},
|
||
): TaskChatTurnItem["summary"] {
|
||
const toolIds = new Set<string>();
|
||
let added = 0;
|
||
let removed = 0;
|
||
let tokens = 0;
|
||
for (const [i, entry] of entries.entries()) {
|
||
// Each status change of a call logs its own tool_call entry sharing the
|
||
// toolUseId; count unique calls so the folded summary matches the rows the
|
||
// expanded list renders (same `tool-${i}` fallback as the parser above).
|
||
if (entry.kind === "tool_call") toolIds.add(entry.toolUseId || `tool-${i}`);
|
||
else if (entry.kind === "diff") {
|
||
if (entry.changeType === "add") added += 1;
|
||
else if (entry.changeType === "remove") removed += 1;
|
||
} else if (
|
||
entry.kind === "result" &&
|
||
entry.subtype !== "paperclip_runner_session_usage"
|
||
) {
|
||
// Session-cumulative measurements remain visible in the expanded
|
||
// transcript, but they can include earlier runs. Only run-scoped usage
|
||
// belongs in this turn (and therefore in a merged-turn total).
|
||
tokens += (entry.inputTokens || 0) + (entry.outputTokens || 0);
|
||
}
|
||
}
|
||
const durationMs = opts.durationMs ?? transcriptSpanMs(entries);
|
||
return {
|
||
durationLabel:
|
||
durationMs != null ? formatDurationLabel(durationMs) : undefined,
|
||
toolCount: toolIds.size,
|
||
added,
|
||
removed,
|
||
tokensLabel: formatTokensLabel(tokens),
|
||
failed: opts.failed || undefined,
|
||
};
|
||
}
|
||
|
||
/** 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,
|
||
};
|
||
}
|
||
|
||
/** One chronological backbone entry (comment / interaction / marker). */
|
||
export interface ThreadBackboneEntry {
|
||
/** Chronological sort key in ms (backbone is already sorted by it). */
|
||
ms: number;
|
||
/** Stable entry id — the anchor key settled turns attach after. */
|
||
id: string;
|
||
item: TaskChatItem;
|
||
}
|
||
|
||
/**
|
||
* Assemble the thread body (PAP-367): backbone entries in order, each followed
|
||
* by the settled turns anchored to it (run → reply-comment linkage), with
|
||
* comment-less settled turns — stopped runs, or a reply not yet fetched —
|
||
* interleaved chronologically at their run's start time instead of
|
||
* bottom-appended under the newest message. Ties go after the backbone entry
|
||
* (trigger comment first); turns with no known start (startMs = Infinity) keep
|
||
* the tail slot. `unanchored` must be sorted ascending by startMs.
|
||
*/
|
||
export function assembleThreadItems(
|
||
entries: readonly ThreadBackboneEntry[],
|
||
turnsByAnchor: ReadonlyMap<string, readonly TaskChatTurnItem[]>,
|
||
unanchored: readonly { turn: TaskChatTurnItem; startMs: number }[],
|
||
): TaskChatItem[] {
|
||
const out: TaskChatItem[] = [];
|
||
let next = 0;
|
||
for (const entry of entries) {
|
||
while (next < unanchored.length && unanchored[next].startMs < entry.ms) {
|
||
out.push(unanchored[next++].turn);
|
||
}
|
||
out.push(entry.item);
|
||
const following = turnsByAnchor.get(entry.id);
|
||
if (following) out.push(...following);
|
||
}
|
||
while (next < unanchored.length) out.push(unanchored[next++].turn);
|
||
return out;
|
||
}
|
||
|
||
/** Stable id of the description-as-first-bubble item (PAP-375). */
|
||
export const ISSUE_BRIEF_ITEM_ID = "issue-brief";
|
||
|
||
/**
|
||
* Prepend the issue-brief placeholder (PAP-375) to the fully assembled thread.
|
||
* Running AFTER assembleThreadItems/coalesce/attach makes the ordering
|
||
* guarantee structural rather than data-dependent: even an unanchored settled
|
||
* turn whose startMs predates every backbone entry (F15) lands below the
|
||
* description bubble.
|
||
*/
|
||
export function prependIssueBrief(
|
||
items: TaskChatItem[],
|
||
hasBrief: boolean,
|
||
): TaskChatItem[] {
|
||
if (!hasBrief) return items;
|
||
return [{ id: ISSUE_BRIEF_ITEM_ID, kind: "brief" }, ...items];
|
||
}
|
||
|
||
/** 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.
|
||
*/
|
||
/** One banner line per server; the later run's report wins. */
|
||
function mergeMcpStartupFailures(
|
||
first: TaskChatTurnItem["mcpStartupFailures"],
|
||
second: TaskChatTurnItem["mcpStartupFailures"],
|
||
): TaskChatTurnItem["mcpStartupFailures"] {
|
||
if (!first?.length) return second;
|
||
if (!second?.length) return first;
|
||
const byServer = new Map(first.map((failure) => [failure.serverName, failure]));
|
||
for (const failure of second) byServer.set(failure.serverName, failure);
|
||
return [...byServer.values()];
|
||
}
|
||
|
||
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 &&
|
||
Boolean(item.historical) === Boolean(held.historical) &&
|
||
meta.agentKey &&
|
||
meta.agentKey === heldMeta.agentKey
|
||
) {
|
||
out.splice(heldIdx, 1);
|
||
const parts = [...heldMeta.parts, ...meta.parts];
|
||
const mcpStartupFailures = mergeMcpStartupFailures(held.mcpStartupFailures, item.mcpStartupFailures);
|
||
out.push({
|
||
...held,
|
||
items: [...held.items, ...item.items],
|
||
...(mcpStartupFailures ? { mcpStartupFailures } : {}),
|
||
finalResponse: item.finalResponse ?? held.finalResponse,
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Final assembly pass (round 9, after coalesceSettledTurns): a settled turn
|
||
* that directly follows its own agent's reply bubble folds INTO that bubble as
|
||
* `attachedTurn` — the "Worked · …" summary renders appended to the bubble's
|
||
* always-visible timestamp line instead of as a standalone row. The turn must
|
||
* belong to the same agent as the bubble (metaById identity); turns without
|
||
* meta, or preceded by anything other than that agent's non-interstitial
|
||
* bubble, keep the standalone-row fallback.
|
||
*/
|
||
export function attachSettledTurns(
|
||
items: readonly TaskChatItem[],
|
||
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
|
||
): TaskChatItem[] {
|
||
const out: TaskChatItem[] = [];
|
||
for (const item of items) {
|
||
const prev = out[out.length - 1];
|
||
if (
|
||
item.kind === "turn" &&
|
||
item.settled &&
|
||
prev?.kind === "message" &&
|
||
prev.author === "agent" &&
|
||
prev.origin !== "fast_response" &&
|
||
!prev.interstitial &&
|
||
!prev.streaming &&
|
||
prev.attachedTurn == null
|
||
) {
|
||
const meta = metaById.get(item.id);
|
||
const sameAgent =
|
||
meta != null &&
|
||
(meta.agentName == null ||
|
||
prev.authorName == null ||
|
||
meta.agentName === prev.authorName);
|
||
if (sameAgent) {
|
||
out[out.length - 1] = { ...prev, attachedTurn: item };
|
||
continue;
|
||
}
|
||
}
|
||
out.push(item);
|
||
}
|
||
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")
|
||
* with tool + target as detail; `toolName` lets the pill show the family icon.
|
||
* A tail assistant message yields "Responding" plus `selfTalk` — the flattened
|
||
* text of the interstitial update streamed so far, which takes over the parent
|
||
* row's line while it streams (PAP-361).
|
||
*/
|
||
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||
label: string;
|
||
detail?: string;
|
||
toolName?: string;
|
||
selfTalk?: string;
|
||
} {
|
||
for (let i = entries.length - 1; i >= 0; i--) {
|
||
const entry = entries[i];
|
||
if (entry.kind === "tool_call") {
|
||
// A tail update may carry the generic placeholder name or a retitle to
|
||
// the invocation ("Terminal" → "ls -la"): the call's FIRST real name is
|
||
// its identity, and a differing later title is the invocation detail.
|
||
let name = entry.name;
|
||
let invocation: string | undefined;
|
||
if (entry.toolUseId) {
|
||
for (const prev of entries) {
|
||
if (prev === entry) break;
|
||
if (
|
||
prev.kind === "tool_call" &&
|
||
prev.toolUseId === entry.toolUseId &&
|
||
!isGenericToolName(prev.name)
|
||
) {
|
||
if (!isGenericToolName(entry.name) && entry.name !== prev.name) {
|
||
invocation = entry.name;
|
||
}
|
||
name = prev.name;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const target =
|
||
summarizeToolInput(entry.input) ??
|
||
(invocation ? clip(invocation, TARGET_MAX) : undefined);
|
||
const display = toolDisplayName(name);
|
||
return {
|
||
label: toolTaxonomy(name).verbLabel,
|
||
detail: target ? `${display} · ${target}` : display,
|
||
toolName: name ?? undefined,
|
||
};
|
||
}
|
||
if (entry.kind === "tool_result") break;
|
||
if (entry.kind === "assistant") {
|
||
// Accumulate the trailing message's deltas (the same coalescing run the
|
||
// parser groups into one item: broken by tool/thinking/diff, not by
|
||
// status-only entries like stdout).
|
||
const parts: string[] = [];
|
||
for (let j = i; j >= 0; j--) {
|
||
const prev = entries[j];
|
||
if (prev.kind === "assistant") {
|
||
if (prev.text) parts.unshift(prev.text);
|
||
continue;
|
||
}
|
||
if (
|
||
prev.kind === "tool_call" ||
|
||
prev.kind === "tool_result" ||
|
||
prev.kind === "thinking" ||
|
||
prev.kind === "diff"
|
||
) {
|
||
break;
|
||
}
|
||
}
|
||
const selfTalk = flattenSelfTalk(parts.join(""));
|
||
return { label: "Responding", selfTalk: selfTalk || undefined };
|
||
}
|
||
if (entry.kind === "thinking") return { label: "Thinking" };
|
||
if (entry.kind === "system" && entry.text === "Reasoning started")
|
||
return { label: "Thinking" };
|
||
}
|
||
return { label: "Running" };
|
||
}
|