fix(adapters): honor paused overrides and isolate UI parser state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-04-04 14:04:33 -05:00
parent c6d2dc8b56
commit d9476abecb
17 changed files with 297 additions and 53 deletions
+28 -12
View File
@@ -16,11 +16,16 @@
*/
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import type { StdoutLineParser } from "./types";
import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types";
interface DynamicParserModule {
parseStdoutLine: StdoutLineParser;
createStdoutParser?: StdoutParserFactory;
}
// Cache of dynamically loaded parsers by adapter type.
// Once loaded, the parser is reused for all runs of that adapter type.
const dynamicParserCache = new Map<string, StdoutLineParser>();
const dynamicParserCache = new Map<string, DynamicParserModule>();
// Track which types we've already attempted to load (to avoid repeat 404s).
const failedLoads = new Set<string>();
@@ -33,7 +38,7 @@ const failedLoads = new Set<string>();
*
* @returns A StdoutLineParser function, or null if unavailable.
*/
export async function loadDynamicParser(adapterType: string): Promise<StdoutLineParser | null> {
export async function loadDynamicParser(adapterType: string): Promise<DynamicParserModule | null> {
// Return cached parser if already loaded
const cached = dynamicParserCache.get(adapterType);
if (cached) return cached;
@@ -56,7 +61,7 @@ export async function loadDynamicParser(adapterType: string): Promise<StdoutLine
const blob = new Blob([source], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob);
let parseFn: StdoutLineParser;
let parserModule: DynamicParserModule;
try {
const mod = await import(/* @vite-ignore */ blobUrl);
@@ -64,13 +69,24 @@ export async function loadDynamicParser(adapterType: string): Promise<StdoutLine
// Prefer the factory function (stateful parser) if available,
// fall back to the static parseStdoutLine function.
if (typeof mod.createStdoutParser === "function") {
// Stateful parser — create one instance for the UI session.
// Each run creates its own transcript builder, so a single
// parser instance is sufficient per adapter type.
const parser = (mod.createStdoutParser as () => { parseLine: StdoutLineParser; reset: () => void })();
parseFn = parser.parseLine.bind(parser);
const createStdoutParser = mod.createStdoutParser as StdoutParserFactory;
parserModule = {
createStdoutParser,
// Fallback for callers that only know about parseStdoutLine.
parseStdoutLine:
typeof mod.parseStdoutLine === "function"
? (mod.parseStdoutLine as StdoutLineParser)
: ((line: string, ts: string) => {
const parser = createStdoutParser() as StatefulStdoutParser;
const entries = parser.parseLine(line, ts);
parser.reset();
return entries;
}),
};
} else if (typeof mod.parseStdoutLine === "function") {
parseFn = mod.parseStdoutLine as StdoutLineParser;
parserModule = {
parseStdoutLine: mod.parseStdoutLine as StdoutLineParser,
};
} else {
console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`);
failedLoads.add(adapterType);
@@ -81,9 +97,9 @@ export async function loadDynamicParser(adapterType: string): Promise<StdoutLine
}
// Cache for reuse
dynamicParserCache.set(adapterType, parseFn);
dynamicParserCache.set(adapterType, parserModule);
console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
return parseFn;
return parserModule;
} catch (err) {
console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
failedLoads.add(adapterType);
+12 -9
View File
@@ -101,12 +101,13 @@ export function getUIAdapter(type: string): UIAdapterModule {
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(type).then((parser) => {
if (parser) {
loadDynamicParser(type).then((parserModule) => {
if (parserModule) {
registerUIAdapter({
type,
label: type,
parseStdoutLine: parser,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
});
@@ -182,13 +183,14 @@ export function syncExternalAdapters(
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(builtinType).then((parser) => {
loadDynamicParser(builtinType).then((parserModule) => {
// Discard if the override was torn down while the load was in-flight.
if (parser && overrideGeneration.get(builtinType) === gen) {
if (parserModule && overrideGeneration.get(builtinType) === gen) {
registerUIAdapter({
type: builtinType,
label,
parseStdoutLine: parser,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: originalBuiltin.ConfigFields,
buildAdapterConfig: originalBuiltin.buildAdapterConfig,
});
@@ -232,12 +234,13 @@ export function syncExternalAdapters(
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(type).then((parser) => {
if (parser) {
loadDynamicParser(type).then((parserModule) => {
if (parserModule) {
registerUIAdapter({
type,
label,
parseStdoutLine: parser,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
});
+43
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript";
import type { UIAdapterModule } from "./types";
describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z";
@@ -27,4 +28,46 @@ describe("buildTranscript", () => {
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
]);
});
it("creates a fresh stateful parser for each transcript build", () => {
const statefulAdapter: UIAdapterModule = {
type: "stateful_test",
label: "Stateful Test",
parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }],
createStdoutParser: () => {
let pending: string | null = null;
return {
parseLine: (line, entryTs) => {
if (line.startsWith("begin:")) {
pending = line.slice("begin:".length);
return [];
}
if (line === "finish" && pending) {
const text = `completed:${pending}`;
pending = null;
return [{ kind: "stdout", ts: entryTs, text }];
}
return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }];
},
reset: () => {
pending = null;
},
};
},
ConfigFields: () => null,
buildAdapterConfig: () => ({}),
};
const first = buildTranscript(
[{ ts, stream: "stdout", chunk: "begin:task-a\n" }],
statefulAdapter,
);
const second = buildTranscript(
[{ ts, stream: "stdout", chunk: "finish\n" }],
statefulAdapter,
);
expect(first).toEqual([]);
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
});
});
+18 -4
View File
@@ -1,9 +1,20 @@
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
import type { TranscriptEntry, StdoutLineParser } from "./types";
import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: 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];
@@ -24,12 +35,13 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
export function buildTranscript(
chunks: RunLogChunk[],
parser: StdoutLineParser,
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") {
@@ -47,15 +59,17 @@ export function buildTranscript(
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
reset?.();
return entries;
}
+13 -2
View File
@@ -4,6 +4,18 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils";
// Re-export shared types so local consumers don't need to change imports
export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils";
export interface StatefulStdoutParser {
parseLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
reset: () => void;
}
export type StdoutParserFactory = () => StatefulStdoutParser;
export interface TranscriptParserSource {
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
createStdoutParser?: StdoutParserFactory;
}
export interface AdapterConfigFieldsProps {
mode: "create" | "edit";
isCreate: boolean;
@@ -24,10 +36,9 @@ export interface AdapterConfigFieldsProps {
hideInstructionsFile?: boolean;
}
export interface UIAdapterModule {
export interface UIAdapterModule extends TranscriptParserSource {
type: string;
label: string;
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
}
@@ -284,7 +284,7 @@ export function useLiveRunTranscripts({
const adapter = getUIAdapter(run.adapterType);
next.set(
run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
censorUsernameInLogs,
}),
);
+1 -1
View File
@@ -3802,7 +3802,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}, []);
const transcript = useMemo(
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
() => buildTranscript(logLines, adapter, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines, parserTick],
);