Files
PaperClipAI/ui/src/adapters/transcript.ts
T
Dotta 209680409d 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
2026-08-31 23:32:13 -05:00

156 lines
4.5 KiB
TypeScript

import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string; seq?: number };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
type RedactionOptions = { enabled: boolean };
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
if (typeof source === "function") {
return { parseLine: source, reset: null as (() => void) | null };
}
if (source.createStdoutParser) {
const parser = source.createStdoutParser();
return { parseLine: parser.parseLine, reset: parser.reset };
}
return { parseLine: source.parseStdoutLine, reset: null as (() => void) | null };
}
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 &&
last.channel === entry.channel
) {
last.text += entry.text;
last.ts = entry.ts;
return;
}
}
entries.push(entry);
}
export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: TranscriptEntry[]) {
for (const entry of incoming) {
appendTranscriptEntry(entries, entry);
}
}
function truncateTranscriptLine(line: string, maxLength = 160) {
if (line.length <= maxLength) return line;
return `${line.slice(0, maxLength - 3)}...`;
}
function formatTranscriptParserError(error: unknown) {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function createTranscriptParseErrorEntry(
line: string,
ts: string,
error: unknown,
redactionOptions: RedactionOptions,
): TranscriptEntry {
const errorText = formatTranscriptParserError(error) || "unknown parser error";
const preview = truncateTranscriptLine(line);
return {
kind: "result",
ts,
text: redactHomePathUserSegments(
`Chat transcript error: ${errorText}. Falling back for line: ${preview}`,
redactionOptions,
),
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
costUsd: 0,
subtype: "transcript_parse_error",
isError: true,
errors: [],
};
}
function appendParsedTranscriptLine(args: {
entries: TranscriptEntry[];
line: string;
ts: string;
parseLine: (line: string, ts: string) => TranscriptEntry[];
reset: (() => void) | null;
redactionOptions: RedactionOptions;
}) {
const { entries, line, ts, parseLine, reset, redactionOptions } = args;
try {
appendTranscriptEntries(
entries,
parseLine(line, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)),
);
} catch (error) {
reset?.();
appendTranscriptEntry(entries, createTranscriptParseErrorEntry(line, ts, error, redactionOptions));
}
}
export function buildTranscript(
chunks: RunLogChunk[],
parserSource: StdoutLineParser | TranscriptParserSource,
opts?: TranscriptBuildOptions,
): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
let stdoutBuffer = "";
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
const { parseLine, reset } = resolveStdoutParser(parserSource);
for (const chunk of chunks) {
if (chunk.stream === "stderr") {
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
if (chunk.stream === "system") {
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
const combined = stdoutBuffer + chunk.chunk;
const lines = combined.split(/\r?\n/);
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
appendParsedTranscriptLine({
entries,
line: trimmed,
ts: chunk.ts,
parseLine,
reset,
redactionOptions,
});
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendParsedTranscriptLine({
entries,
line: trailing,
ts,
parseLine,
reset,
redactionOptions,
});
}
reset?.();
return entries;
}