mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
feat(ui): project native runner turns into task chat (#12617)
## Thinking Path > - Paperclip is the open source app people use to supervise AI agents and their work. > - The task page is the established place to read run progress and answer agent questions. > - Native runner events use PRP envelopes instead of the direct-adapter transcript format. > - The task page needs a narrow projection for those events without changing legacy adapter behavior. > - Unknown event versions and fields must stay hidden until the UI supports them. > - This pull request adds a runtime-gated native turn projection and preserves the classic path. > - The benefit is one task thread for native Codex runs while direct adapters keep their current UI. ## Linked Issues or Issue Description **Subsystem affected** `ui/` task chat and transcript projection. **Problem or motivation** The server can record native runner events, replies, usage, and structured interactions, but the existing task page cannot safely render those records. Reusing the native path for direct adapters would also risk the legacy question and finalization behavior. **Proposed solution** Project supported PRP v1 events into the existing transcript model only when runtime facts identify a native `paperclip_runner` run. Use an exact event and payload allowlist. Keep direct adapters on the existing transcript, composer, interaction, and finalization path. **Alternatives considered** A separate runner page was rejected because it would split task history. A universal transcript replacement was rejected because the experimental runner must not alter legacy adapters. **Roadmap alignment** This change supports activity attribution and recoverable runs. It does not replace the current task page. ## What Changed - Project supported native PRP v1 assistant, reasoning, tool, activity, usage, interaction, and result events. - Render a native runner turn only when both runtime mode and adapter type match. - Recognize the canonical `assistant_message` item kind and preserve final-reply precedence. - Fail closed for unsupported PRP versions, event types, payload schemas, and identity fields. - Keep explicit running and pending provider activities open until a real terminal state arrives. - Include channel and lifecycle identity in memoization so progress-to-final transitions rerender. - Add focused native, empty-transcript, interaction, final, memoization, and direct-adapter regressions. ## Verification - GitHub Actions is the authoritative test environment for this stack. - This top PR receives the full stack-aware CI suite. - Greptile will review this exact nine-file delta after the branch is pushed. ## Risks - The main risk is changing direct-adapter task behavior. The render gate requires both native runtime mode and the `paperclip_runner` adapter. - The next risk is exposing future provider payloads. Version, event, schema, and field allowlists fail closed. - There are no database, lockfile, workflow, build-system, or server changes in this PR. ## Stack 1. [Runner package, SDK, and developer tools](https://github.com/paperclipai/paperclip/pull/12608) 2. [Codex production server integration](https://github.com/paperclipai/paperclip/pull/12616) 3. This PR: provider-neutral task-thread UI ## Model Used OpenAI Codex with GPT-5, extended reasoning, repository tools, and parallel review agents. ## 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 - [ ] 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
1 parent
51ad751e0b
commit
209680409d
9 files changed
+1139
-62
No files matched your search
@@ -42,27 +42,65 @@ function event(
|
||||
};
|
||||
}
|
||||
|
||||
function itemEvent(
|
||||
seq: number,
|
||||
eventType: "item.started" | "item.delta" | "item.completed",
|
||||
itemId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): HeartbeatRunEvent {
|
||||
const value = event(seq, eventType, payload);
|
||||
(value.payload!.prpEvent as Record<string, unknown>).itemId = itemId;
|
||||
return value;
|
||||
}
|
||||
|
||||
function runResult(summary: string): Record<string, unknown> {
|
||||
return {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "done",
|
||||
summary,
|
||||
completionClaim: {
|
||||
contractRevision: "test-v1",
|
||||
objectiveSatisfied: true,
|
||||
criteria: [],
|
||||
remainingWork: [],
|
||||
},
|
||||
evidence: [],
|
||||
verification: [],
|
||||
attentionRequests: [],
|
||||
artifacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("nativeRunEventsToTranscript", () => {
|
||||
it("projects provider-neutral messages, tools, usage, and the final reply", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
event(6, "run.result.proposed", { summary: "Done safely." }),
|
||||
event(6, "run.result.proposed", runResult("Done safely.")),
|
||||
event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Done " }),
|
||||
event(2, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "safely." }),
|
||||
event(3, "item.completed", { itemId: "message-1", kind: "agentMessage", text: "Done safely." }),
|
||||
event(4, "tool.execution.started", {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: "exec-1",
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
name: "pnpm test",
|
||||
status: "running",
|
||||
output: null,
|
||||
outputBytes: 0,
|
||||
outputTruncated: false,
|
||||
outputDigest: null,
|
||||
}),
|
||||
event(5, "tool.execution.completed", {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: "exec-1",
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
name: "pnpm test",
|
||||
status: "completed",
|
||||
output: "all green",
|
||||
outputBytes: 9,
|
||||
outputTruncated: false,
|
||||
outputDigest: null,
|
||||
}),
|
||||
event(7, "usage.reported", {
|
||||
runDeltaAvailable: true,
|
||||
@@ -110,6 +148,49 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("streams canonical kind-less deltas using item identity from item.started", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.started", "message-1", {
|
||||
kind: "assistant_message",
|
||||
channel: "progress",
|
||||
text: "",
|
||||
}),
|
||||
itemEvent(2, "item.delta", "message-1", { text: "Still " }),
|
||||
itemEvent(3, "item.delta", "message-1", { text: "working" }),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "Still ",
|
||||
delta: true,
|
||||
channel: "progress",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "working",
|
||||
delta: true,
|
||||
channel: "progress",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the canonical PRP v1 assistant_message kind as a channel-less final reply", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "item.completed", {
|
||||
kind: "assistant_message",
|
||||
text: "Canonical persisted reply.",
|
||||
}),
|
||||
event(2, "run.result.proposed", runResult(
|
||||
"Structured fallback must not replace the reply.",
|
||||
)),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "Canonical persisted reply.",
|
||||
channel: "unknown",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("sums run deltas without leaking session-cumulative usage", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
event(1, "usage.reported", {
|
||||
@@ -228,12 +309,176 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
|
||||
it("uses the structured run summary when no agent message was emitted", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "run.result.proposed", { summary: "Recovered final reply." }),
|
||||
event(1, "run.result.proposed", runResult("Recovered final reply.")),
|
||||
])).toEqual([
|
||||
expect.objectContaining({ kind: "assistant", text: "Recovered final reply." }),
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "Recovered final reply.",
|
||||
channel: "final",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers an explicit final item completed after the result proposal", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "run.result.proposed", runResult("Structured fallback.")),
|
||||
itemEvent(2, "item.completed", "message-final", {
|
||||
kind: "assistant_message",
|
||||
channel: "final",
|
||||
text: "The complete final reply.",
|
||||
}),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "The complete final reply.",
|
||||
channel: "final",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not append a fallback after a channel-less final delta", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.started", "message-final", {
|
||||
kind: "assistant_message",
|
||||
text: "",
|
||||
}),
|
||||
itemEvent(2, "item.delta", "message-final", {
|
||||
text: "Streamed final reply.",
|
||||
}),
|
||||
event(3, "run.result.proposed", runResult("Structured fallback.")),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "Streamed final reply.",
|
||||
delta: true,
|
||||
channel: "unknown",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps progress separate from the final runner response", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "item.completed", {
|
||||
itemId: "progress-1",
|
||||
kind: "agentMessage",
|
||||
channel: "progress",
|
||||
text: "Checking the implementation.",
|
||||
}),
|
||||
event(2, "run.result.accepted", {
|
||||
result: runResult("The implementation is ready."),
|
||||
}),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "Checking the implementation.",
|
||||
channel: "progress",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "The implementation is ready.",
|
||||
channel: "final",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects provider-neutral activity without exposing provider envelopes", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "research.started", {
|
||||
schema: "paperclip.research.v1",
|
||||
researchId: "research-1",
|
||||
query: "current behavior",
|
||||
status: "running",
|
||||
}),
|
||||
event(2, "research.completed", {
|
||||
schema: "paperclip.research.v1",
|
||||
researchId: "research-1",
|
||||
query: "current behavior",
|
||||
status: "completed",
|
||||
}),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool_call",
|
||||
name: "research",
|
||||
toolUseId: "research:research-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "tool_result",
|
||||
toolUseId: "research:research-1",
|
||||
content: "current behavior",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["running", "pending", "in_progress"])(
|
||||
"keeps an explicitly %s activity open even when its event suffix looks terminal",
|
||||
(status) => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "artifact.generated", {
|
||||
schema: "paperclip.artifact.generated.v1",
|
||||
artifactId: "artifact-1",
|
||||
status,
|
||||
reference: "artifacts/preview.png",
|
||||
}),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool_call",
|
||||
toolUseId: "artifact:artifact-1",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed for unsupported versions, prefix lookalikes, and mismatched payload schemas", () => {
|
||||
const unsupportedVersion = event(1, "model.verification.updated", {
|
||||
schema: "paperclip.model.verification.v1",
|
||||
verificationId: "verification-1",
|
||||
status: "completed",
|
||||
summary: "must not render",
|
||||
});
|
||||
(unsupportedVersion.payload!.prpEvent as Record<string, unknown>).schemaVersion = 2;
|
||||
|
||||
expect(nativeRunEventsToTranscript([
|
||||
unsupportedVersion,
|
||||
event(2, "model.provider_message.recorded", {
|
||||
schema: "paperclip.model.provider_message.v1",
|
||||
routeId: "route-1",
|
||||
message: "provider envelope must not render",
|
||||
}),
|
||||
event(3, "model.verification.updated", {
|
||||
schema: "paperclip.provider.native.v1",
|
||||
verificationId: "verification-2",
|
||||
status: "completed",
|
||||
summary: "wrong payload schema must not render",
|
||||
}),
|
||||
])).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails closed for mismatched tool execution and run result schemas", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "tool.execution.started", {
|
||||
schema: "paperclip.provider.native.v1",
|
||||
executionId: "exec-1",
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
status: "running",
|
||||
}),
|
||||
event(2, "run.result.proposed", {
|
||||
schema: "paperclip.provider.native.v1",
|
||||
summary: "malformed proposal must not render",
|
||||
}),
|
||||
event(3, "run.result.accepted", {
|
||||
result: {
|
||||
schema: "paperclip.provider.native.v1",
|
||||
summary: "malformed accepted result must not render",
|
||||
},
|
||||
}),
|
||||
event(4, "run.result.accepted", {
|
||||
schema: "paperclip.run_result.v1",
|
||||
summary: "accepted wrappers must not masquerade as results",
|
||||
}),
|
||||
])).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails closed for malformed, mismatched, and unknown event envelopes", () => {
|
||||
const mismatched = event(1, "item.delta", {
|
||||
itemId: "message-1",
|
||||
@@ -247,7 +492,7 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
mismatched,
|
||||
malformed,
|
||||
event(3, "plan.updated", { explanation: "not a transcript row" }),
|
||||
event(3, "extension.unknown", { explanation: "not a transcript row" }),
|
||||
])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,269 @@ 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 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;
|
||||
@@ -50,7 +313,7 @@ function toolPresentation(payload: Record<string, unknown>): { name: string; inp
|
||||
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const startedToolIds = new Set<string>();
|
||||
let hasAssistantMessage = false;
|
||||
let hasFinalAssistantMessage = false;
|
||||
let usageSummary: {
|
||||
ts: string;
|
||||
inputTokens: number;
|
||||
@@ -65,53 +328,138 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
cachedTokens: number;
|
||||
costUsd: number;
|
||||
} | null = null;
|
||||
let runResultFallback: { ts: string; text: string } | null = null;
|
||||
const orderedEvents = [...events].sort((a, b) => a.seq - b.seq);
|
||||
const completedAgentMessageIds = new Set<string>();
|
||||
const completedReasoningIds = new Set<string>();
|
||||
const completionItemIdentityById = new Map<string, ItemIdentity>();
|
||||
for (const event of orderedEvents) {
|
||||
if (event.eventType !== "item.completed") continue;
|
||||
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);
|
||||
const itemId = text(payload?.itemId);
|
||||
if (payload?.kind === "agentMessage" && itemId && text(payload.text)) {
|
||||
if (!payload) continue;
|
||||
const itemId = normalizedItemId(envelope, payload);
|
||||
if (!itemId) continue;
|
||||
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") continue;
|
||||
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);
|
||||
|
||||
if (event.eventType === "item.delta" && payload.kind === "agentMessage") {
|
||||
const value = text(payload.text);
|
||||
const itemId = text(payload.itemId);
|
||||
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;
|
||||
hasAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: value, delta: true });
|
||||
const channel = itemIdentity.assistantChannel;
|
||||
if (channel !== "progress") hasFinalAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: value, delta: true, channel });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "item.completed" && payload.kind === "agentMessage") {
|
||||
const value = text(payload.text);
|
||||
if (event.eventType === "item.completed" && isAssistantItemKind(itemKind)) {
|
||||
const value = normalizedItemText(payload);
|
||||
if (!value) continue;
|
||||
hasAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: value });
|
||||
const channel = itemIdentity.assistantChannel;
|
||||
if (channel !== "progress") hasFinalAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: value, channel });
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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 presentation = toolPresentation(payload);
|
||||
@@ -178,19 +526,29 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "run.result.proposed" && !hasAssistantMessage) {
|
||||
const summary = text(payload.summary);
|
||||
if (summary) {
|
||||
hasAssistantMessage = true;
|
||||
entries.push({ kind: "assistant", ts, text: summary });
|
||||
}
|
||||
if (
|
||||
(event.eventType === "run.result.proposed" || event.eventType === "run.result.accepted")
|
||||
&& !hasFinalAssistantMessage
|
||||
) {
|
||||
const result = event.eventType === "run.result.accepted" ? record(payload.result) : payload;
|
||||
if (!result || result.schema !== RUN_RESULT_SCHEMA) continue;
|
||||
const summary = text(result.summary);
|
||||
if (summary && !runResultFallback) runResultFallback = { ts, text: summary };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "provider.notice.recorded" && payload.severity === "error") {
|
||||
const summary = text(payload.summary);
|
||||
if (summary) entries.push({ kind: "stderr", ts, text: summary });
|
||||
}
|
||||
}
|
||||
|
||||
// A structured result can be proposed before its originating final item is
|
||||
// durably completed. Delay the fallback until every event has been examined
|
||||
// so the explicit assistant reply wins regardless of source ordering.
|
||||
if (!hasFinalAssistantMessage && runResultFallback) {
|
||||
entries.push({
|
||||
kind: "assistant",
|
||||
ts: runResultFallback.ts,
|
||||
text: runResultFallback.text,
|
||||
channel: "final",
|
||||
});
|
||||
}
|
||||
|
||||
if (usageSummary) {
|
||||
|
||||
Reference in new issue
Block a user