mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## 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
156 lines
4.5 KiB
TypeScript
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;
|
|
}
|