feat(runner): add administration and observability (#12641)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Administrators need bounded controls for experimental native
execution.
> - The lower stack adds remote Codex execution and the task workspace.
> - Operators need to configure Codex safely and inspect provider
traces.
> - Unsupported providers must not appear as runnable choices.
> - This pull request adds Codex-only administration and observability.
> - The benefit is a default-off operational surface for production
diagnosis.

## Linked Issues or Issue Description

Refs #12640.
Refs #12616.
Refs #12352.

**Subsystem affected**

Agent configuration, instance experimental settings, run ledger,
provider trace inspector, and administrator actions.

**Problem or motivation**

The native runner lacks one safe operator surface for Codex permissions,
lifecycle, raw trace capture, and run inspection. The integration branch
also contains provider choices that the production backend cannot
execute yet.

**Proposed solution**

Expose only the qualified Codex controls. Keep Paperclip Developer Mode
and runner preview ingress off by default. Gate raw trace actions by
administrator access and existing trace authorization.

**Alternatives considered**

Exposing unfinished providers would create configurations that fail at
runtime. Always-on tracing would increase sensitive data and storage
risk.

**Roadmap alignment**

This work supports governed Cloud and Sandbox agents and production
diagnostics.

## Stack

- Base PR: #12640.
- Lower PRs: #12639 and #12638.
- This PR contains only its 54-file administration and observability
delta.
- This is the final feature PR in the Codex production stack.

## What Changed

- Added Codex-only Paperclip Runner permission and lifecycle controls.
- Added bounded warm idle configuration.
- Kept the provider field fixed to Codex.
- Added administrator-only one-run raw trace requests.
- Added a persistent future-run raw trace toggle.
- Added trace status, metadata, ledger, and canonical runner inspection.
- Added JSON-RPC request-origin grouping and finalization lineage.
- Restored the stateful PRP transcript parser and focused projection
tests required by trace inspection.
- Added default-off Paperclip Developer Mode.
- Added Honeycomb run links for authorized developer mode.
- Disabled the legacy operational skill for `paperclip_runner`.
- Did not expose OpenCode, ACPX, Pi, Claude Managed, or AWS runner
choices.
- Did not change migrations, workflows, dependencies, or
`pnpm-lock.yaml`.

## Verification

- GitHub Actions will run UI tests, server tests, repository typecheck,
build, browser tests, security, and policy gates.
- Tests cover Codex configuration defaults and bounds, administrator
trace actions, persistent settings, ledger inspection, trace lineage,
and Honeycomb links.
- Existing server trace authorization and retention tests remain the
backend authority.
- Local tests were not run. The requested verification policy uses
GitHub Actions for this series.
- `git diff --check runner/task-workspace-experience...HEAD` passes.
- The delta contains 54 files.

## Risks

- Raw provider traces can contain sensitive provider data.
- Existing server authorization controls access, reveal, download,
retention, and deletion.
- The UI gates trace actions by administrator access and developer mode.
- All new instance settings remain off by default.
- Fresh Paperclip Runner configuration remains Codex-only.
- Direct adapters and legacy task behavior do not change in this PR.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with GPT-5.6. The work used high-reasoning agent mode,
repository tools, GitHub tools, and parallel code-audit agents.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with Fixes: / Closes /
Refs OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta authored and GitHub committed 2026-09-01 03:41:23 -05:00
1 parent 39206c0096
commit 131f5c4065
52 files changed
+4490 -178

No files matched your search

+3
View File
@@ -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 {
@@ -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<TMode extends string = string> {
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;
}
+3
View File
@@ -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;
@@ -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");
});
});
@@ -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<string, unk
return ac;
}
function paperclipRunnerProvider(value: unknown): "codex" | "opencode" {
return value === "opencode" ? "opencode" : "codex";
}
function openCodePermissionMode(value: unknown): "allow" | "ask" | "deny" {
return value === "ask" || value === "deny" ? value : "allow";
}
/** Build a provider profile accepted by the experimental Rust runner. */
export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string, unknown> {
const config = buildCodexLocalConfig(v);
@@ -91,24 +88,19 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
delete config[unsupportedKey];
}
const schemaValues = v.adapterSchemaValues ?? {};
const provider = paperclipRunnerProvider(schemaValues.provider);
if (provider === "codex") {
return { ...config, provider };
}
const lifecycleMode = schemaValues.lifecycleMode === "warm" ? "warm" : "per_turn";
const configuredIdleTimeoutMs = schemaValues.idleTimeoutMs;
const idleTimeoutMs = typeof configuredIdleTimeoutMs === "number"
&& Number.isSafeInteger(configuredIdleTimeoutMs)
&& configuredIdleTimeoutMs > 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 } : {}),
@@ -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:
@@ -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");
});
});
@@ -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 {
@@ -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)
@@ -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,
@@ -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,
+8
View File
@@ -221,6 +221,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enablePaperclipDeveloperMode: {
title: "Paperclip Developer Mode",
description:
"Show internal Paperclip maintainer tools and observability links, including Honeycomb trace queries on run pages.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
autoRestartDevServerWhenIdle: {
title: "Auto-Restart Dev Server When Idle",
description:
+2
View File
@@ -75,6 +75,8 @@ export interface InstanceExperimentalSettings {
enableDecisions: boolean;
enableGoalsSidebarLink: boolean;
enableServerInfoDebugView: boolean;
/** Shows internal Paperclip maintainer tools and observability links. */
enablePaperclipDeveloperMode: boolean;
/**
* Instructs agents to write user-interaction content (confirmations,
* questions, suggested tasks, checkbox prompts) in ASD-STE100 Simplified
@@ -11,6 +11,13 @@ describe("instance experimental settings validators", () => {
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({});
@@ -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),
@@ -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")
@@ -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();
});
});
@@ -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<Record<string, unknown>>;
agentSvc.list.mockResolvedValue(sourceAgents.map((agent) => ({
...agent,
runtimeConfig: {
...((agent.runtimeConfig ?? {}) as Record<string, unknown>),
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<string, unknown>
| 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<Record<string, unknown>>;
agentSvc.list.mockResolvedValue(sourceAgents.map((agent) => ({
...agent,
runtimeConfig: {
...((agent.runtimeConfig ?? {}) as Record<string, unknown>),
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<string, unknown> | undefined;
expect(runtimeUpdate?.debug).not.toHaveProperty("providerTrace");
});
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
@@ -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);
+8 -4
View File
@@ -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);
+52
View File
@@ -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<string, unknown> {
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,
+12 -2
View File
@@ -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,
+4 -7
View File
@@ -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 =
+2
View File
@@ -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,
@@ -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 ?? [],
@@ -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"]);
});
});
@@ -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<string, unknown>, 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<string, unknown>, 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);
@@ -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, unknown>): string {
return renderToStaticMarkup(
<TooltipProvider>
<CodexLocalConfigFields
mode="edit"
isCreate={false}
adapterType="paperclip_runner"
values={null}
set={null}
config={config}
eff={(_group, _field, original) => original}
mark={() => undefined}
models={[]}
hideInstructionsFile
/>
</TooltipProvider>,
);
}
describe("Paperclip Runner Codex configuration", () => {
it("exposes only the qualified Codex provider and permission modes", () => {
const html = renderRunner({ provider: "opencode" });
expect(html).toContain('disabled=""><option value="codex" selected="">Codex</option>');
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('<option value="untrusted" selected="">Ask for untrusted operations</option>');
});
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)");
});
});
+125 -1
View File
@@ -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({
</select>
</Field>
)}
{runnerManaged && (
<Field
label="Permission mode"
hint={`${codexPermissionCapability.description} Full auto does not widen Paperclip's workspace, network, credential, or planning boundaries.`}
>
<select
className={inputClass}
value={runnerPermissionMode}
onChange={(event) => {
const value = resolvePaperclipRunnerPermissionMode(
"codex",
event.target.value,
) as CodexPermissionMode;
isCreate
? set!({ codexPermissionMode: value })
: mark("adapterConfig", "codexPermissionMode", value);
}}
>
{codexPermissionCapability.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
)}
{runnerManaged && (
<Field
label="Runner lifecycle"
hint="Turn by turn suspends after each run. Warm keeps the same Codex process available between governed runs."
>
<select
className={inputClass}
value={runnerLifecycleMode}
onChange={(event) => {
const value = event.target.value === "warm" ? "warm" : "per_turn";
isCreate
? set!({ paperclipRunnerLifecycleMode: value })
: mark("adapterConfig", "lifecycleMode", value);
}}
>
<option value="per_turn">Turn by turn</option>
<option value="warm">Warm session</option>
</select>
</Field>
)}
{runnerManaged && runnerLifecycleMode === "warm" && (
<Field
label="Warm idle timeout (ms)"
hint="After this much inactivity, runnerd checkpoints and suspends the Codex session. The maximum is 24 hours."
>
{isCreate ? (
<input
type="number"
min={1}
max={PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS}
className={inputClass}
value={runnerIdleTimeoutMs}
onChange={(event) =>
set!({
paperclipRunnerIdleTimeoutMs: resolvePaperclipRunnerIdleTimeoutMs(
Number(event.target.value),
),
})
}
/>
) : (
<DraftNumberInput
value={runnerIdleTimeoutMs}
min={1}
max={PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS}
onCommit={(value) =>
mark(
"adapterConfig",
"idleTimeoutMs",
resolvePaperclipRunnerIdleTimeoutMs(value),
)
}
immediate
className={inputClass}
/>
)}
</Field>
)}
{acpSelected && (
<>
{!managedSandboxOnly && (
@@ -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<string, unknown>, 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<string, unknown>) => 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<string, unknown>, 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<string, unknown>, 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<string, unknown>) => 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<string, unknown>, 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<string, unknown>) => 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" })]);
});
});
+784 -3
View File
@@ -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<string, unknown>;
interface PaperclipRunnerParserState {
assistantDeltaItemIds: Set<string>;
reasoningDeltaItemIds: Set<string>;
resultSummaries: Set<string>;
toolOutputItemIds: Set<string>;
itemChannels: Map<string, "progress" | "final" | "summary" | "detail" | "unknown">;
structuredFinalItemIds: Set<string>;
runtimeRequests: Map<string, Extract<TranscriptEntry, { kind: "runtime_request" }>>;
}
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<string, string> = {
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<TranscriptEntry, { kind: "runtime_request" }> = {
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<PaperclipQuestion["textValidation"]>["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<TranscriptEntry, { kind: "run_result" }> {
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<TranscriptEntry, { kind: "run_terminal" }> {
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,
};
+1
View File
@@ -95,6 +95,7 @@ export interface AgentWakeRequest {
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
forceFreshSession?: boolean;
debug?: { providerTrace: "raw" };
}
function withCompanyScope(path: string, companyId?: string) {
@@ -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 });
+53 -1
View File
@@ -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<string, unknown> | null;
const runtimeDebug =
runtimeConfig && typeof runtimeConfig.debug === "object" && runtimeConfig.debug !== null
? (runtimeConfig.debug as Record<string, unknown>)
: null;
const persistentProviderTrace = runtimeDebug?.providerTrace === "raw";
return (
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
{persistentProviderTrace ? (
<span
className="hidden items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs font-medium text-primary lg:inline-flex"
title="Exact provider traffic will be captured for future runs and retained for up to 24 hours."
>
<Bug className="h-3.5 w-3.5" />
Raw tracing on
</span>
) : null}
<Button
variant="outline"
size={size}
@@ -370,6 +407,21 @@ export function AgentActionButtons({
label={runLabel}
size={size}
/>
{canRunWithProviderTrace && (
<Button
variant="outline"
size={size}
onClick={() => {
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
providerTraceAction.mutate();
}}
disabled={assignAndRunDisabled}
title="Capture exact provider traffic for this run (expires after 24 hours)"
>
<Bug className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Run with provider trace</span>
</Button>
)}
{isError ? (
<ClearErrorButton
onClick={() => agentAction.mutate("clear_error")}
+48 -2
View File
@@ -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<EnvironmentVariablesEditorHandle | null>(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<T>(group: RecordOverlayGroup, field: string, original: T): T {
@@ -451,6 +456,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record<string, unknown>) : {};
const adapterType = isCreate
? props.values.adapterType
@@ -2019,6 +2025,46 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
) : null}
{/* ---- Debugging ---- */}
{!isCreate && canConfigureProviderTrace ? (
<div className={cn(!cards && "border-b border-border")}>
{cards ? (
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Bug className="h-3 w-3" /> Debugging
</h3>
) : (
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
<Bug className="h-3 w-3" /> Debugging
</div>
)}
<div
className={cn(
"border-border bg-accent/30",
cards
? "rounded-lg border p-4"
: "mx-4 mb-4 rounded-md border px-3 py-3",
)}
>
<ToggleField
label="Capture raw provider traces"
hint="Stores exact provider traffic for every future run until disabled. Traces may contain sensitive prompts and tool arguments, are administrator-only, and expire after 24 hours."
checked={eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw"}
onChange={(enabled) =>
mark("debug", "providerTrace", enabled ? "raw" : undefined)
}
/>
{eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw" ? (
<div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-background/60 px-3 py-2 text-xs text-foreground">
<Bug className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Raw tracing is on for future runs. Paperclip keeps at most 64 MiB per run and automatically deletes it after 24 hours.
</span>
</div>
) : null}
</div>
</div>
) : null}
</div>
);
}
@@ -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(<HoneycombRunLink runId="run-123" enabled={false} />);
});
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(<HoneycombRunLink runId="abc" enabled />);
});
await flushReact();
const link = container.querySelector<HTMLAnchorElement>("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");
});
});
+52
View File
@@ -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<string | null>(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 (
<Button asChild variant="ghost" size="xs">
<a
href={href}
target="_blank"
rel="noreferrer"
title="Open this run's task.run trace query in Honeycomb"
>
<ExternalLink />
View in Honeycomb
</a>
</Button>
);
}
+5 -1
View File
@@ -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();
});
+310 -75
View File
@@ -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<string, ProviderTraceMetadata>;
};
type LedgerRun = RunForIssue & {
@@ -109,7 +124,8 @@ const LIVENESS_COPY: Record<RunLivenessState, LivenessCopy> = {
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<Issue["status"]>(["done", "cancelled"]);
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
type RunOutputSilenceLevel = NonNullable<ActiveRunForIssue["outputSilence"]>["level"];
type RunOutputSilenceLevel = NonNullable<
ActiveRunForIssue["outputSilence"]
>["level"];
type RunOutputSilenceCopy = {
label: string;
tone: string;
};
const RUN_OUTPUT_SILENCE_COPY: Partial<Record<RunOutputSilenceLevel, RunOutputSilenceCopy>> = {
const RUN_OUTPUT_SILENCE_COPY: Partial<
Record<RunOutputSilenceLevel, RunOutputSilenceCopy>
> = {
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<Record<RunOutputSilenceLevel, RunOutputSi
};
function asRecord(value: unknown): Record<string, unknown> | 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<string, unknown>;
}
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<LedgerRun, "status" | "isLive">) {
return run.isLive || ACTIVE_RUN_STATUSES.has(run.status);
}
function runSummary(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
function runSummary(
run: LedgerRun,
agentMap: ReadonlyMap<string, Pick<Agent, "name">>,
) {
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<string, Pick<Agent, "name">>) {
return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
function compactAgentName(
run: LedgerRun,
agentMap: ReadonlyMap<string, Pick<Agent, "name">>,
) {
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<string | null>(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<RunForIssue[]>(issueId),
});
const { data: liveRuns } = useQuery({
@@ -433,28 +501,53 @@ export function IssueRunLedger({
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
enabled: hasLiveRuns,
refetchInterval: 3000,
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
placeholderData:
keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(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<ActiveRunForIssue | null>(issueId),
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(
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<string, unknown> = {};
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 (
<IssueRunLedgerContent
@@ -477,9 +612,18 @@ export function IssueRunLedger({
renderActivityEvent={renderActivityEvent}
resolveUserLabel={resolveUserLabel}
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(
companyId,
boardAccess,
)}
watchdogDecisionError={watchdogDecisionError}
onWatchdogDecision={(input) => 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<LedgerRun | null>(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({
<section className="space-y-3" aria-label="Task run ledger">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-sm font-medium text-muted-foreground">Run ledger</h3>
<h3 className="text-sm font-medium text-muted-foreground">
Run ledger
</h3>
<p className="text-xs text-muted-foreground">
{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"
>
<span className="shrink-0 font-mono text-muted-foreground">{child.identifier ?? child.id.slice(0, 8)}</span>
<span className="shrink-0 font-mono text-muted-foreground">
{child.identifier ?? child.id.slice(0, 8)}
</span>
<span className="truncate">{child.title}</span>
<span className="shrink-0 text-muted-foreground">{statusLabel(child.status)}</span>
<span className="shrink-0 text-muted-foreground">
{statusLabel(child.status)}
</span>
</Link>
))}
{children.active.length > 4 ? (
@@ -614,7 +780,9 @@ export function IssueRunLedgerContent({
</p>
<p className="mt-1">
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}
</Link>
{" "}for recovery context.
</Link>{" "}
for recovery context.
</>
) : null}
</p>
@@ -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({
<div className="space-y-1.5">
{feedItems.slice(0, 20).map((item) => {
if (item.kind === "activity") {
return <div key={`activity:${item.id}`}>{renderActivityEvent?.(item.event)}</div>;
return (
<div key={`activity:${item.id}`}>
{renderActivityEvent?.(item.event)}
</div>
);
}
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 (
<article
key={`run:${run.runId}`}
@@ -733,7 +917,8 @@ export function IssueRunLedgerContent({
className="min-w-0 max-w-full truncate text-muted-foreground"
title={`Acting on behalf of ${onBehalfOfLabel}`}
>
on behalf of <span className="text-foreground">{onBehalfOfLabel}</span>
on behalf of{" "}
<span className="text-foreground">{onBehalfOfLabel}</span>
</span>
) : null}
<span className="rounded-md border border-border px-1.5 py-0.5 text-(length:--text-micro) capitalize text-muted-foreground">
@@ -745,6 +930,11 @@ export function IssueRunLedgerContent({
live
</span>
) : null}
<ProviderTraceStatusBadge
trace={providerTraceMetadata.get(run.runId)}
requested={runRequestedProviderTrace(run.contextSnapshot)}
showOff
/>
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
@@ -760,7 +950,9 @@ export function IssueRunLedgerContent({
</span>
) : null}
{continuation ? (
<span className="text-(length:--text-micro) text-muted-foreground">{continuation}</span>
<span className="text-(length:--text-micro) text-muted-foreground">
{continuation}
</span>
) : null}
{retryState ? (
<span
@@ -772,7 +964,8 @@ export function IssueRunLedgerContent({
{retryState.badgeLabel}
</span>
) : null}
{run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? (
{run.outputSilence &&
RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
@@ -785,11 +978,12 @@ export function IssueRunLedgerContent({
{(() => {
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 (
<span
className={cn(
@@ -803,7 +997,16 @@ export function IssueRunLedgerContent({
);
})()}
{sourceResolvedFold ? <SourceResolvedFoldBadge /> : null}
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
<span className="ml-auto shrink-0">
{relativeTime(item.timestamp)}
</span>
<button
type="button"
className="rounded-md border border-border px-1.5 py-0.5 text-(length:--text-micro) text-foreground hover:bg-accent/40"
onClick={() => setInspectedRun(run)}
>
Inspect run
</button>
</div>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
@@ -824,7 +1027,9 @@ export function IssueRunLedgerContent({
{retryState ? (
<div className="rounded-md border border-border/70 bg-accent/20 px-2 py-2 text-xs leading-5 text-muted-foreground">
{retryState.detail ? <p>{retryState.detail}</p> : null}
{retryState.secondary ? <p>{retryState.secondary}</p> : null}
{retryState.secondary ? (
<p>{retryState.secondary}</p>
) : null}
{retryState.retryOfRunId ? (
<p>
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 (
<p className="min-w-0 break-words text-(length:--text-micro) leading-5 text-amber-700 dark:text-amber-300">
{profile.requested === "cheap"
? "Cheap profile fell back to primary"
: `${profile.requested} profile unavailable`}
{": "}
<span className="font-mono">{profile.fallbackReason}</span>
<span className="font-mono">
{profile.fallbackReason}
</span>
</p>
);
})()}
@@ -862,14 +1073,22 @@ export function IssueRunLedgerContent({
{denialCode ? (
<ResponsibleUserDenialNotice
code={denialCode}
userName={run.responsibleUserId ? resolveUserLabel?.(run.responsibleUserId) : null}
userName={
run.responsibleUserId
? resolveUserLabel?.(run.responsibleUserId)
: null
}
/>
) : null}
{run.nextAction ? (
<div className="min-w-0 rounded-md bg-accent/40 px-2 py-1.5 text-xs leading-5">
<span className="font-medium text-foreground">Next action: </span>
<span className="break-words text-muted-foreground">{run.nextAction}</span>
<span className="font-medium text-foreground">
Next action:{" "}
</span>
<span className="break-words text-muted-foreground">
{run.nextAction}
</span>
</div>
) : null}
</article>
@@ -882,6 +1101,22 @@ export function IssueRunLedgerContent({
) : null}
</div>
)}
{inspectedRun ? (
<RunnerInspector
runId={inspectedRun.runId}
run={inspectedRun}
open
onOpenChange={(nextOpen) => {
if (!nextOpen) setInspectedRun(null);
}}
onRerunWithTrace={
!["queued", "running"].includes(inspectedRun.status) &&
onRerunWithTrace
? () => onRerunWithTrace(inspectedRun)
: undefined
}
/>
) : null}
</section>
);
}
@@ -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<string, unknown> | null | undefined,
) {
if (!contextSnapshot) return false;
const debug = contextSnapshot.debug;
return (
typeof debug === "object" &&
debug !== null &&
!Array.isArray(debug) &&
(debug as Record<string, unknown>).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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
label === "Trace off" || label === "Trace deleted" || label === "Trace expired"
? "border-border bg-background text-muted-foreground"
: warning
? "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300"
: "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200",
className,
)}
title={
trace
? `${trace.frameCount} frames · ${trace.byteCount} bytes · expires ${new Date(trace.expiresAt).toLocaleString()}`
: requested
? "This run requested sensitive provider-frame capture."
: "Raw provider-frame capture was disabled for this run."
}
>
<Icon className="h-3 w-3" />
{label}
</span>
);
}
+658
View File
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
onRerunWithTrace={rerun}
/>,
),
);
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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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(
<RunnerInspector
runId="run-2"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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<Blob>();
const pendingDelete = deferred<void>();
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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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(
<RunnerInspector
runId="run-1"
run={{ status: "succeeded", resultJson: null }}
open
onOpenChange={vi.fn()}
/>,
),
);
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");
});
});
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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]). */
+15
View File
@@ -52,12 +52,27 @@ function makeOverlay(patch?: Partial<AgentConfigOverlay>): 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(),
+16 -1
View File
@@ -15,6 +15,7 @@ export interface AgentConfigOverlay {
adapterType?: string;
adapterConfig: Record<string, unknown>;
heartbeat: Record<string, unknown>;
debug: Record<string, unknown>;
runtime: Record<string, unknown>;
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<string, unknown>;
const nextRuntimeConfig: Record<string, unknown> = (patch.runtimeConfig as Record<string, unknown> | 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<string, unknown>;
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<string, unknown>);
const existingCheap = ((existingProfiles.cheap ?? {}) as Record<string, unknown>);
+40
View File
@@ -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",
},
]);
});
});
+47
View File
@@ -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<string> {
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<string> {
return buildHoneycombRunQueryUrl(await hashPaperclipRunId(runId, subtle));
}
+109 -1
View File
@@ -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}
/>
<div>
<h3 className="text-sm font-medium mb-3">API Keys</h3>
@@ -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" ? (
<p className="text-xs text-muted-foreground">
@@ -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<ClaudeLoginResult | null>(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 }
<div className="flex flex-col sm:flex-row">
{/* Left column: status + timing */}
<div className="flex-1 p-4 space-y-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={run.status} />
<ProviderTraceStatusBadge
trace={providerTraceMetadata}
requested={runRequestedProviderTrace(run.contextSnapshot)}
showOff
/>
{(run.status === "running" || run.status === "queued") && (
<Button
variant="ghost"
@@ -3479,6 +3551,31 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
{retryRun.isPending ? "Retrying…" : "Retry"}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() => setInspectorOpen(true)}
>
<Eye className="h-3.5 w-3.5 mr-1" />
Inspect run
</Button>
<HoneycombRunLink
runId={run.id}
enabled={paperclipDeveloperMode && canUseProviderTrace}
/>
{canUseProviderTrace && !["queued", "running"].includes(run.status) ? (
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() => rerunWithTrace.mutate()}
disabled={rerunWithTrace.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
{rerunWithTrace.isPending ? "Starting…" : "Re-run with provider trace"}
</Button>
) : null}
</div>
{/* Adapter type · provider · model */}
{(() => {
@@ -3761,6 +3858,17 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
{/* Log viewer */}
<LogViewer run={run} adapterType={adapterType} />
<ScrollToBottom />
<RunnerInspector
runId={run.id}
run={run}
open={inspectorOpen}
onOpenChange={setInspectorOpen}
onRerunWithTrace={
canUseProviderTrace && !["queued", "running"].includes(run.status)
? () => rerunWithTrace.mutate()
: undefined
}
/>
</div>
);
}
+10
View File
@@ -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}
/>
</div>
<StarToggle
@@ -57,6 +57,8 @@ const DECISIONS_TOGGLE_SELECTOR =
'button[aria-label="Toggle decisions experimental setting"]';
const SERVER_INFO_TOGGLE_SELECTOR =
'button[aria-label="Toggle server info debug view experimental setting"]';
const PAPERCLIP_DEVELOPER_MODE_TOGGLE_SELECTOR =
'button[aria-label="Toggle Paperclip developer mode experimental setting"]';
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
'button[aria-label="Toggle built-in agents experimental setting"]';
const BETA_SKILLS_TOGGLE_SELECTOR =
@@ -68,12 +70,13 @@ const STATUS_CARDS_TOGGLE_SELECTOR =
'button[aria-label="Toggle status cards experimental setting"]';
const AUTO_RECOVERY_TOGGLE_SELECTOR =
'button[aria-label="Toggle task graph liveness auto-recovery"]';
const RUNNER_PREVIEW_INGRESS_TOGGLE_SELECTOR =
'button[aria-label="Toggle runner preview ingress experimental setting"]';
function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
return {
enableEnvironments: false,
enableNativeRunner: false,
enableRunnerPreviewIngress: false,
enableManagedSandboxOnly: false,
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
@@ -93,6 +96,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableGoalsSidebarLink: false,
enableTaskWatchdogs: false,
enableServerInfoDebugView: false,
enablePaperclipDeveloperMode: false,
enableSimplifiedEnglishInteractions: false,
enableSmokeLab: false,
autoRestartDevServerWhenIdle: false,
@@ -102,6 +106,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableWorkspaceDirtyQuarantineRepair: true,
enableOwnerInstanceAdmin: false,
enableSandboxDuplexBridge: false,
enableRunnerPreviewIngress: false,
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
@@ -298,6 +303,26 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
});
});
it("renders and patches the Runner Preview Ingress experimental toggle", async () => {
await renderPage();
expect(container.textContent).toContain("Runner Preview Ingress");
const toggle = container.querySelector<HTMLButtonElement>(
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<HTMLButtonElement>(
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();
@@ -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"
/>
<ExperimentalToggleCard
title="Paperclip Developer Mode"
description="Show internal Paperclip maintainer tools and observability links, including Honeycomb trace queries on run pages."
checked={enablePaperclipDeveloperMode}
onCheckedChange={(checked) =>
toggleMutation.mutate({ enablePaperclipDeveloperMode: checked })
}
disabled={toggleMutation.isPending}
settingKey="enablePaperclipDeveloperMode"
managed={managedKeys.enablePaperclipDeveloperMode}
ariaLabel="Toggle Paperclip developer mode experimental setting"
/>
<ExperimentalToggleCard
title="Runner Preview Ingress"
description="Let Paperclip Runner agents connect through an authenticated sandbox-provider WebSocket preview. Legacy adapters continue using their existing transports."
checked={enableRunnerPreviewIngress}
onCheckedChange={(checked) =>
toggleMutation.mutate({ enableRunnerPreviewIngress: checked })
}
disabled={toggleMutation.isPending}
settingKey="enableRunnerPreviewIngress"
managed={managedKeys.enableRunnerPreviewIngress}
ariaLabel="Toggle runner preview ingress experimental setting"
/>
{inWorktree ? (
<Card className="block p-5">
<div className="flex flex-col gap-4">
+13 -3
View File
@@ -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?:
<AgentSkillReleasePicker
releases={paperclipReleases}
value={pinnedVersionId}
disabled={unsupported || syncSkills.isPending}
disabled={rowDisabled || syncSkills.isPending}
onChange={(versionId) => handleReleaseChange(row.key, versionId)}
/>
) : undefined