From d9476abecb5b5d0604b8bb5d2ad50ecd8a42bc52 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 14:04:33 -0500 Subject: [PATCH] fix(adapters): honor paused overrides and isolate UI parser state Co-Authored-By: Paperclip --- server/src/__tests__/adapter-registry.test.ts | 55 ++++++++++++- server/src/__tests__/adapter-routes.test.ts | 78 +++++++++++++++++++ .../src/__tests__/agent-skills-routes.test.ts | 2 + server/src/__tests__/hire-hook.test.ts | 16 ++-- server/src/adapters/index.ts | 1 + server/src/adapters/registry.ts | 37 +++++++-- server/src/routes/adapters.ts | 3 +- server/src/routes/agents.ts | 5 +- server/src/services/company-skills.ts | 4 +- server/src/services/hire-hook.ts | 4 +- ui/src/adapters/dynamic-loader.ts | 40 +++++++--- ui/src/adapters/registry.ts | 21 ++--- ui/src/adapters/transcript.test.ts | 43 ++++++++++ ui/src/adapters/transcript.ts | 22 +++++- ui/src/adapters/types.ts | 15 +++- .../transcript/useLiveRunTranscripts.ts | 2 +- ui/src/pages/AgentDetail.tsx | 2 +- 17 files changed, 297 insertions(+), 53 deletions(-) create mode 100644 server/src/__tests__/adapter-routes.test.ts diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index e4b109f6..6f7b0973 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,12 +1,15 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import type { ServerAdapterModule } from "../adapters/index.js"; import { + detectAdapterModel, + findActiveServerAdapter, findServerAdapter, listAdapterModels, registerServerAdapter, requireServerAdapter, unregisterServerAdapter, } from "../adapters/index.js"; +import { setOverridePaused } from "../adapters/registry.js"; const externalAdapter: ServerAdapterModule = { type: "external_test", @@ -28,10 +31,14 @@ const externalAdapter: ServerAdapterModule = { describe("server adapter registry", () => { beforeEach(() => { unregisterServerAdapter("external_test"); + unregisterServerAdapter("claude_local"); + setOverridePaused("claude_local", false); }); afterEach(() => { unregisterServerAdapter("external_test"); + unregisterServerAdapter("claude_local"); + setOverridePaused("claude_local", false); }); it("registers external adapters and exposes them through lookup helpers", async () => { @@ -87,4 +94,50 @@ describe("server adapter registry", () => { { id: "plugin-model", label: "Plugin Override" }, ]); }); + + it("switches active adapter behavior back to the builtin when an override is paused", async () => { + const builtIn = findServerAdapter("claude_local"); + expect(builtIn).not.toBeNull(); + + const detectModel = vi.fn(async () => ({ + model: "plugin-model", + provider: "plugin-provider", + source: "plugin-source", + })); + const plugin: ServerAdapterModule = { + type: "claude_local", + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + }), + testEnvironment: async () => ({ + adapterType: "claude_local", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + models: [{ id: "plugin-model", label: "Plugin Override" }], + detectModel, + supportsLocalAgentJwt: false, + }; + + registerServerAdapter(plugin); + + expect(findActiveServerAdapter("claude_local")).toBe(plugin); + expect(await listAdapterModels("claude_local")).toEqual([ + { id: "plugin-model", label: "Plugin Override" }, + ]); + expect(await detectAdapterModel("claude_local")).toMatchObject({ + model: "plugin-model", + provider: "plugin-provider", + }); + + expect(setOverridePaused("claude_local", true)).toBe(true); + + expect(findActiveServerAdapter("claude_local")).not.toBe(plugin); + expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []); + expect(await detectAdapterModel("claude_local")).toBeNull(); + expect(detectModel).toHaveBeenCalledTimes(1); + }); }); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts new file mode 100644 index 00000000..c1ce6c3a --- /dev/null +++ b/server/src/__tests__/adapter-routes.test.ts @@ -0,0 +1,78 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ServerAdapterModule } from "../adapters/index.js"; +import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; +import { setOverridePaused } from "../adapters/registry.js"; +import { adapterRoutes } from "../routes/adapters.js"; +import { errorHandler } from "../middleware/index.js"; + +const overridingConfigSchemaAdapter: ServerAdapterModule = { + type: "claude_local", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "claude_local", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }), + getConfigSchema: async () => ({ + version: 1, + fields: [ + { + key: "mode", + type: "text", + label: "Mode", + }, + ], + }), +}; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: [], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", adapterRoutes()); + app.use(errorHandler); + return app; +} + +describe("adapter routes", () => { + beforeEach(() => { + setOverridePaused("claude_local", false); + registerServerAdapter(overridingConfigSchemaAdapter); + }); + + afterEach(() => { + setOverridePaused("claude_local", false); + unregisterServerAdapter("claude_local"); + }); + + it("uses the active adapter when resolving config schema for a paused builtin override", async () => { + const app = createApp(); + + const active = await request(app).get("/api/adapters/claude_local/config-schema"); + expect(active.status, JSON.stringify(active.body)).toBe(200); + expect(active.body).toMatchObject({ + fields: [{ key: "mode" }], + }); + + const paused = await request(app) + .patch("/api/adapters/claude_local/override") + .send({ paused: true }); + expect(paused.status, JSON.stringify(paused.body)).toBe(200); + + const builtin = await request(app).get("/api/adapters/claude_local/config-schema"); + expect(builtin.status, JSON.stringify(builtin.body)).toBe(404); + expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema"); + }); +}); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 15336558..5523323f 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -61,6 +61,7 @@ const mockAdapter = vi.hoisted(() => ({ vi.mock("@paperclipai/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, + trackErrorHandlerCrash: vi.fn(), })); vi.mock("../telemetry.js", () => ({ @@ -85,6 +86,7 @@ vi.mock("../services/index.js", () => ({ vi.mock("../adapters/index.js", () => ({ findServerAdapter: vi.fn(() => mockAdapter), + findActiveServerAdapter: vi.fn(() => mockAdapter), listAdapterModels: vi.fn(), detectAdapterModel: vi.fn(), })); diff --git a/server/src/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts index 0a2cbbfd..b08e04bc 100644 --- a/server/src/__tests__/hire-hook.test.ts +++ b/server/src/__tests__/hire-hook.test.ts @@ -4,14 +4,14 @@ import { notifyHireApproved } from "../services/hire-hook.js"; // Mock the registry so we control whether the adapter has onHireApproved and what it does. vi.mock("../adapters/registry.js", () => ({ - findServerAdapter: vi.fn(), + findActiveServerAdapter: vi.fn(), })); vi.mock("../services/activity-log.js", () => ({ logActivity: vi.fn().mockResolvedValue(undefined), })); -const { findServerAdapter } = await import("../adapters/registry.js"); +const { findActiveServerAdapter } = await import("../adapters/registry.js"); const { logActivity } = await import("../services/activity-log.js"); function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record }): Db { @@ -39,7 +39,7 @@ afterEach(() => { describe("notifyHireApproved", () => { it("writes success activity when adapter hook returns ok", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: true }), } as any); @@ -88,11 +88,11 @@ describe("notifyHireApproved", () => { }), ).resolves.toBeUndefined(); - expect(findServerAdapter).not.toHaveBeenCalled(); + expect(findActiveServerAdapter).not.toHaveBeenCalled(); }); it("does nothing when adapter has no onHireApproved", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any); + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "process" } as any); const db = mockDbWithAgent({ id: "a1", @@ -110,12 +110,12 @@ describe("notifyHireApproved", () => { }), ).resolves.toBeUndefined(); - expect(findServerAdapter).toHaveBeenCalledWith("process"); + expect(findActiveServerAdapter).toHaveBeenCalledWith("process"); expect(logActivity).not.toHaveBeenCalled(); }); it("logs failed result when adapter onHireApproved returns ok=false", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), } as any); @@ -147,7 +147,7 @@ describe("notifyHireApproved", () => { }); it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { - vi.mocked(findServerAdapter).mockReturnValue({ + vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "openclaw_gateway", onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), } as any); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 84bcaa3a..49530dc7 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -3,6 +3,7 @@ export { listAdapterModels, listServerAdapters, findServerAdapter, + findActiveServerAdapter, detectAdapterModel, registerServerAdapter, unregisterServerAdapter, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 78854dc9..8a30a9c8 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -278,16 +278,33 @@ export function waitForExternalAdapters(): Promise { } export function registerServerAdapter(adapter: ServerAdapterModule): void { + if (BUILTIN_ADAPTER_TYPES.has(adapter.type) && !builtinFallbacks.has(adapter.type)) { + const existing = adaptersByType.get(adapter.type); + if (existing) { + builtinFallbacks.set(adapter.type, existing); + } + } adaptersByType.set(adapter.type, adapter); } export function unregisterServerAdapter(type: string): void { if (type === processAdapter.type || type === httpAdapter.type) return; + if (builtinFallbacks.has(type)) { + pausedOverrides.delete(type); + const fallback = builtinFallbacks.get(type); + if (fallback) { + adaptersByType.set(type, fallback); + } + return; + } + if (BUILTIN_ADAPTER_TYPES.has(type)) { + return; + } adaptersByType.delete(type); } export function requireServerAdapter(type: string): ServerAdapterModule { - const adapter = adaptersByType.get(type); + const adapter = findActiveServerAdapter(type); if (!adapter) { throw new Error(`Unknown adapter type: ${type}`); } @@ -295,15 +312,11 @@ export function requireServerAdapter(type: string): ServerAdapterModule { } export function getServerAdapter(type: string): ServerAdapterModule { - if (pausedOverrides.has(type)) { - const fallback = builtinFallbacks.get(type); - if (fallback) return fallback; - } - return adaptersByType.get(type) ?? processAdapter; + return findActiveServerAdapter(type) ?? processAdapter; } export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> { - const adapter = adaptersByType.get(type); + const adapter = findActiveServerAdapter(type); if (!adapter) return []; if (adapter.listModels) { const discovered = await adapter.listModels(); @@ -332,7 +345,7 @@ export function listEnabledServerAdapters(): ServerAdapterModule[] { export async function detectAdapterModel( type: string, ): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> { - const adapter = adaptersByType.get(type); + const adapter = findActiveServerAdapter(type); if (!adapter?.detectModel) return null; const detected = await adapter.detectModel(); if (!detected) return null; @@ -390,3 +403,11 @@ export function getPausedOverrides(): Set { export function findServerAdapter(type: string): ServerAdapterModule | null { return adaptersByType.get(type) ?? null; } + +export function findActiveServerAdapter(type: string): ServerAdapterModule | null { + if (pausedOverrides.has(type)) { + const fallback = builtinFallbacks.get(type); + if (fallback) return fallback; + } + return adaptersByType.get(type) ?? null; +} diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 3218cb8c..27e32a06 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -20,6 +20,7 @@ import { Router } from "express"; import { listServerAdapters, findServerAdapter, + findActiveServerAdapter, listEnabledServerAdapters, registerServerAdapter, unregisterServerAdapter, @@ -593,7 +594,7 @@ export function adapterRoutes() { assertBoard(req); const { type } = req.params; - const adapter = findServerAdapter(type); + const adapter = findActiveServerAdapter(type); if (!adapter) { res.status(404).json({ error: `Adapter "${type}" is not registered.` }); return; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8bf2a104..f1c15b8d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -48,6 +48,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { detectAdapterModel, + findActiveServerAdapter, findServerAdapter, listAdapterModels, requireServerAdapter, @@ -820,7 +821,7 @@ export function agentRoutes(db: Db) { } await assertCanReadConfigurations(req, agent.companyId); - const adapter = findServerAdapter(agent.adapterType); + const adapter = findActiveServerAdapter(agent.adapterType); if (!adapter?.listSkills) { const preference = readPaperclipSkillSyncPreference( agent.adapterConfig as Record, @@ -898,7 +899,7 @@ export function agentRoutes(db: Db) { return; } - const adapter = findServerAdapter(updated.adapterType); + const adapter = findActiveServerAdapter(updated.adapterType); const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( updated.companyId, updated.adapterConfig, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index fae77e5f..faea351c 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -27,7 +27,7 @@ import type { CompanySkillUsageAgent, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; -import { findServerAdapter } from "../adapters/index.js"; +import { findActiveServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; @@ -1575,7 +1575,7 @@ export function companySkillService(db: Db) { return Promise.all( desiredAgents.map(async (agent) => { - const adapter = findServerAdapter(agent.adapterType); + const adapter = findActiveServerAdapter(agent.adapterType); let actualState: string | null = null; if (!adapter?.listSkills) { diff --git a/server/src/services/hire-hook.ts b/server/src/services/hire-hook.ts index 6b6e22ce..79a38177 100644 --- a/server/src/services/hire-hook.ts +++ b/server/src/services/hire-hook.ts @@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents } from "@paperclipai/db"; import type { HireApprovedPayload } from "@paperclipai/adapter-utils"; -import { findServerAdapter } from "../adapters/registry.js"; +import { findActiveServerAdapter } from "../adapters/registry.js"; import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; @@ -40,7 +40,7 @@ export async function notifyHireApproved( } const adapterType = row.adapterType ?? "process"; - const adapter = findServerAdapter(adapterType); + const adapter = findActiveServerAdapter(adapterType); const onHireApproved = adapter?.onHireApproved; if (!onHireApproved) { return; diff --git a/ui/src/adapters/dynamic-loader.ts b/ui/src/adapters/dynamic-loader.ts index 23d5f443..aec2efa8 100644 --- a/ui/src/adapters/dynamic-loader.ts +++ b/ui/src/adapters/dynamic-loader.ts @@ -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(); +const dynamicParserCache = new Map(); // Track which types we've already attempted to load (to avoid repeat 404s). const failedLoads = new Set(); @@ -33,7 +38,7 @@ const failedLoads = new Set(); * * @returns A StdoutLineParser function, or null if unavailable. */ -export async function loadDynamicParser(adapterType: string): Promise { +export async function loadDynamicParser(adapterType: string): Promise { // 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 { 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 { 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, }); diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index 8b56163e..c33c9008 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -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" }]); + }); }); diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 98b19454..307aa5ae 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -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; } diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index 6a7ae48a..74bd48e5 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -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; buildAdapterConfig: (values: CreateConfigValues) => Record; } diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index a0c9068d..a34fedae 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -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, }), ); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index cc238c35..40d342c7 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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], );