diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 987a4e156d..b718d327c0 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -115,8 +115,11 @@ export type { LoginRunnerRaceResult, } from "./login-runner-lifecycle.js"; export { + PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS, + PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS, PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, isPaperclipRunnerProvider, + resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, } from "./paperclip-runner-permissions.js"; export { diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 51f169fc8f..36bb7aa29a 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -12,6 +12,9 @@ export type PaperclipRunnerPermissionMode = | OpenCodePermissionMode | AcpxPermissionMode; +export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS = 300_000; +export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS = 86_400_000; + export interface PaperclipRunnerPermissionOption { value: TMode; label: string; @@ -35,7 +38,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { codex: { configurable: true, configKey: "codexPermissionMode", - defaultMode: "never", + defaultMode: "untrusted", description: "Controls when Codex asks before an operation inside the assigned Paperclip environment.", options: [ { value: "never", label: "Full auto (never ask)", description: "Run without Codex approval pauses." }, @@ -80,3 +83,12 @@ export function resolvePaperclipRunnerPermissionMode( ? value as PaperclipRunnerPermissionMode : capability.defaultMode; } + +export function resolvePaperclipRunnerIdleTimeoutMs(value: unknown): number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value > 0 + && value <= PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS + ? value + : PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS; +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 6bccf00878..c9ee155d16 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -665,6 +665,9 @@ export interface CLIAdapterModule { export interface CreateConfigValues { adapterType: string; + codexPermissionMode?: "never" | "on-request" | "untrusted"; + paperclipRunnerLifecycleMode?: "per_turn" | "warm"; + paperclipRunnerIdleTimeoutMs?: number; cwd: string; instructionsFilePath?: string; promptTemplate: string; diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index 97201f40f0..79b011397e 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -87,6 +87,8 @@ describe("buildPaperclipRunnerConfig", () => { expect(config).toMatchObject({ provider: "codex", + codexPermissionMode: "untrusted", + lifecycleMode: "per_turn", model: "gpt-5.4", timeoutSec: 0, graceSec: 15, @@ -107,43 +109,53 @@ describe("buildPaperclipRunnerConfig", () => { } }); - it("builds a bounded OpenCode runner profile from schema values", () => { + it("persists bounded Codex permission and warm lifecycle values", () => { const config = buildPaperclipRunnerConfig(makeValues({ adapterType: "paperclip_runner", - model: "", - codexEngine: "acp", - dangerouslyBypassSandbox: true, - adapterSchemaValues: { - provider: "opencode", - opencodePermissionMode: "ask", - lifecycleMode: "warm", - idleTimeoutMs: 45_000, - }, + codexPermissionMode: "untrusted", + paperclipRunnerLifecycleMode: "warm", + paperclipRunnerIdleTimeoutMs: 45_000, })); expect(config).toMatchObject({ - provider: "opencode", - model: "openrouter/deepseek/deepseek-v4-flash-0731", - opencodePermissionMode: "ask", + provider: "codex", + codexPermissionMode: "untrusted", lifecycleMode: "warm", idleTimeoutMs: 45_000, }); - expect(config).not.toHaveProperty("engine"); - expect(config).not.toHaveProperty("dangerouslyBypassApprovalsAndSandbox"); }); - it("falls back to safe OpenCode defaults for invalid schema values", () => { + it("fails closed to the Codex profile and safe defaults for stale schema values", () => { expect(buildPaperclipRunnerConfig(makeValues({ adapterSchemaValues: { provider: "opencode", - opencodePermissionMode: "unrestricted", + codexPermissionMode: "unrestricted", lifecycleMode: "forever", idleTimeoutMs: -1, }, }))).toMatchObject({ - provider: "opencode", - opencodePermissionMode: "allow", + provider: "codex", + codexPermissionMode: "untrusted", lifecycleMode: "per_turn", }); }); + + it("bounds warm lifecycle values to the shared safe default", () => { + expect(buildPaperclipRunnerConfig(makeValues({ + paperclipRunnerLifecycleMode: "warm", + paperclipRunnerIdleTimeoutMs: 86_400_001, + }))).toMatchObject({ + lifecycleMode: "warm", + idleTimeoutMs: 300_000, + }); + }); + + it("omits an idle timeout for turn-by-turn sessions", () => { + const config = buildPaperclipRunnerConfig(makeValues({ + paperclipRunnerLifecycleMode: "per_turn", + paperclipRunnerIdleTimeoutMs: 45_000, + })); + + expect(config).not.toHaveProperty("idleTimeoutMs"); + }); }); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index f5090d136d..43b0c21b47 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -1,4 +1,9 @@ -import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils"; +import { + buildAdapterEnvConfig, + resolvePaperclipRunnerIdleTimeoutMs, + resolvePaperclipRunnerPermissionMode, + type CreateConfigValues, +} from "@paperclipai/adapter-utils"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "../index.js"; function parseCommaArgs(value: string): string[] { @@ -61,14 +66,6 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record { const config = buildCodexLocalConfig(v); @@ -91,24 +88,19 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record 0 - ? configuredIdleTimeoutMs - : 300_000; + const lifecycleCandidate = v.paperclipRunnerLifecycleMode ?? schemaValues.lifecycleMode; + const lifecycleMode = lifecycleCandidate === "warm" ? "warm" : "per_turn"; + const configuredIdleTimeoutMs = + v.paperclipRunnerIdleTimeoutMs ?? schemaValues.idleTimeoutMs; + const idleTimeoutMs = resolvePaperclipRunnerIdleTimeoutMs( + configuredIdleTimeoutMs, + ); return { ...config, - provider, - model: v.model || "openrouter/deepseek/deepseek-v4-flash-0731", - opencodePermissionMode: openCodePermissionMode( - schemaValues.opencodePermissionMode, + provider: "codex", + codexPermissionMode: resolvePaperclipRunnerPermissionMode( + "codex", + v.codexPermissionMode ?? schemaValues.codexPermissionMode, ), lifecycleMode, ...(lifecycleMode === "warm" ? { idleTimeoutMs } : {}), diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index ee137a7c85..88a7c83bf1 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -44,7 +44,7 @@ export function createCodexNativeSessionBackend( return new HarnessDriverBackend(new CodexAppServerDriver({ ...(input.provider.model ? { model: input.provider.model } : {}), - approvalPolicy: input.provider.approvalPolicy ?? "never", + approvalPolicy: input.provider.approvalPolicy ?? "untrusted", baseInstructions: nativeSystemInstructions(input), includeSkillInstructions: "runtimeContext" in input, requestedCollaborationMode: diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 980ae6e3a9..a45c6a664c 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -326,6 +326,13 @@ describe("NativeExecutionInputV1", () => { lifecyclePolicy: { mode: "warm", idleTimeoutMs: 0 }, }, })).toThrow("positive integer"); + expect(() => parseNativeExecutionInput({ + ...input, + session: { + ...input.session, + lifecyclePolicy: { mode: "warm", idleTimeoutMs: 86_400_001 }, + }, + })).toThrow("no greater than 86400000"); }); }); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 9e51fbcc71..bf18d9895c 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -7,6 +7,7 @@ export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-inpu export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v4" as const; export const NATIVE_MODEL_ENVELOPE_SCHEMA_V1 = "paperclip.native-model-envelope.v1" as const; export const NATIVE_MODEL_ENVELOPE_SCHEMA = "paperclip.native-model-envelope.v2" as const; +export const NATIVE_SESSION_IDLE_TIMEOUT_MAX_MS = 86_400_000; export type NativeExecutionMode = "default" | "plan"; @@ -368,8 +369,14 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput } lifecyclePolicy = { mode: "per_turn", idleTimeoutMs: null }; } else if (lifecyclePolicyValue.mode === "warm") { - if (!Number.isSafeInteger(lifecyclePolicyValue.idleTimeoutMs) || Number(lifecyclePolicyValue.idleTimeoutMs) <= 0) { - throw new NativeExecutionInputError("input.session.lifecyclePolicy.idleTimeoutMs must be a positive integer for warm"); + if ( + !Number.isSafeInteger(lifecyclePolicyValue.idleTimeoutMs) + || Number(lifecyclePolicyValue.idleTimeoutMs) <= 0 + || Number(lifecyclePolicyValue.idleTimeoutMs) > NATIVE_SESSION_IDLE_TIMEOUT_MAX_MS + ) { + throw new NativeExecutionInputError( + `input.session.lifecyclePolicy.idleTimeoutMs must be a positive integer no greater than ${NATIVE_SESSION_IDLE_TIMEOUT_MAX_MS} for warm`, + ); } lifecyclePolicy = { mode: "warm", idleTimeoutMs: Number(lifecyclePolicyValue.idleTimeoutMs) }; } else { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 950cd02090..7087754906 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -214,7 +214,7 @@ export class CodexAppServerDriver implements HarnessDriver { this.#options.includeCollaborationModeInstructions ?? true, this.#options.includeSkillInstructions ?? false, ), - approvalPolicy: this.#options.approvalPolicy ?? "never", + approvalPolicy: this.#options.approvalPolicy ?? "untrusted", ...(this.#options.model ? { model: this.#options.model } : {}), ...(this.#direct() ? {} @@ -338,7 +338,7 @@ export class CodexAppServerDriver implements HarnessDriver { baseInstructions: this.#direct() ? "" : this.#baseInstructions(), - approvalPolicy: this.#options.approvalPolicy ?? "never", + approvalPolicy: this.#options.approvalPolicy ?? "untrusted", ...(this.#options.model ? { model: this.#options.model } : {}), persistExtendedHistory: false, })); @@ -696,7 +696,7 @@ export class CodexAppServerDriver implements HarnessDriver { networkAccess: false, }, approvalPolicy: boundedCodexValue( - response.approvalPolicy ?? this.#options.approvalPolicy ?? "never", + response.approvalPolicy ?? this.#options.approvalPolicy ?? "untrusted", ), baseInstructions: this.#baseInstructions(), instructionSources: Array.isArray(response.instructionSources) diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts index c52117f7f9..07bc4ea443 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts @@ -61,7 +61,7 @@ describe("Codex app-server Codex driver", () => { model: "gpt-test", modelProvider: "openai", workingDirectory: WORKSPACE, - approvalPolicy: "never", + approvalPolicy: "untrusted", instructionSources: [], instructionPolicy: { skillInstructions: false, @@ -77,7 +77,7 @@ describe("Codex app-server Codex driver", () => { expect( transport.calls.find((call) => call.method === "thread/start")?.params, ).toMatchObject({ - approvalPolicy: "never", + approvalPolicy: "untrusted", config: { "skills.include_instructions": false, include_apps_instructions: false, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts index b84b39011a..af6794aaae 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts @@ -1242,7 +1242,7 @@ describe("Codex app-server Codex driver", () => { model: "gpt-test", modelProvider: "openai", workingDirectory: TEST_WORKING_DIRECTORY, - approvalPolicy: "never", + approvalPolicy: "untrusted", instructionSources: [], instructionPolicy: { skillInstructions: false, @@ -1258,7 +1258,7 @@ describe("Codex app-server Codex driver", () => { expect( transport.calls.find((call) => call.method === "thread/start")?.params, ).toMatchObject({ - approvalPolicy: "never", + approvalPolicy: "untrusted", config: { "skills.include_instructions": false, include_apps_instructions: false, diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 81366d14f0..2c103bd39a 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -221,6 +221,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { expect(settings.enableServerInfoDebugView).toBe(false); }); + it("defaults Paperclip developer mode off and accepts explicit patches", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enablePaperclipDeveloperMode).toBe(false); + expect( + patchInstanceExperimentalSettingsSchema.parse({ enablePaperclipDeveloperMode: true }), + ).toEqual({ enablePaperclipDeveloperMode: true }); + }); + it("defaults workspace branch repair settings on", () => { const settings = instanceExperimentalSettingsSchema.parse({}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index f0ee9ad538..c2dfead9ef 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -65,6 +65,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableDecisions: z.boolean().default(false), enableGoalsSidebarLink: z.boolean().default(false), enableServerInfoDebugView: z.boolean().default(false), + enablePaperclipDeveloperMode: z.boolean().default(false), enableSimplifiedEnglishInteractions: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 55009f5145..57c038bdf4 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -851,6 +851,32 @@ describe.sequential("agent skill routes", () => { expect(mockAdapter.syncSkills).not.toHaveBeenCalled(); }); + it("allows paperclip_runner to remove a pre-existing legacy Paperclip skill", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent("paperclip_runner"), + adapterConfig: { + paperclipSkillSync: { + desiredSkills: ["company-1/keep", "paperclipai/paperclip/paperclip"], + }, + }, + }); + + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) + .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") + .send({ desiredSkills: ["paperclipai/paperclip/paperclip"], mode: "remove" })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: { desiredSkills: ["company-1/keep"] }, + }), + }), + expect.any(Object), + ); + }); + it("syncs skills without resolving required user-secret env bindings", async () => { const adapterConfig = { env: { @@ -1215,6 +1241,33 @@ describe.sequential("agent skill routes", () => { ); }); + it("omits the legacy operational skill from paperclip_runner CEO defaults", async () => { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enableBetaSkills: false, + enableNativeRunner: true, + }); + + const res = await request(await createApp(createDb(true))) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "Native Lead", + role: "ceo", + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex" }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + const createInput = mockAgentService.create.mock.calls[0]?.[1] as { + adapterConfig: { paperclipSkillSync: { desiredSkills: string[] } }; + }; + expect(createInput.adapterConfig.paperclipSkillSync.desiredSkills).not.toContain( + "paperclipai/paperclip/paperclip", + ); + expect(createInput.adapterConfig.paperclipSkillSync.desiredSkills).toContain( + "paperclipai/paperclip/paperclip-board", + ); + }); + it("unions requested skills with the CEO defaults instead of replacing them", async () => { const res = await request(await createApp(createDb(true))) .post("/api/companies/company-1/agent-hires") diff --git a/server/src/__tests__/agents-paperclip-runner-skills.test.ts b/server/src/__tests__/agents-paperclip-runner-skills.test.ts new file mode 100644 index 0000000000..1bd43a3dc1 --- /dev/null +++ b/server/src/__tests__/agents-paperclip-runner-skills.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { PAPERCLIP_OPERATIONAL_SKILL_KEY } from "@paperclipai/adapter-utils/server-utils"; +import { assertPaperclipRunnerOperationalSkillInvariant } from "../services/agents.js"; + +const legacyConfig = { + paperclipSkillSync: { + desiredSkills: [PAPERCLIP_OPERATIONAL_SKILL_KEY], + }, +}; + +describe("paperclip_runner operational skill invariant", () => { + it("rejects the legacy operational skill on new runners", () => { + expect(() => assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: "paperclip_runner", + nextConfig: legacyConfig, + })).toThrow("does not support the legacy Paperclip operational skill"); + }); + + it("rejects adding the legacy skill or carrying it onto a runner adapter", () => { + expect(() => assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: "paperclip_runner", + nextConfig: legacyConfig, + priorAdapterType: "paperclip_runner", + priorConfig: {}, + })).toThrow("does not support the legacy Paperclip operational skill"); + expect(() => assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: "paperclip_runner", + nextConfig: legacyConfig, + priorAdapterType: "codex_local", + priorConfig: legacyConfig, + })).toThrow("does not support the legacy Paperclip operational skill"); + }); + + it("allows stale runner assignments to remain inert while they are edited or removed", () => { + expect(() => assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: "paperclip_runner", + nextConfig: legacyConfig, + priorAdapterType: "paperclip_runner", + priorConfig: legacyConfig, + })).not.toThrow(); + expect(() => assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: "paperclip_runner", + nextConfig: {}, + priorAdapterType: "paperclip_runner", + priorConfig: legacyConfig, + })).not.toThrow(); + }); + + it("does not apply the native-runner invariant to direct adapters", () => { + expect(() => assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: "codex_local", + nextConfig: legacyConfig, + })).not.toThrow(); + }); +}); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index a3ab5d0a52..d0b0606fe8 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -3409,7 +3409,7 @@ describe("company portability", () => { }); }); - it("disables timer heartbeats on imported agents", async () => { + it("disables timer heartbeats and strips raw provider tracing on created imports", async () => { const portability = companyPortabilityService({} as any); companySvc.create.mockResolvedValue({ @@ -3423,6 +3423,16 @@ describe("company portability", () => { runtimeConfig: input.runtimeConfig, })); + const sourceAgents = (await agentSvc.list()) as Array>; + agentSvc.list.mockResolvedValue(sourceAgents.map((agent) => ({ + ...agent, + runtimeConfig: { + ...((agent.runtimeConfig ?? {}) as Record), + debug: { providerTrace: "raw", retainedDebugSetting: true }, + }, + }))); + agentSvc.list.mockClear(); + const exported = await portability.exportBundle("company-1", { include: { company: true, @@ -3457,12 +3467,19 @@ describe("company portability", () => { const createdClaude = agentSvc.create.mock.calls.find(([, input]) => input.name === "ClaudeCoder"); expect(createdClaude?.[1]).toMatchObject({ runtimeConfig: { + debug: { + retainedDebugSetting: true, + }, heartbeat: { enabled: false, maxConcurrentRuns: 20, }, }, }); + const createdRuntimeConfig = createdClaude?.[1].runtimeConfig as + | Record + | undefined; + expect(createdRuntimeConfig?.debug).not.toHaveProperty("providerTrace"); }); it("imports only selected files and leaves unchecked company metadata alone", async () => { @@ -5656,6 +5673,15 @@ describe("company portability", () => { it("normalizes adapter config on replace imports before updating existing agents", async () => { const portability = companyPortabilityService({} as any); + const sourceAgents = (await agentSvc.list()) as Array>; + agentSvc.list.mockResolvedValue(sourceAgents.map((agent) => ({ + ...agent, + runtimeConfig: { + ...((agent.runtimeConfig ?? {}) as Record), + debug: { providerTrace: "raw", retainedDebugSetting: true }, + }, + }))); + agentSvc.list.mockClear(); const exported = await portability.exportBundle("company-1", { include: { company: true, @@ -5716,7 +5742,20 @@ describe("company portability", () => { adapterConfig: { normalized: "updated", }, + runtimeConfig: { + debug: { + retainedDebugSetting: true, + }, + heartbeat: { + enabled: false, + maxConcurrentRuns: 20, + }, + }, })); + const runtimeUpdate = agentSvc.update.mock.calls.find( + ([, patch]) => patch.runtimeConfig !== undefined, + )?.[1].runtimeConfig as Record | undefined; + expect(runtimeUpdate?.debug).not.toHaveProperty("providerTrace"); }); it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => { diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 27f80f5411..0a446ad02d 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -19,6 +19,7 @@ describe("instance settings service", () => { enableBuiltInAgents: true, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, + enablePaperclipDeveloperMode: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, @@ -48,6 +49,7 @@ describe("instance settings service", () => { enableDecisions: false, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, + enablePaperclipDeveloperMode: true, enableSimplifiedEnglishInteractions: false, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, @@ -129,6 +131,15 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enablePaperclipDeveloperMode to false for empty and legacy settings", () => { + expect(normalizeExperimentalSettings(undefined).enablePaperclipDeveloperMode).toBe(false); + expect(normalizeExperimentalSettings({}).enablePaperclipDeveloperMode).toBe(false); + expect( + normalizeExperimentalSettings({ enableServerInfoDebugView: true }) + .enablePaperclipDeveloperMode, + ).toBe(false); + }); + it("defaults enableGoalsSidebarLink to false for empty and legacy stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableGoalsSidebarLink).toBe(false); expect(normalizeExperimentalSettings({}).enableGoalsSidebarLink).toBe(false); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2155902915..7d292f47e0 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -36,6 +36,7 @@ import { } from "@paperclipai/shared"; import { isForbiddenConfigEnvKey, + PAPERCLIP_OPERATIONAL_SKILL_KEY, parseObject, resolvePaperclipInstanceRootForAdapter, readPaperclipSkillSyncPreference, @@ -2347,7 +2348,9 @@ export function agentRoutes( if (role !== "ceo") return undefined; const adapter = findActiveServerAdapter(adapterType); if (!adapter?.listSkills && !adapter?.syncSkills) return undefined; - return PAPERCLIP_CORE_SKILL_KEYS.map((key) => ({ key, versionId: null })); + return PAPERCLIP_CORE_SKILL_KEYS + .filter((key) => adapterType !== "paperclip_runner" || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY) + .map((key) => ({ key, versionId: null })); } function withDefaultRoleSkillSelections( @@ -2470,11 +2473,12 @@ export function agentRoutes( const desiredSkillEntries = mergeDesiredSkillEntries(currentSkillEntries, requestedSkillEntries, mode); if ( - adapterType === "paperclip_runner" && - desiredSkillEntries.some((entry) => entry.key === "paperclipai/paperclip/paperclip") + adapterType === "paperclip_runner" + && mode !== "remove" + && requestedSkillEntries.some((entry) => entry.key === PAPERCLIP_OPERATIONAL_SKILL_KEY) ) { throw unprocessable( - "paperclip_runner does not support the legacy Paperclip operational skill (paperclipai/paperclip/paperclip); remove it from this agent", + `paperclip_runner does not support the legacy Paperclip operational skill (${PAPERCLIP_OPERATIONAL_SKILL_KEY}); remove it from this agent`, ); } const desiredSkills = desiredSkillEntries.map((entry) => entry.key); diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index d4f6a12963..c1937641ea 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -25,6 +25,10 @@ import { type AgentEligibilityAgent, type AgentApiKeyScope, } from "@paperclipai/shared"; +import { + PAPERCLIP_OPERATIONAL_SKILL_KEY, + readPaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; import { conflict, notFound, unprocessable } from "../errors.js"; import { collectSecretRefs, @@ -127,6 +131,32 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function hasPaperclipOperationalSkill(config: unknown): boolean { + if (!isPlainRecord(config)) return false; + return readPaperclipSkillSyncPreference(config).desiredSkillEntries.some( + (entry) => entry.key.trim().toLowerCase() === PAPERCLIP_OPERATIONAL_SKILL_KEY, + ); +} + +export function assertPaperclipRunnerOperationalSkillInvariant(input: { + adapterType: string; + nextConfig: unknown; + priorAdapterType?: string | null; + priorConfig?: unknown; +}): void { + if (input.adapterType !== "paperclip_runner" || !hasPaperclipOperationalSkill(input.nextConfig)) { + return; + } + const preservesStaleAssignment = + input.priorAdapterType === "paperclip_runner" + && hasPaperclipOperationalSkill(input.priorConfig); + if (preservesStaleAssignment) return; + throw unprocessable( + `paperclip_runner does not support the legacy Paperclip operational skill (${PAPERCLIP_OPERATIONAL_SKILL_KEY}); remove it from this agent`, + { code: "paperclip_runner_legacy_operational_skill" }, + ); +} + function jsonEqual(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } @@ -679,6 +709,16 @@ export function agentService(db: Db) { { adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string }, ); } + const nextAdapterType = (normalizedPatch.adapterType ?? existing.adapterType) as string; + const nextAdapterConfig = Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") + ? normalizedPatch.adapterConfig + : existing.adapterConfig; + assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: nextAdapterType, + nextConfig: nextAdapterConfig, + priorAdapterType: existing.adapterType, + priorConfig: existing.adapterConfig, + }); // Run the server-enforced binding invariant when the patch touches the // adapter config. The update, approval, and rollback paths keep an existing // fixed binding but reject a newly introduced binding, because they carry no @@ -790,6 +830,10 @@ export function agentService(db: Db) { const adapterConfig = isPlainRecord(data.adapterConfig) ? await secretsSvc.normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType }) : {}; + assertPaperclipRunnerOperationalSkillInvariant({ + adapterType, + nextConfig: adapterConfig, + }); // Run the server-enforced binding invariant after generic normalization // and before any database write. A create has no prior config. const bindingDecision = assertClaudeOAuthBindingInvariant({ @@ -1003,6 +1047,14 @@ export function agentService(db: Db) { priorConfig: existing.adapterConfig, }); } + assertPaperclipRunnerOperationalSkillInvariant({ + adapterType: (patch.adapterType ?? existing.adapterType) as string, + nextConfig: Object.prototype.hasOwnProperty.call(patch, "adapterConfig") + ? patch.adapterConfig + : existing.adapterConfig, + priorAdapterType: existing.adapterType, + priorConfig: existing.adapterConfig, + }); if (patch.permissions !== undefined) { patch.permissions = normalizeAgentPermissions( patch.permissions, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 0a2e1ac8b9..ca8d06fcf5 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1304,7 +1304,7 @@ function parseFiniteNumberLike(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null; } -function disableImportedTimerHeartbeat(runtimeConfig: unknown) { +function sanitizeImportedAgentRuntimeConfig(runtimeConfig: unknown) { const next = clonePortableRecord(runtimeConfig) ?? {}; const heartbeat = isPlainRecord(next.heartbeat) ? { ...next.heartbeat } : {}; heartbeat.enabled = false; @@ -1312,6 +1312,16 @@ function disableImportedTimerHeartbeat(runtimeConfig: unknown) { heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; } next.heartbeat = heartbeat; + if (isPlainRecord(next.debug)) { + const debug = { ...next.debug }; + // Company imports are available below the instance-admin trust boundary. + // Never let a portable bundle enable persistent capture of raw provider + // traffic; an administrator can opt in afterward through the guarded + // agent configuration route. + delete debug.providerTrace; + if (Object.keys(debug).length === 0) delete next.debug; + else next.debug = debug; + } return next; } @@ -5591,7 +5601,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { reportsTo: null, adapterType: normalizedAdapter.adapterType, adapterConfig: normalizedAdapter.adapterConfig, - runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig), + runtimeConfig: sanitizeImportedAgentRuntimeConfig(manifestAgent.runtimeConfig), budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, metadata: manifestAgent.metadata, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0ae8506387..a0b2445dc6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -377,6 +377,7 @@ import { redactEventPayload, redactSensitiveText } from "../redaction.js"; import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js"; import { hasSessionCompactionThresholds, + resolvePaperclipRunnerIdleTimeoutMs, resolvePaperclipRunnerPermissionMode, resolveSessionCompactionPolicy, type RuntimeStatusUpdate, @@ -19935,13 +19936,9 @@ export function heartbeatService( parseObject(agent.adapterConfig).lifecycleMode === "warm" ? { mode: "warm" as const, - idleTimeoutMs: - Number.isSafeInteger( - parseObject(agent.adapterConfig).idleTimeoutMs, - ) && - Number(parseObject(agent.adapterConfig).idleTimeoutMs) > 0 - ? Number(parseObject(agent.adapterConfig).idleTimeoutMs) - : 300_000, + idleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs( + parseObject(agent.adapterConfig).idleTimeoutMs, + ), } : { mode: "per_turn" as const, idleTimeoutMs: null }; const environmentLifecyclePolicy = diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 78c94b8dd9..dc279f78d4 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -242,6 +242,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableDecisions: parsed.data.enableDecisions ?? false, enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, + enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false, enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, @@ -282,6 +283,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableDecisions: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, + enablePaperclipDeveloperMode: false, enableSimplifiedEnglishInteractions: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index b43e0fe839..bcd0d40ef4 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -142,7 +142,7 @@ export function buildNativeExecutionInput(input: { : { kind: "codex", model: input.model ?? null, - approvalPolicy: input.codexApprovalPolicy ?? "never", + approvalPolicy: input.codexApprovalPolicy ?? "untrusted", }, completionContract: input.completionContract, interactionResponses: input.interactionResponses ?? [], diff --git a/server/src/services/native-runtime/runtime-context.test.ts b/server/src/services/native-runtime/runtime-context.test.ts index 0868f604bb..475b249ecc 100644 --- a/server/src/services/native-runtime/runtime-context.test.ts +++ b/server/src/services/native-runtime/runtime-context.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, mkdir, readFile, readdir, chmod, lstat, rm, stat, writeFile } import { tmpdir } from "node:os"; import path from "node:path"; import type { Db } from "@paperclipai/db"; -import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; +import { + PAPERCLIP_OPERATIONAL_SKILL_KEY, + type PaperclipSkillEntry, +} from "@paperclipai/adapter-utils/server-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const serviceMocks = vi.hoisted(() => ({ @@ -20,10 +23,7 @@ vi.mock("../tool-access.js", () => ({ }), })); -import { - LEGACY_PAPERCLIP_OPERATIONAL_SKILL_KEY, - buildNativeRuntimeContext, -} from "./runtime-context.js"; +import { buildNativeRuntimeContext } from "./runtime-context.js"; const temporaryRoots: string[] = []; let previousPaperclipHome: string | undefined; @@ -151,7 +151,7 @@ describe("buildNativeRuntimeContext", () => { expect(repeated.skills[0]!.bundle.rootPath).toBe(context.skills[0]!.bundle.rootPath); }); - it("fails closed for a missing assigned skill and the unsupported legacy operational skill", async () => { + it("fails closed for a missing assigned skill and omits a stale legacy operational skill", async () => { serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "Test\n" } }); const base = { db: {} as Db, @@ -175,14 +175,29 @@ describe("buildNativeRuntimeContext", () => { missingDetail: "assigned skill checkout is unavailable", }], })).rejects.toThrow("assigned skill checkout is unavailable"); - await expect(buildNativeRuntimeContext({ + const supportedRoot = await mkdtemp(path.join(tmpdir(), "paperclip-native-supported-skill-")); + temporaryRoots.push(supportedRoot); + await writeFile(path.join(supportedRoot, "SKILL.md"), "# Supported\n"); + const context = await buildNativeRuntimeContext({ ...base, - runtimeConfig: { paperclipSkillSync: { desiredSkills: [LEGACY_PAPERCLIP_OPERATIONAL_SKILL_KEY] } }, - runtimeSkillEntries: [{ - key: LEGACY_PAPERCLIP_OPERATIONAL_SKILL_KEY, - runtimeName: "paperclip", - source: "/unused", - }], - })).rejects.toThrow("does not support legacy operational skill"); + runtimeConfig: { + paperclipSkillSync: { + desiredSkills: [PAPERCLIP_OPERATIONAL_SKILL_KEY, "company-1/supported"], + }, + }, + runtimeSkillEntries: [ + { + key: PAPERCLIP_OPERATIONAL_SKILL_KEY, + runtimeName: "paperclip", + source: "/unused", + }, + { + key: "company-1/supported", + runtimeName: "supported", + source: supportedRoot, + }, + ], + }); + expect(context.skills.map((skill) => skill.key)).toEqual(["company-1/supported"]); }); }); diff --git a/server/src/services/native-runtime/runtime-context.ts b/server/src/services/native-runtime/runtime-context.ts index d6e8e8a8e0..ea84917a4e 100644 --- a/server/src/services/native-runtime/runtime-context.ts +++ b/server/src/services/native-runtime/runtime-context.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; -import { resolvePaperclipDesiredSkillNames } from "@paperclipai/adapter-utils/server-utils"; +import { + PAPERCLIP_OPERATIONAL_SKILL_KEY, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, @@ -18,7 +21,6 @@ import { resolvePaperclipInstanceRoot } from "../../home-paths.js"; import { agentInstructionsService } from "../agent-instructions.js"; import { toolAccessService } from "../tool-access.js"; -export const LEGACY_PAPERCLIP_OPERATIONAL_SKILL_KEY = "paperclipai/paperclip/paperclip" as const; const MAX_ASSET_FILES = 10_000; const MAX_ASSET_BYTES = 64 * 1024 * 1024; type RuntimeAgent = { id: string; companyId: string; name: string; adapterType?: string | null; adapterConfig: unknown }; @@ -147,9 +149,10 @@ async function materializeInstructionBundle(agent: RuntimeAgent) { return { entryPath, bundle: await materializeAsset(files) }; } -async function materializeSelectedSkills(runtimeConfig: Record, entries: PaperclipSkillEntry[], rejectLegacy: boolean) { - const desiredKeys = resolvePaperclipDesiredSkillNames(runtimeConfig, entries); - if (rejectLegacy && desiredKeys.includes(LEGACY_PAPERCLIP_OPERATIONAL_SKILL_KEY)) throw new Error(`paperclip_runner does not support legacy operational skill ${LEGACY_PAPERCLIP_OPERATIONAL_SKILL_KEY}; remove it from this agent`); +async function materializeSelectedSkills(runtimeConfig: Record, entries: PaperclipSkillEntry[], omitLegacy: boolean) { + const desiredKeys = resolvePaperclipDesiredSkillNames(runtimeConfig, entries).filter( + (key) => !omitLegacy || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY, + ); const byKey = new Map(entries.map((entry) => [entry.key, entry])); return Promise.all(desiredKeys.sort().map(async (key) => { const entry = byKey.get(key); diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx new file mode 100644 index 0000000000..0a28bbc340 --- /dev/null +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -0,0 +1,63 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { TooltipProvider } from "@/components/ui/tooltip"; + +import { CodexLocalConfigFields } from "./config-fields"; + +function renderRunner(config: Record): string { + return renderToStaticMarkup( + + original} + mark={() => undefined} + models={[]} + hideInstructionsFile + /> + , + ); +} + +describe("Paperclip Runner Codex configuration", () => { + it("exposes only the qualified Codex provider and permission modes", () => { + const html = renderRunner({ provider: "opencode" }); + + expect(html).toContain('disabled="">'); + expect(html).toContain("Full auto (never ask)"); + expect(html).toContain("Ask when requested"); + expect(html).toContain("Ask for untrusted operations"); + expect(html).not.toContain("OpenCode"); + expect(html).not.toContain("ACPX"); + expect(html).not.toContain("Claude Agent"); + expect(html).not.toContain("AWS AgentCore"); + expect(html).not.toContain("Bypass sandbox"); + }); + + it("falls back to the fail-closed Codex permission mode", () => { + const html = renderRunner({ codexPermissionMode: "unrestricted" }); + + expect(html).toContain(''); + }); + + it("shows a bounded idle timeout only for warm sessions", () => { + const warmHtml = renderRunner({ + lifecycleMode: "warm", + idleTimeoutMs: 45_000, + }); + const turnHtml = renderRunner({ + lifecycleMode: "per_turn", + idleTimeoutMs: 45_000, + }); + + expect(warmHtml).toContain("Warm idle timeout (ms)"); + expect(warmHtml).toContain('value="45000"'); + expect(warmHtml).toContain('max="86400000"'); + expect(turnHtml).not.toContain("Warm idle timeout (ms)"); + }); +}); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index d202b06193..5a9490c5ea 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -13,12 +13,19 @@ import { isCodexLocalFastModeSupported, isCodexLocalManualModel, } from "@paperclipai/adapter-codex-local"; +import { + PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS, + PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS, + PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, + resolvePaperclipRunnerIdleTimeoutMs, + resolvePaperclipRunnerPermissionMode, + type CodexPermissionMode, +} from "@paperclipai/adapter-utils"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; const instructionsFileHint = "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace."; - export function CodexLocalConfigFields({ mode, isCreate, @@ -38,6 +45,39 @@ export function CodexLocalConfigFields({ // both, so the managed-sandbox-only policy hides them the same way // `runnerManaged` already does for the Paperclip Runner. const hideEngineChoice = runnerManaged || managedSandboxOnly === true; + const codexPermissionCapability = PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex; + const runnerPermissionMode = runnerManaged + ? resolvePaperclipRunnerPermissionMode( + "codex", + isCreate + ? values!.codexPermissionMode + : eff( + "adapterConfig", + "codexPermissionMode", + config.codexPermissionMode, + ), + ) + : codexPermissionCapability.defaultMode; + const runnerLifecycleMode = runnerManaged + ? isCreate + ? values!.paperclipRunnerLifecycleMode ?? "per_turn" + : eff( + "adapterConfig", + "lifecycleMode", + config.lifecycleMode === "warm" ? "warm" : "per_turn", + ) + : "per_turn"; + const runnerIdleTimeoutMs = runnerManaged + ? resolvePaperclipRunnerIdleTimeoutMs( + isCreate + ? values!.paperclipRunnerIdleTimeoutMs + : eff( + "adapterConfig", + "idleTimeoutMs", + config.idleTimeoutMs, + ), + ) + : PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS; const rawEngine = runnerManaged ? "cli" : isCreate ? values!.codexEngine ?? "auto" : eff("adapterConfig", "engine", String(config.engine ?? "auto")); @@ -85,6 +125,90 @@ export function CodexLocalConfigFields({ )} + {runnerManaged && ( + + + + )} + {runnerManaged && ( + + + + )} + {runnerManaged && runnerLifecycleMode === "warm" && ( + + {isCreate ? ( + + set!({ + paperclipRunnerIdleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs( + Number(event.target.value), + ), + }) + } + /> + ) : ( + + mark( + "adapterConfig", + "idleTimeoutMs", + resolvePaperclipRunnerIdleTimeoutMs(value), + ) + } + immediate + className={inputClass} + /> + )} + + )} {acpSelected && ( <> {!managedSandboxOnly && ( diff --git a/ui/src/adapters/paperclip-runner/index.test.ts b/ui/src/adapters/paperclip-runner/index.test.ts new file mode 100644 index 0000000000..8cfc046a8a --- /dev/null +++ b/ui/src/adapters/paperclip-runner/index.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { paperclipRunnerUIAdapter } from "./index"; + +describe("paperclip runner transcript projection", () => { + it("renders committed PRP semantic tool items with the existing chat parts", () => { + const started = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ + type: "paperclip.prp.event", + event: { + eventType: "item.started", + payload: { item: { type: "tool_use", id: "call-1", name: "get_task_context", input: {} } }, + }, + }), "2026-08-21T12:00:00.000Z"); + const completed = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ + type: "paperclip.prp.event", + event: { + eventType: "item.completed", + payload: { item: { type: "tool_result", id: "call-1", tool_use_id: "call-1", result: { ok: true } } }, + }, + }), "2026-08-21T12:00:01.000Z"); + expect(started).toEqual([expect.objectContaining({ kind: "tool_call", name: "get_task_context", toolUseId: "call-1" })]); + expect(completed).toEqual([expect.objectContaining({ kind: "tool_result", toolUseId: "call-1", isError: false })]); + }); + + it("maps native Codex deltas, camel-case tools, and usage into the shared chat transcript", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record, itemId?: string) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, itemId, payload }, + }), "2026-08-21T12:00:00.000Z"); + + expect(event("item.delta", { kind: "reasoning", text: "Inspecting the runner" }, "reason-1")) + .toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting the runner", delta: true, channel: "unknown" }]); + expect(event("item.delta", { kind: "agentMessage", text: "Here is" }, "message-1")) + .toEqual([{ kind: "assistant", ts: expect.any(String), text: "Here is", delta: true, channel: "unknown" }]); + expect(event("item.started", { + kind: "commandExecution", + item: { id: "exec-1", type: "commandExecution", command: "pnpm test", status: "inProgress" }, + }, "exec-1")).toEqual([ + expect.objectContaining({ kind: "tool_call", name: "command", toolUseId: "exec-1" }), + ]); + expect(event("item.completed", { + kind: "usage", + usage: { total: { inputTokens: 120, outputTokens: 30, cachedInputTokens: 80 } }, + })).toEqual([ + expect.objectContaining({ kind: "result", subtype: "paperclip.usage", inputTokens: 120, outputTokens: 30 }), + ]); + }); + + it("emits a proposed and accepted terminal summary only once", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const line = (eventType: string, payload: Record) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, payload }, + }), "2026-08-21T12:00:00.000Z"); + + expect(line("run.result.proposed", { summary: "Finished the task" })) + .toEqual([ + expect.objectContaining({ kind: "run_result", disposition: "done", summary: "Finished the task" }), + { kind: "assistant", ts: expect.any(String), text: "Finished the task", channel: "final" }, + ]); + expect(line("run.result.accepted", { result: { summary: "Finished the task" } })) + .toEqual([]); + }); + + it("preserves progress, final-answer, and reasoning channels across item deltas", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record, itemId: string) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, itemId, payload }, + }), "2026-08-21T12:00:00.000Z"); + + expect(event("item.started", { kind: "agentMessage", channel: "progress", item: { id: "p1", type: "agentMessage", phase: "commentary", text: "" } }, "p1")).toEqual([]); + expect(event("item.delta", { kind: "agentMessage", channel: "progress", text: "Running it now." }, "p1")) + .toEqual([{ kind: "assistant", ts: expect.any(String), text: "Running it now.", delta: true, channel: "progress" }]); + + expect(event("item.started", { kind: "agentMessage", channel: "final", item: { id: "f1", type: "agentMessage", phase: "final_answer", text: "" } }, "f1")).toEqual([]); + expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "Completed." }, "f1")) + .toEqual([{ kind: "assistant", ts: expect.any(String), text: "Completed.", delta: true, channel: "final" }]); + + expect(event("item.delta", { kind: "reasoning", channel: "summary", text: "Inspecting" }, "r1")) + .toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting", delta: true, channel: "summary" }]); + expect(event("item.delta", { kind: "reasoning", channel: "detail", text: "Detailed trace" }, "r1")) + .toEqual([{ kind: "thinking", ts: expect.any(String), text: "Detailed trace", delta: true, channel: "detail" }]); + }); + + it("preserves empty reasoning lifecycle events as real thinking activity", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { + eventType, + itemId: "reason-empty", + payload: { + kind: "reasoning", + channel: "summary", + item: { id: "reason-empty", type: "reasoning", text: "" }, + }, + }, + }), "2026-08-21T12:00:00.000Z"); + + expect(event("item.started")).toEqual([expect.objectContaining({ + kind: "thinking", + text: "", + lifecycle: "started", + channel: "summary", + })]); + expect(event("item.completed")).toEqual([expect.objectContaining({ + kind: "thinking", + text: "", + lifecycle: "completed", + channel: "summary", + })]); + }); + + it("never exposes the structured task result envelope as final-response prose", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record, itemId = "result-1") => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, itemId, payload }, + }), "2026-08-21T12:00:00.000Z"); + event("item.started", { kind: "agentMessage", channel: "final", item: { id: "result-1", type: "agentMessage", phase: "final_answer", text: "" } }); + expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "{\"schema\":" })).toEqual([]); + expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "\"paperclip.run_result.v1\"}" })).toEqual([]); + expect(event("run.result.proposed", { summary: "Human-readable completion." })) + .toEqual([ + expect.objectContaining({ kind: "run_result", summary: "Human-readable completion." }), + { kind: "assistant", ts: expect.any(String), text: "Human-readable completion.", channel: "final" }, + ]); + }); + + it("projects every canonical provider family as structured activity instead of JSON prose", () => { + const cases = [ + ["plan.updated", "plan", { complete: true, explanation: "Ship safely" }], + ["tool.execution.completed", "tool_execution", { status: "completed", name: "tests" }], + ["research.completed", "research", { status: "completed", query: "PRP" }], + ["delegation.completed", "delegation", { status: "completed", action: "spawn" }], + ["model.route.changed", "model_identity", { provider: "claude", requestedModel: "claude", effectiveModel: "Claude Sonnet" }], + ["context.compacted", "context", { reason: "window" }], + ["artifact.generated", "artifact", { status: "completed", reference: "image.png" }], + ["review.mode.changed", "review", { state: "entered" }], + ["hook.completed", "hook", { status: "completed", event: "post-tool" }], + ["memory.citation.referenced", "memory", { label: "Decision" }], + ["safety.review.completed", "safety", { status: "completed", decision: "allowed" }], + ["terminal.input.sent", "terminal", { byteCount: 1 }], + ["wait.completed", "wait", { status: "completed", reason: "timer" }], + ["provider.notice.recorded", "provider_notice", { summary: "Provider warning" }], + ] as const; + + for (const [eventType, family, payload] of cases) { + const entries = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, payload }, + }), "2026-08-21T12:00:00.000Z"); + expect(entries, eventType).toEqual([expect.objectContaining({ + kind: "provider_activity", + family, + eventType, + })]); + expect(entries, eventType).not.toEqual([expect.objectContaining({ kind: "assistant" })]); + } + + const modelRoute = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType: "model.route.changed", payload: { provider: "claude", requestedModel: "claude", effectiveModel: "Claude Sonnet" } }, + }), "2026-08-21T12:00:01.000Z"); + expect(modelRoute).toEqual([expect.objectContaining({ kind: "provider_activity", family: "model_identity", summary: "Claude Sonnet" })]); + }); + + it("projects workspace changes and verified file references as bounded structured entries", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, payload }, + }), "2026-08-21T12:00:00.000Z"); + + expect(event("workspace.diff.recorded", { + changeSetId: "changes-1", + revision: 2, + source: "runner_verified", + complete: true, + files: [{ path: "ui/src/App.tsx", operation: "modify", previousPath: null, additions: 3, deletions: 1, binary: false, diff: "+hello" }], + totals: { files: 1, additions: 3, deletions: 1 }, + patchArtifactRef: null, + })).toEqual([expect.objectContaining({ kind: "workspace_change", complete: true, source: "runner_verified" })]); + + expect(event("workspace.file.referenced", { + referenceId: "file-1", + source: "runner_verified", + path: "doc/protocol.md", + displayName: "protocol.md", + mediaType: "text/markdown", + presentation: "document", + line: 12, + preview: "# Protocol", + previewTruncated: false, + contentDigest: null, + })).toEqual([expect.objectContaining({ kind: "workspace_file_reference", path: "doc/protocol.md", line: 12 })]); + + expect(event("workspace.file.referenced", { + referenceId: "unsafe", + path: "../secrets.env", + })).toEqual([expect.objectContaining({ kind: "system", text: expect.stringContaining("unsafe") })]); + }); + + it("coalesces runtime request lifecycle data and emits terminal state", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record, turnId = "turn-1") => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, turnId, payload }, + }), "2026-08-21T12:00:00.000Z"); + expect(event("runtime_request.created", { request: { requestId: "request-1", requestKind: "command_approval", type: "item/commandExecution/requestApproval", status: "pending", prompt: "Allow command?" } })) + .toEqual([expect.objectContaining({ + kind: "runtime_request", + requestKind: "command_approval", + turnId: "turn-1", + status: "pending", + choices: [ + { key: "accept", label: "Allow once" }, + { key: "accept_for_session", label: "Allow for session" }, + { key: "decline", label: "Deny" }, + { key: "cancel", label: "Cancel" }, + ], + })]); + expect(event("runtime_request.resolved", { requestId: "request-1" })) + .toEqual([expect.objectContaining({ kind: "runtime_request", status: "resolved", prompt: "Allow command?", requestKind: "command_approval", turnId: "turn-1" })]); + expect(event("runtime_request.created", { request: { requestId: "request-2", requestKind: "runtime", type: "input", status: "pending", prompt: "Choose", input: { schema: "paperclip.question_set.v1", questions: [{ id: "environment", prompt: "Where?", required: true, answerMode: "single_select", options: [{ id: "staging", label: "Staging" }] }] } } })) + .toEqual([expect.objectContaining({ kind: "runtime_request", requestId: "request-2", status: "pending", requestType: "input" })]); + expect(event("runtime_request.resolved", { + requestId: "request-2", + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { environment: { selectedOptionIds: ["staging"] } }, + }, + })).toEqual([expect.objectContaining({ + kind: "runtime_request", + requestId: "request-2", + status: "resolved", + prompt: "Choose", + requestType: "input", + resolvedAction: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { environment: { selectedOptionIds: ["staging"] } }, + }, + })]); + expect(event("runtime_request.created", { request: { requestId: "request-redacted", requestKind: "runtime", type: "input", status: "pending", prompt: "Choose", input: { schema: "***REDACTED***", questions: [{ id: "environment", prompt: "Where?", required: true, answerMode: "single_select", options: [{ id: "staging", label: "Staging" }] }] } } })) + .toEqual([expect.objectContaining({ kind: "runtime_request", requestId: "request-redacted", questionSet: expect.objectContaining({ schema: "paperclip.question_set.v1" }) })]); + expect(event("runtime_request.expired", { requestId: "request-redacted", reason: "provider_process_lost" })) + .toEqual([expect.objectContaining({ kind: "runtime_request", requestId: "request-redacted", status: "expired", prompt: "Choose", requestType: "input" })]); + expect(event("runtime_request.created", { request: { requestId: "request-cancel", requestKind: "runtime", type: "input", status: "pending", prompt: "Cancel me", input: { schema: "paperclip.question_set.v1", questions: [{ id: "reason", prompt: "Why?", required: false, answerMode: "text" }] } } })) + .toEqual([expect.objectContaining({ requestId: "request-cancel", status: "pending" })]); + expect(event("runtime_request.resolved", { requestId: "request-cancel", action: "cancel" })) + .toEqual([expect.objectContaining({ requestId: "request-cancel", status: "cancelled", resolvedAction: "cancel" })]); + expect(event("run.terminal", { turnTerminalState: "interrupted", runTerminalState: "cancelled", reportedWorkDisposition: "yielded", stopReason: { code: "user_stop" } })) + .toEqual([expect.objectContaining({ kind: "run_terminal", turnState: "interrupted", runState: "cancelled", disposition: "yielded", stopReason: "user_stop" })]); + }); + + it("normalizes MCP semantic tools, canonical usage, and actionable failures", () => { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const event = (eventType: string, payload: Record) => parse(JSON.stringify({ + type: "paperclip.prp.event", + event: { eventType, payload }, + }), "2026-08-21T12:00:00.000Z"); + + expect(event("mcp_app.tool_input", { + semantic_tool: { callId: "mcp-1", operationId: "paperclip.tasks.create", content: { references: [{ kind: "issue", id: "PAP-2" }] } }, + })).toEqual([expect.objectContaining({ kind: "tool_call", toolUseId: "mcp-1", name: "paperclip.tasks.create" })]); + expect(event("mcp_app.tool_result", { + semantic_tool: { callId: "mcp-1", operationId: "paperclip.tasks.create", outcome: "denied", code: "audience_denied" }, + })).toEqual([expect.objectContaining({ kind: "tool_result", toolUseId: "mcp-1", isError: true })]); + expect(event("usage.reported", { + runDelta: { inputTokens: 240, outputTokens: 60, cacheReadTokens: 120 }, + })).toEqual([expect.objectContaining({ kind: "result", inputTokens: 240, outputTokens: 60, cachedTokens: 120 })]); + expect(event("mcp_app.failed", { code: "host_unavailable", message: "Artifact host unavailable" })) + .toEqual([expect.objectContaining({ kind: "system", text: "Runner: Artifact host unavailable" })]); + }); +}); diff --git a/ui/src/adapters/paperclip-runner/index.ts b/ui/src/adapters/paperclip-runner/index.ts index 2573aaea10..3168242a14 100644 --- a/ui/src/adapters/paperclip-runner/index.ts +++ b/ui/src/adapters/paperclip-runner/index.ts @@ -1,11 +1,792 @@ -import { buildPaperclipRunnerConfig, parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui"; -import { CodexLocalConfigFields } from "../codex-local/config-fields"; +import type { PaperclipQuestion, PaperclipQuestionResponse, PaperclipQuestionSet, TranscriptEntry } from "@paperclipai/adapter-utils"; import type { UIAdapterModule } from "../types"; +import { parseCodexStdoutLine, buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui"; +import { CodexLocalConfigFields } from "../codex-local/config-fields"; + +type JsonRecord = Record; + +interface PaperclipRunnerParserState { + assistantDeltaItemIds: Set; + reasoningDeltaItemIds: Set; + resultSummaries: Set; + toolOutputItemIds: Set; + itemChannels: Map; + structuredFinalItemIds: Set; + runtimeRequests: Map>; +} + +function itemChannel(payload: JsonRecord): "progress" | "final" | "summary" | "detail" | "unknown" { + const channel = text(payload.channel); + return channel === "progress" || channel === "final" || channel === "summary" || channel === "detail" + ? channel + : "unknown"; +} + +function structuredResultSummary(value: string): string | null { + if (!value.trimStart().startsWith("{")) return null; + try { + const parsed = record(JSON.parse(value)); + return text(parsed.schema) === "paperclip.run_result.v1" && text(parsed.summary) + ? text(parsed.summary) + : null; + } catch { + return null; + } +} + +function record(value: unknown): JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as JsonRecord + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function number(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function stringify(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined || value === null) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function itemText(item: JsonRecord, payload: JsonRecord): string { + const direct = text(item.text, text(payload.text)); + if (direct) return direct; + const summary = Array.isArray(item.summary) ? item.summary : []; + return summary + .map((part) => text(record(part).text, text(part))) + .filter(Boolean) + .join("\n"); +} + +function normalizedItemType(item: JsonRecord, payload: JsonRecord): string { + return text(item.type, text(payload.kind)).replaceAll("_", "").toLowerCase(); +} + +function itemId(event: JsonRecord, item: JsonRecord): string { + return text(event.itemId, text(item.id, "paperclip-runner-item")); +} + +function toolFailure(item: JsonRecord): boolean { + const status = text(item.status).toLowerCase(); + const exitCode = item.exitCode ?? item.exit_code; + return item.isError === true + || item.is_error === true + || item.success === false + || (typeof exitCode === "number" && exitCode !== 0) + || ["failed", "error", "errored", "cancelled"].includes(status); +} + +function commandEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const commandActions = Array.isArray(item.commandActions) ? item.commandActions : []; + const command = text(item.command, text(record(commandActions[0]).command)); + if (phase === "started") { + return [{ kind: "tool_call", ts, name: "command", toolUseId: id, input: { command } }]; + } + const output = text(item.aggregatedOutput, text(item.aggregated_output)); + const exitCode = item.exitCode ?? item.exit_code; + const detail = [ + typeof exitCode === "number" ? `exit_code: ${exitCode}` : "", + output, + ].filter(Boolean).join("\n"); + return [{ + kind: "tool_result", + ts, + toolUseId: id, + toolName: "command", + content: detail || text(item.status, "command completed"), + isError: toolFailure(item), + }]; +} + +function diffEntries(change: JsonRecord, ts: string): TranscriptEntry[] { + const path = text(change.path); + const kind = text(record(change.kind).type, text(change.kind, "update")); + const raw = text(change.diff); + const entries: TranscriptEntry[] = []; + if (path) entries.push({ kind: "diff", ts, changeType: "file_header", text: path }); + for (const line of raw.split("\n")) { + if (!line && raw.length === 0) continue; + entries.push({ + kind: "diff", + ts, + changeType: kind === "add" ? "add" : kind === "delete" ? "remove" : "context", + text: line, + }); + } + return entries; +} + +function fileChangeEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const changes = Array.isArray(item.changes) ? item.changes.map(record) : []; + const paths = changes.map((change) => text(change.path)).filter(Boolean); + if (phase === "started") { + return [{ + kind: "tool_call", + ts, + name: "file_change", + toolUseId: id, + input: { path: paths[0] ?? "", paths }, + }]; + } + return [ + ...changes.flatMap((change) => diffEntries(change, ts)), + { + kind: "tool_result" as const, + ts, + toolUseId: id, + toolName: "file_change", + content: paths.length > 0 ? paths.join("\n") : "file change completed", + isError: toolFailure(item), + }, + ]; +} + +function dynamicToolEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const name = text(item.tool, text(item.name, "Paperclip tool")); + if (phase === "started") { + return [{ kind: "tool_call", ts, name, toolUseId: id, input: item.arguments ?? item.input ?? {} }]; + } + return [{ + kind: "tool_result", + ts, + toolUseId: id, + toolName: name, + content: stringify(item.contentItems ?? item.result ?? item.output) || `${name} completed`, + isError: toolFailure(item), + }]; +} + +function genericToolEntries( + event: JsonRecord, + item: JsonRecord, + phase: "started" | "completed", + ts: string, +): TranscriptEntry[] { + const id = itemId(event, item); + const name = text(item.name, text(item.tool, "Tool")); + if (phase === "started") { + return [{ kind: "tool_call", ts, name, toolUseId: id, input: item.input ?? item.arguments ?? {} }]; + } + return [{ + kind: "tool_result", + ts, + toolUseId: text(item.tool_use_id, id), + toolName: name, + content: stringify(item.content ?? item.result ?? item.output ?? item.error) || `${name} completed`, + isError: toolFailure(item), + }]; +} + +function parseItemEvent( + event: JsonRecord, + payload: JsonRecord, + phase: "started" | "completed", + ts: string, + state: PaperclipRunnerParserState, +): TranscriptEntry[] { + const item = record(payload.item); + const type = normalizedItemType(item, payload); + const id = itemId(event, item); + const channel = itemChannel(payload); + if (phase === "started") state.itemChannels.set(id, channel); + const resolvedChannel = channel === "unknown" ? state.itemChannels.get(id) ?? "unknown" : channel; + if (type === "agentmessage") { + const value = itemText(item, payload); + if (phase === "completed") state.itemChannels.delete(id); + if (!value || state.assistantDeltaItemIds.has(id)) return []; + if (resolvedChannel === "final") { + const summary = structuredResultSummary(value); + if (summary) { + state.resultSummaries.add(summary); + return [{ kind: "assistant", ts, text: summary, channel: "final" }]; + } + } + return [{ kind: "assistant", ts, text: value, channel: resolvedChannel === "final" ? "final" : resolvedChannel === "progress" ? "progress" : "unknown" }]; + } + if (type === "reasoning") { + const value = itemText(item, payload); + if (phase === "completed") state.itemChannels.delete(id); + return [{ + kind: "thinking", + ts, + text: value && !state.reasoningDeltaItemIds.has(id) ? value : "", + lifecycle: phase, + channel: resolvedChannel === "detail" ? "detail" : resolvedChannel === "summary" ? "summary" : "unknown", + }]; + } + if (type === "commandexecution") { + const entries = commandEntries(event, item, phase, ts); + if (phase === "completed" && state.toolOutputItemIds.has(id)) { + const result = entries[0]; + if (result?.kind === "tool_result") result.content = ""; + } + return entries; + } + if (type === "filechange") return fileChangeEntries(event, item, phase, ts); + if (type === "dynamictoolcall") return dynamicToolEntries(event, item, phase, ts); + if (type === "tooluse" || type === "toolresult" || type === "mcptoolcall") { + return genericToolEntries(event, item, phase, ts); + } + if (type === "usage") return []; + if (type === "usermessage") return []; + const detail = itemText(item, payload); + return detail ? [{ kind: "system", ts, text: detail }] : []; +} + +function parseDeltaEvent( + event: JsonRecord, + payload: JsonRecord, + ts: string, + state: PaperclipRunnerParserState, +): TranscriptEntry[] { + const kind = text(payload.kind).replaceAll("_", "").toLowerCase(); + const value = text(payload.text); + const id = text(event.itemId, `${kind || "item"}-delta`); + const explicitChannel = itemChannel(payload); + const channel = explicitChannel === "unknown" ? state.itemChannels.get(id) ?? "unknown" : explicitChannel; + if (!value) return []; + if (kind === "agentmessage") { + state.assistantDeltaItemIds.add(id); + if (channel === "final" && (state.structuredFinalItemIds.has(id) || value.trimStart().startsWith("{"))) { + state.structuredFinalItemIds.add(id); + return []; + } + return [{ kind: "assistant", ts, text: value, delta: true, channel: channel === "final" ? "final" : channel === "progress" ? "progress" : "unknown" }]; + } + if (kind === "reasoning") { + state.reasoningDeltaItemIds.add(id); + return [{ kind: "thinking", ts, text: value, delta: true, channel: channel === "detail" ? "detail" : channel === "summary" ? "summary" : "unknown" }]; + } + if (kind === "commandexecution") { + state.toolOutputItemIds.add(id); + return [{ + kind: "tool_result", + ts, + toolUseId: id, + toolName: "command", + content: value, + isError: false, + delta: true, + }]; + } + if (kind === "filechange" || kind === "diff") { + return value.split("\n").map((line) => ({ + kind: "diff" as const, + ts, + changeType: line.startsWith("+") ? "add" as const : line.startsWith("-") ? "remove" as const : "context" as const, + text: /^[+-]/.test(line) ? line.slice(1) : line, + })); + } + if (kind === "plan") return [{ kind: "system", ts, text: value }]; + return [{ kind: "system", ts, text: value }]; +} + +function usageEntry(payload: JsonRecord, ts: string): TranscriptEntry { + const usage = Object.keys(record(payload.usage)).length > 0 ? record(payload.usage) : payload; + const reported = Object.keys(record(usage.total)).length > 0 + ? record(usage.total) + : Object.keys(record(usage.runDelta)).length > 0 + ? record(usage.runDelta) + : usage; + return { + kind: "result", + ts, + text: "", + inputTokens: number(reported.inputTokens ?? reported.input_tokens), + outputTokens: number(reported.outputTokens ?? reported.output_tokens), + cachedTokens: number(reported.cachedInputTokens ?? reported.cached_input_tokens ?? reported.cacheReadTokens), + costUsd: number(usage.costUsd ?? usage.cost_usd), + subtype: "paperclip.usage", + isError: false, + errors: [], + }; +} + +const SAFE_WORKSPACE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/; + +function nullableText(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function workspaceChangeEntry(payload: JsonRecord, ts: string): TranscriptEntry { + const totals = record(payload.totals); + const files = (Array.isArray(payload.files) ? payload.files : []) + .map(record) + .filter((file) => SAFE_WORKSPACE_PATH.test(text(file.path))) + .slice(0, 2000) + .map((file) => { + const rawOperation = text(file.operation); + const operation: "create" | "modify" | "delete" | "rename" | "mode_change" = rawOperation === "create" || rawOperation === "delete" || rawOperation === "rename" || rawOperation === "mode_change" + ? rawOperation + : "modify"; + const previousPath = nullableText(file.previousPath); + return { + path: text(file.path), + operation, + previousPath: previousPath && SAFE_WORKSPACE_PATH.test(previousPath) ? previousPath : null, + additions: nullableNumber(file.additions), + deletions: nullableNumber(file.deletions), + binary: file.binary === true, + diff: nullableText(file.diff), + }; + }); + return { + kind: "workspace_change", + ts, + changeSetId: text(payload.changeSetId, "workspace-change"), + revision: Math.max(1, number(payload.revision)), + source: payload.source === "runner_verified" ? "runner_verified" : "harness_reported", + complete: payload.complete === true, + files, + totals: { + files: Math.max(files.length, number(totals.files)), + additions: nullableNumber(totals.additions), + deletions: nullableNumber(totals.deletions), + }, + patchArtifactRef: nullableText(payload.patchArtifactRef), + }; +} + +function workspaceFileReferenceEntry(payload: JsonRecord, ts: string): TranscriptEntry | null { + const path = text(payload.path); + if (!SAFE_WORKSPACE_PATH.test(path)) return null; + const rawPresentation = text(payload.presentation); + const presentation = rawPresentation === "document" || rawPresentation === "code" || rawPresentation === "image" + ? rawPresentation + : "generic"; + return { + kind: "workspace_file_reference", + ts, + referenceId: text(payload.referenceId, `workspace-file:${path}`), + source: payload.source === "runner_verified" ? "runner_verified" : "harness_reported", + path, + displayName: text(payload.displayName, path.split("/").at(-1) ?? path), + mediaType: nullableText(payload.mediaType), + presentation, + line: nullableNumber(payload.line), + preview: nullableText(payload.preview), + previewTruncated: payload.previewTruncated === true, + contentDigest: nullableText(payload.contentDigest), + }; +} + +function runtimeRequestEntry( + eventType: string, + payload: JsonRecord, + event: JsonRecord, + ts: string, + state: PaperclipRunnerParserState, +): TranscriptEntry | null { + const request = record(payload.request ?? payload); + const requestId = text(request.requestId, text(payload.requestId)); + if (!requestId) return null; + const previous = state.runtimeRequests.get(requestId); + const suffix = eventType.split(".").at(-1); + const resolvedAction = nullableText(request.action) ?? nullableText(payload.action) ?? previous?.resolvedAction ?? null; + const rawStatus = text(request.status, suffix); + const lifecycleStatus = rawStatus === "resolved" || rawStatus === "expired" || rawStatus === "cancelled" + ? rawStatus + : "pending"; + const status = lifecycleStatus === "resolved" && (resolvedAction === "cancel" || resolvedAction === "decline") + ? "cancelled" + : lifecycleStatus; + const rawKind = text(request.requestKind, previous?.requestKind ?? undefined); + const requestKind = rawKind === "runtime" + ? "runtime" + : rawKind === "command_approval" + || rawKind === "file_approval" + || rawKind === "permission_approval" + || rawKind === "user_input" + || rawKind === "elicitation" + ? rawKind + : null; + const rawType = text(request.type, previous?.requestType); + const requestType = requestKind === "user_input" || requestKind === "elicitation" + || rawType === "input" || rawType.includes("input") || rawType.includes("elicitation") + ? "input" + : "permission"; + const explicitChoices = (Array.isArray(request.choices) ? request.choices : []) + .map(record) + .map((choice) => ({ key: text(choice.key), label: text(choice.label) })) + .filter((choice) => choice.key && choice.label) + .slice(0, 32); + const actionLabels: Record = { + accept: "Allow once", + accept_for_session: "Allow for session", + decline: "Deny", + cancel: "Cancel", + }; + const actions = (Array.isArray(request.actions) ? request.actions : []) + .filter((action): action is string => typeof action === "string" && action in actionLabels) + .slice(0, 4) + .map((key) => ({ key, label: actionLabels[key] })); + const defaultChoices = requestType === "permission" + ? [ + { key: "accept", label: actionLabels.accept }, + { key: "accept_for_session", label: actionLabels.accept_for_session }, + { key: "decline", label: actionLabels.decline }, + { key: "cancel", label: actionLabels.cancel }, + ] + : []; + const choices = explicitChoices.length > 0 + ? explicitChoices + : actions.length > 0 + ? actions + : previous?.choices.length + ? previous.choices + : defaultChoices; + const details = record(request.details); + const fields = (Array.isArray(details.fields) ? details.fields : previous?.fields ?? []) + .map(record) + .map((field, index) => ({ + name: text(field.name, `answer_${index + 1}`).slice(0, 160), + label: text(field.label, text(field.name, `Answer ${index + 1}`)).slice(0, 240), + placeholder: nullableText(field.placeholder)?.slice(0, 500) ?? null, + })) + .filter((field) => field.name && field.label) + .slice(0, 16); + const questionSet = parseQuestionSet(request.input) ?? previous?.questionSet ?? null; + const response = parseQuestionResponse(request.response ?? payload.response) + ?? previous?.response + ?? null; + const entry: Extract = { + kind: "runtime_request", + ts, + requestId, + requestKind, + turnId: nullableText(request.turnId) ?? nullableText(payload.turnId) ?? nullableText(event.turnId) ?? previous?.turnId ?? null, + requestType, + status, + prompt: text(request.prompt, previous?.prompt ?? "Runtime approval requested"), + choices, + fields, + questionSet, + resolvedAction, + response, + }; + state.runtimeRequests.set(requestId, entry); + return entry; +} + +function parseQuestionResponse(value: unknown): PaperclipQuestionResponse | null { + const response = record(value); + if (response.schema !== "paperclip.question_response.v1") return null; + const rawAnswers = record(response.answers); + const answers: PaperclipQuestionResponse["answers"] = {}; + for (const [questionId, rawAnswer] of Object.entries(rawAnswers).slice(0, 64)) { + const answer = record(rawAnswer); + const selectedOptionIds = (Array.isArray(answer.selectedOptionIds) ? answer.selectedOptionIds : []) + .filter((optionId): optionId is string => typeof optionId === "string") + .slice(0, 128) + .map((optionId) => optionId.slice(0, 160)); + const textAnswer = nullableText(answer.text)?.slice(0, 12_000); + const customText = nullableText(answer.customText)?.slice(0, 12_000); + answers[questionId.slice(0, 160)] = { + ...(selectedOptionIds.length > 0 ? { selectedOptionIds } : {}), + ...(textAnswer != null ? { text: textAnswer } : {}), + ...(customText != null ? { customText } : {}), + }; + } + return { schema: "paperclip.question_response.v1", answers }; +} + +function parseQuestionSet(value: unknown): PaperclipQuestionSet | null { + const input = record(value); + if (!Array.isArray(input.questions) || input.questions.length === 0) return null; + // Early v2 events passed through a broad JWT redactor that replaced the + // dotted schema discriminator while leaving the bounded question set + // intact. Recover those already-persisted requests on replay; reject other + // explicit schema families so this remains a narrow migration path. + if (input.schema !== undefined + && input.schema !== "paperclip.question_set.v1" + && input.schema !== "***REDACTED***") return null; + const questions = input.questions.map(record).slice(0, 64).map((question, questionIndex): PaperclipQuestion => { + const answerMode: PaperclipQuestion["answerMode"] = question.answerMode === "single_select" || question.answerMode === "multi_select" ? question.answerMode : "text"; + const options = (Array.isArray(question.options) ? question.options : []).map(record).slice(0, 128).map((option, optionIndex) => ({ + id: text(option.id, `option-${optionIndex + 1}`).slice(0, 160), + label: text(option.label, `Option ${optionIndex + 1}`).slice(0, 1_000), + ...(nullableText(option.description) ? { description: text(option.description).slice(0, 4_000) } : {}), + })); + const customAnswer = record(question.customAnswer); + const validation = record(question.textValidation); + const inputType: NonNullable["inputType"] = + validation.inputType === "number" || validation.inputType === "integer" || validation.inputType === "text" + ? validation.inputType + : undefined; + const parsedQuestion: PaperclipQuestion = { + id: text(question.id, `question-${questionIndex + 1}`).slice(0, 160), + ...(nullableText(question.header) ? { header: text(question.header).slice(0, 1_000) } : {}), + prompt: text(question.prompt, `Question ${questionIndex + 1}`).slice(0, 4_000), + ...(nullableText(question.helpText) ? { helpText: text(question.helpText).slice(0, 4_000) } : {}), + required: question.required === true, + answerMode, + ...(answerMode !== "text" ? { options } : {}), + ...(customAnswer.enabled === true ? { customAnswer: { + enabled: true as const, + ...(nullableText(customAnswer.label) ? { label: text(customAnswer.label).slice(0, 1_000) } : {}), + ...(nullableText(customAnswer.placeholder) ? { placeholder: text(customAnswer.placeholder).slice(0, 1_000) } : {}), + } } : {}), + ...(Object.keys(validation).length > 0 ? { textValidation: { + ...(typeof validation.minLength === "number" ? { minLength: validation.minLength } : {}), + ...(typeof validation.maxLength === "number" ? { maxLength: validation.maxLength } : {}), + ...(typeof validation.pattern === "string" ? { pattern: validation.pattern.slice(0, 1_000) } : {}), + ...(inputType ? { inputType } : {}), + ...(typeof validation.minimum === "number" ? { minimum: validation.minimum } : {}), + ...(typeof validation.maximum === "number" ? { maximum: validation.maximum } : {}), + } } : {}), + }; + return parsedQuestion; + }); + return { + schema: "paperclip.question_set.v1", + ...(nullableText(input.title) ? { title: text(input.title).slice(0, 1_000) } : {}), + ...(nullableText(input.description) ? { description: text(input.description).slice(0, 4_000) } : {}), + ...(nullableText(input.submitLabel) ? { submitLabel: text(input.submitLabel).slice(0, 200) } : {}), + questions, + }; +} + +function runResultEntry(payload: JsonRecord, ts: string): Extract { + const completion = record(payload.completionClaim); + const blocker = record(payload.blocker); + const rawDisposition = text(payload.reportedWorkDisposition); + const disposition = rawDisposition === "blocked" || rawDisposition === "needs_review" || rawDisposition === "yielded" + ? rawDisposition + : "done"; + return { + kind: "run_result", + ts, + disposition, + summary: text(payload.summary, "Run completed"), + objectiveSatisfied: typeof completion.objectiveSatisfied === "boolean" ? completion.objectiveSatisfied : null, + verification: (Array.isArray(payload.verification) ? payload.verification : []).map(record).slice(0, 64).map((item) => ({ + commandOrCheck: text(item.commandOrCheck, "Verification"), + status: item.status === "passed" || item.status === "failed" ? item.status : "not_run", + detail: nullableText(item.detail) ?? undefined, + artifactRef: nullableText(item.artifactRef) ?? undefined, + })), + remainingWork: (Array.isArray(completion.remainingWork) ? completion.remainingWork : []).map(record).slice(0, 64).map((item) => ({ + description: text(item.description, "Remaining work"), + blocksCompletion: item.blocksCompletion === true, + })), + blocker: Object.keys(blocker).length > 0 ? { + reasonCode: text(blocker.reasonCode, "blocked"), + unblockAction: text(blocker.unblockAction, "Resolve the blocker to continue."), + scope: blocker.scope === "task_wide" ? "task_wide" : "current_track", + } : null, + artifacts: (Array.isArray(payload.artifacts) ? payload.artifacts : []).map(record).slice(0, 64).map((item) => ({ + kind: text(item.kind, "artifact"), + ref: text(item.ref), + title: nullableText(item.title) ?? undefined, + })).filter((item) => item.ref.length > 0), + }; +} + +function runTerminalEntry(payload: JsonRecord, ts: string): Extract { + const rawTurnState = text(payload.turnTerminalState); + const turnState = rawTurnState === "failed" || rawTurnState === "interrupted" || rawTurnState === "cancelled" + ? rawTurnState + : "completed"; + const rawRunState = text(payload.runTerminalState); + const runState = rawRunState === "failed" || rawRunState === "cancelled" ? rawRunState : "succeeded"; + const rawDisposition = text(payload.reportedWorkDisposition); + const disposition = rawDisposition === "blocked" || rawDisposition === "needs_review" || rawDisposition === "yielded" + ? rawDisposition + : "done"; + const stopReason = record(payload.stopReason); + return { + kind: "run_terminal", + ts, + turnState, + runState, + disposition, + stopReason: nullableText(stopReason.message) ?? nullableText(stopReason.code) ?? undefined, + }; +} + +function semanticToolEntries(eventType: string, payload: JsonRecord, ts: string): TranscriptEntry[] { + const semantic = record(payload.semantic_tool ?? payload.semanticTool); + const callId = text(semantic.callId, "semantic-tool"); + const operationId = text(semantic.operationId, "Paperclip operation"); + const content = record(semantic.content); + const references = (Array.isArray(content.references) ? content.references : []).map(record); + const input = { + reference: references.map((reference) => `${text(reference.kind)}:${text(reference.id)}`).filter((value) => value !== ":").join(", "), + }; + if (eventType.endsWith("input")) { + return [{ kind: "tool_call", ts, name: operationId, toolUseId: callId, input }]; + } + const outcome = text(semantic.outcome, "succeeded"); + const code = text(semantic.code, outcome); + const receipt = text(semantic.operationReceiptId); + return [{ + kind: "tool_result", + ts, + toolUseId: callId, + toolName: operationId, + content: [code, receipt ? `receipt: ${receipt}` : ""].filter(Boolean).join("\n"), + isError: outcome === "denied" || outcome === "conflict" || outcome === "unavailable" || outcome === "failed", + }]; +} + +function parsePrpEvent( + event: JsonRecord, + ts: string, + state: PaperclipRunnerParserState, +): TranscriptEntry[] { + const eventType = text(event.eventType); + const payload = record(event.payload); + const family = eventType.startsWith("plan.") ? "plan" + : eventType.startsWith("tool.execution.") ? "tool_execution" + : eventType.startsWith("research.") ? "research" + : eventType.startsWith("delegation.") ? "delegation" + : eventType.startsWith("model.") ? "model_identity" + : eventType.startsWith("context.") ? "context" + : eventType.startsWith("artifact.") ? "artifact" + : eventType.startsWith("review.") ? "review" + : eventType.startsWith("hook.") ? "hook" + : eventType.startsWith("memory.") ? "memory" + : eventType.startsWith("safety.") ? "safety" + : eventType.startsWith("terminal.") ? "terminal" + : eventType.startsWith("wait.") ? "wait" + : eventType.startsWith("provider.notice.") ? "provider_notice" : null; + if (family !== null) { + const rawStatus = text(payload.status); + const status = (family === "plan" && payload.complete === false) || rawStatus === "running" || rawStatus === "pending" || eventType.endsWith("started") || eventType.endsWith("progressed") ? "running" + : rawStatus === "failed" || rawStatus === "denied" ? "failed" + : rawStatus === "interrupted" || rawStatus === "cancelled" ? "interrupted" + : (family === "plan" && payload.complete === true) || eventType.endsWith("completed") || rawStatus === "completed" ? "completed" : "informational"; + const title = ({ plan: "Plan", tool_execution: "Tool execution", research: "Research", delegation: "Delegation", model_identity: "Model", context: "Context", artifact: "Artifact", review: "Review mode", hook: "Hook", memory: "Memory citation", safety: "Safety review", terminal: "Terminal input", wait: "Intentional wait", provider_notice: "Provider notice" } as const)[family]; + const summary = family === "model_identity" + ? text(payload.summary, text(payload.effectiveModel, text(payload.requestedModel, text(payload.provider, eventType)))) + : text(payload.summary, text(payload.name, text(payload.query, eventType))); + return [{ kind: "provider_activity", ts, family, eventType, status, title, summary, payload }]; + } + if (eventType === "workspace.change.updated" || eventType === "workspace.diff.recorded") { + return [workspaceChangeEntry(payload, ts)]; + } + if (eventType === "workspace.file.referenced") { + const entry = workspaceFileReferenceEntry(payload, ts); + return entry ? [entry] : [{ kind: "system", ts, text: "Runner: Ignored an unsafe workspace file reference" }]; + } + if (eventType.startsWith("runtime_request.")) { + const entry = runtimeRequestEntry(eventType, payload, event, ts, state); + return entry ? [entry] : []; + } + if ( + eventType === "semantic_tool.input" + || eventType === "semantic_tool.result" + || eventType === "mcp_app.tool_input" + || eventType === "mcp_app.tool_result" + ) { + return semanticToolEntries(eventType, payload, ts); + } + if (eventType === "session.started" || eventType === "session.resumed") { + const context = record(payload.context); + const model = text(context.model, text(record(payload.model).name, "Paperclip runner")); + const sessionId = text(payload.providerSessionId, text(payload.driverSessionId, text(event.normalizedSessionId))); + return [{ kind: "system", ts, text: `Paperclip session ${eventType === "session.resumed" ? "resumed" : "started"} · ${model}${sessionId ? ` · ${sessionId}` : ""}` }]; + } + if (eventType === "turn.started") return [{ kind: "system", ts, text: "Turn started" }]; + if (eventType === "turn.completed") return [{ kind: "system", ts, text: "Turn completed" }]; + if (eventType === "turn.failed") return [{ kind: "stderr", ts, text: text(record(payload.error).message, "Turn failed") }]; + if (eventType === "item.started" || eventType === "item.completed") { + if (payload.kind === "usage") return [usageEntry(payload, ts)]; + return parseItemEvent(event, payload, eventType === "item.started" ? "started" : "completed", ts, state); + } + if (eventType === "item.failed") { + const item = record(payload.item); + const error = record(payload.error); + return [{ kind: "stderr", ts, text: text(error.message, text(item.error, "Runner item failed")) }]; + } + if (eventType === "item.delta") return parseDeltaEvent(event, payload, ts, state); + if (eventType === "usage.reported") return [usageEntry(payload, ts)]; + if (eventType === "run.result.proposed" || eventType === "run.result.accepted") { + const result = eventType === "run.result.accepted" ? record(payload.result) : payload; + const summary = text(result.summary); + if (!summary || state.resultSummaries.has(summary)) return []; + state.resultSummaries.add(summary); + return [runResultEntry(result, ts), { kind: "assistant", ts, text: summary, channel: "final" }]; + } + if (eventType === "run.terminal") return [runTerminalEntry(payload, ts)]; + if (eventType === "harness.diagnostic" || eventType === "runner.diagnostic" || eventType === "session.failed" || eventType === "mcp_app.failed") { + return [{ kind: "system", ts, text: `Runner: ${text(payload.message, text(payload.code, eventType))}` }]; + } + return []; +} + +function createParserState(): PaperclipRunnerParserState { + return { + assistantDeltaItemIds: new Set(), + reasoningDeltaItemIds: new Set(), + resultSummaries: new Set(), + toolOutputItemIds: new Set(), + itemChannels: new Map(), + structuredFinalItemIds: new Set(), + runtimeRequests: new Map(), + }; +} + +function parsePaperclipRunnerLine(line: string, ts: string, state: PaperclipRunnerParserState): TranscriptEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return parseCodexStdoutLine(line, ts); + } + const envelope = record(parsed); + if (envelope.type !== "paperclip.prp.event") return parseCodexStdoutLine(line, ts); + const event = record(envelope.event); + return Object.keys(event).length > 0 ? parsePrpEvent(event, ts, state) : []; +} + +export function parsePaperclipRunnerStdoutLine(line: string, ts: string): TranscriptEntry[] { + return parsePaperclipRunnerLine(line, ts, createParserState()); +} export const paperclipRunnerUIAdapter: UIAdapterModule = { type: "paperclip_runner", label: "Paperclip Runner", - parseStdoutLine: parseCodexStdoutLine, + parseStdoutLine: parsePaperclipRunnerStdoutLine, + createStdoutParser: () => { + let state = createParserState(); + return { + parseLine: (line, ts) => parsePaperclipRunnerLine(line, ts, state), + reset: () => { state = createParserState(); }, + }; + }, ConfigFields: CodexLocalConfigFields, buildAdapterConfig: buildPaperclipRunnerConfig, }; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 402d313708..6a89834b8c 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -95,6 +95,7 @@ export interface AgentWakeRequest { payload?: Record | null; idempotencyKey?: string | null; forceFreshSession?: boolean; + debug?: { providerTrace: "raw" }; } function withCompanyScope(path: string, companyId?: string) { diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx index 901717f9b7..b90994ac9a 100644 --- a/ui/src/components/AgentActionButtons.test.tsx +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -177,6 +177,26 @@ describe("AgentActionButtons", () => { expect(container.textContent).not.toContain("Clear error"); }); + it("starts an administrator-selected run with raw provider tracing", async () => { + render(makeAgent(), { canRunWithProviderTrace: true }); + await flushReact(); + + const traceButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Run with provider trace"), + ); + expect(traceButton).toBeTruthy(); + + await act(async () => { + traceButton?.click(); + }); + await flushReact(); + + expect(mockAgentsApi.invoke).toHaveBeenCalledWith("agent-1", "company-1", { + debug: { providerTrace: "raw" }, + }); + expect(mockNavigate).toHaveBeenCalledWith("/agents/alpha/runs/run-1"); + }); + it("calls the terminate success handler after terminating an agent", async () => { const onTerminateSuccess = vi.fn(); render(makeAgent(), { onTerminateSuccess }); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index a062a71af2..f5ea136e60 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -11,6 +11,7 @@ import { RotateCcw, Trash2, CheckCircle2, + Bug, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -165,6 +166,7 @@ export function AgentActionButtons({ workActionsDisabled = false, workActionsDisabledReason, navigateToRunOnInvoke = true, + canRunWithProviderTrace = false, hasPendingNavigationChanges = false, onBeforeNavigate, onActionError, @@ -184,6 +186,8 @@ export function AgentActionButtons({ workActionsDisabled?: boolean; workActionsDisabledReason?: string; navigateToRunOnInvoke?: boolean; + /** Instance administrators may opt one manual run into short-lived raw provider capture. */ + canRunWithProviderTrace?: boolean; /** Whether the caller currently has an unsaved draft that navigation would discard. */ hasPendingNavigationChanges?: boolean; /** Return false to stop an action whose success would navigate away. */ @@ -289,6 +293,24 @@ export function AgentActionButtons({ }, }); + const providerTraceAction = useMutation({ + mutationFn: () => + agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined, { + debug: { providerTrace: "raw" }, + }), + onSuccess: (run) => { + onActionError?.(null); + invalidateAgent(); + if (navigateToRunOnInvoke) { + if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return; + navigate(`/agents/${canonicalAgentRef}/runs/${run.id}`); + } + }, + onError: (err) => { + reportError(err instanceof Error ? err.message : "Failed to start traced run"); + }, + }); + const duplicateAgent = useMutation({ mutationFn: async () => { if (!resolvedCompanyId) { @@ -344,13 +366,28 @@ export function AgentActionButtons({ }); const isPendingApproval = agent.status === "pending_approval"; - const disabled = actionsDisabled || agentAction.isPending; + const disabled = actionsDisabled || agentAction.isPending || providerTraceAction.isPending; const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled; const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled); const clearErrorDisabled = disabled; + const runtimeConfig = agent.runtimeConfig as Record | null; + const runtimeDebug = + runtimeConfig && typeof runtimeConfig.debug === "object" && runtimeConfig.debug !== null + ? (runtimeConfig.debug as Record) + : null; + const persistentProviderTrace = runtimeDebug?.providerTrace === "raw"; return (
+ {persistentProviderTrace ? ( + + + Raw tracing on + + ) : null} + )} {isError ? ( agentAction.mutate("clear_error")} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index ec83912df6..ec020030a8 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -29,7 +29,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert } from "lucide-react"; +import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react"; import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { copyTextToClipboard } from "../lib/clipboard"; import { @@ -107,6 +107,8 @@ type AgentConfigFormProps = { showAdapterTestEnvironmentButton?: boolean; showCreateRunPolicySection?: boolean; hideInstructionsFile?: boolean; + /** Allow instance administrators to configure short-lived raw provider capture. */ + canConfigureProviderTrace?: boolean; /** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */ hidePromptTemplate?: boolean; /** Render the main configuration sections or the dedicated edit-only Secrets surface. */ @@ -133,6 +135,7 @@ const emptyOverlay: AgentConfigOverlay = { identity: {}, adapterConfig: {}, heartbeat: {}, + debug: {}, runtime: {}, }; @@ -149,6 +152,7 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean { o.adapterType !== undefined || Object.keys(o.adapterConfig).length > 0 || Object.keys(o.heartbeat).length > 0 || + Object.keys(o.debug).length > 0 || Object.keys(o.runtime).length > 0 || o.modelProfiles?.cheap !== undefined ); @@ -241,6 +245,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const hideInstructionsFile = props.hideInstructionsFile ?? false; + const canConfigureProviderTrace = props.canConfigureProviderTrace === true; const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const environmentVariablesEditorRef = useRef(null); @@ -365,7 +370,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isDirty = !isCreate && isOverlayDirty(overlay); - type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; + type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime"; /** Read effective value: overlay if dirty, else original */ function eff(group: RecordOverlayGroup, field: string, original: T): T { @@ -451,6 +456,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record) : {}; + const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record) : {}; const adapterType = isCreate ? props.values.adapterType @@ -2019,6 +2025,46 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
) : null} + {/* ---- Debugging ---- */} + {!isCreate && canConfigureProviderTrace ? ( +
+ {cards ? ( +

+ Debugging +

+ ) : ( +
+ Debugging +
+ )} +
+ ("debug", "providerTrace", debug.providerTrace) === "raw"} + onChange={(enabled) => + mark("debug", "providerTrace", enabled ? "raw" : undefined) + } + /> + {eff("debug", "providerTrace", debug.providerTrace) === "raw" ? ( +
+ + + Raw tracing is on for future runs. Paperclip keeps at most 64 MiB per run and automatically deletes it after 24 hours. + +
+ ) : null} +
+
+ ) : null} + ); } diff --git a/ui/src/components/HoneycombRunLink.test.tsx b/ui/src/components/HoneycombRunLink.test.tsx new file mode 100644 index 0000000000..55aa81cd36 --- /dev/null +++ b/ui/src/components/HoneycombRunLink.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import { webcrypto } from "node:crypto"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HoneycombRunLink } from "./HoneycombRunLink"; +import { HONEYCOMB_RUN_HASH_ATTRIBUTE } from "@/lib/honeycomb-run-link"; + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("HoneycombRunLink", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("crypto", webcrypto); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("stays hidden when Paperclip developer mode is off", async () => { + flushSync(() => { + root.render(); + }); + await flushReact(); + + expect(container.textContent).not.toContain("View in Honeycomb"); + }); + + it("links the run hash query when Paperclip developer mode is on", async () => { + flushSync(() => { + root.render(); + }); + await flushReact(); + + const link = container.querySelector("a"); + expect(link?.textContent).toContain("View in Honeycomb"); + expect(link?.target).toBe("_blank"); + const query = JSON.parse( + new URL(link?.href ?? "about:blank").searchParams.get("query") ?? "null", + ) as { filters: Array<{ column: string; value: string }> }; + expect( + query.filters.find( + (filter) => filter.column === HONEYCOMB_RUN_HASH_ATTRIBUTE, + )?.value, + ).toBe("ba7816bf8f01"); + }); +}); diff --git a/ui/src/components/HoneycombRunLink.tsx b/ui/src/components/HoneycombRunLink.tsx new file mode 100644 index 0000000000..f023b07984 --- /dev/null +++ b/ui/src/components/HoneycombRunLink.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { buildHoneycombRunUrl } from "@/lib/honeycomb-run-link"; + +export function HoneycombRunLink({ + runId, + enabled, +}: { + runId: string; + enabled: boolean; +}) { + const [href, setHref] = useState(null); + + useEffect(() => { + let active = true; + if (!enabled) { + setHref(null); + return () => { + active = false; + }; + } + + void buildHoneycombRunUrl(runId) + .then((url) => { + if (active) setHref(url); + }) + .catch(() => { + if (active) setHref(null); + }); + + return () => { + active = false; + }; + }, [enabled, runId]); + + if (!enabled || !href) return null; + + return ( + + ); +} diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index c1c7239a01..574e380778 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -579,7 +579,11 @@ describe("IssueRunLedger", () => { expect(container.textContent).not.toContain("Continue monitoring"); expect(container.textContent).not.toContain("Snooze 1h"); expect(container.textContent).not.toContain("Mark false positive"); - expect(container.querySelectorAll("button")).toHaveLength(0); + expect( + Array.from(container.querySelectorAll("button")).filter((button) => + /continue|snooze|false positive/i.test(button.textContent ?? ""), + ), + ).toHaveLength(0); expect(onWatchdogDecision).not.toHaveBeenCalled(); }); diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index c1de0e6a96..45de011e42 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -1,10 +1,17 @@ -import { useMemo, useState, type ReactNode } from "react"; -import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared"; -import { isResponsibleUserDenialCode, responsibleUserLabel } from "@paperclipai/shared"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { ActivityEvent, Issue, Agent, ProviderTraceMetadata } from "@paperclipai/shared"; +import { + isResponsibleUserDenialCode, + responsibleUserLabel, +} from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@/lib/router"; import { accessApi, type CurrentBoardAccess } from "../api/access"; -import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity"; +import { + activityApi, + type RunForIssue, + type RunLivenessState, +} from "../api/activity"; import { ApiError } from "../api/client"; import { heartbeatsApi, @@ -20,6 +27,12 @@ import { describeRunRetryState } from "../lib/runRetryState"; import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold"; import { SourceResolvedFoldBadge } from "./SourceResolvedFoldBadge"; import { ResponsibleUserDenialNotice } from "./ResponsibleUserDenialNotice"; +import { RunnerInspector } from "./RunnerInspector"; +import { agentsApi } from "../api/agents"; +import { + ProviderTraceStatusBadge, + runRequestedProviderTrace, +} from "./ProviderTraceStatusBadge"; type IssueRunLedgerProps = { issueId: string; @@ -47,6 +60,8 @@ type IssueRunLedgerContentProps = { canRecordWatchdogDecisions?: boolean; watchdogDecisionError?: string | null; onWatchdogDecision?: (input: WatchdogDecisionInput) => void; + onRerunWithTrace?: (run: RunForIssue) => void; + providerTraceMetadata?: ReadonlyMap; }; type LedgerRun = RunForIssue & { @@ -109,7 +124,8 @@ const LIVENESS_COPY: Record = { needs_followup: { label: "Needs follow-up", tone: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300", - description: "Run produced useful output but did not prove concrete progress.", + description: + "Run produced useful output but did not prove concrete progress.", }, }; @@ -134,14 +150,18 @@ const MISSING_LIVENESS_COPY: LivenessCopy = { const TERMINAL_CHILD_STATUSES = new Set(["done", "cancelled"]); const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]); -type RunOutputSilenceLevel = NonNullable["level"]; +type RunOutputSilenceLevel = NonNullable< + ActiveRunForIssue["outputSilence"] +>["level"]; type RunOutputSilenceCopy = { label: string; tone: string; }; -const RUN_OUTPUT_SILENCE_COPY: Partial> = { +const RUN_OUTPUT_SILENCE_COPY: Partial< + Record +> = { suspicious: { label: "Output silence", tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", @@ -157,12 +177,15 @@ const RUN_OUTPUT_SILENCE_COPY: Partial | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + if (typeof value !== "object" || value === null || Array.isArray(value)) + return null; return value as Record; } function readString(value: unknown) { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; } interface ModelProfileSummary { @@ -208,7 +231,10 @@ function readNumber(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined) { +function formatDuration( + start: string | Date | null | undefined, + end: string | Date | null | undefined, +) { if (!start) return null; const startMs = new Date(start).getTime(); const endMs = end ? new Date(end).getTime() : Date.now(); @@ -217,7 +243,8 @@ function formatDuration(start: string | Date | null | undefined, end: string | D if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; - if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + if (minutes < 60) + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; @@ -228,7 +255,9 @@ function toIsoString(value: string | Date | null | undefined) { return value instanceof Date ? value.toISOString() : value; } -function liveRunToLedgerRun(run: LiveRunForIssue | ActiveRunForIssue): LedgerRun { +function liveRunToLedgerRun( + run: LiveRunForIssue | ActiveRunForIssue, +): LedgerRun { return { runId: run.id, status: run.status, @@ -258,7 +287,12 @@ function mergeRuns( byId.set( run.id, existing - ? { ...existing, isLive: true, agentName: run.agentName, outputSilence: run.outputSilence } + ? { + ...existing, + isLive: true, + agentName: run.agentName, + outputSilence: run.outputSilence, + } : liveRunToLedgerRun(run), ); } @@ -292,11 +326,15 @@ function isActiveRun(run: Pick) { return run.isLive || ACTIVE_RUN_STATUSES.has(run.status); } -function runSummary(run: LedgerRun, agentMap: ReadonlyMap>) { +function runSummary( + run: LedgerRun, + agentMap: ReadonlyMap>, +) { const agentName = compactAgentName(run, agentMap); if (run.status === "running") return `Running now by ${agentName}`; if (run.status === "queued") return `Queued for ${agentName}`; - if (run.status === "scheduled_retry") return `Automatic retry scheduled for ${agentName}`; + if (run.status === "scheduled_retry") + return `Automatic retry scheduled for ${agentName}`; return `${statusLabel(run.status)} by ${agentName}`; } @@ -312,19 +350,27 @@ function stopReasonLabel(run: RunForIssue) { const timeoutFired = result?.timeoutFired === true; const effectiveTimeoutSec = readNumber(result?.effectiveTimeoutSec); const timeoutText = - effectiveTimeoutSec && effectiveTimeoutSec > 0 ? `${effectiveTimeoutSec}s timeout` : null; + effectiveTimeoutSec && effectiveTimeoutSec > 0 + ? `${effectiveTimeoutSec}s timeout` + : null; if (timeoutFired || stopReason === "timeout") { return timeoutText ? `timeout (${timeoutText})` : "timeout"; } - if (stopReason === "max_turns_exhausted" || stopReason === "turn_limit_exhausted") return "max turns exhausted"; + if ( + stopReason === "max_turns_exhausted" || + stopReason === "turn_limit_exhausted" + ) + return "max turns exhausted"; if (stopReason === "budget_paused") return "budget paused"; if (stopReason === "cancelled") return "cancelled"; if (stopReason === "paused") return "paused by board"; if (stopReason === "process_lost") return "process lost"; - if (stopReason === "unmanaged_background_task_stopped") return "unmanaged background task stopped"; + if (stopReason === "unmanaged_background_task_stopped") + return "unmanaged background task stopped"; if (stopReason === "adapter_failed") return "adapter failed"; - if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed"; + if (stopReason === "completed") + return timeoutText ? `completed (${timeoutText})` : "completed"; return timeoutText; } @@ -341,7 +387,10 @@ function lastUsefulActionLabel(run: LedgerRun) { if (run.status === "scheduled_retry") return "Waiting for next attempt"; if (run.lastUsefulActionAt) return relativeTime(run.lastUsefulActionAt); if (isActiveRun(run)) return "No action recorded yet"; - if (run.livenessState === "plan_only" || run.livenessState === "needs_followup") { + if ( + run.livenessState === "plan_only" || + run.livenessState === "needs_followup" + ) { return "No concrete action"; } if (run.livenessState === "empty_response") return "No useful output"; @@ -359,21 +408,31 @@ function hasExhaustedContinuation(run: RunForIssue) { } function childIssueSummary(childIssues: Issue[]) { - const active = childIssues.filter((issue) => !TERMINAL_CHILD_STATUSES.has(issue.status)); + const active = childIssues.filter( + (issue) => !TERMINAL_CHILD_STATUSES.has(issue.status), + ); const done = childIssues.filter((issue) => issue.status === "done").length; - const cancelled = childIssues.filter((issue) => issue.status === "cancelled").length; + const cancelled = childIssues.filter( + (issue) => issue.status === "cancelled", + ).length; return { active, done, cancelled, total: childIssues.length }; } -function compactAgentName(run: LedgerRun, agentMap: ReadonlyMap>) { - return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8); +function compactAgentName( + run: LedgerRun, + agentMap: ReadonlyMap>, +) { + return ( + run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8) + ); } function formatSilenceAge(ms: number | null | undefined) { if (!ms || ms <= 0) return null; const totalMinutes = Math.floor(ms / 60_000); if (totalMinutes < 1) return "under 1 minute"; - if (totalMinutes < 60) return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`; + if (totalMinutes < 60) + return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (minutes === 0) return `${hours} hour${hours === 1 ? "" : "s"}`; @@ -385,13 +444,19 @@ function canBoardRecordWatchdogDecision( boardAccess: CurrentBoardAccess | undefined, ) { if (!boardAccess) return false; - if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true; + if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) + return true; const membership = boardAccess.memberships?.find( (item) => item.companyId === companyId && item.status === "active", ); - if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships; - return membership.membershipRole !== "viewer" && membership.membershipRole !== null; + if (!membership) + return ( + boardAccess.companyIds.includes(companyId) && !boardAccess.memberships + ); + return ( + membership.membershipRole !== "viewer" && membership.membershipRole !== null + ); } function watchdogDecisionErrorMessage(error: unknown) { @@ -416,7 +481,9 @@ export function IssueRunLedger({ }: IssueRunLedgerProps) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); - const [watchdogDecisionError, setWatchdogDecisionError] = useState(null); + const [watchdogDecisionError, setWatchdogDecisionError] = useState< + string | null + >(null); const { data: boardAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), @@ -425,7 +492,8 @@ export function IssueRunLedger({ const { data: runs } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), - refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 5000 : false, + refetchInterval: + hasLiveRuns || issueStatus === "in_progress" ? 5000 : false, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const { data: liveRuns } = useQuery({ @@ -433,28 +501,53 @@ export function IssueRunLedger({ queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), enabled: hasLiveRuns, refetchInterval: 3000, - placeholderData: keepPreviousDataForSameQueryTail(issueId), + placeholderData: + keepPreviousDataForSameQueryTail(issueId), }); const { data: activeRun = null } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId), queryFn: () => heartbeatsApi.activeRunForIssue(issueId), enabled: hasLiveRuns || issueStatus === "in_progress", refetchInterval: hasLiveRuns ? false : 3000, - placeholderData: keepPreviousDataForSameQueryTail(issueId), + placeholderData: keepPreviousDataForSameQueryTail( + issueId, + ), }); + const traceRunIds = useMemo( + () => (runs ?? []).slice(0, 100).map((run) => run.runId), + [runs], + ); + const canInspectProviderTrace = + boardAccess?.source === "local_implicit" || boardAccess?.isInstanceAdmin === true; + const { data: providerTraceRows } = useQuery({ + queryKey: queryKeys.providerTraceMetadata(companyId, traceRunIds), + queryFn: () => heartbeatsApi.providerTraceMetadata(companyId, traceRunIds), + enabled: canInspectProviderTrace && traceRunIds.length > 0, + retry: false, + }); + const providerTraceMetadata = useMemo( + () => new Map((providerTraceRows ?? []).map((trace) => [trace.runId, trace])), + [providerTraceRows], + ); const watchdogDecision = useMutation({ - mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input), + mutationFn: (input: WatchdogDecisionInput) => + heartbeatsApi.recordWatchdogDecision(input), onMutate: () => { setWatchdogDecisionError(null); }, onSuccess: () => { setWatchdogDecisionError(null); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); + queryClient.invalidateQueries({ + queryKey: queryKeys.issues.activeRun(issueId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.issues.liveRuns(issueId), + }); }, onError: (error) => { const message = watchdogDecisionErrorMessage(error); - const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error"; + const dedupeSuffix = + error instanceof ApiError ? String(error.status) : "error"; setWatchdogDecisionError(message); pushToast({ title: "Watchdog decision not recorded", @@ -464,6 +557,48 @@ export function IssueRunLedger({ }); }, }); + const rerunWithTrace = useMutation({ + mutationFn: async (run: RunForIssue) => { + const context = asRecord(run.contextSnapshot); + const payload: Record = {}; + for (const key of ["issueId", "taskId", "taskKey"] as const) { + const value = readString(context?.[key]); + if (value) payload[key] = value; + } + const result = await agentsApi.wakeup( + run.agentId, + { + source: "on_demand", + triggerDetail: "manual", + reason: "rerun_with_provider_trace", + payload, + debug: { providerTrace: "raw" }, + }, + companyId, + ); + if (!("id" in result)) + throw new Error(result.message ?? "Trace re-run was skipped."); + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.issues.runs(issueId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.issues.liveRuns(issueId), + }); + }, + onError: (error) => + pushToast({ + title: "Trace re-run not started", + body: + error instanceof Error + ? error.message + : "Paperclip could not start the trace re-run.", + tone: "error", + dedupeKey: `provider-trace-rerun:${issueId}`, + }), + }); return ( watchdogDecision.mutate(input)} + onRerunWithTrace={ + canInspectProviderTrace + ? (run) => rerunWithTrace.mutate(run) + : undefined + } + providerTraceMetadata={providerTraceMetadata} /> ); } @@ -498,14 +642,29 @@ export function IssueRunLedgerContent({ canRecordWatchdogDecisions = true, watchdogDecisionError, onWatchdogDecision, + onRerunWithTrace, + providerTraceMetadata = new Map(), }: IssueRunLedgerContentProps) { - const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]); + const [inspectedRun, setInspectedRun] = useState(null); + const ledgerRuns = useMemo( + () => mergeRuns(runs, liveRuns, activeRun), + [activeRun, liveRuns, runs], + ); + useEffect(() => { + if (inspectedRun || typeof window === "undefined") return; + const requestedRunId = new URLSearchParams(window.location.search).get("inspectRun"); + if (!requestedRunId) return; + const requestedRun = ledgerRuns.find((run) => run.runId === requestedRunId); + if (requestedRun) setInspectedRun(requestedRun); + }, [inspectedRun, ledgerRuns]); const latestRun = ledgerRuns[0] ?? null; const latestSilentRun = useMemo( () => - ledgerRuns.find((run) => - isActiveRun(run) - && (run.outputSilence?.level === "critical" || run.outputSilence?.level === "suspicious"), + ledgerRuns.find( + (run) => + isActiveRun(run) && + (run.outputSilence?.level === "critical" || + run.outputSilence?.level === "suspicious"), ) ?? null, [ledgerRuns], ); @@ -526,9 +685,10 @@ export function IssueRunLedgerContent({ items.push({ kind: "activity", id: event.id, - timestamp: event.createdAt instanceof Date - ? event.createdAt.toISOString() - : String(event.createdAt), + timestamp: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : String(event.createdAt), event, }); } @@ -546,7 +706,9 @@ export function IssueRunLedgerContent({
-

Run ledger

+

+ Run ledger +

{latestRun ? runSummary(latestRun, agentMap) @@ -583,9 +745,13 @@ export function IssueRunLedgerContent({ to={`/issues/${child.identifier ?? child.id}`} className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-(length:--text-micro) hover:bg-accent/40" > - {child.identifier ?? child.id.slice(0, 8)} + + {child.identifier ?? child.id.slice(0, 8)} + {child.title} - {statusLabel(child.status)} + + {statusLabel(child.status)} + ))} {children.active.length > 4 ? ( @@ -614,7 +780,9 @@ export function IssueRunLedgerContent({

Latest active run has been silent for{" "} - {formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? "an extended period"}. + {formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? + "an extended period"} + . {latestSilentRun.outputSilence.evaluationIssueIdentifier ? ( <> {" "} @@ -624,8 +792,8 @@ export function IssueRunLedgerContent({ className="font-medium underline underline-offset-2" > {latestSilentRun.outputSilence.evaluationIssueIdentifier} - - {" "}for recovery context. + {" "} + for recovery context. ) : null}

@@ -643,8 +811,10 @@ export function IssueRunLedgerContent({ onWatchdogDecision({ runId: latestSilentRun.runId, decision: "continue", - evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null, - })} + evaluationIssueId: + latestSilentRun.outputSilence?.evaluationIssueId ?? null, + }) + } disabled={pendingWatchdogDecision != null} > Continue monitoring @@ -656,10 +826,14 @@ export function IssueRunLedgerContent({ onWatchdogDecision({ runId: latestSilentRun.runId, decision: "snooze", - evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null, - snoozedUntil: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + evaluationIssueId: + latestSilentRun.outputSilence?.evaluationIssueId ?? null, + snoozedUntil: new Date( + Date.now() + 60 * 60 * 1000, + ).toISOString(), reason: "Snoozed from issue run ledger", - })} + }) + } disabled={pendingWatchdogDecision != null} > Snooze 1h @@ -671,9 +845,11 @@ export function IssueRunLedgerContent({ onWatchdogDecision({ runId: latestSilentRun.runId, decision: "dismissed_false_positive", - evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null, + evaluationIssueId: + latestSilentRun.outputSilence?.evaluationIssueId ?? null, reason: "Dismissed from issue run ledger", - })} + }) + } disabled={pendingWatchdogDecision != null} > Mark false positive @@ -698,7 +874,11 @@ export function IssueRunLedgerContent({
{feedItems.slice(0, 20).map((item) => { if (item.kind === "activity") { - return
{renderActivityEvent?.(item.event)}
; + return ( +
+ {renderActivityEvent?.(item.event)} +
+ ); } const run = item.run; const liveness = livenessCopyForRun(run); @@ -711,8 +891,12 @@ export function IssueRunLedgerContent({ const onBehalfOfLabel = run.responsibleUserId ? responsibleUserLabel(resolveUserLabel?.(run.responsibleUserId)) : null; - const denialCode = isResponsibleUserDenialCode(run.errorCode) ? run.errorCode : null; - const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson); + const denialCode = isResponsibleUserDenialCode(run.errorCode) + ? run.errorCode + : null; + const sourceResolvedFold = readSourceResolvedWatchdogFold( + run.resultJson, + ); return (
- on behalf of {onBehalfOfLabel} + on behalf of{" "} + {onBehalfOfLabel} ) : null} @@ -745,6 +930,11 @@ export function IssueRunLedgerContent({ live ) : null} + ) : null} {continuation ? ( - {continuation} + + {continuation} + ) : null} {retryState ? ( ) : null} - {run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? ( + {run.outputSilence && + RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? ( { const profile = modelProfileForRun(run); if (!profile) return null; - const label = profile.applied === profile.requested - ? `Profile: ${profile.requested}` - : profile.applied - ? `Profile: ${profile.requested} → ${profile.applied}` - : `Profile: ${profile.requested} (unavailable)`; + const label = + profile.applied === profile.requested + ? `Profile: ${profile.requested}` + : profile.applied + ? `Profile: ${profile.requested} → ${profile.applied}` + : `Profile: ${profile.requested} (unavailable)`; return ( : null} - {relativeTime(item.timestamp)} + + {relativeTime(item.timestamp)} + +
@@ -824,7 +1027,9 @@ export function IssueRunLedgerContent({ {retryState ? (
{retryState.detail ?

{retryState.detail}

: null} - {retryState.secondary ?

{retryState.secondary}

: null} + {retryState.secondary ? ( +

{retryState.secondary}

+ ) : null} {retryState.retryOfRunId ? (

Retry of{" "} @@ -841,14 +1046,20 @@ export function IssueRunLedgerContent({ {(() => { const profile = modelProfileForRun(run); - if (!profile?.fallbackReason || profile.applied === profile.requested) return null; + if ( + !profile?.fallbackReason || + profile.applied === profile.requested + ) + return null; return (

{profile.requested === "cheap" ? "Cheap profile fell back to primary" : `${profile.requested} profile unavailable`} {": "} - {profile.fallbackReason} + + {profile.fallbackReason} +

); })()} @@ -862,14 +1073,22 @@ export function IssueRunLedgerContent({ {denialCode ? ( ) : null} {run.nextAction ? (
- Next action: - {run.nextAction} + + Next action:{" "} + + + {run.nextAction} +
) : null} @@ -882,6 +1101,22 @@ export function IssueRunLedgerContent({ ) : null}
)} + {inspectedRun ? ( + { + if (!nextOpen) setInspectedRun(null); + }} + onRerunWithTrace={ + !["queued", "running"].includes(inspectedRun.status) && + onRerunWithTrace + ? () => onRerunWithTrace(inspectedRun) + : undefined + } + /> + ) : null}
); } diff --git a/ui/src/components/ProviderTraceStatusBadge.tsx b/ui/src/components/ProviderTraceStatusBadge.tsx new file mode 100644 index 0000000000..5f70abcaba --- /dev/null +++ b/ui/src/components/ProviderTraceStatusBadge.tsx @@ -0,0 +1,83 @@ +import type { ProviderTraceMetadata } from "@paperclipai/shared"; +import { Bug, CircleOff } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export function runRequestedProviderTrace( + contextSnapshot: Record | null | undefined, +) { + if (!contextSnapshot) return false; + const debug = contextSnapshot.debug; + return ( + typeof debug === "object" && + debug !== null && + !Array.isArray(debug) && + (debug as Record).providerTrace === "raw" + ); +} + +export function ProviderTraceStatusBadge({ + trace, + requested = false, + showOff = false, + className, +}: { + trace?: ProviderTraceMetadata | null; + requested?: boolean; + showOff?: boolean; + className?: string; +}) { + const status = trace?.status; + const expired = trace + ? new Date(trace.expiresAt).getTime() <= Date.now() + : false; + const label = expired + ? "Trace expired" + : status === "capturing" + ? "Raw tracing enabled" + : status === "complete" + ? "Trace captured" + : status === "incomplete" + ? "Trace incomplete" + : status === "truncated" + ? "Trace truncated" + : status === "expired" + ? "Trace expired" + : status === "deleted" + ? "Trace deleted" + : requested + ? "Trace requested" + : showOff + ? "Trace off" + : null; + if (!label) return null; + const warning = + status === "incomplete" || + status === "truncated" || + status === "expired" || + status === "deleted" || + expired; + const Icon = label === "Trace off" ? CircleOff : Bug; + return ( + + + {label} + + ); +} diff --git a/ui/src/components/RunnerInspector.test.tsx b/ui/src/components/RunnerInspector.test.tsx new file mode 100644 index 0000000000..33d222a87c --- /dev/null +++ b/ui/src/components/RunnerInspector.test.tsx @@ -0,0 +1,658 @@ +// @vitest-environment jsdom + +import { createElement, type ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RunnerInspector } from "./RunnerInspector"; + +const accessMock = vi.hoisted(() => vi.fn()); +const eventsMock = vi.hoisted(() => vi.fn()); +const traceMock = vi.hoisted(() => vi.fn()); +const revealMock = vi.hoisted(() => vi.fn()); +const downloadMock = vi.hoisted(() => vi.fn()); +const deleteMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/access", () => ({ + accessApi: { getCurrentBoardAccess: accessMock }, +})); + +vi.mock("@/api/heartbeats", () => ({ + heartbeatsApi: { + events: eventsMock, + providerTrace: traceMock, + revealProviderTraceFrame: revealMock, + downloadProviderTrace: downloadMock, + deleteProviderTrace: deleteMock, + }, +})); + +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ open, children }: { open: boolean; children: ReactNode }) => + open ? createElement("div", null, children) : null, + SheetContent: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + SheetDescription: ({ children }: { children: ReactNode }) => + createElement("p", null, children), + SheetHeader: ({ children }: { children: ReactNode }) => + createElement("header", null, children), + SheetTitle: ({ children }: { children: ReactNode }) => + createElement("h2", null, children), +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + SelectContent: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + SelectItem: ({ children }: { children: ReactNode }) => + createElement("span", null, children), + SelectTrigger: ({ children }: { children: ReactNode }) => + createElement("button", { type: "button" }, children), + SelectValue: ({ placeholder }: { placeholder?: string }) => + createElement("span", null, placeholder), +})); + +vi.mock("@/components/ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children: ReactNode }) => + createElement("div", null, children), +})); + +( + globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); + } +} + +function traceInspection(runId: string, marker: string) { + return { + trace: { + id: `trace-${runId}`, + runId, + companyId: "company-1", + status: "complete", + provider: "codex", + frameCount: 1, + byteCount: 31, + digest: `sha256:${"a".repeat(64)}`, + reason: null, + requestedBy: "local-admin", + createdAt: "2026-08-22T12:00:00.000Z", + expiresAt: "2026-08-23T12:00:00.000Z", + deletedAt: null, + schema: "paperclip.provider_trace_metadata.v1", + }, + entries: [ + { + kind: "frame", + frameId: 1, + direction: "provider_to_client", + byteLength: 31, + parsed: { method: "item/completed", marker }, + withheldPaths: ["secret"], + }, + ], + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("RunnerInspector", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + window.history.replaceState(null, "", "/"); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + accessMock.mockResolvedValue({ + source: "local_implicit", + isInstanceAdmin: false, + }); + eventsMock.mockResolvedValue([]); + traceMock.mockResolvedValue({ trace: null, entries: [] }); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("keeps canonical inspection available when raw capture was disabled", async () => { + const rerun = vi.fn(); + flushSync(() => + root.render( + , + ), + ); + await flush(); + + expect(container.textContent).toContain( + "Correlate exact provider traffic with every interpretation stage", + ); + expect(container.textContent).toContain( + "Raw provider capture was off for this run.", + ); + const rerunButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Re-run with provider trace"), + ); + flushSync(() => rerunButton?.click()); + expect(rerun).toHaveBeenCalledOnce(); + }); + + it("shows redacted frames by default and warns before exact reveal", async () => { + traceMock.mockResolvedValue({ + trace: { + id: "trace-1", + runId: "run-1", + companyId: "company-1", + status: "complete", + provider: "codex", + frameCount: 1, + byteCount: 31, + digest: `sha256:${"a".repeat(64)}`, + reason: null, + requestedBy: "local-admin", + createdAt: "2026-08-22T12:00:00.000Z", + expiresAt: "2026-08-23T12:00:00.000Z", + deletedAt: null, + schema: "paperclip.provider_trace_metadata.v1", + }, + entries: [ + { + kind: "frame", + frameId: 1, + direction: "provider_to_client", + byteLength: 31, + parsed: { method: "item/completed", authorization: "[withheld]" }, + withheldPaths: ["authorization"], + }, + ], + }); + revealMock.mockResolvedValue({ + schema: "paperclip.provider_trace_frame.v1", + frameId: 1, + timestamp: "1", + direction: "provider_to_client", + transport: "stdio_jsonl", + provider: "codex", + byteLength: 31, + digest: `sha256:${"b".repeat(64)}`, + rawBase64: btoa(JSON.stringify({ method: "item/completed" })), + }); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + + flushSync(() => + root.render( + , + ), + ); + await flush(); + + expect(container.textContent).toContain("Withheld paths: authorization"); + expect(container.textContent).not.toContain("rawBase64"); + const reveal = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Reveal exact frame"), + ); + flushSync(() => reveal?.click()); + await flush(); + + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining("may contain prompts"), + ); + expect(revealMock).toHaveBeenCalledWith("run-1", 1); + }); + + it("does not reuse an exact frame after switching runs with the same frame id", async () => { + traceMock.mockImplementation(async (requestedRunId: string) => + traceInspection(requestedRunId, `${requestedRunId}-redacted`), + ); + revealMock.mockResolvedValue({ + schema: "paperclip.provider_trace_frame.v1", + frameId: 1, + timestamp: "1", + direction: "provider_to_client", + transport: "stdio_jsonl", + provider: "codex", + byteLength: 31, + digest: `sha256:${"b".repeat(64)}`, + rawBase64: btoa(JSON.stringify({ secret: "run-one-secret" })), + }); + vi.spyOn(window, "confirm").mockReturnValue(true); + + flushSync(() => + root.render( + , + ), + ); + await flush(); + const reveal = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Reveal exact frame"), + ); + flushSync(() => reveal?.click()); + await flush(); + expect(container.textContent).toContain("run-one-secret"); + + flushSync(() => + root.render( + , + ), + ); + expect(container.textContent).not.toContain("run-one-secret"); + await flush(); + expect(container.textContent).toContain("run-2-redacted"); + expect(container.textContent).not.toContain("run-one-secret"); + }); + + it("clears exact frames when raw-trace access is revoked while open", async () => { + traceMock.mockResolvedValue(traceInspection("run-1", "run-1-redacted")); + revealMock.mockResolvedValue({ + schema: "paperclip.provider_trace_frame.v1", + frameId: 1, + timestamp: "1", + direction: "provider_to_client", + transport: "stdio_jsonl", + provider: "codex", + byteLength: 31, + digest: `sha256:${"b".repeat(64)}`, + rawBase64: btoa(JSON.stringify({ secret: "revoked-secret" })), + }); + vi.spyOn(window, "confirm").mockReturnValue(true); + + flushSync(() => + root.render( + , + ), + ); + await flush(); + const reveal = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Reveal exact frame"), + ); + flushSync(() => reveal?.click()); + await flush(); + expect(container.textContent).toContain("revoked-secret"); + + accessMock.mockResolvedValue({ + source: "session", + isInstanceAdmin: false, + }); + window.dispatchEvent(new Event("focus")); + await flush(); + expect(accessMock.mock.calls.length).toBeGreaterThan(1); + expect(container.textContent).toContain( + "Raw provider traces require an instance administrator.", + ); + expect(container.textContent).not.toContain("Reveal exact frame"); + expect(container.textContent).not.toContain("revoked-secret"); + }); + + it("does not let a stale initial access check override a newer denial", async () => { + const pendingInitialAccess = deferred<{ + source: "local_implicit"; + isInstanceAdmin: false; + }>(); + accessMock + .mockReturnValueOnce(pendingInitialAccess.promise) + .mockResolvedValue({ source: "session", isInstanceAdmin: false }); + traceMock.mockResolvedValue(traceInspection("run-1", "stale-secret")); + + flushSync(() => + root.render( + , + ), + ); + window.dispatchEvent(new Event("focus")); + await flush(); + expect(container.textContent).toContain( + "Raw provider traces require an instance administrator.", + ); + + pendingInitialAccess.resolve({ + source: "local_implicit", + isInstanceAdmin: false, + }); + await flush(); + expect(traceMock).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain("Reveal exact frame"); + expect(container.textContent).not.toContain("stale-secret"); + }); + + it("discards in-flight exact reads and disables privileged controls during deletion", async () => { + traceMock.mockResolvedValue(traceInspection("run-1", "run-1-redacted")); + const pendingReveal = deferred<{ + schema: string; + frameId: number; + timestamp: string; + direction: string; + transport: string; + provider: string; + byteLength: number; + digest: string; + rawBase64: string; + }>(); + const pendingDownload = deferred(); + const pendingDelete = deferred(); + const pendingPostDeleteAccess = deferred<{ + source: "local_implicit"; + isInstanceAdmin: false; + }>(); + revealMock.mockReturnValue(pendingReveal.promise); + downloadMock.mockReturnValue(pendingDownload.promise); + deleteMock.mockReturnValue(pendingDelete.promise); + vi.spyOn(window, "confirm").mockReturnValue(true); + const anchorClick = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + + flushSync(() => + root.render( + , + ), + ); + await flush(); + const buttons = () => Array.from(container.querySelectorAll("button")); + flushSync(() => + buttons() + .find((button) => button.textContent?.includes("Reveal exact frame")) + ?.click(), + ); + flushSync(() => + buttons() + .find((button) => button.textContent?.includes("Download exact trace")) + ?.click(), + ); + await Promise.resolve(); + expect(revealMock).toHaveBeenCalledWith("run-1", 1); + expect(downloadMock).toHaveBeenCalledWith("run-1"); + + flushSync(() => + buttons() + .find((button) => button.textContent?.includes("Delete trace")) + ?.click(), + ); + expect(deleteMock).toHaveBeenCalledWith("run-1"); + expect(container.textContent).not.toContain("Reveal exact frame"); + expect(container.textContent).not.toContain("Download exact trace"); + expect(container.textContent).not.toContain("Delete trace"); + + pendingReveal.resolve({ + schema: "paperclip.provider_trace_frame.v1", + frameId: 1, + timestamp: "1", + direction: "provider_to_client", + transport: "stdio_jsonl", + provider: "codex", + byteLength: 31, + digest: `sha256:${"b".repeat(64)}`, + rawBase64: btoa(JSON.stringify({ secret: "late-secret" })), + }); + pendingDownload.resolve(new Blob(["late raw trace"])); + await flush(); + expect(container.textContent).not.toContain("late-secret"); + expect(anchorClick).not.toHaveBeenCalled(); + + accessMock + .mockReturnValueOnce(pendingPostDeleteAccess.promise) + .mockResolvedValue({ source: "session", isInstanceAdmin: false }); + pendingDelete.resolve(); + await Promise.resolve(); + window.dispatchEvent(new Event("focus")); + await flush(); + expect(container.textContent).toContain( + "Raw provider traces require an instance administrator.", + ); + pendingPostDeleteAccess.resolve({ + source: "local_implicit", + isInstanceAdmin: false, + }); + await flush(); + expect(container.textContent).toContain( + "Raw provider traces require an instance administrator.", + ); + expect(container.textContent).not.toContain("late-secret"); + expect(anchorClick).not.toHaveBeenCalled(); + }); + + it("keeps client and provider JSON-RPC id spaces separate when grouping operations", async () => { + eventsMock.mockResolvedValue([{ + id: 41, + companyId: "company-1", + runId: "run-1", + agentId: "agent-1", + seq: 9, + eventType: "run.result.proposed", + stream: "stdout", + level: "info", + color: null, + message: null, + payload: { prpEvent: { sourceEventId: "runner:run-1:9", payload: {} } }, + createdAt: "2026-08-22T12:00:03.000Z", + }]); + traceMock.mockResolvedValue({ + trace: null, + entries: [ + { kind: "frame", frameId: 1, timestamp: "1", direction: "client_to_provider", parsed: { id: 1, method: "initialize" } }, + { kind: "frame", frameId: 2, timestamp: "2", direction: "provider_to_client", parsed: { id: 1, result: {} } }, + { kind: "frame", frameId: 3, timestamp: "3", direction: "provider_to_client", parsed: { id: 1, method: "item/tool/call", params: { callId: "finish-1", tool: "paperclip_finish" } } }, + { kind: "frame", frameId: 4, timestamp: "4", direction: "client_to_provider", parsed: { id: 1, result: { success: true } } }, + { kind: "interpretation", frameId: 3, stage: "typescript_codex_driver_normalization", disposition: "mapped", emittedEventIds: ["runner:run-1:9"], ruleId: "codex_driver.normalize.item/tool/call" }, + ], + }); + + flushSync(() => + root.render( + , + ), + ); + await flush(); + + expect(container.textContent).toContain("frames 1–2 · 0 PRP events"); + expect(container.textContent).toContain("frames 3–4 · 1 PRP event"); + expect(container.textContent).not.toContain("frames 1–4"); + }); + + it("correlates a Codex web search through every stage to canonical PRP and presentation", async () => { + eventsMock.mockResolvedValue([ + { + id: 91, + companyId: "company-1", + runId: "run-1", + agentId: "agent-1", + seq: 42, + eventType: "research.completed", + stream: "stdout", + level: "info", + color: null, + message: null, + payload: { + prpEvent: { + eventType: "research.completed", + sourceEventId: "event_runner_000001", + sourceSequence: 42, + payload: { query: "best bbq sauce", status: "completed" }, + }, + }, + createdAt: "2026-08-22T12:00:01.000Z", + }, + ]); + traceMock.mockResolvedValue({ + trace: { + id: "trace-1", + runId: "run-1", + companyId: "company-1", + status: "complete", + provider: "codex", + frameCount: 1, + byteCount: 512, + digest: `sha256:${"a".repeat(64)}`, + reason: null, + requestedBy: "local-admin", + createdAt: "2026-08-22T12:00:00.000Z", + expiresAt: "2026-08-23T12:00:00.000Z", + deletedAt: null, + schema: "paperclip.provider_trace_metadata.v1", + }, + entries: [ + { + kind: "frame", + frameId: 27, + timestamp: "1787400001000", + direction: "provider_to_client", + byteLength: 512, + digest: `sha256:${"b".repeat(64)}`, + parsed: { + method: "item/completed", + params: { + item: { + id: "search-1", + type: "webSearch", + query: "best bbq sauce", + results: [{ title: "Sauce guide", url: "https://example.com" }], + }, + }, + }, + withheldPaths: [], + }, + { + kind: "interpretation", + frameId: 27, + debugChannel: "rust_native", + debugSequence: 1, + stage: "rust_jsonrpc_parse", + ruleId: "codex.notification", + disposition: "mapped", + emittedEventIds: [], + droppedFields: [], + reason: "Parsed Codex notification", + }, + { + kind: "interpretation", + frameId: 27, + debugChannel: "rust_native", + debugSequence: 2, + stage: "rust_durable_normalization", + ruleId: "provider.notification.known", + disposition: "mapped", + emittedEventIds: ["event_runner_000001"], + droppedFields: ["params.item.results"], + fieldMappings: [ + { + inputPath: "params.item.query", + outputPath: "payload.query", + action: "copied", + reason: "Preserved the search query", + }, + { + inputPath: "params.item.results", + action: "dropped", + reason: "Raw provider results are not part of this semantic event", + }, + ], + reason: "Normalized provider web search", + }, + { + kind: "interpretation", + frameId: 27, + debugChannel: "typescript_runnerd_rehydration", + debugSequence: 1, + stage: "typescript_runnerd_rehydration", + ruleId: "runnerd.rehydrate.research.completed", + disposition: "mapped", + emittedEventIds: ["event_runner_000001"], + droppedFields: [], + reason: "Rehydrated canonical event", + }, + { + kind: "interpretation", + frameId: 27, + debugChannel: "typescript_runnerd_rehydration", + debugSequence: 2, + stage: "typescript_codex_driver_normalization", + ruleId: "codex_driver.normalize.item/completed", + disposition: "mapped", + emittedEventIds: ["event_runner_000001"], + droppedFields: [], + reason: "Emitted canonical PRP event", + }, + ], + }); + + flushSync(() => + root.render( + , + ), + ); + await flush(); + + expect(container.textContent).toContain("webSearch"); + expect(container.textContent).toContain("rust_jsonrpc_parse"); + expect(container.textContent).toContain("rust_durable_normalization"); + expect(container.textContent).toContain("typescript_runnerd_rehydration"); + expect(container.textContent).toContain("typescript_codex_driver_normalization"); + expect(container.textContent).toContain("params.item.results"); + expect(container.textContent).toContain("payload.query"); + expect(container.textContent).toContain("research.completed"); + expect(container.textContent).toContain("Production surface preview"); + }); +}); diff --git a/ui/src/components/RunnerInspector.tsx b/ui/src/components/RunnerInspector.tsx new file mode 100644 index 0000000000..52b9681bc0 --- /dev/null +++ b/ui/src/components/RunnerInspector.tsx @@ -0,0 +1,1235 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + HeartbeatRunEvent, + ProviderTraceFieldMapping, + ProviderTraceFrame, +} from "@paperclipai/shared"; +import { + ArrowRight, + Braces, + Check, + ChevronDown, + ChevronRight, + CircleOff, + Copy, + Download, + Eye, + EyeOff, + FileJson2, + Layers3, + RefreshCw, + Search, + ShieldAlert, + Trash2, +} from "lucide-react"; +import { heartbeatsApi, type ProviderTraceInspection } from "@/api/heartbeats"; +import { accessApi } from "@/api/access"; +import { parsePaperclipRunnerStdoutLine } from "@/adapters/paperclip-runner"; +import { TaskChatProtocolCard } from "@/components/task-chat/TaskChatProtocolCard"; +import type { TaskChatProtocolItem } from "@/components/task-chat/task-chat-model"; +import { transcriptToTaskChatItems } from "@/components/task-chat/transcript-adapter"; +import { TASK_PROTOCOL_EVENT_SURFACE_REGISTRY } from "@/components/task-chat/task-protocol-surfaces"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/clipboard"; + +type TraceEntry = Record; +type InspectorView = "overview" | "pipeline" | "trace"; + +type RawTraceAccess = { + runId: string; + allowed: boolean; + epoch: number; + phase: "granted" | "denied" | "deleting"; +}; + +type TraceOperation = { + key: string; + frames: TraceEntry[]; + interpretations: TraceEntry[]; + events: HeartbeatRunEvent[]; + title: string; + subtitle: string; + itemType: string; + nativeMethods: string[]; + directions: string[]; + dispositions: string[]; + prpTypes: string[]; + visible: boolean; + timestamp: number; +}; + +const VIEW_OPTIONS: Array<{ + value: InspectorView; + label: string; + icon: typeof Layers3; +}> = [ + { value: "overview", label: "Overview", icon: Layers3 }, + { value: "pipeline", label: "Pipeline", icon: ArrowRight }, + { value: "trace", label: "Exact trace", icon: FileJson2 }, +]; + +const RAW_TRACE_ACCESS_REVALIDATION_MS = 1_000; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function revealedFrameKey(runId: string, frameId: number): string { + return `${runId}:${frameId}`; +} + +function scalar(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return ""; +} + +function unique(values: string[]) { + return [...new Set(values.filter(Boolean))]; +} + +function formatBytes(value: number) { + if (value < 1_024) return `${value} B`; + if (value < 1_024 * 1_024) return `${(value / 1_024).toFixed(1)} KiB`; + return `${(value / (1_024 * 1_024)).toFixed(1)} MiB`; +} + +function downloadBlob(blob: Blob, filename: string) { + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(href); +} + +function decodeExactFrame(rawBase64: string): unknown { + const decoded = atob(rawBase64); + try { + return JSON.parse(decoded); + } catch { + return decoded; + } +} + +function statusVariant(status: string | undefined) { + if (status === "complete") return "secondary" as const; + if (status === "incomplete" || status === "truncated") + return "destructive" as const; + return "outline" as const; +} + +function traceBadgeLabel(status: string, expiresAt: string | Date) { + if (status === "capturing") return "Raw trace enabled"; + if (status === "incomplete") return "Incomplete"; + if (status === "truncated") return "Truncated"; + if (status === "deleted") return "Deleted"; + if (status === "expired") return "Expired"; + const remainingMs = new Date(expiresAt).getTime() - Date.now(); + if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Expired"; + const hours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1_000))); + return `Expires in ${hours}h`; +} + +function frameParsed(entry: TraceEntry) { + return record(entry.parsed); +} + +function frameMethod(entry: TraceEntry) { + const parsed = frameParsed(entry); + return text(parsed.method) || text(record(parsed.params).method); +} + +function frameItem(entry: TraceEntry) { + const parsed = frameParsed(entry); + const params = record(parsed.params); + return record(params.item ?? parsed.item); +} + +function frameItemType(entry: TraceEntry) { + const parsed = frameParsed(entry); + const params = record(parsed.params); + return text(frameItem(entry).type) || text(params.itemType) || text(parsed.type); +} + +function eventPrp(event: HeartbeatRunEvent) { + const payload = record(event.payload); + const prpEvent = record(payload.prpEvent); + return Object.keys(prpEvent).length > 0 ? prpEvent : payload; +} + +function eventSourceId(event: HeartbeatRunEvent) { + return text(eventPrp(event).sourceEventId); +} + +function eventTimestamp(event: HeartbeatRunEvent) { + const value = new Date(event.createdAt).getTime(); + return Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER; +} + +function frameTimestamp(frame: TraceEntry) { + const raw = scalar(frame.timestamp); + const numeric = Number(raw); + if (Number.isFinite(numeric)) { + return numeric < 10_000_000_000 ? numeric * 1_000 : numeric; + } + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +} + +function visibilityDecision(event: HeartbeatRunEvent) { + const registration = TASK_PROTOCOL_EVENT_SURFACE_REGISTRY[event.eventType]; + if (!registration) { + return { + visible: false, + surface: "run_debug", + container: "Runner Inspector", + state: "hidden", + action: "none", + reasonCode: "presentation_surface_unregistered", + reason: "No production surface registration exists for this event type.", + }; + } + return { + visible: registration.disposition !== "debug-only", + surface: registration.surface, + container: + registration.disposition === "inline" + ? "primary run turn" + : "collapsed activity", + state: registration.disposition, + action: + registration.disposition === "inline" + ? "render" + : registration.disposition === "folded" + ? "fold" + : "operator only", + reasonCode: `presentation_${registration.disposition}`, + reason: registration.rationale, + }; +} + +function interpretationEventIds(entry: TraceEntry) { + return Array.isArray(entry.emittedEventIds) + ? entry.emittedEventIds.map(String) + : []; +} + +function frameCorrelationTokens(frame: TraceEntry, interpretations: TraceEntry[]) { + const parsed = frameParsed(frame); + const params = record(parsed.params); + const item = frameItem(frame); + const tokens = interpretations.flatMap(interpretationEventIds).map((id) => `event:${id}`); + const rpcId = scalar(parsed.id); + if (rpcId) { + const direction = text(frame.direction); + const isRequest = Boolean(frameMethod(frame)); + const requestOrigin = isRequest + ? direction === "client_to_provider" ? "client" : "provider" + : direction === "client_to_provider" ? "provider" : "client"; + tokens.push(`rpc:${requestOrigin}:${rpcId}`); + } + for (const id of [item.id, params.itemId, params.callId, parsed.itemId]) { + const value = scalar(id); + if (value) tokens.push(`item:${value}`); + } + return unique(tokens); +} + +function buildOperations( + frames: TraceEntry[], + interpretations: TraceEntry[], + events: HeartbeatRunEvent[], +): TraceOperation[] { + const sortedFrames = [...frames].sort( + (left, right) => Number(left.frameId) - Number(right.frameId), + ); + const frameById = new Map(sortedFrames.map((frame) => [Number(frame.frameId), frame])); + const parent = new Map([...frameById.keys()].map((id) => [id, id])); + const find = (id: number): number => { + const current = parent.get(id) ?? id; + if (current === id) return id; + const root = find(current); + parent.set(id, root); + return root; + }; + const union = (left: number, right: number) => { + const leftRoot = find(left); + const rightRoot = find(right); + if (leftRoot !== rightRoot) parent.set(rightRoot, leftRoot); + }; + const tokenOwner = new Map(); + for (const frame of sortedFrames) { + const frameId = Number(frame.frameId); + const stages = interpretations.filter((entry) => Number(entry.frameId) === frameId); + for (const token of frameCorrelationTokens(frame, stages)) { + const owner = tokenOwner.get(token); + if (owner === undefined) tokenOwner.set(token, frameId); + else union(owner, frameId); + } + } + + const groupedFrames = new Map(); + for (const frame of sortedFrames) { + const root = find(Number(frame.frameId)); + groupedFrames.set(root, [...(groupedFrames.get(root) ?? []), frame]); + } + const claimedEvents = new Set(); + const operations: TraceOperation[] = []; + for (const groupFrames of groupedFrames.values()) { + const frameIds = new Set(groupFrames.map((frame) => Number(frame.frameId))); + const stages = interpretations.filter((entry) => frameIds.has(Number(entry.frameId))); + const emittedIds = new Set(stages.flatMap(interpretationEventIds)); + const groupEvents = events.filter((event) => emittedIds.has(eventSourceId(event))); + groupEvents.forEach((event) => claimedEvents.add(event.id)); + const methods = unique(groupFrames.map(frameMethod)); + const itemTypes = unique(groupFrames.map(frameItemType)); + const title = itemTypes.at(-1) || methods.at(-1) || text(groupFrames[0]?.direction) || "Provider frame"; + const firstFrame = groupFrames[0]; + const lastFrame = groupFrames.at(-1); + const range = firstFrame === lastFrame + ? `frame ${firstFrame?.frameId}` + : `frames ${firstFrame?.frameId}–${lastFrame?.frameId}`; + operations.push({ + key: `frames:${firstFrame?.frameId}`, + frames: groupFrames, + interpretations: stages, + events: groupEvents, + title, + subtitle: `${range} · ${groupEvents.length} PRP event${groupEvents.length === 1 ? "" : "s"}`, + itemType: itemTypes.at(-1) ?? "", + nativeMethods: methods, + directions: unique(groupFrames.map((frame) => text(frame.direction))), + dispositions: unique(stages.map((entry) => text(entry.disposition))), + prpTypes: unique(groupEvents.map((event) => event.eventType)), + visible: groupEvents.some((event) => visibilityDecision(event).visible), + timestamp: Math.min(...groupFrames.map(frameTimestamp)), + }); + } + for (const event of events) { + if (claimedEvents.has(event.id)) continue; + const decision = visibilityDecision(event); + operations.push({ + key: `event:${event.id}`, + frames: [], + interpretations: [], + events: [event], + title: event.eventType, + subtitle: `PRP ${eventSourceId(event) || `event ${event.seq}`} · no raw correlation`, + itemType: "", + nativeMethods: [], + directions: [], + dispositions: [], + prpTypes: [event.eventType], + visible: decision.visible, + timestamp: eventTimestamp(event), + }); + } + return operations.sort((left, right) => left.timestamp - right.timestamp); +} + +async function loadAllRunEvents(runId: string) { + const events: HeartbeatRunEvent[] = []; + let afterSeq = 0; + for (;;) { + const page = await heartbeatsApi.events(runId, afterSeq, 1_000); + events.push(...page); + if (page.length < 1_000) return events; + const nextSeq = page.at(-1)?.seq ?? afterSeq; + if (nextSeq <= afterSeq) return events; + afterSeq = nextSeq; + } +} + +function jsonMatches(value: unknown, query: string): boolean { + if (!query) return true; + try { + return JSON.stringify(value).toLowerCase().includes(query.toLowerCase()); + } catch { + return String(value).toLowerCase().includes(query.toLowerCase()); + } +} + +function JsonPrimitive({ value }: { value: unknown }) { + if (typeof value === "string") return "{value}"; + if (typeof value === "number") return {value}; + if (typeof value === "boolean") return {String(value)}; + if (value === null) return null; + return {String(value)}; +} + +function JsonNode({ + label, + value, + path, + depth, + query, +}: { + label?: string; + value: unknown; + path: string; + depth: number; + query: string; +}) { + const expandable = value !== null && typeof value === "object"; + const entries = Array.isArray(value) + ? value.map((child, index) => [String(index), child] as const) + : Object.entries(record(value)); + const large = entries.length > 8; + const [expanded, setExpanded] = useState(depth < 1 && !large); + const forcedOpen = Boolean(query) && jsonMatches(value, query); + const isOpen = expandable && (expanded || forcedOpen); + const visibleEntries = query + ? entries.filter(([key, child]) => + key.toLowerCase().includes(query.toLowerCase()) || jsonMatches(child, query), + ) + : entries; + const copyValue = () => { + const serialized = typeof value === "string" ? value : JSON.stringify(value, null, 2); + void copyTextToClipboard(serialized ?? String(value)); + }; + return ( +
0 && "border-l border-border/60 pl-3")}> +
+ {expandable ? ( + + ) : ( + + )} + {label !== undefined ? {label}: : null} + {expandable ? ( + + {Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`} + + ) : ( + + )} + + + + +
+ {isOpen ? ( +
+ {visibleEntries.map(([key, child]) => ( + + ))} + {query && visibleEntries.length === 0 ? ( +

No fields match.

+ ) : null} +
+ ) : null} +
+ ); +} + +function JsonExplorer({ value, label = "Search this JSON" }: { value: unknown; label?: string }) { + const [query, setQuery] = useState(""); + return ( +
+
+ + setQuery(event.target.value)} + placeholder={label} + className="h-8 border-0 bg-transparent pl-8 text-xs shadow-none focus-visible:ring-0" + /> +
+
+ +
+
+ ); +} + +function fieldMappings(entry: TraceEntry): ProviderTraceFieldMapping[] { + const explicit = Array.isArray(entry.fieldMappings) + ? entry.fieldMappings + .map(record) + .filter((mapping) => text(mapping.action)) + .map((mapping) => ({ + inputPath: text(mapping.inputPath) || undefined, + outputPath: text(mapping.outputPath) || undefined, + action: text(mapping.action) as ProviderTraceFieldMapping["action"], + reason: text(mapping.reason) || undefined, + })) + : []; + const knownDrops = new Set(explicit.filter((mapping) => mapping.action === "dropped").map((mapping) => mapping.inputPath)); + const legacy = Array.isArray(entry.droppedFields) + ? entry.droppedFields + .map(String) + .filter((path) => !knownDrops.has(path)) + .map((path) => ({ + inputPath: path, + action: "dropped" as const, + reason: text(entry.reason) || "Field was not carried into the next stage", + })) + : []; + return [...explicit, ...legacy]; +} + +function mappingTone(action: ProviderTraceFieldMapping["action"]) { + if (action === "dropped" || action === "redacted") return "border-destructive/30 bg-destructive/10 text-destructive"; + if (action === "derived") return "border-secondary bg-secondary text-secondary-foreground"; + return "border-primary/30 bg-primary/10 text-primary"; +} + +function InterpretationStage({ entry, last }: { entry: TraceEntry; last: boolean }) { + const mappings = fieldMappings(entry); + return ( +
+
+ {!last ? : null} + +
+
+
+ {text(entry.stage)} + {text(entry.disposition)} + {text(entry.ruleId)} +
+

{text(entry.reason)}

+ {mappings.length > 0 ? ( +
+ + + + + + + + + + + {mappings.map((mapping, index) => ( + + + + + + + ))} + +
ActionProvider pathOutput pathReason
+ {mapping.action} + {mapping.inputPath ?? "—"}{mapping.outputPath ?? "—"}{mapping.reason ?? "—"}
+
+ ) : ( +

No field-level mapping was recorded at this stage.

+ )} +
+
+ ); +} + +function typedPrpFields(event: HeartbeatRunEvent) { + const prp = eventPrp(event); + const payload = record(prp.payload); + const item = record(payload.item); + return [ + ["Event type", event.eventType], + ["Source event", text(prp.sourceEventId)], + ["Sequence", scalar(prp.sourceSequence) || String(event.seq)], + ["Item type", text(item.type) || text(payload.kind)], + ["Status", text(payload.status) || text(item.status)], + ["Query", text(payload.query) || text(item.query)], + ].filter(([, value]) => Boolean(value)); +} + +function ProductionSurfacePreview({ event, runId }: { event: HeartbeatRunEvent; runId: string }) { + const prp = eventPrp(event); + const ts = new Date(event.createdAt).toISOString(); + const entries = parsePaperclipRunnerStdoutLine( + JSON.stringify({ type: "paperclip.prp.event", event: prp }), + ts, + ); + const item = transcriptToTaskChatItems(entries, { + runId, + agentName: "Runner", + running: false, + }).find((candidate): candidate is TaskChatProtocolItem => candidate.kind === "protocol"); + if (item) return ; + const decision = visibilityDecision(event); + const VisibleIcon = decision.visible ? Eye : EyeOff; + return ( +
+
+ +
+

{event.eventType}

+

+ Production surface: {decision.surface} · {decision.action} in {decision.container} +

+
+
+
+ ); +} + +function StatCard({ label, value, detail }: { label: string; value: string | number; detail?: string }) { + return ( +
+

{label}

+

{value}

+ {detail ?

{detail}

: null} +
+ ); +} + +export function RunnerInspector({ + runId, + run, + open, + onOpenChange, + onRerunWithTrace, +}: { + runId: string; + run?: { resultJson: Record | null; status: string } | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onRerunWithTrace?: () => void; +}) { + const [inspection, setInspection] = useState(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [view, setView] = useState("pipeline"); + const [selectedKey, setSelectedKey] = useState(null); + const [selectedFrameId, setSelectedFrameId] = useState(null); + const [query, setQuery] = useState(""); + const [direction, setDirection] = useState("all"); + const [nativeMethod, setNativeMethod] = useState("all"); + const [disposition, setDisposition] = useState("all"); + const [prpType, setPrpType] = useState("all"); + const [visibility, setVisibility] = useState("all"); + const [revealed, setRevealed] = useState>({}); + const [rawTraceAccess, setRawTraceAccess] = useState(null); + const rawTraceAccessRef = useRef(null); + const rawTraceAccessEpochRef = useRef(0); + const canInspectRaw = + open && rawTraceAccess?.runId === runId ? rawTraceAccess.allowed : null; + + useLayoutEffect(() => { + rawTraceAccessEpochRef.current += 1; + rawTraceAccessRef.current = null; + setRawTraceAccess(null); + setInspection(null); + setEvents([]); + setLoading(open); + setError(null); + setSelectedKey(null); + setSelectedFrameId(null); + setRevealed({}); + }, [open, runId]); + + const denyRawTraceAccess = useCallback(() => { + const currentAccess = rawTraceAccessRef.current; + if ( + currentAccess?.runId === runId && + currentAccess.phase === "denied" + ) { + return; + } + rawTraceAccessEpochRef.current += 1; + const deniedAccess: RawTraceAccess = { + runId, + allowed: false, + epoch: rawTraceAccessEpochRef.current, + phase: "denied", + }; + rawTraceAccessRef.current = deniedAccess; + setRawTraceAccess(deniedAccess); + setInspection({ trace: null, entries: [] }); + setLoading(false); + setError(null); + setSelectedKey(null); + setSelectedFrameId(null); + setRevealed({}); + }, [runId]); + + useEffect(() => { + if (!open) return; + const params = new URLSearchParams(window.location.search); + const requestedView = params.get("traceView"); + if (requestedView === "overview" || requestedView === "pipeline" || requestedView === "trace") setView(requestedView); + const requestedOperation = params.get("traceOperation"); + if (requestedOperation) setSelectedKey(requestedOperation); + }, [open]); + + useEffect(() => { + if (!open) return; + let active = true; + const loadEpoch = rawTraceAccessEpochRef.current; + setLoading(true); + setError(null); + Promise.all([accessApi.getCurrentBoardAccess(), loadAllRunEvents(runId)]) + .then(async ([boardAccess, nextEvents]) => { + if (!active || rawTraceAccessEpochRef.current !== loadEpoch) return; + const canRaw = boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin; + const access: RawTraceAccess = { + runId, + allowed: canRaw, + epoch: rawTraceAccessEpochRef.current, + phase: canRaw ? "granted" : "denied", + }; + rawTraceAccessRef.current = access; + setRawTraceAccess(access); + if (!canRaw) setRevealed({}); + const trace = canRaw + ? await heartbeatsApi.providerTrace(runId) + : ({ trace: null, entries: [] } satisfies ProviderTraceInspection); + const currentAccess = rawTraceAccessRef.current; + if ( + !active || + currentAccess?.runId !== runId || + currentAccess.epoch !== loadEpoch || + currentAccess.allowed !== canRaw + ) { + return; + } + setInspection(trace); + setEvents(nextEvents); + }) + .catch((cause) => { + if (!active || rawTraceAccessEpochRef.current !== loadEpoch) return; + setError(cause instanceof Error ? cause.message : "Runner inspection failed"); + }) + .finally(() => { + if (active && rawTraceAccessEpochRef.current === loadEpoch) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [open, runId]); + + useEffect(() => { + if (!open) return; + let active = true; + const revalidate = async () => { + try { + const boardAccess = await accessApi.getCurrentBoardAccess(); + if (!active) return; + const canRaw = + boardAccess.source === "local_implicit" || + boardAccess.isInstanceAdmin; + if (!canRaw) denyRawTraceAccess(); + } catch { + if (active) denyRawTraceAccess(); + } + }; + const onFocus = () => void revalidate(); + const onVisibilityChange = () => { + if (document.visibilityState === "visible") void revalidate(); + }; + const interval = window.setInterval( + () => void revalidate(), + RAW_TRACE_ACCESS_REVALIDATION_MS, + ); + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + active = false; + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [denyRawTraceAccess, open]); + + const entries = inspection?.entries ?? []; + const frames = useMemo(() => entries.filter((entry) => entry.kind === "frame"), [entries]); + const interpretations = useMemo(() => entries.filter((entry) => entry.kind === "interpretation"), [entries]); + const capturedProviders = unique(frames.map((frame) => text(frame.provider))); + const operations = useMemo( + () => buildOperations(frames, interpretations, events), + [events, frames, interpretations], + ); + const filteredOperations = useMemo( + () => operations.filter((operation) => { + if (query.trim() && !jsonMatches(operation, query.trim())) return false; + if (direction !== "all" && !operation.directions.includes(direction)) return false; + if (nativeMethod !== "all" && !operation.nativeMethods.includes(nativeMethod)) return false; + if (disposition !== "all" && !operation.dispositions.includes(disposition)) return false; + if (prpType !== "all" && !operation.prpTypes.includes(prpType)) return false; + if (visibility === "visible" && !operation.visible) return false; + if (visibility === "hidden" && operation.visible) return false; + return true; + }), + [direction, disposition, nativeMethod, operations, prpType, query, visibility], + ); + + useEffect(() => { + if (!filteredOperations.length) return; + if (!selectedKey || !filteredOperations.some((operation) => operation.key === selectedKey)) { + const preferred = + filteredOperations.find( + (operation) => operation.itemType && operation.events.length > 0, + ) ?? + filteredOperations.find( + (operation) => operation.frames.length > 0 && operation.events.length > 0, + ) ?? + filteredOperations[0]!; + setSelectedKey(preferred.key); + } + }, [filteredOperations, selectedKey]); + + const selectedOperation = filteredOperations.find((operation) => operation.key === selectedKey) ?? filteredOperations[0] ?? null; + useEffect(() => { + if (!selectedOperation) return; + if (!selectedOperation.frames.some((frame) => Number(frame.frameId) === selectedFrameId)) { + const preferred = [...selectedOperation.frames].reverse().find((frame) => frameMethod(frame).includes("completed")) ?? selectedOperation.frames.at(-1); + setSelectedFrameId(preferred ? Number(preferred.frameId) : null); + } + }, [selectedFrameId, selectedOperation]); + + useEffect(() => { + if (!open || !selectedOperation) return; + const params = new URLSearchParams(window.location.search); + params.set("inspectRun", runId); + params.set("traceView", view); + params.set("traceOperation", selectedOperation.key); + const next = `${window.location.pathname}?${params.toString()}${window.location.hash}`; + window.history.replaceState(window.history.state, "", next); + }, [open, runId, selectedOperation, view]); + + const selectedFrame = selectedOperation?.frames.find((frame) => Number(frame.frameId) === selectedFrameId) ?? selectedOperation?.frames.at(-1) ?? null; + const revealedFrame = + canInspectRaw === true && selectedFrame + ? (revealed[ + revealedFrameKey(runId, Number(selectedFrame.frameId)) + ] ?? null) + : null; + const selectedInterpretations = selectedFrame + ? selectedOperation?.interpretations.filter((entry) => Number(entry.frameId) === Number(selectedFrame.frameId)) ?? [] + : selectedOperation?.interpretations ?? []; + const selectedEvents = selectedOperation?.events ?? []; + const runResultJson = record(run?.resultJson); + const presentationDecision = record(runResultJson.presentationDecision); + const verificationCaveats = Array.isArray(runResultJson.verificationCaveats) + ? runResultJson.verificationCaveats + : []; + const ignoredAttentionRequests = Array.isArray(runResultJson.ignoredAttentionRequests) + ? runResultJson.ignoredAttentionRequests + : []; + const visibleEventCount = events.filter((event) => visibilityDecision(event).visible).length; + const ignoredCount = interpretations.filter((entry) => entry.disposition === "ignored").length; + const dispositionCounts = interpretations.reduce>((counts, entry) => { + const key = text(entry.disposition) || "unknown"; + counts[key] = (counts[key] ?? 0) + 1; + return counts; + }, {}); + + const selectOperation = (operation: TraceOperation) => { + setSelectedKey(operation.key); + setView("pipeline"); + }; + + const reveal = async (frameId: number) => { + if (canInspectRaw !== true) return; + const requestedAccess = rawTraceAccessRef.current; + if (requestedAccess?.runId !== runId || requestedAccess.allowed !== true) { + return; + } + if (!window.confirm("This exact provider frame may contain prompts, tool arguments, secrets, or reasoning. Reveal it now?")) return; + const requestedRunId = runId; + const frame = await heartbeatsApi.revealProviderTraceFrame( + requestedRunId, + frameId, + ); + const currentAccess = rawTraceAccessRef.current; + if ( + currentAccess?.runId !== requestedRunId || + currentAccess.allowed !== true || + currentAccess.epoch !== requestedAccess.epoch + ) { + return; + } + setRevealed((current) => ({ + ...current, + [revealedFrameKey(requestedRunId, frameId)]: frame, + })); + }; + + const downloadTrace = async () => { + if (canInspectRaw !== true) return; + const requestedAccess = rawTraceAccessRef.current; + if (requestedAccess?.runId !== runId || requestedAccess.allowed !== true) { + return; + } + if (!window.confirm("Download the exact raw trace? It may contain sensitive prompts, tool arguments, and provider-only fields.")) return; + const blob = await heartbeatsApi.downloadProviderTrace(runId); + const currentAccess = rawTraceAccessRef.current; + if ( + currentAccess?.runId !== runId || + currentAccess.allowed !== true || + currentAccess.epoch !== requestedAccess.epoch + ) { + return; + } + downloadBlob(blob, `provider-trace-${runId}.ndjson`); + }; + + const deleteTrace = async () => { + if (canInspectRaw !== true) return; + const currentAccess = rawTraceAccessRef.current; + if (currentAccess?.runId !== runId || currentAccess.allowed !== true) { + return; + } + if (!window.confirm("Delete this raw trace immediately? This cannot be undone.")) return; + rawTraceAccessEpochRef.current += 1; + const deletionAccess: RawTraceAccess = { + ...currentAccess, + allowed: false, + epoch: rawTraceAccessEpochRef.current, + phase: "deleting", + }; + rawTraceAccessRef.current = deletionAccess; + setRawTraceAccess(deletionAccess); + setRevealed({}); + try { + await heartbeatsApi.deleteProviderTrace(runId); + } catch (cause) { + if (rawTraceAccessRef.current?.epoch === deletionAccess.epoch) { + setError( + cause instanceof Error ? cause.message : "Raw trace deletion failed", + ); + } + return; + } + if (rawTraceAccessRef.current?.epoch !== deletionAccess.epoch) return; + let boardAccess; + try { + boardAccess = await accessApi.getCurrentBoardAccess(); + } catch { + if (rawTraceAccessRef.current?.epoch === deletionAccess.epoch) { + denyRawTraceAccess(); + } + return; + } + if (rawTraceAccessRef.current?.epoch !== deletionAccess.epoch) return; + const canRaw = + boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin; + if (!canRaw) { + denyRawTraceAccess(); + return; + } + rawTraceAccessEpochRef.current += 1; + const restoredAccess: RawTraceAccess = { + runId, + allowed: true, + epoch: rawTraceAccessEpochRef.current, + phase: "granted", + }; + rawTraceAccessRef.current = restoredAccess; + setRawTraceAccess(restoredAccess); + setInspection({ trace: null, entries: [] }); + setSelectedKey(null); + setSelectedFrameId(null); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + rawTraceAccessEpochRef.current += 1; + rawTraceAccessRef.current = null; + setRawTraceAccess(null); + setInspection(null); + setEvents([]); + setSelectedKey(null); + setSelectedFrameId(null); + setRevealed({}); + const params = new URLSearchParams(window.location.search); + params.delete("inspectRun"); + params.delete("traceView"); + params.delete("traceOperation"); + const suffix = params.toString(); + window.history.replaceState( + window.history.state, + "", + `${window.location.pathname}${suffix ? `?${suffix}` : ""}${window.location.hash}`, + ); + } + onOpenChange(nextOpen); + }; + + return ( + + + +
+ Runner Inspector + {inspection?.trace ? ( + <> + + {traceBadgeLabel(inspection.trace.status, inspection.trace.expiresAt)} + + + {capturedProviders.join(", ") || inspection.trace.provider} + + + ) : null} +
+ + Correlate exact provider traffic with every interpretation stage, canonical PRP event, and production surface. + +
+ {VIEW_OPTIONS.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+
+ +
+ {error ?
{error}
: null} + {!loading && !error && canInspectRaw === false ? ( +
+

Raw provider traces require an instance administrator.

+

Canonical PRP events and presentation decisions remain inspectable below.

+
+ ) : null} + {!loading && !error && canInspectRaw === true && !inspection?.trace ? ( +
+
+

Raw provider capture was off for this run.

+

Canonical PRP events and persisted presentation decisions remain available below.

+
+ {onRerunWithTrace && canInspectRaw === true ? ( + + ) : null} +
+ ) : null} + + {view === "overview" ? ( + +
+ {loading ?

Loading run pipeline…

: null} +
+ + + + + + +
+
+
+

Interpretation outcomes

+
+ {Object.entries(dispositionCounts).length ? Object.entries(dispositionCounts).map(([label, count]) => ( +
+ {label} +
+ {count} +
+ )) :

No interpretation records were persisted.

} +
+
+
+

Recent correlated operations

+
+ {operations.slice(-6).reverse().map((operation) => ( + + ))} +
+
+
+
+

Sensitive debug data

Parsed frames are redacted on the server. Exact reveals and downloads are administrator-only, warned, audited, and automatically expire.

+
+
+
+ ) : null} + + {view === "pipeline" ? ( + <> +
+
setQuery(event.target.value)} placeholder="Search operations, fields, and events" />
+ + + + + +
+
+ +
+ {loading ?

Loading run pipeline…

: null} + {!loading && filteredOperations.length === 0 ?

No operations match these filters.

: null} + {filteredOperations.map((operation) => ( + + ))} +
+
+ + {selectedOperation ? ( +
+
+ {selectedOperation.title}{selectedOperation.frames.length} raw frame{selectedOperation.frames.length === 1 ? "" : "s"}{selectedOperation.interpretations.length} mapping stage{selectedOperation.interpretations.length === 1 ? "" : "s"}{selectedEvents.length} PRP event{selectedEvents.length === 1 ? "" : "s"} +
+
+
1

Provider frames

server-redacted by default
+ {selectedOperation.frames.length > 1 ?
{selectedOperation.frames.map((frame) => )}
: null} + {selectedFrame ? ( +
+
frame {String(selectedFrame.frameId)}{text(selectedFrame.direction).replaceAll("_", " ")}{formatBytes(Number(selectedFrame.byteLength) || 0)}{text(selectedFrame.digest)}
+ + {Array.isArray(selectedFrame.withheldPaths) && selectedFrame.withheldPaths.length > 0 ?
Withheld paths: {selectedFrame.withheldPaths.join(", ")}
: null} + {canInspectRaw === true ? : null} + {revealedFrame ?

Exact unredacted frame

: null} +
+ ) :

This PRP event has no recoverable raw frame.

} +
+
+
2

Interpretation stages

+ {selectedInterpretations.length ? selectedInterpretations.map((entry, index) => ) :

No interpretation stage was recorded for this frame.

} +
+
+
3

Canonical PRP events

+ {selectedEvents.length ? selectedEvents.map((event) => ( +
+
{event.eventType}seq {event.seq}
+
{typedPrpFields(event).map(([label, value]) =>
{label}
{value}
)}
+
Canonical event JSON
+
+ )) :

This provider operation emitted no canonical PRP events.

} +
+
+
4

Production presentation

+ {selectedEvents.length ? selectedEvents.map((event) => { + const decision = visibilityDecision(event); + return
Visible
{decision.visible ? : }{decision.visible ? "yes" : "no"}
Surface
{decision.surface}
Container
{decision.container}
State
{decision.state}
Action
{decision.action}
Reason
{decision.reason}
Reason code
{decision.reasonCode}

Production surface preview

; + }) :

No presentation surface was emitted for this operation.

} + {Object.keys(presentationDecision).length > 0 ?
Resolved final response decision
: null} + {verificationCaveats.length > 0 || ignoredAttentionRequests.length > 0 ? ( +
+

Semantic finalization lineage

+

+ Provider-native transport and model-authored tool arguments are separate layers. PRP normalized the model payload, then server policy chose the issue disposition. +

+ {ignoredAttentionRequests.some((candidate) => record(candidate).sourceKind === "environment_constraint") ? ( +

+ environment_constraint was model-authored tool payload data, not a Codex app-server event. It was normalized into a non-blocking verification caveat. +

+ ) : null} +
+
Policy
{text(runResultJson.finalizationPolicyVersion) || "unknown"}
+
Decision reason
{text(runResultJson.finalizationReasonCode) || "unknown"}
+
+
Normalized caveats and ignored requests
+
+ ) : null} +
+
+ ) :
Select a correlated operation to inspect its pipeline.
} +
+
+ + ) : null} + + {view === "trace" ? ( +
+
{frames.length ? [...frames].sort((left, right) => Number(left.frameId) - Number(right.frameId)).map((frame) => ) :

No raw frames were captured for this run.

}
+
{selectedFrame ? <>

Exact trace frame #{String(selectedFrame.frameId)}

{frameMethod(selectedFrame) || text(selectedFrame.direction)}
{canInspectRaw === true ? : null}{revealedFrame ? : null} :

Select a frame from the chronological trace.

}
+
+ ) : null} + + {canInspectRaw === true && inspection?.trace?.runId === runId ? ( +
+

{inspection.trace.frameCount} frames · {formatBytes(inspection.trace.byteCount)} · expires {new Date(inspection.trace.expiresAt).toLocaleString()}

+
+
+ ) : null} +
+
+
+ ); +} diff --git a/ui/src/index.css b/ui/src/index.css index ce07222ebe..7455a5e4d9 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -2367,6 +2367,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --sz-70px: 70px; /* Extracted from ui/src/pages/Secrets.tsx (min-h-[70px]). */ --sz-calc-41: min(520px,calc(100vw - 2rem)); /* Extracted from ui/src/pages/Secrets.tsx (w-[min(520px,calc(100vw-2rem))]). */ --sz-calc-42: min(80vh,34rem); /* Extracted from ui/src/pages/Secrets.tsx (max-h-[min(80vh,34rem)]). */ + --sz-calc-43: min(98vw,90rem); /* RunnerInspector sheet width. */ --sz-14rem: 14rem; /* Extracted from ui/src/pages/TeamCatalog.tsx (max-w-[14rem]). */ --rad-3: 3px; /* Extracted from ui/src/components/ExternalObjectPill.tsx (ring-[3px]). */ --rad-4: 4px; /* Extracted from ui/src/components/IssueChatThread.tsx (rounded-br-[4px]). */ diff --git a/ui/src/lib/agent-config-patch.test.ts b/ui/src/lib/agent-config-patch.test.ts index 46e6e85829..9fac4e16d5 100644 --- a/ui/src/lib/agent-config-patch.test.ts +++ b/ui/src/lib/agent-config-patch.test.ts @@ -52,12 +52,27 @@ function makeOverlay(patch?: Partial): AgentConfigOverlay { identity: {}, adapterConfig: {}, heartbeat: {}, + debug: {}, runtime: {}, ...patch, }; } describe("buildAgentUpdatePatch", () => { + it("merges the agent-scoped provider trace debug setting into runtime config", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ debug: { providerTrace: "raw" } }), + ); + + expect(patch).toMatchObject({ + runtimeConfig: { + heartbeat: { enabled: true, intervalSec: 300 }, + debug: { providerTrace: "raw" }, + }, + }); + }); + it("replaces adapter config and drops env when the last env binding is cleared", () => { const patch = buildAgentUpdatePatch( makeAgent(), diff --git a/ui/src/lib/agent-config-patch.ts b/ui/src/lib/agent-config-patch.ts index cc4d4f1e30..f5f90de8d9 100644 --- a/ui/src/lib/agent-config-patch.ts +++ b/ui/src/lib/agent-config-patch.ts @@ -15,6 +15,7 @@ export interface AgentConfigOverlay { adapterType?: string; adapterConfig: Record; heartbeat: Record; + debug: Record; runtime: Record; modelProfiles?: { cheap?: AgentModelProfileOverlay }; } @@ -60,7 +61,11 @@ export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) const cheapOverlay = overlay.modelProfiles?.cheap; const hasModelProfileChange = cheapOverlay !== undefined; - if (Object.keys(overlay.heartbeat).length > 0 || hasModelProfileChange) { + if ( + Object.keys(overlay.heartbeat).length > 0 + || Object.keys(overlay.debug).length > 0 + || hasModelProfileChange + ) { const existingRc = (agent.runtimeConfig ?? {}) as Record; const nextRuntimeConfig: Record = (patch.runtimeConfig as Record | undefined) ?? { ...existingRc }; @@ -70,6 +75,16 @@ export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) nextRuntimeConfig.heartbeat = { ...existingHb, ...overlay.heartbeat }; } + if (Object.keys(overlay.debug).length > 0) { + const existingDebug = (existingRc.debug ?? {}) as Record; + const nextDebug = omitUndefinedEntries({ ...existingDebug, ...overlay.debug }); + if (Object.keys(nextDebug).length === 0) { + delete nextRuntimeConfig.debug; + } else { + nextRuntimeConfig.debug = nextDebug; + } + } + if (hasModelProfileChange) { const existingProfiles = ((existingRc.modelProfiles ?? {}) as Record); const existingCheap = ((existingProfiles.cheap ?? {}) as Record); diff --git a/ui/src/lib/honeycomb-run-link.test.ts b/ui/src/lib/honeycomb-run-link.test.ts new file mode 100644 index 0000000000..c563691a60 --- /dev/null +++ b/ui/src/lib/honeycomb-run-link.test.ts @@ -0,0 +1,40 @@ +import { webcrypto } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + HONEYCOMB_RUN_HASH_ATTRIBUTE, + buildHoneycombRunQueryUrl, + hashPaperclipRunId, +} from "./honeycomb-run-link"; + +describe("Honeycomb run links", () => { + it("uses the same 12-character SHA-256 run hash as the tracer", async () => { + await expect( + hashPaperclipRunId("abc", webcrypto.subtle as unknown as SubtleCrypto), + ).resolves.toBe("ba7816bf8f01"); + }); + + it("builds an exact task.run query with clickable trace-id breakdowns", () => { + const url = new URL(buildHoneycombRunQueryUrl("ba7816bf8f01")); + const query = JSON.parse(url.searchParams.get("query") ?? "null") as { + calculations: Array<{ op: string }>; + breakdowns: string[]; + filters: Array<{ column: string; op: string; value: string }>; + }; + + expect(url.origin).toBe("https://ui.honeycomb.io"); + expect(url.pathname).toBe( + "/paperclip/environments/test/datasets/paperclip/", + ); + expect(query.calculations).toEqual([{ op: "COUNT" }]); + expect(query.breakdowns).toEqual(["trace.trace_id"]); + expect(query.filters).toEqual([ + { column: "service.name", op: "=", value: "paperclip" }, + { column: "name", op: "=", value: "task.run" }, + { + column: HONEYCOMB_RUN_HASH_ATTRIBUTE, + op: "=", + value: "ba7816bf8f01", + }, + ]); + }); +}); diff --git a/ui/src/lib/honeycomb-run-link.ts b/ui/src/lib/honeycomb-run-link.ts new file mode 100644 index 0000000000..9efb8218ed --- /dev/null +++ b/ui/src/lib/honeycomb-run-link.ts @@ -0,0 +1,47 @@ +const HONEYCOMB_QUERY_URL = + "https://ui.honeycomb.io/paperclip/environments/test/datasets/paperclip/"; + +export const HONEYCOMB_RUN_HASH_ATTRIBUTE = "paperclip.task.run.run_id"; + +export async function hashPaperclipRunId( + runId: string, + subtle: SubtleCrypto = globalThis.crypto.subtle, +): Promise { + const digest = await subtle.digest( + "SHA-256", + new TextEncoder().encode(runId), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ) + .join("") + .slice(0, 12); +} + +export function buildHoneycombRunQueryUrl(runIdHash: string): string { + const query = { + time_range: 60 * 60 * 24 * 7, + granularity: 0, + calculations: [{ op: "COUNT" }], + breakdowns: ["trace.trace_id"], + filters: [ + { column: "service.name", op: "=", value: "paperclip" }, + { column: "name", op: "=", value: "task.run" }, + { column: HONEYCOMB_RUN_HASH_ATTRIBUTE, op: "=", value: runIdHash }, + ], + filter_combination: "AND", + orders: [], + havings: [], + limit: 100, + }; + const url = new URL(HONEYCOMB_QUERY_URL); + url.searchParams.set("query", JSON.stringify(query)); + return url.toString(); +} + +export async function buildHoneycombRunUrl( + runId: string, + subtle: SubtleCrypto = globalThis.crypto.subtle, +): Promise { + return buildHoneycombRunQueryUrl(await hashPaperclipRunId(runId, subtle)); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 213016d4ee..54c0dd3bbd 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -113,6 +113,12 @@ import { } from "@paperclipai/shared"; import { ResponsibleUserDenialNotice } from "../components/ResponsibleUserDenialNotice"; import { RunWorkspaceRecoverySurface } from "../components/RunWorkspaceRecoverySurface"; +import { RunnerInspector } from "../components/RunnerInspector"; +import { HoneycombRunLink } from "../components/HoneycombRunLink"; +import { + ProviderTraceStatusBadge, + runRequestedProviderTrace, +} from "../components/ProviderTraceStatusBadge"; import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui"; import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils"; import { agentRouteRef } from "../lib/utils"; @@ -792,6 +798,14 @@ export function AgentDetail() { const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; const agentLookupRef = agent?.id ?? routeAgentRef; const resolvedAgentId = agent?.id ?? null; + const { data: boardAccess } = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + retry: false, + }); + const canUseProviderTrace = + boardAccess?.source === "local_implicit" || + boardAccess?.isInstanceAdmin === true; const membershipsQuery = useResourceMemberships(resolvedCompanyId); const membershipMutation = useResourceMembershipMutation(resolvedCompanyId); const agentMembershipState = resolvedAgentId @@ -1309,6 +1323,7 @@ export function AgentDetail() { companyId={resolvedCompanyId} assignLabel="Assign Task" runLabel="Run Heartbeat" + canRunWithProviderTrace={canUseProviderTrace} actionsDisabled={agentAction.isPending} workActionsDisabled={hasInvalidOrgChain} workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" @@ -1507,6 +1522,7 @@ export function AgentDetail() { onCancelActionChange={setCancelConfigAction} onSavingChange={setConfigSaving} updatePermissions={updatePermissions} + canConfigureProviderTrace={canUseProviderTrace} /> )} @@ -1957,6 +1973,7 @@ function AgentConfigurePage({ onCancelActionChange, onSavingChange, updatePermissions, + canConfigureProviderTrace, }: { agent: AgentDetailRecord; agentId: string; @@ -1966,6 +1983,7 @@ function AgentConfigurePage({ onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; + canConfigureProviderTrace: boolean; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -2000,6 +2018,7 @@ function AgentConfigurePage({ companyId={companyId} hidePromptTemplate hideInstructionsFile + canConfigureProviderTrace={canConfigureProviderTrace} />

API Keys

@@ -2073,6 +2092,7 @@ function ConfigurationTab({ hidePromptTemplate, hideInstructionsFile, content = "configuration", + canConfigureProviderTrace = false, }: { agent: AgentDetailRecord; companyId?: string; @@ -2084,6 +2104,7 @@ function ConfigurationTab({ hidePromptTemplate?: boolean; hideInstructionsFile?: boolean; content?: "configuration" | "secrets"; + canConfigureProviderTrace?: boolean; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -2187,6 +2208,7 @@ function ConfigurationTab({ hideInstructionsFile={hideInstructionsFile} content={content} sectionLayout="cards" + canConfigureProviderTrace={canConfigureProviderTrace} /> {content === "configuration" ? (

@@ -3276,6 +3298,29 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } ), }); const run = hydratedRun ?? initialRun; + const { data: boardAccess } = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + retry: false, + }); + const canUseProviderTrace = + boardAccess?.source === "local_implicit" || + boardAccess?.isInstanceAdmin === true; + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const paperclipDeveloperMode = + experimentalSettings?.enablePaperclipDeveloperMode === true; + const { data: providerTraceRows } = useQuery({ + queryKey: queryKeys.providerTraceMetadata(run.companyId, [run.id]), + queryFn: () => heartbeatsApi.providerTraceMetadata(run.companyId, [run.id]), + enabled: canUseProviderTrace, + retry: false, + refetchInterval: + run.status === "running" || run.status === "queued" ? 3000 : false, + }); + const providerTraceMetadata = providerTraceRows?.[0] ?? null; const metrics = runMetrics(run); const { data: userDirectory } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(run.companyId), @@ -3292,6 +3337,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } }, [run.responsibleUserId, userDirectory]); const responsibleDenialCode = isResponsibleUserDenialCode(run.errorCode) ? run.errorCode : null; const [sessionOpen, setSessionOpen] = useState(false); + const [inspectorOpen, setInspectorOpen] = useState(false); const [claudeLoginResult, setClaudeLoginResult] = useState(null); useEffect(() => { @@ -3372,6 +3418,27 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } }, }); + const rerunWithTrace = useMutation({ + mutationFn: async () => { + const result = await agentsApi.wakeup(run.agentId, { + source: "on_demand", + triggerDetail: "manual", + reason: "rerun_with_provider_trace", + payload: retryPayload, + debug: { providerTrace: "raw" }, + }, run.companyId); + if (!("id" in result)) { + throw new Error(result.message ?? "Trace re-run was skipped."); + } + return result; + }, + onSuccess: (newRun) => { + setInspectorOpen(false); + queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) }); + navigate(`/agents/${agentRouteId}/runs/${newRun.id}`); + }, + }); + const { data: touchedIssues } = useQuery({ queryKey: queryKeys.runIssues(run.id), queryFn: () => activityApi.issuesForRun(run.id), @@ -3442,8 +3509,13 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }

{/* Left column: status + timing */}
-
+
+ {(run.status === "running" || run.status === "queued") && ( + + {canUseProviderTrace && !["queued", "running"].includes(run.status) ? ( + + ) : null}
{/* Adapter type · provider · model */} {(() => { @@ -3761,6 +3858,17 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } {/* Log viewer */} + rerunWithTrace.mutate() + : undefined + } + />
); } diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 01c90c944f..33394acaf9 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -6,6 +6,7 @@ import { builtInAgentsApi, type BuiltInAgentState } from "../api/builtInAgents"; import { environmentsApi } from "../api/environments"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; +import { accessApi } from "../api/access"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -200,6 +201,14 @@ export function Agents() { const [view, setView] = useState<"list" | "org">("org"); const forceListView = isMobile; const effectiveView: "list" | "org" = forceListView ? "list" : view; + const { data: boardAccess } = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + retry: false, + }); + const canUseProviderTrace = + boardAccess?.source === "local_implicit" || + boardAccess?.isInstanceAdmin === true; const { data: instanceSettings } = useQuery({ queryKey: queryKeys.instance.settings, @@ -456,6 +465,7 @@ export function Agents() { companyId={selectedCompanyId} runLabel="Run Heartbeat" showStatus={false} + canRunWithProviderTrace={canUseProviderTrace} />
{ + await renderPage(); + + expect(container.textContent).toContain("Runner Preview Ingress"); + const toggle = container.querySelector( + RUNNER_PREVIEW_INGRESS_TOGGLE_SELECTOR, + ); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ + enableRunnerPreviewIngress: true, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + }); + it("renders and patches the Classic Task Interface experimental toggle on and off", async () => { await renderPage(); @@ -615,6 +640,28 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(toggle?.getAttribute("aria-checked")).toBe("true"); }); + it("renders and patches Paperclip Developer Mode", async () => { + await renderPage(); + + expect(container.textContent).toContain("Paperclip Developer Mode"); + expect(container.textContent).toContain("including Honeycomb trace queries on run pages"); + + const toggle = container.querySelector( + PAPERCLIP_DEVELOPER_MODE_TOGGLE_SELECTOR, + ); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ + enablePaperclipDeveloperMode: true, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + }); + it("removes the auto-recovery confirmation overlay after enabling only", async () => { mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(emptyRecoveryPreview()); await renderPage(); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 284fed071b..55e0c37e3a 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -362,6 +362,8 @@ export function InstanceExperimentalSettings() { getWorktreeInstanceId(), ); const enableEnvironments = experimentalQuery.data?.enableEnvironments === true; + const enableRunnerPreviewIngress = + experimentalQuery.data?.enableRunnerPreviewIngress === true; const enableManagedSandboxOnly = experimentalQuery.data?.enableManagedSandboxOnly === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; const enableApps = experimentalQuery.data?.enableApps === true; @@ -387,6 +389,8 @@ export function InstanceExperimentalSettings() { const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true; const enableCases = experimentalQuery.data?.enableCases === true; const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true; + const enablePaperclipDeveloperMode = + experimentalQuery.data?.enablePaperclipDeveloperMode === true; const enableSimplifiedEnglishInteractions = experimentalQuery.data?.enableSimplifiedEnglishInteractions === true; const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true; @@ -715,6 +719,32 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle managed environment only experimental setting" /> + + toggleMutation.mutate({ enablePaperclipDeveloperMode: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enablePaperclipDeveloperMode" + managed={managedKeys.enablePaperclipDeveloperMode} + ariaLabel="Toggle Paperclip developer mode experimental setting" + /> + + + toggleMutation.mutate({ enableRunnerPreviewIngress: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableRunnerPreviewIngress" + managed={managedKeys.enableRunnerPreviewIngress} + ariaLabel="Toggle runner preview ingress experimental setting" + /> + {inWorktree ? (
diff --git a/ui/src/pages/agent-skills/AgentSkillsTab.tsx b/ui/src/pages/agent-skills/AgentSkillsTab.tsx index f4057d4b11..06cb049fde 100644 --- a/ui/src/pages/agent-skills/AgentSkillsTab.tsx +++ b/ui/src/pages/agent-skills/AgentSkillsTab.tsx @@ -345,6 +345,16 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?: const releasePickerActive = betaSkillsEnabled && paperclipReleases.length > 0; const renderRow = (row: AgentSkillRowData, variant: "enabled" | "available") => { + // Historical assignments stay interactive so the user can remove them. + // The server rejects new assignments and omits stale ones from native + // runtime context, so disabling an enabled row would only trap stale data. + const legacyPaperclipBlocked = agent.adapterType === "paperclip_runner" + && variant === "available" + && row.key === PAPERCLIP_CORE_SKILL_KEY; + const rowDisabled = unsupported || legacyPaperclipBlocked; + const rowDisabledReason = legacyPaperclipBlocked + ? "Paperclip Runner uses native semantic coordination and cannot attach the legacy Paperclip operational skill." + : unsupportedMessage; const showReleasePicker = releasePickerActive && variant === "enabled" && row.key === PAPERCLIP_CORE_SKILL_KEY; const pinnedVersionId = versionPins[row.key] ?? null; @@ -358,8 +368,8 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?: variant={variant} data={row} checked={variant === "enabled"} - disabled={unsupported} - disabledReason={unsupportedMessage} + disabled={rowDisabled} + disabledReason={rowDisabledReason} onCheckedChange={(next) => toggleSkill(row.key, next)} badge={ showReleasePicker && pinnedRelease ? ( @@ -373,7 +383,7 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?: handleReleaseChange(row.key, versionId)} /> ) : undefined