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

View file

@ -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 type { ServerAdapterModule } from "../adapters/index.js";
import { import {
detectAdapterModel,
findActiveServerAdapter,
findServerAdapter, findServerAdapter,
listAdapterModels, listAdapterModels,
registerServerAdapter, registerServerAdapter,
requireServerAdapter, requireServerAdapter,
unregisterServerAdapter, unregisterServerAdapter,
} from "../adapters/index.js"; } from "../adapters/index.js";
import { setOverridePaused } from "../adapters/registry.js";
const externalAdapter: ServerAdapterModule = { const externalAdapter: ServerAdapterModule = {
type: "external_test", type: "external_test",
@ -28,10 +31,14 @@ const externalAdapter: ServerAdapterModule = {
describe("server adapter registry", () => { describe("server adapter registry", () => {
beforeEach(() => { beforeEach(() => {
unregisterServerAdapter("external_test"); unregisterServerAdapter("external_test");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
}); });
afterEach(() => { afterEach(() => {
unregisterServerAdapter("external_test"); unregisterServerAdapter("external_test");
unregisterServerAdapter("claude_local");
setOverridePaused("claude_local", false);
}); });
it("registers external adapters and exposes them through lookup helpers", async () => { 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" }, { 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);
});
}); });

View file

@ -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");
});
});

View file

