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:
parent
51ad751e0b
commit
209680409d
|
|
@ -19,7 +19,12 @@ function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource)
|
|||
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
|
||||
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
|
||||
const last = entries[entries.length - 1];
|
||||
if (last && last.kind === entry.kind && last.delta) {
|
||||
if (
|
||||
last &&
|
||||
last.kind === entry.kind &&
|
||||
last.delta &&
|
||||
last.channel === entry.channel
|
||||
) {
|
||||
last.text += entry.text;
|
||||
last.ts = entry.ts;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ const sidebarState = vi.hoisted(() => ({ isMobile: false }));
|
|||
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
|
||||
useLiveRunTranscripts: ({ runs }: { runs: unknown[] }) => {
|
||||
transcriptHookRuns.legacy.push(runs);
|
||||
return transcriptState;
|
||||
return { transcriptByRun: new Map(transcriptState.transcriptByRun) };
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/transcript/useNativeRunTranscripts", () => ({
|
||||
useNativeRunTranscripts: (runs: unknown[]) => {
|
||||
transcriptHookRuns.native.push(runs);
|
||||
return nativeTranscriptState;
|
||||
return { transcriptByRun: new Map(nativeTranscriptState.transcriptByRun) };
|
||||
},
|
||||
}));
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
|
|
@ -164,6 +164,225 @@ describe("TaskChatThread runtime transcript selection", () => {
|
|||
expect(legacyRuns.map((run) => run.id)).toEqual(["legacy-run"]);
|
||||
expect(nativeRuns.map((run) => run.id)).toEqual(["native-run"]);
|
||||
});
|
||||
|
||||
it("uses runner-only controls only for an actual native Paperclip Runner run", () => {
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: "Checking the task.",
|
||||
channel: "progress",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:02.000Z",
|
||||
text: "The task is ready.",
|
||||
channel: "final",
|
||||
},
|
||||
]);
|
||||
|
||||
const run = {
|
||||
id: "native-run",
|
||||
runtimeMode: "native" as const,
|
||||
status: "running" as const,
|
||||
invocationSource: "issue" as const,
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
};
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={run}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Checking the task.");
|
||||
expect(container.textContent).toContain("The task is ready.");
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={{ ...run, runtimeMode: "legacy" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull();
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={{ ...run, adapterType: "codex_local" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-turn"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps legacy channel-less native messages readable across settlement", () => {
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: "Persisted before message channels existed.",
|
||||
channel: "unknown",
|
||||
},
|
||||
]);
|
||||
|
||||
const run = {
|
||||
id: "native-run",
|
||||
runtimeMode: "native" as const,
|
||||
status: "running" as const,
|
||||
invocationSource: "issue" as const,
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
};
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={run}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent)
|
||||
.toContain("Persisted before message channels existed.");
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')).toBeNull();
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="done"
|
||||
activeRun={{ ...run, status: "succeeded", finishedAt: "2026-08-25T18:00:02.000Z" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-progress-update"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent)
|
||||
.toContain("Persisted before message channels existed.");
|
||||
});
|
||||
|
||||
it("recomputes a runner turn when only the message channel changes", () => {
|
||||
const run = {
|
||||
id: "native-run",
|
||||
runtimeMode: "native" as const,
|
||||
status: "running" as const,
|
||||
invocationSource: "issue" as const,
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
};
|
||||
const renderRun = () => render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={run}
|
||||
/>,
|
||||
);
|
||||
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: "Same text.",
|
||||
channel: "progress",
|
||||
}]);
|
||||
renderRun();
|
||||
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent)
|
||||
.toContain("Same text.");
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')).toBeNull();
|
||||
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: "Same text.",
|
||||
channel: "final",
|
||||
}]);
|
||||
renderRun();
|
||||
expect(container.querySelector('[data-testid="task-chat-progress-update"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent)
|
||||
.toContain("Same text.");
|
||||
});
|
||||
|
||||
it("recomputes a runner turn when only usage totals change", () => {
|
||||
const run = {
|
||||
id: "native-run",
|
||||
runtimeMode: "native" as const,
|
||||
status: "running" as const,
|
||||
invocationSource: "issue" as const,
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
};
|
||||
const usageEntry = (inputTokens: number) => ({
|
||||
kind: "result" as const,
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: "",
|
||||
inputTokens,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "paperclip_runner_usage",
|
||||
isError: false,
|
||||
errors: [],
|
||||
});
|
||||
const renderRun = () => render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={run}
|
||||
/>,
|
||||
);
|
||||
const revealUsage = () => {
|
||||
const summary = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-phase-summary"]',
|
||||
);
|
||||
expect(summary).not.toBeNull();
|
||||
if (summary?.getAttribute("aria-expanded") !== "true") {
|
||||
flushSync(() => summary!.click());
|
||||
}
|
||||
};
|
||||
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [usageEntry(10)]);
|
||||
renderRun();
|
||||
revealUsage();
|
||||
expect(container.textContent).toContain("↑10");
|
||||
|
||||
nativeTranscriptState.transcriptByRun.set("native-run", [usageEntry(20)]);
|
||||
renderRun();
|
||||
revealUsage();
|
||||
expect(container.textContent).toContain("↑20");
|
||||
expect(container.textContent).not.toContain("↑10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread composer alignment", () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "@/components/transcript/useLiveRunTranscripts";
|
||||
import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts";
|
||||
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
|
||||
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import {
|
||||
assembleThreadItems,
|
||||
|
|
@ -514,11 +515,25 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
|
||||
const tailStreaming = Boolean(liveRun);
|
||||
const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
|
||||
const tailContentKey = tailEntries.reduce((total, entry) => {
|
||||
if ("text" in entry) return total + entry.text.length;
|
||||
if ("content" in entry) return total + entry.content.length;
|
||||
return total + entry.kind.length;
|
||||
}, tailEntries.length);
|
||||
const tailRunSource = tailRunId
|
||||
? runs.find((run) => run.id === tailRunId)
|
||||
: undefined;
|
||||
const paperclipRunnerTail =
|
||||
tailRunSource?.runtimeMode === "native" &&
|
||||
tailRunSource.adapterType === "paperclip_runner";
|
||||
const tailContentKey = tailEntries.reduce((key, entry) => {
|
||||
const textIdentity = "text" in entry ? entry.text : "";
|
||||
const contentIdentity = "content" in entry ? entry.content : "";
|
||||
const channelIdentity = "channel" in entry ? entry.channel ?? "" : "";
|
||||
const lifecycleIdentity = "lifecycle" in entry ? entry.lifecycle ?? "" : "";
|
||||
const statusIdentity = "isError" in entry
|
||||
? entry.isError ? "error" : "ok"
|
||||
: "";
|
||||
const usageIdentity = entry.kind === "result"
|
||||
? `${entry.subtype}:${entry.inputTokens}:${entry.outputTokens}:${entry.cachedTokens}:${entry.costUsd}`
|
||||
: "";
|
||||
return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}`;
|
||||
}, String(tailEntries.length));
|
||||
const blockerContentKey = blockerLinks
|
||||
? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}`
|
||||
: liveWorkLinks
|
||||
|
|
@ -713,26 +728,38 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
<>
|
||||
{tailRunId ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: // Before the first transcript token, surface the run's
|
||||
// live runtime status (sandbox preparation phases like
|
||||
// "Syncing workspace to environment" emitted via
|
||||
// onRuntimeProgress) instead of an opaque wait message.
|
||||
(liveRun && liveRun.id === tailRunId
|
||||
? liveRun.currentStatusMessage
|
||||
: null) || "Waiting for transcript..."
|
||||
}
|
||||
/>
|
||||
{paperclipRunnerTail ? (
|
||||
<TaskChatRunnerTurn
|
||||
items={tailItems}
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: // Before the first transcript token, surface the run's
|
||||
// live runtime status (sandbox preparation phases like
|
||||
// "Syncing workspace to environment" emitted via
|
||||
// onRuntimeProgress) instead of an opaque wait message.
|
||||
(liveRun && liveRun.id === tailRunId
|
||||
? liveRun.currentStatusMessage
|
||||
: null) || "Waiting for transcript..."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import { Brain } from "lucide-react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
TaskChatItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatThinkingItem,
|
||||
TaskChatToolItem,
|
||||
} from "./task-chat-model";
|
||||
import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
|
||||
import { TaskChatLiveTail } from "./TaskChatLiveTail";
|
||||
import { isTerminalRunStatus } from "./transcript-adapter";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
function lastOf<T extends TaskChatItem>(
|
||||
items: readonly TaskChatItem[],
|
||||
predicate: (item: TaskChatItem) => item is T,
|
||||
): T | undefined {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (item && predicate(item)) return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
|
||||
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
|
||||
items,
|
||||
(item): item is TaskChatThinkingItem | TaskChatToolItem =>
|
||||
item.kind === "thinking" || item.kind === "tool",
|
||||
);
|
||||
if (!activity || activity.kind === "thinking") {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground"
|
||||
data-testid="task-chat-current-activity"
|
||||
>
|
||||
<Brain className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="shimmer-text shimmer-text-muted">Thinking</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
|
||||
const Icon = taxonomy.icon;
|
||||
const active = activity.status === "pending" || activity.status === "in_progress";
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground"
|
||||
data-testid="task-chat-current-activity"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>
|
||||
{taxonomy.verbLabel}
|
||||
</span>
|
||||
{activity.target ? (
|
||||
<span className="min-w-0 truncate font-mono text-xs">{activity.target}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runner-only live turn. Its compact parent row and final response are driven
|
||||
* by persisted runtime facts; direct adapters continue through TaskChatLiveTail.
|
||||
*/
|
||||
export function TaskChatRunnerTurn({
|
||||
items,
|
||||
status,
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
}: {
|
||||
items: readonly TaskChatItem[];
|
||||
status: string;
|
||||
startedAtMs: number | null;
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
}) {
|
||||
const terminal = isTerminalRunStatus(status);
|
||||
const progress = lastOf<TaskChatMessageItem>(
|
||||
items,
|
||||
(item): item is TaskChatMessageItem =>
|
||||
item.kind === "message" &&
|
||||
(item.channel === "progress" || (!terminal && item.channel === "unknown")),
|
||||
);
|
||||
const final = lastOf<TaskChatMessageItem>(
|
||||
items,
|
||||
(item): item is TaskChatMessageItem =>
|
||||
item.kind === "message" &&
|
||||
(item.channel === "final" || (terminal && item.channel === "unknown")),
|
||||
);
|
||||
const activityItems = items.filter((item) => item.kind !== "message");
|
||||
|
||||
if (status === "queued") {
|
||||
return (
|
||||
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
|
||||
<CurrentActivity items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
|
||||
<TaskChatLiveRunPill
|
||||
status={status}
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs}
|
||||
toolSummary={toolSummary}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<TaskChatLiveTail items={activityItems} />
|
||||
</div>
|
||||
{progress || (!final && !terminal) ? (
|
||||
<div className="flex min-w-0 flex-col py-1">
|
||||
{progress ? (
|
||||
<div
|
||||
className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90"
|
||||
data-testid="task-chat-progress-update"
|
||||
>
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{progress.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{!final && !terminal ? <CurrentActivity items={items} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{final ? (
|
||||
<div
|
||||
className="tc-enter-bubble px-1 py-2 text-sm text-foreground"
|
||||
data-testid="task-chat-final-response"
|
||||
>
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{final.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -65,6 +65,8 @@ export interface TaskChatMessageItem {
|
|||
author: TaskChatAuthorKind;
|
||||
authorName?: string;
|
||||
text: string;
|
||||
/** Runner-authored output channel. Legacy adapters leave this unset. */
|
||||
channel?: "progress" | "final" | "unknown";
|
||||
timestamp?: string;
|
||||
/** Show a streaming cursor and suppress collapse while true. */
|
||||
streaming?: boolean;
|
||||
|
|
@ -121,6 +123,8 @@ export interface TaskChatThinkingItem {
|
|||
collapsed?: boolean;
|
||||
/** Human-readable elapsed label for the collapsed header. */
|
||||
summaryLabel?: string;
|
||||
/** Provider-emitted reasoning surface; never synthesized by the UI. */
|
||||
channel?: "summary" | "detail" | "unknown";
|
||||
}
|
||||
|
||||
/** A tool invocation row (ACP tool_call / tool_call_update). */
|
||||
|
|
|
|||
|
|
@ -163,6 +163,38 @@ describe("transcriptToTaskChatItems tool_call updates", () => {
|
|||
});
|
||||
|
||||
describe("transcriptToTaskChatItems native usage", () => {
|
||||
it("does not merge progress and final runner messages", () => {
|
||||
const items = transcriptToTaskChatItems([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: TS,
|
||||
text: "Checking files.",
|
||||
channel: "progress",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: TS,
|
||||
text: "The change is ready.",
|
||||
channel: "final",
|
||||
},
|
||||
], { runId: "native-run", running: true });
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "message",
|
||||
text: "Checking files.",
|
||||
channel: "progress",
|
||||
interstitial: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "message",
|
||||
text: "The change is ready.",
|
||||
channel: "final",
|
||||
interstitial: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders runner usage without inventing a context-window size", () => {
|
||||
const items = transcriptToTaskChatItems([{
|
||||
kind: "result",
|
||||
|
|
@ -477,6 +509,37 @@ describe("settledRunChildren (PAP-361)", () => {
|
|||
expect(phase.kind === "activity_phase" && phase.items.map((item) => item.kind)).toEqual(["tool", "tool"]);
|
||||
});
|
||||
|
||||
it("excludes an explicit final reply when runner usage follows it", () => {
|
||||
const finalThenUsage = transcriptToTaskChatItems([
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: TS,
|
||||
text: "Done — the limiter is wired in.",
|
||||
channel: "final",
|
||||
} as TranscriptEntry,
|
||||
{
|
||||
kind: "result",
|
||||
ts: TS,
|
||||
text: "",
|
||||
inputTokens: 40,
|
||||
outputTokens: 10,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "paperclip_runner_usage",
|
||||
isError: false,
|
||||
errors: [],
|
||||
} as TranscriptEntry,
|
||||
], { runId: "native-run", running: false });
|
||||
|
||||
const children = settledRunChildren(finalThenUsage);
|
||||
expect(children).toHaveLength(1);
|
||||
const phase = children[0];
|
||||
expect(phase.kind).toBe("activity_phase");
|
||||
if (phase.kind !== "activity_phase") return;
|
||||
expect(phase.interstitial).toBeUndefined();
|
||||
expect(phase.items.map((item) => item.kind)).toEqual(["usage"]);
|
||||
});
|
||||
|
||||
it("matches the folded summary's tool count exactly (row-count parity)", () => {
|
||||
const children = settledRunChildren(parsed);
|
||||
const summary = buildTurnSummary(transcript);
|
||||
|
|
|
|||
|
|
@ -170,18 +170,23 @@ export function transcriptToTaskChatItems(
|
|||
const thinkingStartTs = new Map<number, string>();
|
||||
let lastToolIndex = -1;
|
||||
let thinkingIndex = -1;
|
||||
let thinkingChannel: "summary" | "detail" | "unknown" | undefined;
|
||||
let messageIndex = -1;
|
||||
let messageChannel: "progress" | "final" | "unknown" | undefined;
|
||||
|
||||
const resetInline = () => {
|
||||
thinkingIndex = -1;
|
||||
thinkingChannel = undefined;
|
||||
messageIndex = -1;
|
||||
messageChannel = undefined;
|
||||
};
|
||||
|
||||
for (const [i, entry] of entries.entries()) {
|
||||
switch (entry.kind) {
|
||||
case "thinking": {
|
||||
if (!entry.text) break;
|
||||
if (thinkingIndex >= 0) {
|
||||
const channel = entry.channel;
|
||||
if (thinkingIndex >= 0 && thinkingChannel === channel) {
|
||||
const it = items[thinkingIndex];
|
||||
if (it.kind === "thinking") {
|
||||
it.lines.push(...entry.text.split("\n"));
|
||||
|
|
@ -198,16 +203,20 @@ export function transcriptToTaskChatItems(
|
|||
// Settled history folds its thinking behind the header (v7);
|
||||
// the in-flight run streams it expanded.
|
||||
collapsed: !running,
|
||||
channel,
|
||||
});
|
||||
thinkingIndex = items.length - 1;
|
||||
thinkingChannel = channel;
|
||||
thinkingStartTs.set(thinkingIndex, entry.ts);
|
||||
messageIndex = -1;
|
||||
messageChannel = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
if (!entry.text) break;
|
||||
if (messageIndex >= 0) {
|
||||
const channel = entry.channel;
|
||||
if (messageIndex >= 0 && messageChannel === channel) {
|
||||
const it = items[messageIndex];
|
||||
if (it.kind === "message") it.text += entry.text;
|
||||
} else {
|
||||
|
|
@ -218,14 +227,17 @@ export function transcriptToTaskChatItems(
|
|||
author: "agent",
|
||||
authorName: agentName,
|
||||
text: entry.text,
|
||||
channel,
|
||||
streaming: running,
|
||||
// Everything the agent says inside a run turn is self-talk until it
|
||||
// lands as the posted comment — live and history tag it alike.
|
||||
interstitial: true,
|
||||
interstitial: channel !== "final",
|
||||
atMs: Number.isFinite(atMs) ? atMs : undefined,
|
||||
});
|
||||
messageIndex = items.length - 1;
|
||||
messageChannel = channel;
|
||||
thinkingIndex = -1;
|
||||
thinkingChannel = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -412,9 +424,13 @@ export function buildActivityPhases(
|
|||
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
|
||||
for (const item of parsed) {
|
||||
if (item.kind === "message") {
|
||||
// A settled transcript's trailing assistant text is the posted reply.
|
||||
// Live/settle-gap tails keep it visible until that canonical reply lands.
|
||||
if (!running && item === lastVisible) continue;
|
||||
// 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 === lastVisible;
|
||||
if (!running && (explicitFinal || legacyTrailingReply)) continue;
|
||||
current = {
|
||||
id: `${item.id}:phase`,
|
||||
kind: "activity_phase",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue