Files
PaperClipAI/ui/src/components/transcript/native-run-events.ts
T
DottaandPaperclip 8ee851a0d8 fix(ui): keep runner turns stable through startup and completion (#16989)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task chat shows each agent turn from message submission through
completion.
> - Run admission can publish adapter metadata before native runtime
metadata.
> - The UI changed layouts during that gap and removed the agent
identity.
> - Completion reports can also arrive before the provider writes its
final reply.
> - The event reader treated those reports as final replies and then
replaced them.
> - This pull request keeps the runner layout stable and delays report
fallbacks until the final history fetch completes.
> - Readers see one provider reply, while report-only runs keep a useful
answer.

## Linked Issues or Issue Description

**What happened?**

Sending a task message briefly replaced the runner avatar, name, and
Thinking row with a generic Working row. At completion, a full report
summary appeared as the answer. A shorter provider reply then replaced
it while streaming.

**Expected behavior**

Keep the runner identity and header spacing stable during admission.
Stream the provider's final reply once. Use a report summary as a
fallback only after the run ends and its final history fetch completes
without a provider reply.

**Steps to reproduce**

1. Send a message to a native Paperclip Runner agent.
2. Observe the initial run record before native runtime metadata
arrives.
3. Let the agent call its completion tool before writing its final
response.
4. Observe the report summary, final response stream, and saved reply
handoff.

**Paperclip version or commit**

Reproduced from source at `6b804bfeba`. The event-reader behavior was
also present in earlier revisions.

**Deployment mode**

Self-hosted server. Local component tests and browser fixtures reproduce
the UI transitions.

Related work: Refs #12617, which added native runner turns to task chat.
The duplicate search found no open PR for this specific defect.

## What Changed

- Keep Paperclip Runner turns in the runner layout while runtime
metadata resolves. Use the same header spacing for queued and running
states.
- Keep structured reports as report entries. Do not create synthetic
provider final messages from them.
- Track successful event fetches for each run status. Permit a terminal
report fallback after the final fetch, or when the transcript contains
its terminal receipt.
- Preserve report-only answers and saved reply deduplication.
- Add desktop and mobile regressions for streamed replies, late final
events, report-only runs, pagination, and fetch failures. Delay
missing-answer notices until terminal history is ready. Document the
presentation rules in `DESIGN.md`.

## Verification

- Passed all 8,750 UI tests across 769 files. This includes 456 focused
tests for the thread, runner turn, native projection, native transcript
hook, transcript adapter, and boundary fixtures.
- The six completion regressions fail against the old native event
reader and pass with the fix. Three empty-history regressions fail
before the review fix and pass afterward. The startup regressions also
fail against the old layout condition.
- Passed UI TypeScript checking with `node
node_modules/typescript/bin/tsc -b ui/tsconfig.json`.
- Passed the UI production build with `cd ui && node
node_modules/vite/bin/vite.js build`.
- Passed `pnpm check:token-gates`, `git diff --check`, and the Storybook
catalog generator.
- Browser check: mounted the production turn with native event snapshots
at desktop and 390-pixel mobile widths. The report stayed hidden while
running. The actual reply stayed unchanged at completion. The
report-only fallback appeared after completion. The identity row kept
the same position and size. No browser errors or horizontal overflow
occurred. The temporary fixture was removed.
- Passed `pnpm -r typecheck` and `pnpm build`. Initial attempts failed
with `COPILOT_INSTALLATION_INVALID`. Reinstalling the generated Copilot
assets resolved that local packaging failure without source changes.
- The full repository test run was attempted locally and stopped after
repeated embedded PostgreSQL bootstrap failures in server suites. The
complete UI suite passes independently. In CI, serialized shard 5/9 had
one 15-second timeout in the unchanged `agent-live-run-routes.test.ts`
history-limit case; its other 387 tests passed. All five history-limit
variants pass locally. The single CI job retry passed. The original
timeout cause is not established.

## Risks

- Report-only answers appear later because the UI waits for terminal
history. Failed event fetches keep that fallback pending until a retry
succeeds; provider replies remain visible.
- Historical replies and accepted response-wake behavior retain their
existing resolution rules. The focused tests cover these paths.
- The change does not alter provider execution, completion authority,
database schema, or run timing.

## Model Used

OpenAI GPT-6 through Codex. The exact serving model ID and context
window are not exposed in this session. Used reasoning, code execution,
and browser inspection.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 12:45:47 -05:00

1074 lines
36 KiB
TypeScript

import type { HeartbeatRunEvent } from "@paperclipai/shared";
import type { TranscriptEntry } from "@/adapters";
import { isRunLogOnlyProviderEvent } from "./run-log-only-events";
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function finiteNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
}
function normalizedItem(payload: Record<string, unknown>): Record<string, unknown> {
return record(payload.item) ?? payload;
}
function normalizedItemKind(payload: Record<string, unknown>): string {
const item = normalizedItem(payload);
return (text(payload.kind) ?? text(item.kind) ?? text(item.type) ?? "")
.replaceAll("_", "")
.toLowerCase();
}
function isAssistantItemKind(kind: string): boolean {
return kind === "agentmessage" || kind === "assistantmessage";
}
function normalizedItemId(
envelope: Record<string, unknown>,
payload: Record<string, unknown>,
): string | null {
const item = normalizedItem(payload);
return text(envelope.itemId) ?? text(payload.itemId) ?? text(item.id);
}
function normalizedItemText(payload: Record<string, unknown>): string | null {
const item = normalizedItem(payload);
return text(payload.text) ?? text(item.text);
}
function assistantChannel(
payload: Record<string, unknown>,
fallback: "progress" | "final" | "unknown" = "unknown",
): "progress" | "final" | "unknown" {
const item = normalizedItem(payload);
const value = text(payload.channel) ?? text(item.channel);
if (value === "progress" || value === "final" || value === "unknown") return value;
return fallback;
}
function reasoningChannel(
payload: Record<string, unknown>,
fallback: "summary" | "detail" | "unknown" = "unknown",
): "summary" | "detail" | "unknown" {
const item = normalizedItem(payload);
const value = text(payload.channel) ?? text(item.channel);
if (value === "summary" || value === "detail" || value === "unknown") return value;
return fallback;
}
interface ItemIdentity {
kind: string;
assistantChannel: "progress" | "final" | "unknown";
reasoningChannel: "summary" | "detail" | "unknown";
}
function resolveItemIdentity(
payload: Record<string, unknown>,
previous?: ItemIdentity,
): ItemIdentity {
return {
kind: normalizedItemKind(payload) || previous?.kind || "",
assistantChannel: assistantChannel(payload, previous?.assistantChannel),
reasoningChannel: reasoningChannel(payload, previous?.reasoningChannel),
};
}
function isItemIdentityEvent(eventType: string): boolean {
return eventType === "item.started"
|| eventType === "item.delta"
|| eventType === "item.completed";
}
const TOOL_EXECUTION_SCHEMA = "paperclip.tool.execution.v1";
const RUN_RESULT_SCHEMA = "paperclip.run_result.v1";
const RUN_TERMINAL_SCHEMA = "paperclip.prp.terminal.v1";
function canonicalQuestionSet(
value: unknown,
): Extract<TranscriptEntry, { kind: "runtime_request" }>["questionSet"] {
const candidate = record(value);
if (
!candidate
|| candidate.schema !== "paperclip.question_set.v1"
|| !Array.isArray(candidate.questions)
|| candidate.questions.length === 0
) return null;
const valid = candidate.questions.every((rawQuestion) => {
const question = record(rawQuestion);
return Boolean(
question
&& text(question.id)
&& text(question.prompt)
&& typeof question.required === "boolean"
&& (
question.answerMode === "single_select"
|| question.answerMode === "multi_select"
|| question.answerMode === "text"
),
);
});
return valid
? structuredClone(candidate) as unknown as NonNullable<
Extract<TranscriptEntry, { kind: "runtime_request" }>["questionSet"]
>
: null;
}
function canonicalQuestionResponse(
value: unknown,
): Extract<TranscriptEntry, { kind: "runtime_request" }>["response"] {
const candidate = record(value);
return candidate
&& candidate.schema === "paperclip.question_response.v1"
&& record(candidate.answers)
? structuredClone(candidate) as unknown as NonNullable<
Extract<TranscriptEntry, { kind: "runtime_request" }>["response"]
>
: null;
}
function verificationStatus(value: unknown): "passed" | "failed" | "not_run" {
return value === "passed" || value === "failed" ? value : "not_run";
}
function runtimeRequestEntry(input: {
eventType: string;
envelope: Record<string, unknown>;
payload: Record<string, unknown>;
ts: string;
previous?: Extract<TranscriptEntry, { kind: "runtime_request" }>;
}): Extract<TranscriptEntry, { kind: "runtime_request" }> | null {
const request = record(input.payload.request) ?? input.payload;
const requestId = text(request.requestId) ?? text(input.payload.requestId);
if (!requestId) return null;
const suffix = input.eventType.split(".").at(-1);
// The lifecycle event is authoritative. ACP delivery receipts carry
// status:"delivered" in their payload, which must not reopen a resolved
// request as pending just because it is not a UI lifecycle status.
const rawStatus = suffix === "resolved" || suffix === "expired" || suffix === "cancelled"
? suffix
: text(request.status) ?? suffix;
const resolvedAction = text(request.action)
?? text(input.payload.action)
?? input.previous?.resolvedAction
?? null;
const lifecycleStatus = rawStatus === "resolved"
|| rawStatus === "expired"
|| rawStatus === "cancelled"
? rawStatus
: "pending";
const status = lifecycleStatus === "resolved"
&& (resolvedAction === "cancel" || resolvedAction === "decline")
? "cancelled"
: lifecycleStatus;
const rawKind = text(request.requestKind) ?? input.previous?.requestKind ?? null;
const requestKind = rawKind === "runtime"
|| rawKind === "command_approval"
|| rawKind === "file_approval"
|| rawKind === "permission_approval"
|| rawKind === "user_input"
|| rawKind === "elicitation"
? rawKind
: null;
const rawType = text(request.type) ?? input.previous?.requestType ?? "permission";
const requestType = requestKind === "user_input"
|| requestKind === "elicitation"
|| rawType === "input"
|| rawType.includes("input")
|| rawType.includes("elicitation")
? "input"
: "permission";
const choices = (Array.isArray(request.choices) ? request.choices : [])
.map(record)
.flatMap((choice) => {
const key = text(choice?.key);
const label = text(choice?.label);
return key && label ? [{ key, label }] : [];
})
.slice(0, 32);
const details = record(request.details);
const fields = (Array.isArray(details?.fields) ? details.fields : [])
.map(record)
.flatMap((field, index) => {
const name = text(field?.name) ?? `answer_${index + 1}`;
const label = text(field?.label) ?? text(field?.name) ?? `Answer ${index + 1}`;
return name && label
? [{ name: name.slice(0, 160), label: label.slice(0, 240), placeholder: text(field?.placeholder)?.slice(0, 500) ?? null }]
: [];
})
.slice(0, 16);
return {
kind: "runtime_request",
ts: input.ts,
requestId,
requestKind,
turnId: text(request.turnId)
?? text(input.payload.turnId)
?? text(input.envelope.turnId)
?? input.previous?.turnId
?? null,
requestType,
status,
prompt: text(request.prompt)
?? input.previous?.prompt
?? "Runtime approval requested",
choices: choices.length > 0 ? choices : input.previous?.choices ?? [],
fields: fields.length > 0 ? fields : input.previous?.fields ?? [],
questionSet: canonicalQuestionSet(request.input)
?? input.previous?.questionSet
?? null,
resolvedAction,
response: canonicalQuestionResponse(request.response ?? input.payload.response)
?? input.previous?.response
?? null,
};
}
function runResultEntry(
payload: Record<string, unknown>,
ts: string,
): Extract<TranscriptEntry, { kind: "run_result" }> {
const completion = record(payload.completionClaim) ?? {};
const blocker = record(payload.blocker);
const rawDisposition = text(payload.reportedWorkDisposition);
const disposition = rawDisposition === "blocked"
|| rawDisposition === "needs_review"
|| rawDisposition === "yielded"
? rawDisposition
: "done";
return {
kind: "run_result",
ts,
disposition,
summary: text(payload.summary) ?? "Run completed",
objectiveSatisfied: typeof completion.objectiveSatisfied === "boolean"
? completion.objectiveSatisfied
: null,
verification: (Array.isArray(payload.verification) ? payload.verification : [])
.map(record)
.flatMap((item) => item ? [{
commandOrCheck: text(item.commandOrCheck) ?? "Verification",
status: verificationStatus(item.status),
...(text(item.detail) ? { detail: text(item.detail)! } : {}),
...(text(item.artifactRef) ? { artifactRef: text(item.artifactRef)! } : {}),
}] : [])
.slice(0, 64),
remainingWork: (Array.isArray(completion.remainingWork) ? completion.remainingWork : [])
.map(record)
.flatMap((item) => item && text(item.description) ? [{
description: text(item.description)!,
blocksCompletion: item.blocksCompletion === true,
}] : [])
.slice(0, 64),
blocker: blocker ? {
reasonCode: text(blocker.reasonCode) ?? "blocked",
unblockAction: text(blocker.unblockAction) ?? "Resolve the blocker to continue.",
scope: blocker.scope === "task_wide" ? "task_wide" : "current_track",
} : null,
artifacts: (Array.isArray(payload.artifacts) ? payload.artifacts : [])
.map(record)
.flatMap((item) => item && text(item.ref) ? [{
kind: text(item.kind) ?? "artifact",
ref: text(item.ref)!,
...(text(item.title) ? { title: text(item.title)! } : {}),
}] : [])
.slice(0, 64),
};
}
function runTerminalEntry(
payload: Record<string, unknown>,
ts: string,
): Extract<TranscriptEntry, { kind: "run_terminal" }> | null {
if (payload.schema !== RUN_TERMINAL_SCHEMA) return null;
const rawTurnState = text(payload.turnTerminalState);
const rawRunState = text(payload.runTerminalState);
const rawDisposition = text(payload.reportedWorkDisposition);
if (
!rawTurnState
|| !["completed", "failed", "interrupted", "cancelled"].includes(rawTurnState)
|| !rawRunState
|| !["succeeded", "failed", "cancelled"].includes(rawRunState)
|| !rawDisposition
|| !["done", "blocked", "needs_review", "yielded"].includes(rawDisposition)
) return null;
const stopReason = record(payload.stopReason);
return {
kind: "run_terminal",
ts,
turnState: rawTurnState as Extract<TranscriptEntry, { kind: "run_terminal" }>["turnState"],
runState: rawRunState as Extract<TranscriptEntry, { kind: "run_terminal" }>["runState"],
disposition: rawDisposition as Extract<TranscriptEntry, { kind: "run_terminal" }>["disposition"],
...(text(stopReason?.message) || text(stopReason?.code)
? { stopReason: text(stopReason?.message) ?? text(stopReason?.code)! }
: {}),
};
}
const PROVIDER_ACTIVITY_PRESENTATIONS = {
"plan.updated": {
schema: "paperclip.plan.updated.v1",
idKey: "planId",
name: "plan",
summaryKeys: ["explanation"],
},
"research.started": {
schema: "paperclip.research.v1",
idKey: "researchId",
name: "research",
summaryKeys: ["query", "pattern", "url"],
},
"research.progressed": {
schema: "paperclip.research.v1",
idKey: "researchId",
name: "research",
summaryKeys: ["query", "pattern", "url"],
},
"research.completed": {
schema: "paperclip.research.v1",
idKey: "researchId",
name: "research",
summaryKeys: ["query", "pattern", "url"],
},
"delegation.started": {
schema: "paperclip.delegation.v1",
idKey: "delegationId",
name: "delegation",
summaryKeys: ["action"],
},
"delegation.updated": {
schema: "paperclip.delegation.v1",
idKey: "delegationId",
name: "delegation",
summaryKeys: ["action"],
},
"delegation.completed": {
schema: "paperclip.delegation.v1",
idKey: "delegationId",
name: "delegation",
summaryKeys: ["action"],
},
"model.route.changed": {
schema: "paperclip.model.route_changed.v1",
idKey: "routeId",
name: "model",
summaryKeys: ["reason", "effectiveModel"],
},
"model.verification.updated": {
schema: "paperclip.model.verification.v1",
idKey: "verificationId",
name: "model",
summaryKeys: ["summary"],
},
"context.compacted": {
schema: "paperclip.context.compacted.v1",
idKey: "compactionId",
name: "context",
summaryKeys: ["reason"],
},
"artifact.viewed": {
schema: "paperclip.artifact.viewed.v1",
idKey: "artifactId",
name: "artifact",
summaryKeys: ["title", "reference"],
},
"artifact.generated": {
schema: "paperclip.artifact.generated.v1",
idKey: "artifactId",
name: "artifact",
summaryKeys: ["failure", "reference"],
},
"review.mode.changed": {
schema: "paperclip.review.mode_changed.v1",
idKey: "reviewId",
name: "review",
summaryKeys: ["scope", "state"],
},
"hook.started": {
schema: "paperclip.hook.v1",
idKey: "hookId",
name: "hook",
summaryKeys: ["summary", "event"],
},
"hook.completed": {
schema: "paperclip.hook.v1",
idKey: "hookId",
name: "hook",
summaryKeys: ["summary", "event"],
},
"memory.citation.referenced": {
schema: "paperclip.memory.citation.v1",
idKey: "citationId",
name: "memory",
summaryKeys: ["label"],
},
"safety.review.started": {
schema: "paperclip.safety.review.v1",
idKey: "reviewId",
name: "safety",
summaryKeys: ["summary", "decision"],
},
"safety.review.completed": {
schema: "paperclip.safety.review.v1",
idKey: "reviewId",
name: "safety",
summaryKeys: ["summary", "decision"],
},
"terminal.input.sent": {
schema: "paperclip.terminal.input_sent.v1",
idKey: "executionId",
name: "terminal",
summaryKeys: ["inputClass"],
},
"wait.started": {
schema: "paperclip.wait.v1",
idKey: "waitId",
name: "wait",
summaryKeys: ["reason"],
},
"wait.completed": {
schema: "paperclip.wait.v1",
idKey: "waitId",
name: "wait",
summaryKeys: ["reason"],
},
"provider.notice.recorded": {
schema: "paperclip.provider.notice.v1",
idKey: "noticeId",
name: "Provider notice",
summaryKeys: ["summary"],
},
} as const;
type ProviderActivityEventType = keyof typeof PROVIDER_ACTIVITY_PRESENTATIONS;
const NONTERMINAL_PROVIDER_ACTIVITY_STATUSES = new Set([
"running",
"pending",
"in_progress",
"waiting",
]);
const TERMINAL_PROVIDER_ACTIVITY_STATUSES = new Set([
"completed",
"failed",
"cancelled",
"interrupted",
"closed",
"denied",
]);
function providerActivityPresentation(
event: HeartbeatRunEvent,
payload: Record<string, unknown>,
): { id: string; name: string; summary: string; terminal: boolean; failed: boolean } | null {
if (!Object.prototype.hasOwnProperty.call(PROVIDER_ACTIVITY_PRESENTATIONS, event.eventType)) {
return null;
}
const presentation = PROVIDER_ACTIVITY_PRESENTATIONS[
event.eventType as ProviderActivityEventType
];
if (!presentation || payload.schema !== presentation.schema) return null;
const identity = text(payload[presentation.idKey]);
if (!identity) return null;
const summary = presentation.summaryKeys
.map((key) => text(payload[key]))
.find((value): value is string => value !== null)
?? event.eventType;
const status = text(payload.status);
const failed = status === "failed" || status === "denied" || payload.severity === "error";
const terminal = failed
? true
: NONTERMINAL_PROVIDER_ACTIVITY_STATUSES.has(status ?? "")
? false
: TERMINAL_PROVIDER_ACTIVITY_STATUSES.has(status ?? "")
|| event.eventType.endsWith(".completed")
|| event.eventType.endsWith(".failed")
|| (!event.eventType.endsWith(".started") && !event.eventType.endsWith(".progressed"));
return {
id: `${event.eventType.split(".")[0]}:${identity}`,
name: presentation.name,
summary,
terminal,
failed,
};
}
function timestamp(event: HeartbeatRunEvent, envelope: Record<string, unknown>): string {
const emittedAt = text(envelope.emittedAt);
if (emittedAt) return emittedAt;
const createdAt = event.createdAt instanceof Date ? event.createdAt.toISOString() : String(event.createdAt);
return Number.isNaN(Date.parse(createdAt)) ? new Date(0).toISOString() : createdAt;
}
interface NativeToolItemDetails {
name: string | null;
input?: unknown;
result?: unknown;
isError: boolean;
}
function nativeToolItemDetails(
payload: Record<string, unknown>,
): {
id: string | null;
kind: "tooluse" | "toolresult";
details: NativeToolItemDetails;
} | null {
const item = normalizedItem(payload);
const kind = (text(item.type) ?? "").replaceAll("_", "").toLowerCase();
if (kind !== "tooluse" && kind !== "toolresult") return null;
const id = kind === "toolresult"
? text(item.tool_use_id) ?? text(item.id)
: text(item.id);
return {
id,
kind,
details: {
name: text(item.name),
...(Object.prototype.hasOwnProperty.call(item, "input")
? { input: item.input }
: {}),
...(Object.prototype.hasOwnProperty.call(item, "result")
? { result: item.result }
: {}),
isError: item.isError === true || item.is_error === true,
},
};
}
function serializedNativeToolResult(item: NativeToolItemDetails): string {
if (item.result === undefined) return "";
try {
return JSON.stringify(item.result) ?? "";
} catch {
return "Tool result could not be serialized";
}
}
function toolPresentation(
payload: Record<string, unknown>,
item?: NativeToolItemDetails,
): { name: string; input: unknown } {
const transport = text(payload.transport);
const operation = text(payload.operation);
const reportedName = text(payload.name) ?? item?.name ?? null;
if (transport === "process") {
return {
name: "Bash",
input: reportedName ? { command: reportedName } : { operation: operation ?? "execute" },
};
}
return {
name: reportedName ?? operation ?? "Tool",
input: item?.input ?? {
...(operation ? { operation } : {}),
...(text(payload.namespace) ? { namespace: text(payload.namespace) } : {}),
...(text(payload.target) ? { target: text(payload.target) } : {}),
},
};
}
/**
* Project persisted, provider-neutral PRP events into the legacy transcript
* model already consumed by the task thread. Provider-native envelopes never
* reach this boundary and unknown event kinds remain safely invisible.
*/
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
const startedToolIds = new Set<string>();
const completedToolIds = new Set<string>();
let usageSummary: {
ts: string;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
costUsd: number;
} | null = null;
let cumulativeUsageSummary: {
ts: string;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
costUsd: number;
} | null = null;
const seenSourceEventIds = new Set<string>();
const orderedEvents = [...events]
.sort((a, b) => a.seq - b.seq)
.filter((event) => {
const envelope = record(event.payload?.prpEvent);
const sourceEventId = text(envelope?.sourceEventId);
if (!sourceEventId) return true;
if (seenSourceEventIds.has(sourceEventId)) return false;
seenSourceEventIds.add(sourceEventId);
return true;
}).flatMap((event) => {
const envelope = record(event.payload?.prpEvent);
const payload = record(envelope?.payload);
if (!envelope || envelope.eventType !== event.eventType
|| !/^(item|turn|session)\.(completed|failed|cancelled|interrupted|closed)$/.test(event.eventType)
|| !Array.isArray(payload?.outputTails)) return [event];
// Redaction can hold a possible key prefix until the item/turn ends.
// Project its settled tail as display-only deltas before the terminal.
const tails = payload.outputTails.flatMap((value, index): HeartbeatRunEvent[] => {
const tail = record(value);
const tailPayload = record(tail?.payload);
if (!tailPayload || typeof tailPayload.text !== "string") return [];
return [{ ...event, eventType: "item.delta", payload: { prpEvent: {
...envelope, eventType: "item.delta", itemId: tail?.itemId,
sourceEventId: `${envelope.sourceEventId}:output-tail:${index}`, payload: tailPayload,
} } }];
});
return [...tails, event];
});
const hasAcceptedResult = orderedEvents.some(
(event) => event.eventType === "run.result.accepted",
);
const runtimeRequests = new Map<
string,
Extract<TranscriptEntry, { kind: "runtime_request" }>
>();
let hasRunResult = false;
let responseWakeCandidate: {
entry: Extract<TranscriptEntry, { kind: "run_result" }>;
runId: string;
sourceEventId: string;
sourceInstanceId: string;
normalizedSessionId: string;
turnId: string | null;
seq: number;
} | null = null;
const acceptedResultCounts = new Map<string, number>();
const terminalsByRun = new Map<
string,
{
entry: Extract<TranscriptEntry, { kind: "run_terminal" }>;
seq: number;
envelope: Record<string, unknown>;
}
>();
const completedAgentMessageIds = new Set<string>();
const completedReasoningIds = new Set<string>();
const completionItemIdentityById = new Map<string, ItemIdentity>();
const nativeToolItemsById = new Map<string, NativeToolItemDetails>();
for (const event of orderedEvents) {
if (!isItemIdentityEvent(event.eventType)) continue;
const envelope = record(event.payload?.prpEvent);
if (
!envelope
|| envelope.schema !== "paperclip.prp.event.v1"
|| envelope.schemaVersion !== 1
|| envelope.runId !== event.runId
|| envelope.eventType !== event.eventType
) continue;
const payload = record(envelope?.payload);
if (!payload) continue;
const itemId = normalizedItemId(envelope, payload);
if (!itemId) continue;
const toolItem = nativeToolItemDetails(payload);
if (toolItem) {
const toolId = toolItem.id ?? itemId;
const previous = nativeToolItemsById.get(toolId);
nativeToolItemsById.set(toolId, {
name: toolItem.details.name ?? previous?.name ?? null,
...(toolItem.details.input !== undefined
? { input: toolItem.details.input }
: previous?.input !== undefined
? { input: previous.input }
: {}),
...(toolItem.details.result !== undefined
? { result: toolItem.details.result }
: previous?.result !== undefined
? { result: previous.result }
: {}),
isError: toolItem.details.isError || previous?.isError === true,
});
}
const identity = resolveItemIdentity(
payload,
completionItemIdentityById.get(itemId),
);
if (identity.kind) completionItemIdentityById.set(itemId, identity);
if (event.eventType !== "item.completed") continue;
const kind = identity.kind;
if (isAssistantItemKind(kind) && itemId && normalizedItemText(payload)) {
completedAgentMessageIds.add(itemId);
}
if (kind === "reasoning" && itemId && normalizedItemText(payload)) {
completedReasoningIds.add(itemId);
}
}
const itemIdentityById = new Map<string, ItemIdentity>();
for (const event of orderedEvents) {
const envelope = record(event.payload?.prpEvent);
if (
!envelope
|| envelope.schema !== "paperclip.prp.event.v1"
|| envelope.schemaVersion !== 1
) continue;
if (envelope.runId !== event.runId || envelope.eventType !== event.eventType) continue;
const payload = record(envelope.payload);
if (!payload) continue;
const ts = timestamp(event, envelope);
const itemId = normalizedItemId(envelope, payload);
const itemIdentity = resolveItemIdentity(
payload,
itemId ? itemIdentityById.get(itemId) : undefined,
);
if (itemId && itemIdentity.kind && isItemIdentityEvent(event.eventType)) {
itemIdentityById.set(itemId, itemIdentity);
}
const itemKind = itemIdentity.kind;
if (event.eventType === "item.delta" && isAssistantItemKind(itemKind)) {
const value = normalizedItemText(payload);
if (!value || !itemId) continue;
// Once the loss-resistant completion is present, prefer its full text.
// Before that point the deltas still provide the live streaming view.
if (completedAgentMessageIds.has(itemId)) continue;
const channel = itemIdentity.assistantChannel;
entries.push({ kind: "assistant", ts, text: value, delta: true, channel, itemId });
continue;
}
if (event.eventType === "item.completed" && isAssistantItemKind(itemKind)) {
const value = normalizedItemText(payload);
if (!value) continue;
const channel = itemIdentity.assistantChannel;
entries.push({ kind: "assistant", ts, text: value, channel, ...(itemId ? { itemId } : {}) });
continue;
}
if (event.eventType === "item.delta" && itemKind === "reasoning") {
const value = normalizedItemText(payload);
if (!value || !itemId || completedReasoningIds.has(itemId)) continue;
entries.push({
kind: "thinking",
ts,
text: value,
delta: true,
lifecycle: "started",
channel: itemIdentity.reasoningChannel,
itemId,
});
continue;
}
if (event.eventType === "item.completed" && itemKind === "reasoning") {
const value = normalizedItemText(payload);
if (!value) continue;
entries.push({
kind: "thinking",
ts,
text: value,
lifecycle: "completed",
channel: itemIdentity.reasoningChannel,
...(itemId ? { itemId } : {}),
});
continue;
}
// Some provider transports expose their complete dynamic-tool lifecycle
// directly as item.started/item.completed events and do not emit the
// parallel tool.execution.* activity stream. Project those canonical
// tool items at their own stable item boundary so saved documents can be
// embedded beside the write that created them instead of falling back to
// the end of the run timeline.
const nativeToolEvent = isItemIdentityEvent(event.eventType)
? nativeToolItemDetails(payload)
: null;
if (nativeToolEvent) {
const toolId = nativeToolEvent.id ?? itemId;
if (!toolId) continue;
const nativeToolItem = nativeToolItemsById.get(toolId)
?? nativeToolEvent.details;
const presentation = toolPresentation({}, nativeToolItem);
if (!startedToolIds.has(toolId)) {
startedToolIds.add(toolId);
entries.push({
kind: "tool_call",
ts,
name: presentation.name,
input: presentation.input,
toolUseId: toolId,
});
}
if (
event.eventType === "item.completed"
&& (nativeToolEvent.kind === "toolresult"
|| nativeToolEvent.details.result !== undefined)
&& !completedToolIds.has(toolId)
) {
completedToolIds.add(toolId);
entries.push({
kind: "tool_result",
ts,
toolUseId: toolId,
toolName: presentation.name,
content: serializedNativeToolResult(nativeToolItem),
isError: nativeToolItem.isError,
});
}
continue;
}
// Notices are provider diagnostics, not tool calls. Preserve their message
// and category for the shared notice row instead of serializing an input blob.
if (event.eventType === "provider.notice.recorded" && payload.schema === "paperclip.provider.notice.v1") {
if (isRunLogOnlyProviderEvent(event.eventType, payload)) continue;
entries.push({
kind: "provider_activity",
ts,
family: "provider_notice",
eventType: event.eventType,
status: payload.severity === "error" ? "failed" : "informational",
title: "Provider notice",
summary: text(payload.summary)?.trim() || text(payload.message)?.trim() || "Provider notice",
payload,
});
continue;
}
const providerActivity = providerActivityPresentation(event, payload);
if (providerActivity) {
if (!startedToolIds.has(providerActivity.id)) {
startedToolIds.add(providerActivity.id);
entries.push({
kind: "tool_call",
ts,
name: providerActivity.name,
toolUseId: providerActivity.id,
input: { eventType: event.eventType, summary: providerActivity.summary },
});
}
if (providerActivity.terminal) {
entries.push({
kind: "tool_result",
ts,
toolUseId: providerActivity.id,
toolName: providerActivity.name,
content: providerActivity.summary,
isError: providerActivity.failed,
});
}
continue;
}
if (event.eventType === "tool.execution.started" || event.eventType === "tool.execution.completed") {
if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue;
const executionId = text(payload.executionId);
if (!executionId) continue;
const nativeToolItem = nativeToolItemsById.get(executionId);
const presentation = toolPresentation(payload, nativeToolItem);
if (!startedToolIds.has(executionId)) {
startedToolIds.add(executionId);
entries.push({
kind: "tool_call",
ts,
name: presentation.name,
input: presentation.input,
toolUseId: executionId,
});
}
if (event.eventType === "tool.execution.completed" && !completedToolIds.has(executionId)) {
completedToolIds.add(executionId);
const output = text(payload.output);
const content = output ?? (nativeToolItem
? serializedNativeToolResult(nativeToolItem)
: "");
entries.push({
kind: "tool_result",
ts,
toolUseId: executionId,
toolName: presentation.name,
content,
isError: payload.status === "failed" || nativeToolItem?.isError === true,
});
}
continue;
}
if (event.eventType === "usage.reported") {
// A provider may report only session-cumulative usage. Preserve the
// latest snapshot as explicitly session-scoped usage instead of either
// summing cumulative values or relabelling them as a per-run delta.
if (payload.runDeltaAvailable !== true) {
const cumulative = record(payload.cumulative);
if (cumulative) {
cumulativeUsageSummary = {
ts,
inputTokens: finiteNumber(cumulative.inputTokens),
outputTokens: finiteNumber(cumulative.outputTokens),
cachedTokens: finiteNumber(cumulative.cacheReadTokens),
costUsd: finiteNumber(cumulative.providerCostUsd),
};
}
continue;
}
const measurement = record(payload.runDelta);
if (!measurement) continue;
const next = {
ts,
inputTokens: finiteNumber(measurement.inputTokens),
outputTokens: finiteNumber(measurement.outputTokens),
cachedTokens: finiteNumber(measurement.cacheReadTokens),
costUsd: finiteNumber(measurement.providerCostUsd),
};
// Provider cumulative values are session-scoped and can include earlier
// runs. Fold only the event's run delta into this run's transcript.
usageSummary = usageSummary
? {
ts,
inputTokens: usageSummary.inputTokens + next.inputTokens,
outputTokens: usageSummary.outputTokens + next.outputTokens,
cachedTokens: usageSummary.cachedTokens + next.cachedTokens,
costUsd: usageSummary.costUsd + next.costUsd,
}
: next;
continue;
}
if (event.eventType.startsWith("runtime_request.")) {
const requestId = text((record(payload.request) ?? payload).requestId)
?? text(payload.requestId);
const entry = runtimeRequestEntry({
eventType: event.eventType,
envelope,
payload,
ts,
...(requestId ? { previous: runtimeRequests.get(requestId) } : {}),
});
if (entry) {
runtimeRequests.set(entry.requestId, entry);
entries.push(entry);
}
continue;
}
if (event.eventType === "run.terminal") {
const terminal = runTerminalEntry(payload, ts);
if (terminal) {
entries.push(terminal);
terminalsByRun.set(event.runId, {
entry: terminal,
seq: event.seq,
envelope,
});
}
continue;
}
if (
event.eventType === "run.result.proposed" ||
event.eventType === "run.result.accepted"
) {
if (event.eventType === "run.result.proposed" && hasAcceptedResult)
continue;
const result =
event.eventType === "run.result.accepted"
? record(payload.result)
: payload;
if (!result || result.schema !== RUN_RESULT_SCHEMA) continue;
if (event.eventType === "run.result.accepted") {
acceptedResultCounts.set(
event.runId,
(acceptedResultCounts.get(event.runId) ?? 0) + 1,
);
}
if (!hasRunResult) {
const entry = runResultEntry(result, ts);
entries.push(entry);
const continuation = record(result.continuation);
const sourceEventId = text(envelope.sourceEventId);
const sourceInstanceId = text(envelope.sourceInstanceId);
const normalizedSessionId = text(envelope.normalizedSessionId);
// Never infer this authority from provider prose, a proposed result,
// or a similarly named field supplied inside the semantic result.
if (
event.eventType === "run.result.accepted" &&
envelope.sourceKind === "control_plane" &&
entry.disposition === "yielded" &&
text(result.summary)?.trim() &&
continuation?.kind === "response_wake" &&
text(continuation.idempotencyKey)?.trim() &&
Array.isArray(result.attentionRequests) &&
result.attentionRequests.length === 0 &&
sourceEventId?.trim() &&
sourceInstanceId?.trim() &&
normalizedSessionId?.trim()
) {
responseWakeCandidate = {
entry,
runId: event.runId,
sourceEventId,
sourceInstanceId,
normalizedSessionId,
turnId: text(envelope.turnId),
seq: event.seq,
};
}
hasRunResult = true;
}
continue;
}
}
if (responseWakeCandidate) {
const candidate = responseWakeCandidate;
const terminal = terminalsByRun.get(candidate.runId);
if (
acceptedResultCounts.get(candidate.runId) === 1 &&
terminal &&
terminal.seq > candidate.seq &&
terminal.envelope.sourceKind === "control_plane" &&
text(terminal.envelope.sourceEventId)?.trim() &&
terminal.envelope.sourceInstanceId === candidate.sourceInstanceId &&
terminal.envelope.normalizedSessionId === candidate.normalizedSessionId &&
text(terminal.envelope.turnId) === candidate.turnId &&
terminal.entry.runState === "succeeded" &&
terminal.entry.turnState === "completed" &&
terminal.entry.disposition === "yielded" &&
![...runtimeRequests.values()].some(
(request) => request.status === "pending",
)
) {
candidate.entry.acceptedResponseWake = {
runId: candidate.runId,
sourceEventId: candidate.sourceEventId,
};
}
}
if (usageSummary) {
entries.push({
kind: "result",
...usageSummary,
text: "",
subtype: "paperclip_runner_usage",
isError: false,
errors: [],
});
} else if (cumulativeUsageSummary) {
entries.push({
kind: "result",
...cumulativeUsageSummary,
text: "Provider-reported session-cumulative usage; a per-run delta was unavailable.",
subtype: "paperclip_runner_session_usage",
isError: false,
errors: [],
});
}
return entries;
}