@ -61,6 +61,7 @@ const mockAdapter = vi.hoisted(() => ({
vi.mock("@paperclipai/shared/telemetry", () => ({ vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated, trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
})); }));
vi.mock("../telemetry.js", () => ({ vi.mock("../telemetry.js", () => ({
@ -85,6 +86,7 @@ vi.mock("../services/index.js", () => ({
vi.mock("../adapters/index.js", () => ({ vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter), findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
listAdapterModels: vi.fn(), listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(), detectAdapterModel: vi.fn(),
})); }));

View file

@ -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. // Mock the registry so we control whether the adapter has onHireApproved and what it does.
vi.mock("../adapters/registry.js", () => ({ vi.mock("../adapters/registry.js", () => ({
findServerAdapter: vi.fn(), findActiveServerAdapter: vi.fn(),
})); }));
vi.mock("../services/activity-log.js", () => ({ vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn().mockResolvedValue(undefined), 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"); const { logActivity } = await import("../services/activity-log.js");
function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }): Db { function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }): Db {
@ -39,7 +39,7 @@ afterEach(() => {
describe("notifyHireApproved", () => { describe("notifyHireApproved", () => {
it("writes success activity when adapter hook returns ok", async () => { it("writes success activity when adapter hook returns ok", async () => {
vi.mocked(findServerAdapter).mockReturnValue({ vi.mocked(findActiveServerAdapter).mockReturnValue({
type: "openclaw_gateway", type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: true }), onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
} as any); } as any);
@ -88,11 +88,11 @@ describe("notifyHireApproved", () => {
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(findServerAdapter).not.toHaveBeenCalled(); expect(findActiveServerAdapter).not.toHaveBeenCalled();
}); });
it("does nothing when adapter has no onHireApproved", async () => { 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({ const db = mockDbWithAgent({
id: "a1", id: "a1",
@ -110,12 +110,12 @@ describe("notifyHireApproved", () => {
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(findServerAdapter).toHaveBeenCalledWith("process"); expect(findActiveServerAdapter).toHaveBeenCalledWith("process");
expect(logActivity).not.toHaveBeenCalled(); expect(logActivity).not.toHaveBeenCalled();
}); });
it("logs failed result when adapter onHireApproved returns ok=false", async () => { it("logs failed result when adapter onHireApproved returns ok=false", async () => {
vi.mocked(findServerAdapter).mockReturnValue({ vi.mocked(findActiveServerAdapter).mockReturnValue({
type: "openclaw_gateway", type: "openclaw_gateway",
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
} as any); } as any);
@ -147,7 +147,7 @@ describe("notifyHireApproved", () => {
}); });
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
vi.mocked(findServerAdapter).mockReturnValue({ vi.mocked(findActiveServerAdapter).mockReturnValue({
type: "openclaw_gateway", type: "openclaw_gateway",
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
} as any); } as any);

View file

@ -3,6 +3,7 @@ export {
listAdapterModels, listAdapterModels,
listServerAdapters, listServerAdapters,
findServerAdapter, findServerAdapter,
findActiveServerAdapter,
detectAdapterModel, detectAdapterModel,
registerServerAdapter, registerServerAdapter,
unregisterServerAdapter, unregisterServerAdapter,

View file

@ -278,16 +278,33 @@ export function waitForExternalAdapters(): Promise<void> {
} }
export function registerServerAdapter(adapter: ServerAdapterModule): void { 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); adaptersByType.set(adapter.type, adapter);
} }
export function unregisterServerAdapter(type: string): void { export function unregisterServerAdapter(type: string): void {
if (type === processAdapter.type || type === httpAdapter.type) return; 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); adaptersByType.delete(type);
} }
export function requireServerAdapter(type: string): ServerAdapterModule { export function requireServerAdapter(type: string): ServerAdapterModule {
const adapter = adaptersByType.get(type); const adapter = findActiveServerAdapter(type);
if (!adapter) { if (!adapter) {
throw new Error(`Unknown adapter type: ${type}`); throw new Error(`Unknown adapter type: ${type}`);
} }
@ -295,15 +312,11 @@ export function requireServerAdapter(type: string): ServerAdapterModule {
} }
export function getServerAdapter(type: string): ServerAdapterModule { export function getServerAdapter(type: string): ServerAdapterModule {
if (pausedOverrides.has(type)) { return findActiveServerAdapter(type) ?? processAdapter;
const fallback = builtinFallbacks.get(type);
if (fallback) return fallback;
}
return adaptersByType.get(type) ?? processAdapter;
} }
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> { 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) return [];
if (adapter.listModels) { if (adapter.listModels) {
const discovered = await adapter.listModels(); const discovered = await adapter.listModels();
@ -332,7 +345,7 @@ export function listEnabledServerAdapters(): ServerAdapterModule[] {
export async function detectAdapterModel( export async function detectAdapterModel(
type: string, type: string,
): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> { ): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> {
const adapter = adaptersByType.get(type); const adapter = findActiveServerAdapter(type);
if (!adapter?.detectModel) return null; if (!adapter?.detectModel) return null;
const detected = await adapter.detectModel(); const detected = await adapter.detectModel();
if (!detected) return null; if (!detected) return null;
@ -390,3 +403,11 @@ export function getPausedOverrides(): Set<string> {
export function findServerAdapter(type: string): ServerAdapterModule | null { export function findServerAdapter(type: string): ServerAdapterModule | null {
return adaptersByType.get(type) ?? 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;
}

View file

@ -20,6 +20,7 @@ import { Router } from "express";
import { import {
listServerAdapters, listServerAdapters,
findServerAdapter, findServerAdapter,
findActiveServerAdapter,
listEnabledServerAdapters, listEnabledServerAdapters,
registerServerAdapter, registerServerAdapter,
unregisterServerAdapter, unregisterServerAdapter,
@ -593,7 +594,7 @@ export function adapterRoutes() {
assertBoard(req); assertBoard(req);
const { type } = req.params; const { type } = req.params;
const adapter = findServerAdapter(type); const adapter = findActiveServerAdapter(type);
if (!adapter) { if (!adapter) {
res.status(404).json({ error: `Adapter "${type}" is not registered.` }); res.status(404).json({ error: `Adapter "${type}" is not registered.` });
return; return;

View file

@ -48,6 +48,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { import {
detectAdapterModel, detectAdapterModel,
findActiveServerAdapter,
findServerAdapter, findServerAdapter,
listAdapterModels, listAdapterModels,
requireServerAdapter, requireServerAdapter,
@ -820,7 +821,7 @@ export function agentRoutes(db: Db) {
} }
await assertCanReadConfigurations(req, agent.companyId); await assertCanReadConfigurations(req, agent.companyId);
const adapter = findServerAdapter(agent.adapterType); const adapter = findActiveServerAdapter(agent.adapterType);
if (!adapter?.listSkills) { if (!adapter?.listSkills) {
const preference = readPaperclipSkillSyncPreference( const preference = readPaperclipSkillSyncPreference(
agent.adapterConfig as Record<string, unknown>, agent.adapterConfig as Record<string, unknown>,
@ -898,7 +899,7 @@ export function agentRoutes(db: Db) {
return; return;
} }
const adapter = findServerAdapter(updated.adapterType); const adapter = findActiveServerAdapter(updated.adapterType);
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
updated.companyId, updated.companyId,
updated.adapterConfig, updated.adapterConfig,

View file

@ -27,7 +27,7 @@ import type {
CompanySkillUsageAgent, CompanySkillUsageAgent,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { normalizeAgentUrlKey } 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 { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { notFound, unprocessable } from "../errors.js"; import { notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
@ -1575,7 +1575,7 @@ export function companySkillService(db: Db) {
return Promise.all( return Promise.all(
desiredAgents.map(async (agent) => { desiredAgents.map(async (agent) => {
const adapter = findServerAdapter(agent.adapterType); const adapter = findActiveServerAdapter(agent.adapterType);
let actualState: string | null = null; let actualState: string | null = null;
if (!adapter?.listSkills) { if (!adapter?.listSkills) {

View file

@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { agents } from "@paperclipai/db"; import { agents } from "@paperclipai/db";
import type { HireApprovedPayload } from "@paperclipai/adapter-utils"; 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 { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js"; import { logActivity } from "./activity-log.js";
@ -40,7 +40,7 @@ export async function notifyHireApproved(
} }
const adapterType = row.adapterType ?? "process"; const adapterType = row.adapterType ?? "process";
const adapter = findServerAdapter(adapterType); const adapter = findActiveServerAdapter(adapterType);
const onHireApproved = adapter?.onHireApproved; const onHireApproved = adapter?.onHireApproved;
if (!onHireApproved) { if (!onHireApproved) {
return; return;

View file

@ -16,11 +16,16 @@
*/ */
import type { TranscriptEntry } from "@paperclipai/adapter-utils"; 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. // Cache of dynamically loaded parsers by adapter type.
// Once loaded, the parser is reused for all runs of that 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). // Track which types we've already attempted to load (to avoid repeat 404s).
const failedLoads = new Set<string>(); const failedLoads = new Set<string>();
@ -33,7 +38,7 @@ const failedLoads = new Set<string>();
* *
* @returns A StdoutLineParser function, or null if unavailable. * @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 // Return cached parser if already loaded
const cached = dynamicParserCache.get(adapterType); const cached = dynamicParserCache.get(adapterType);
if (cached) return cached; 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 blob = new Blob([source], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
let parseFn: StdoutLineParser; let parserModule: DynamicParserModule;
try { try {
const mod = await import(/* @vite-ignore */ blobUrl); 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, // Prefer the factory function (stateful parser) if available,
// fall back to the static parseStdoutLine function. // fall back to the static parseStdoutLine function.
if (typeof mod.createStdoutParser === "function") { if (typeof mod.createStdoutParser === "function") {
// Stateful parser — create one instance for the UI session. const createStdoutParser = mod.createStdoutParser as StdoutParserFactory;
// Each run creates its own transcript builder, so a single parserModule = {
// parser instance is sufficient per adapter type. createStdoutParser,
const parser = (mod.createStdoutParser as () => { parseLine: StdoutLineParser; reset: () => void })(); // Fallback for callers that only know about parseStdoutLine.
parseFn = parser.parseLine.bind(parser); 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") { } else if (typeof mod.parseStdoutLine === "function") {
parseFn = mod.parseStdoutLine as StdoutLineParser; parserModule = {
parseStdoutLine: mod.parseStdoutLine as StdoutLineParser,
};
} else { } else {
console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`); console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`);
failedLoads.add(adapterType); failedLoads.add(adapterType);
@ -81,9 +97,9 @@ export async function loadDynamicParser(adapterType: string): Promise<StdoutLine
} }
// Cache for reuse // Cache for reuse
dynamicParserCache.set(adapterType, parseFn); dynamicParserCache.set(adapterType, parserModule);
console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`); console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
return parseFn; return parserModule;
} catch (err) { } catch (err) {
console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err); console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
failedLoads.add(adapterType); failedLoads.add(adapterType);

View file

@ -101,12 +101,13 @@ export function getUIAdapter(type: string): UIAdapterModule {
parseStdoutLine: (line: string, ts: string) => { parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) { if (!loadStarted) {
loadStarted = true; loadStarted = true;
loadDynamicParser(type).then((parser) => { loadDynamicParser(type).then((parserModule) => {
if (parser) { if (parserModule) {
registerUIAdapter({ registerUIAdapter({
type, type,
label: type, label: type,
parseStdoutLine: parser, parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: SchemaConfigFields, ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig, buildAdapterConfig: buildSchemaAdapterConfig,
}); });
@ -182,13 +183,14 @@ export function syncExternalAdapters(
parseStdoutLine: (line: string, ts: string) => { parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) { if (!loadStarted) {
loadStarted = true; loadStarted = true;
loadDynamicParser(builtinType).then((parser) => { loadDynamicParser(builtinType).then((parserModule) => {
// Discard if the override was torn down while the load was in-flight. // 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({ registerUIAdapter({
type: builtinType, type: builtinType,
label, label,
parseStdoutLine: parser, parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: originalBuiltin.ConfigFields, ConfigFields: originalBuiltin.ConfigFields,
buildAdapterConfig: originalBuiltin.buildAdapterConfig, buildAdapterConfig: originalBuiltin.buildAdapterConfig,
}); });
@ -232,12 +234,13 @@ export function syncExternalAdapters(
parseStdoutLine: (line: string, ts: string) => { parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) { if (!loadStarted) {
loadStarted = true; loadStarted = true;
loadDynamicParser(type).then((parser) => { loadDynamicParser(type).then((parserModule) => {
if (parser) { if (parserModule) {
registerUIAdapter({ registerUIAdapter({
type, type,
label, label,
parseStdoutLine: parser, parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields, ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig, buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
}); });

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript"; import { buildTranscript, type RunLogChunk } from "./transcript";
import type { UIAdapterModule } from "./types";
describe("buildTranscript", () => { describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z"; const ts = "2026-03-20T13:00:00.000Z";
@ -27,4 +28,46 @@ describe("buildTranscript", () => {
{ kind: "stderr", ts, text: "stderr /Users/d****/project" }, { 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" }]);
});
}); });

View file

@ -1,9 +1,20 @@
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils"; 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 }; export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean }; 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) { export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
const last = entries[entries.length - 1]; const last = entries[entries.length - 1];
@ -24,12 +35,13 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
export function buildTranscript( export function buildTranscript(
chunks: RunLogChunk[], chunks: RunLogChunk[],
parser: StdoutLineParser, parserSource: StdoutLineParser | TranscriptParserSource,
opts?: TranscriptBuildOptions, opts?: TranscriptBuildOptions,
): TranscriptEntry[] { ): TranscriptEntry[] {
const entries: TranscriptEntry[] = []; const entries: TranscriptEntry[] = [];
let stdoutBuffer = ""; let stdoutBuffer = "";
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false }; const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
const { parseLine, reset } = resolveStdoutParser(parserSource);
for (const chunk of chunks) { for (const chunk of chunks) {
if (chunk.stream === "stderr") { if (chunk.stream === "stderr") {
@ -47,15 +59,17 @@ export function buildTranscript(
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; 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(); const trailing = stdoutBuffer.trim();
if (trailing) { if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); 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; return entries;
} }

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 // Re-export shared types so local consumers don't need to change imports
export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils"; 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 { export interface AdapterConfigFieldsProps {
mode: "create" | "edit"; mode: "create" | "edit";
isCreate: boolean; isCreate: boolean;
@ -24,10 +36,9 @@ export interface AdapterConfigFieldsProps {
hideInstructionsFile?: boolean; hideInstructionsFile?: boolean;
} }
export interface UIAdapterModule { export interface UIAdapterModule extends TranscriptParserSource {
type: string; type: string;
label: string; label: string;
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
ConfigFields: ComponentType<AdapterConfigFieldsProps>; ConfigFields: ComponentType<AdapterConfigFieldsProps>;
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>; buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
} }

View file

@ -284,7 +284,7 @@ export function useLiveRunTranscripts({
const adapter = getUIAdapter(run.adapterType); const adapter = getUIAdapter(run.adapterType);
next.set( next.set(
run.id, run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, { buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
censorUsernameInLogs, censorUsernameInLogs,
}), }),
); );

View file

@ -3802,7 +3802,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}, []); }, []);
const transcript = useMemo( const transcript = useMemo(
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }), () => buildTranscript(logLines, adapter, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines, parserTick], [adapter, censorUsernameInLogs, logLines, parserTick],
); );