Merge public-gh/master into search page branch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-06 06:16:36 -05:00
commit 18e54fa87f
102 files changed
+24118 -455

No files matched your search

+2
View File
@@ -244,6 +244,7 @@ describe("renderCompanyImportPreview", () => {
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
comments: [],
metadata: null,
},
],
@@ -460,6 +461,7 @@ describe("import selection catalog", () => {
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
comments: [],
metadata: null,
},
],
+158 -2
View File
@@ -85,10 +85,11 @@ Worker:
- database namespace via `ctx.db`
- scoped JSON API routes declared with `apiRoutes`
- entities
- projects and project workspaces
- projects, project workspaces, and plugin-managed projects
- companies
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
- agents and agent sessions
- agents, plugin-managed agents, and agent sessions
- plugin-managed routines
- goals
- data/actions
- streams
@@ -145,6 +146,161 @@ handler. The worker receives sanitized headers, route params, query, parsed JSON
body, actor context, and company id. Do not use plugin routes to claim core
paths; they always remain under `/api/plugins/:pluginId/api/*`.
## Managed Paperclip resources
Plugins that provide durable Paperclip business objects should declare them in
the manifest and let the host create or relink the actual records per company.
Do this for plugin-owned agents, plugin-owned projects, and recurring automation.
Do not hide long-lived work behind private plugin state when it should be visible
to the board, scoped to a company, audited, budgeted, and assigned like normal
Paperclip work.
Use these surfaces:
- Managed agents: declare top-level `agents[]` and require
`agents.managed`. Use this when the plugin provides a named worker the board
should see in the org, budget, pause, invoke, and inspect. Managed agents are
normal Paperclip agents with plugin ownership metadata, not background plugin
workers.
- Managed projects: declare top-level `projects[]` and require
`projects.managed`. Use this when the plugin needs a stable company-scoped
project for its issues, routines, or workspace-oriented UI. Keep plugin work
in a project instead of scattering generated issues across unrelated projects.
- Managed routines: declare top-level `routines[]` and require
`routines.managed`. Use this for scheduled, webhook, or manually triggered
jobs that should create visible Paperclip issues. Prefer managed routines over
plugin `jobs[]` for recurring business work; plugin jobs are for plugin
runtime maintenance that does not need a board-visible task trail.
Managed resources are resolved by stable plugin keys, not hardcoded database
ids. In a worker action or data handler, call `ctx.agents.managed.reconcile()`,
`ctx.projects.managed.reconcile()`, and `ctx.routines.managed.reconcile()` for
the current `companyId`. `reconcile()` creates the missing resource, relinks a
recoverable binding, or returns the existing resource. `reset()` reapplies the
manifest defaults when the operator wants to restore the plugin's suggested
configuration.
Declare dependencies between managed resources with refs. A routine can point
at a managed agent through `assigneeRef` and at a managed project through
`projectRef`. Reconcile the referenced agent and project before reconciling the
routine; if a ref is still missing, the routine resolution reports
`missing_refs` instead of guessing.
```ts
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "example.research-plugin",
apiVersion: 1,
version: "0.1.0",
displayName: "Research Plugin",
description: "Creates a managed research agent and scheduled research routine.",
author: "Example",
categories: ["automation"],
capabilities: [
"agents.managed",
"projects.managed",
"routines.managed",
"instance.settings.register",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
agents: [
{
agentKey: "researcher",
displayName: "Researcher",
role: "research",
title: "Research Agent",
capabilities: "Runs recurring research briefs for this company.",
adapterPreference: ["codex_local", "claude_local", "process"],
instructions: {
content: "Follow the Paperclip heartbeat and produce concise research briefs.",
},
},
],
projects: [
{
projectKey: "research",
displayName: "Research",
description: "Recurring research work created by the Research Plugin.",
status: "in_progress",
},
],
routines: [
{
routineKey: "weekly-brief",
title: "Weekly research brief",
description: "Create a short research brief for the board.",
assigneeRef: { resourceKind: "agent", resourceKey: "researcher" },
projectRef: { resourceKind: "project", resourceKey: "research" },
priority: "medium",
triggers: [
{
kind: "schedule",
label: "Monday morning",
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
enabled: false,
},
],
},
],
ui: {
slots: [
{
type: "settingsPage",
id: "settings",
displayName: "Research",
exportName: "SettingsPage",
},
],
},
};
export default manifest;
```
In the worker, expose a small setup action or settings-page action that
reconciles the resources for the selected company:
```ts
import { definePlugin } from "@paperclipai/plugin-sdk";
export default definePlugin({
setup(ctx) {
ctx.actions.register("setup-company", async (params) => {
const companyId = String(params.companyId ?? "");
if (!companyId) throw new Error("companyId is required");
const project = await ctx.projects.managed.reconcile("research", companyId);
const agent = await ctx.agents.managed.reconcile("researcher", companyId);
const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId);
return { project, agent, routine };
});
},
});
```
Authoring rules:
- Keep keys stable once published. Renaming `agentKey`, `projectKey`, or
`routineKey` creates a new managed resource from the host's point of view.
- Use managed agents for plugin-provided labor. Use `ctx.agents.invoke()` or
`ctx.agents.sessions` only after you have a real agent id, either selected by
the operator or resolved from `ctx.agents.managed`.
- Use managed routines for recurring or externally triggered work that should
produce tasks. Schedule, webhook, and API triggers are visible routine
triggers, and each run has the normal Paperclip issue/audit trail.
- Use managed projects to keep plugin-generated work organized and to give
project-scoped plugin UI a stable home. For filesystem access inside a
project, still resolve project workspaces through `ctx.projects`.
- Keep defaults conservative. Managed declarations are suggestions owned by the
plugin, but the resulting resources are normal Paperclip records that the
operator can inspect, pause, and adjust.
UI:
- `usePluginData`
@@ -19,6 +19,7 @@ describe("sandbox adapter execution targets", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
@@ -141,6 +142,92 @@ describe("sandbox adapter execution targets", () => {
}));
});
it("strips inherited host identity env before sandbox execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
vi.stubEnv("TMPDIR", "/var/folders/local/T");
const runner = {
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
remoteCwd: "/workspace",
runner,
};
await runAdapterExecutionTargetProcess("run-1b", target, "agent-cli", ["--json"], {
cwd: "/local/workspace",
env: {
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
TMPDIR: "/var/folders/local/T",
SAFE_VALUE: "visible",
},
timeoutSec: 5,
graceSec: 1,
onLog: async () => {},
});
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
env: {
SAFE_VALUE: "visible",
},
}));
});
it("preserves explicit remote identity env overrides for sandbox execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
const runner = {
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
remoteCwd: "/workspace",
runner,
};
await runAdapterExecutionTargetProcess("run-1c", target, "agent-cli", ["--json"], {
cwd: "/local/workspace",
env: {
PATH: "/custom/remote/bin:/usr/bin",
HOME: "/home/sandbox",
SAFE_VALUE: "visible",
},
timeoutSec: 5,
graceSec: 1,
onLog: async () => {},
});
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
env: {
PATH: "/custom/remote/bin:/usr/bin",
HOME: "/home/sandbox",
SAFE_VALUE: "visible",
},
}));
});
it("treats SSH targets as bridge-only", () => {
const target = {
kind: "remote" as const,
@@ -1,15 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as ssh from "./ssh.js";
import * as serverUtils from "./server-utils.js";
import {
adapterExecutionTargetUsesManagedHome,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
} from "./execution-target.js";
describe("runAdapterExecutionTargetShellCommand", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("quotes remote shell commands with the shared SSH quoting helper", async () => {
@@ -42,16 +45,68 @@ describe("runAdapterExecutionTargetShellCommand", () => {
},
);
// runSshCommand owns profile sourcing and the outer `sh -lc` wrapper —
// the caller passes the raw command string. Wrapping it here would
// double-nest the login shell and re-source profiles after the explicit
// env override, silently undoing identity-var preservation.
expect(runSshCommandSpy).toHaveBeenCalledWith(
expect.objectContaining({
host: "ssh.example.test",
username: "ssh-user",
}),
`sh -lc ${ssh.shellQuote(`printf '%s\\n' "$HOME" && echo "it's ok"`)}`,
`printf '%s\\n' "$HOME" && echo "it's ok"`,
expect.any(Object),
);
});
it("sanitizes inherited host env before SSH shell execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({
stdout: "",
stderr: "",
});
await runAdapterExecutionTargetShellCommand(
"run-1b",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
"env",
{
cwd: "/tmp/local",
env: {
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
SAFE_VALUE: "visible",
},
},
);
expect(runSshCommandSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(String),
expect.objectContaining({
env: {
SAFE_VALUE: "visible",
},
}),
);
});
it("returns a timedOut result when the SSH shell command times out", async () => {
vi.spyOn(ssh, "runSshCommand").mockRejectedValue(Object.assign(new Error("timed out"), {
code: "ETIMEDOUT",
@@ -162,6 +217,71 @@ describe("runAdapterExecutionTargetShellCommand", () => {
});
});
describe("runAdapterExecutionTargetProcess", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("sanitizes inherited host env before SSH process execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
const runChildProcessSpy = vi.spyOn(serverUtils, "runChildProcess").mockResolvedValue({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
});
await runAdapterExecutionTargetProcess(
"run-ssh-process",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
"agent-cli",
["--json"],
{
cwd: "/tmp/local",
env: {
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
SAFE_VALUE: "visible",
},
timeoutSec: 5,
graceSec: 1,
onLog: async () => {},
},
);
expect(runChildProcessSpy).toHaveBeenCalledWith(
"run-ssh-process",
"agent-cli",
["--json"],
expect.objectContaining({
env: {
SAFE_VALUE: "visible",
},
}),
);
});
});
describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => {
afterEach(() => {
vi.restoreAllMocks();
+20 -4
View File
@@ -26,6 +26,7 @@ import {
type RunProcessResult,
type TerminalResultCleanupOptions,
} from "./server-utils.js";
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
import { preferredShellForSandbox } from "./sandbox-shell.js";
export interface AdapterLocalExecutionTarget {
@@ -95,6 +96,8 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle {
stop(): Promise<void>;
}
export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
function parseObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -340,11 +343,12 @@ export async function runAdapterExecutionTargetProcess(
): Promise<RunProcessResult> {
if (target?.kind === "remote" && target.transport === "sandbox") {
const runner = requireSandboxRunner(target);
const env = sanitizeRemoteExecutionEnv(options.env);
return await runner.execute({
command,
args,
cwd: target.remoteCwd,
env: options.env,
env,
stdin: options.stdin,
timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined,
onLog: options.onLog,
@@ -354,9 +358,14 @@ export async function runAdapterExecutionTargetProcess(
});
}
const env =
target?.kind === "remote" && target.transport === "ssh"
? sanitizeRemoteExecutionEnv(options.env)
: options.env;
return await runChildProcess(runId, command, args, {
cwd: options.cwd,
env: options.env,
env,
stdin: options.stdin,
timeoutSec: options.timeoutSec,
graceSec: options.graceSec,
@@ -376,9 +385,16 @@ export async function runAdapterExecutionTargetShellCommand(
const onLog = options.onLog ?? (async () => {});
if (target?.kind === "remote") {
const startedAt = new Date().toISOString();
const env = sanitizeRemoteExecutionEnv(options.env);
if (target.transport === "ssh") {
try {
const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, {
// Pass the raw command — `runSshCommand` owns profile sourcing and
// the outer `sh -lc` wrapper. Wrapping again here would nest a second
// `sh -lc` after the explicit `env KEY=VAL` overrides, re-sourcing
// login profiles AFTER the override and silently undoing any
// identity var (NVM_DIR / PATH / etc.) that a profile re-exports.
const result = await runSshCommand(target.spec, command, {
env,
timeoutMs: (options.timeoutSec ?? 15) * 1000,
});
if (result.stdout) await onLog("stdout", result.stdout);
@@ -435,7 +451,7 @@ export async function runAdapterExecutionTargetShellCommand(
command: shellCommand,
args: ["-lc", command],
cwd: target.remoteCwd,
env: options.env,
env,
timeoutMs: (options.timeoutSec ?? 15) * 1000,
onLog,
});
@@ -0,0 +1,49 @@
const REMOTE_EXECUTION_ENV_IDENTITY_KEYS = new Set([
"PATH",
"HOME",
"PWD",
"SHELL",
"USER",
"LOGNAME",
"NVM_DIR",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_RUNTIME_DIR",
]);
function readEnvValueCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined {
const direct = env[key];
if (typeof direct === "string") return direct;
const upper = key.toUpperCase();
for (const [candidateKey, candidateValue] of Object.entries(env)) {
if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") {
return candidateValue;
}
}
return undefined;
}
export function sanitizeRemoteExecutionEnv(
env: Record<string, string>,
inheritedEnv: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
const normalizedKey = key.toUpperCase();
if (!REMOTE_EXECUTION_ENV_IDENTITY_KEYS.has(normalizedKey)) {
sanitized[key] = value;
continue;
}
const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key);
if (typeof inheritedValue === "string" && inheritedValue === value) {
continue;
}
sanitized[key] = value;
}
return sanitized;
}
@@ -7,10 +7,13 @@ import { afterEach, describe, expect, it } from "vitest";
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
import {
authorizeSandboxCallbackBridgeRequestWithRoutes,
createCommandManagedSandboxCallbackBridgeQueueClient,
createFileSystemSandboxCallbackBridgeQueueClient,
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
sandboxCallbackBridgeDirectories,
syncSandboxCallbackBridgeEntrypoint,
startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js";
@@ -419,6 +422,98 @@ describe("sandbox callback bridge", () => {
);
});
it("serializes remote response writes so stop does not recreate a late orphaned response", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-response-lock-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge response lock test\n", "utf8");
const runner = createExecRunner();
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "bridge", localDir: bridgeAsset.localDir }],
});
const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const bridgeToken = createSandboxCallbackBridgeToken();
const seenRequestIds: string[] = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createCommandManagedSandboxCallbackBridgeQueueClient({
runner,
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
}),
queueDir,
authorizeRequest: async () => null,
handleRequest: async (request) => {
seenRequestIds.push(request.id);
await new Promise((resolve) => setTimeout(resolve, 100));
return {
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, id: request.id }),
};
},
});
cleanupFns.push(async () => {
await worker.stop();
});
const bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
});
cleanupFns.push(async () => {
await bridge.stop();
});
const responsePromise = fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
for (let attempt = 0; attempt < 50 && seenRequestIds.length === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(seenRequestIds).toHaveLength(1);
await worker.stop({ drainTimeoutMs: 10 });
const response = await responsePromise;
expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({
error: "Bridge worker stopped before request could be handled.",
});
await new Promise((resolve) => setTimeout(resolve, 150));
await expect(readdir(directories.responsesDir)).resolves.toEqual([]);
await expect(
readdir(directories.responsesDir).then((entries) =>
entries.filter((entry) => entry.endsWith(".tmp") || entry.includes(".paperclip-write.lock")),
),
).resolves.toEqual([]);
});
it("rejects non-JSON request bodies and full queues at the bridge server", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-server-guards-"));
cleanupDirs.push(rootDir);
@@ -613,4 +708,201 @@ describe("sandbox callback bridge", () => {
error: expect.stringMatching(/JSON|Unexpected|Unterminated/i),
});
});
it("reuses an already-uploaded bridge entrypoint when the remote file hash matches", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-sync-"));
cleanupDirs.push(rootDir);
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const remoteAssetDir = path.posix.join(
remoteWorkspaceDir,
".paperclip-runtime",
"codex",
"paperclip-bridge",
"server",
);
await mkdir(remoteWorkspaceDir, { recursive: true });
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const originalSource = await readFile(bridgeAsset.entrypoint, "utf8");
const expandedSource = `${originalSource}\n// bridge payload padding\n`;
await writeFile(bridgeAsset.entrypoint, expandedSource, "utf8");
const runner = createExecRunner();
const first = await syncSandboxCallbackBridgeEntrypoint({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: remoteAssetDir,
bridgeAsset,
timeoutMs: 30_000,
});
const second = await syncSandboxCallbackBridgeEntrypoint({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: remoteAssetDir,
bridgeAsset,
timeoutMs: 30_000,
});
expect(first.uploaded).toBe(true);
expect(second.uploaded).toBe(false);
await expect(readFile(path.posix.join(remoteAssetDir, "paperclip-bridge-server.mjs"), "utf8")).resolves.toBe(expandedSource);
await expect(
readdir(remoteAssetDir).then((entries) =>
entries.filter(
(entry) =>
entry.endsWith(".paperclip-upload.b64") ||
entry.endsWith(".partial") ||
entry === ".paperclip-bridge-upload.lock",
),
),
).resolves.toEqual([]);
});
it("rejects a corrupted bridge entrypoint upload without committing a torn remote file", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-sync-corrupt-"));
cleanupDirs.push(rootDir);
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const remoteAssetDir = path.posix.join(
remoteWorkspaceDir,
".paperclip-runtime",
"codex",
"paperclip-bridge",
"server",
);
await mkdir(remoteWorkspaceDir, { recursive: true });
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}) =>
await createExecRunner().execute({
...input,
stdin: input.stdin != null ? "" : input.stdin,
}),
};
await expect(
syncSandboxCallbackBridgeEntrypoint({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: remoteAssetDir,
bridgeAsset,
timeoutMs: 30_000,
}),
).rejects.toThrow(/sha mismatch/i);
await expect(readFile(path.posix.join(remoteAssetDir, "paperclip-bridge-server.mjs"), "utf8")).rejects.toThrow();
await expect(
readdir(remoteAssetDir).then((entries) =>
entries.filter(
(entry) =>
entry.endsWith(".paperclip-upload.b64") ||
entry.endsWith(".partial") ||
entry === ".paperclip-bridge-upload.lock",
),
),
).resolves.toEqual([]);
});
it("permits the documented heartbeat surface and denies unrelated routes", () => {
const allowed: Array<{ method: string; path: string }> = [
{ method: "GET", path: "/api/agents/me" },
{ method: "GET", path: "/api/agents/me/inbox-lite" },
{ method: "GET", path: "/api/agents/me/inbox/mine" },
{ method: "GET", path: "/api/agents/agent-1" },
{ method: "GET", path: "/api/agents/agent-1/skills" },
{ method: "POST", path: "/api/agents/agent-1/skills/sync" },
{ method: "PATCH", path: "/api/agents/agent-1/instructions-path" },
{ method: "GET", path: "/api/companies/co-1" },
{ method: "GET", path: "/api/companies/co-1/dashboard" },
{ method: "GET", path: "/api/companies/co-1/agents" },
{ method: "GET", path: "/api/companies/co-1/issues" },
{ method: "GET", path: "/api/companies/co-1/projects" },
{ method: "GET", path: "/api/companies/co-1/goals" },
{ method: "GET", path: "/api/companies/co-1/org" },
{ method: "GET", path: "/api/companies/co-1/approvals" },
{ method: "GET", path: "/api/companies/co-1/routines" },
{ method: "GET", path: "/api/companies/co-1/skills" },
{ method: "GET", path: "/api/projects/proj-1" },
{ method: "GET", path: "/api/goals/goal-1" },
{ method: "GET", path: "/api/issues/issue-1" },
{ method: "GET", path: "/api/issues/issue-1/heartbeat-context" },
{ method: "GET", path: "/api/issues/issue-1/comments" },
{ method: "GET", path: "/api/issues/issue-1/comments/c-1" },
{ method: "POST", path: "/api/issues/issue-1/comments" },
{ method: "GET", path: "/api/issues/issue-1/documents" },
{ method: "GET", path: "/api/issues/issue-1/documents/plan" },
{ method: "GET", path: "/api/issues/issue-1/documents/plan/revisions" },
{ method: "PUT", path: "/api/issues/issue-1/documents/plan" },
{ method: "POST", path: "/api/issues/issue-1/checkout" },
{ method: "POST", path: "/api/issues/issue-1/release" },
{ method: "PATCH", path: "/api/issues/issue-1" },
{ method: "GET", path: "/api/issues/issue-1/approvals" },
{ method: "GET", path: "/api/issues/issue-1/interactions" },
{ method: "GET", path: "/api/issues/issue-1/interactions/inter-1" },
{ method: "POST", path: "/api/issues/issue-1/interactions" },
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/accept" },
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/reject" },
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/respond" },
{ method: "POST", path: "/api/companies/co-1/issues" },
{ method: "GET", path: "/api/approvals/ap-1" },
{ method: "GET", path: "/api/approvals/ap-1/issues" },
{ method: "GET", path: "/api/approvals/ap-1/comments" },
{ method: "POST", path: "/api/approvals/ap-1/comments" },
{ method: "POST", path: "/api/companies/co-1/approvals" },
{ method: "GET", path: "/api/execution-workspaces/ws-1" },
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/start" },
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/stop" },
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/restart" },
{ method: "GET", path: "/api/routines/r-1" },
{ method: "GET", path: "/api/routines/r-1/runs" },
{ method: "POST", path: "/api/companies/co-1/routines" },
{ method: "PATCH", path: "/api/routines/r-1" },
{ method: "POST", path: "/api/routines/r-1/run" },
{ method: "POST", path: "/api/routines/r-1/triggers" },
{ method: "PATCH", path: "/api/routine-triggers/t-1" },
{ method: "DELETE", path: "/api/routine-triggers/t-1" },
];
for (const request of allowed) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBeNull();
}
const denied: Array<{ method: string; path: string }> = [
{ method: "DELETE", path: "/api/secrets" },
// Pin the runtime-services regex to start/stop/restart only — anything
// else (delete, reset, wipe, etc.) must stay denied even if the API
// grows new actions later.
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/delete" },
{ method: "POST", path: "/api/companies/co-1/agents" },
{ method: "POST", path: "/api/agents/agent-1/pause" },
{ method: "POST", path: "/api/agents/agent-1/terminate" },
{ method: "POST", path: "/api/agents/agent-1/keys" },
{ method: "POST", path: "/api/companies/co-1/exports" },
{ method: "POST", path: "/api/companies/co-1/imports/apply" },
{ method: "POST", path: "/api/companies/co-1/archive" },
{ method: "DELETE", path: "/api/issues/issue-1/documents/plan" },
{ method: "DELETE", path: "/api/issues/issue-1/approvals/ap-1" },
{ method: "POST", path: "/api/approvals/ap-1/approve" },
{ method: "POST", path: "/api/approvals/ap-1/reject" },
{ method: "POST", path: "/api/companies/co-1/logo" },
{ method: "GET", path: "/api/companies/co-1/secrets" },
{ method: "PATCH", path: "/api/secrets/secret-1" },
];
for (const request of denied) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe(
`Route not allowed: ${request.method} ${request.path}`,
);
}
});
});
@@ -1,4 +1,4 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -23,15 +23,76 @@ export interface SandboxCallbackBridgeRouteRule {
path: RegExp;
}
// Routes the in-sandbox heartbeat skill is documented to call. The server
// still enforces actor-level permissions on top of this allowlist; the list
// exists to bound the surface area a compromised CLI could reach via the
// reverse bridge. Keep this in sync with the Paperclip skill in
// `skills/paperclip/SKILL.md` and `references/api-reference.md`.
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
// Identity, inbox, agent self-management
{ method: "GET", path: /^\/api\/agents\/me$/ },
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
{ method: "GET", path: /^\/api\/agents\/me\/inbox\/mine$/ },
{ method: "GET", path: /^\/api\/agents\/[^/]+$/ },
{ method: "GET", path: /^\/api\/agents\/[^/]+\/skills$/ },
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
// Company-level reads used to discover work and context
{ method: "GET", path: /^\/api\/companies\/[^/]+$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/dashboard$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/agents$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/issues$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/projects$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/goals$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/org$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/approvals$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/routines$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/skills$/ },
{ method: "GET", path: /^\/api\/projects\/[^/]+$/ },
{ method: "GET", path: /^\/api\/goals\/[^/]+$/ },
// Issue lifecycle: read context, checkout, update, comment, document, release
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+\/revisions$/ },
{ method: "PUT", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/release$/ },
{ method: "PATCH", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ },
// Issue-thread interactions (suggest tasks, ask questions, request confirmation)
{ method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions\/[^/]+\/(?:accept|reject|respond)$/ },
// Subtasks / delegation
{ method: "POST", path: /^\/api\/companies\/[^/]+\/issues$/ },
// Approvals (request, read, comment)
{ method: "GET", path: /^\/api\/approvals\/[^/]+$/ },
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/issues$/ },
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/comments$/ },
{ method: "POST", path: /^\/api\/approvals\/[^/]+\/comments$/ },
{ method: "POST", path: /^\/api\/companies\/[^/]+\/approvals$/ },
// Execution workspaces and runtime services (start/stop/restart dev servers)
{ method: "GET", path: /^\/api\/execution-workspaces\/[^/]+$/ },
{ method: "POST", path: /^\/api\/execution-workspaces\/[^/]+\/runtime-services\/(?:start|stop|restart)$/ },
// Routines (agents manage their own routines and triggers)
{ method: "GET", path: /^\/api\/routines\/[^/]+$/ },
{ method: "GET", path: /^\/api\/routines\/[^/]+\/runs$/ },
{ method: "POST", path: /^\/api\/companies\/[^/]+\/routines$/ },
{ method: "PATCH", path: /^\/api\/routines\/[^/]+$/ },
{ method: "POST", path: /^\/api\/routines\/[^/]+\/run$/ },
{ method: "POST", path: /^\/api\/routines\/[^/]+\/triggers$/ },
{ method: "PATCH", path: /^\/api\/routine-triggers\/[^/]+$/ },
{ method: "DELETE", path: /^\/api\/routine-triggers\/[^/]+$/ },
] as const;
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
@@ -84,6 +145,13 @@ export interface SandboxCallbackBridgeQueueClient {
listJsonFiles(remotePath: string): Promise<string[]>;
readTextFile(remotePath: string): Promise<string>;
writeTextFile(remotePath: string, body: string): Promise<void>;
writeResponseFile?(
responsePath: string,
body: string,
options?: {
requestPath?: string | null;
},
): Promise<{ wrote: boolean }>;
rename(fromPath: string, toPath: string): Promise<void>;
remove(remotePath: string): Promise<void>;
}
@@ -135,12 +203,14 @@ async function runShell(
script: string,
timeoutMs: number,
shellCommand: "bash" | "sh" = "sh",
stdin?: string,
): Promise<RunProcessResult> {
return await runner.execute({
command: shellCommand,
args: ["-lc", script],
cwd,
timeoutMs,
stdin,
});
}
@@ -157,6 +227,43 @@ function base64Chunks(body: string): string[] {
return out;
}
async function pathExists(filePath: string): Promise<boolean> {
return await fs.stat(filePath).then(() => true).catch(() => false);
}
function buildRemotePidLockAcquireScript(lockDirExpr: string, timeoutMessage: string): string[] {
return [
"attempts=0",
`while ! mkdir ${lockDirExpr} 2>/dev/null; do`,
" holder_pid=\"\"",
` if [ -s ${lockDirExpr}/pid ]; then`,
` holder_pid="$(cat ${lockDirExpr}/pid 2>/dev/null || true)"`,
" fi",
" if [ -n \"$holder_pid\" ] && ! kill -0 \"$holder_pid\" 2>/dev/null; then",
` rm -rf ${lockDirExpr}`,
" continue",
" fi",
" attempts=$((attempts + 1))",
" if [ \"$attempts\" -ge 600 ]; then",
` echo ${shellQuote(timeoutMessage)} >&2`,
" exit 1",
" fi",
" sleep 0.05",
"done",
`printf '%s\\n' "$$" > ${lockDirExpr}/pid`,
];
}
function buildRemotePidLockCleanupScript(lockDirExpr: string, cleanupLines: string[]): string[] {
return [
"cleanup() {",
...cleanupLines.map((line) => ` ${line}`),
` rm -rf ${lockDirExpr}`,
"}",
"trap cleanup EXIT INT TERM",
];
}
export function createSandboxCallbackBridgeToken(bytes = DEFAULT_BRIDGE_TOKEN_BYTES): string {
return randomBytes(bytes).toString("base64url");
}
@@ -254,6 +361,80 @@ export function createFileSystemSandboxCallbackBridgeQueueClient(): SandboxCallb
await fs.mkdir(path.posix.dirname(remotePath), { recursive: true });
await fs.writeFile(remotePath, body, "utf8");
},
writeResponseFile: async (responsePath, body, options = {}) => {
const responseDir = path.posix.dirname(responsePath);
const tempPath = `${responsePath}.tmp`;
const lockDir = `${responsePath}.paperclip-write.lock`;
const lockPidFile = `${lockDir}/pid`;
if (options.requestPath) {
const requestExists = await pathExists(options.requestPath);
if (!requestExists) {
return { wrote: false };
}
}
await fs.mkdir(responseDir, { recursive: true });
// PID-liveness mkdir-mutex: mirrors the shell-based bridge mutex so a
// crashed holder (SIGKILL / OOM) doesn't deadlock subsequent writers
// for the full timeout window.
let attempts = 0;
while (true) {
try {
await fs.mkdir(lockDir);
await fs.writeFile(lockPidFile, `${process.pid}\n`, "utf8");
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code !== "EEXIST") {
throw error;
}
let holderPid: number | null = null;
try {
const raw = await fs.readFile(lockPidFile, "utf8");
const parsed = Number.parseInt(raw.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) holderPid = parsed;
} catch {
// pid file missing or unreadable — treat as stale lock
}
let holderAlive = false;
if (holderPid !== null) {
try {
process.kill(holderPid, 0);
holderAlive = true;
} catch {
holderAlive = false;
}
}
if (!holderAlive) {
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
continue;
}
attempts += 1;
if (attempts >= 600) {
throw new Error("Timed out acquiring sandbox callback bridge response lock.");
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
try {
if (options.requestPath) {
const requestExists = await pathExists(options.requestPath);
if (!requestExists) {
return { wrote: false };
}
}
const responseExists = await pathExists(responsePath);
if (responseExists) {
return { wrote: false };
}
await fs.writeFile(tempPath, body, "utf8");
await fs.rename(tempPath, responsePath);
return { wrote: true };
} finally {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
}
},
rename: async (fromPath, toPath) => {
await fs.mkdir(path.posix.dirname(toPath), { recursive: true });
await fs.rename(fromPath, toPath);
@@ -324,6 +505,53 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
`base64 -d < ${shellQuote(tempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
);
},
writeResponseFile: async (responsePath, body, options = {}) => {
const responseDir = path.posix.dirname(responsePath);
const tempPath = `${responsePath}.tmp`;
const lockDir = `${responsePath}.paperclip-write.lock`;
const requestPath = options.requestPath?.trim() || "";
const result = await runShell(
input.runner,
input.remoteCwd,
[
"set -eu",
`response_dir=${shellQuote(responseDir)}`,
`response_path=${shellQuote(responsePath)}`,
`temp_path=${shellQuote(tempPath)}`,
`lock_dir=${shellQuote(lockDir)}`,
`request_path=${shellQuote(requestPath)}`,
"mkdir -p \"$response_dir\"",
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge response lock."),
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
"rm -f \"$temp_path\"",
]),
"if [ -n \"$request_path\" ] && [ ! -f \"$request_path\" ]; then",
" printf '{\"wrote\":false}\\n'",
" exit 0",
"fi",
"if [ -f \"$response_path\" ]; then",
" printf '{\"wrote\":false}\\n'",
" exit 0",
"fi",
"cat > \"$temp_path\"",
"mv \"$temp_path\" \"$response_path\"",
"printf '{\"wrote\":true}\\n'",
].join("\n"),
timeoutMs,
shellCommand,
body,
);
requireSuccessfulResult(`write bridge response ${responsePath}`, result);
try {
return {
wrote: JSON.parse(result.stdout.trim())?.wrote === true,
};
} catch (error) {
throw new Error(
`Sandbox callback bridge response write wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
rename: async (fromPath, toPath) => {
await runChecked(
`rename ${fromPath}`,
@@ -338,11 +566,17 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
async function writeBridgeResponse(
client: SandboxCallbackBridgeQueueClient,
requestPath: string,
responsePath: string,
response: SandboxCallbackBridgeResponse,
) {
const body = `${JSON.stringify(response)}\n`;
if (client.writeResponseFile) {
await client.writeResponseFile(responsePath, body, { requestPath });
return;
}
const tempPath = `${responsePath}.tmp`;
await client.writeTextFile(tempPath, `${JSON.stringify(response)}\n`);
await client.writeTextFile(tempPath, body);
await client.rename(tempPath, responsePath);
}
@@ -386,7 +620,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
request = JSON.parse(raw) as SandboxCallbackBridgeRequest;
} catch {
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: requestId,
status: 400,
headers: { "content-type": "application/json" },
@@ -399,7 +633,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
const denialReason = await authorizeRequest(request);
if (denialReason) {
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: request.id,
status: 403,
headers: { "content-type": "application/json" },
@@ -416,7 +650,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
if (Buffer.byteLength(responseBody, "utf8") > maxBodyBytes) {
throw new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
}
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: request.id,
status: result.status,
headers: result.headers ?? {},
@@ -427,7 +661,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
console.warn(
`[paperclip] sandbox callback bridge handler failed for ${request.id}: ${error instanceof Error ? error.message : String(error)}`,
);
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: request.id,
status: 502,
headers: { "content-type": "application/json" },
@@ -450,7 +684,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
try {
const raw = await input.client.readTextFile(requestPath);
const parsed = JSON.parse(raw) as Partial<SandboxCallbackBridgeRequest>;
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : requestId,
status: 503,
headers: { "content-type": "application/json" },
@@ -517,6 +751,99 @@ export async function startSandboxCallbackBridgeWorker(input: {
};
}
export async function syncSandboxCallbackBridgeEntrypoint(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
assetRemoteDir: string;
bridgeAsset: SandboxCallbackBridgeAsset;
timeoutMs?: number | null;
shellCommand?: "bash" | "sh" | null;
}): Promise<{ remoteEntrypoint: string; sha256: string; uploaded: boolean }> {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const shellCommand = preferredShellForSandbox(input.shellCommand);
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
const remoteEntrypointPartial = `${remoteEntrypoint}.partial`;
const remoteUploadPath = `${remoteEntrypoint}.paperclip-upload.b64`;
const remoteLockDir = path.posix.join(input.assetRemoteDir, ".paperclip-bridge-upload.lock");
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
const entrypointBase64 = toBuffer(Buffer.from(entrypointSource, "utf8")).toString("base64");
const sha256 = createHash("sha256").update(entrypointSource, "utf8").digest("hex");
const syncResult = await runShell(
input.runner,
input.remoteCwd,
[
"set -eu",
`remote_dir=${shellQuote(input.assetRemoteDir)}`,
`remote_path=${shellQuote(remoteEntrypoint)}`,
`remote_partial=${shellQuote(remoteEntrypointPartial)}`,
`remote_upload=${shellQuote(remoteUploadPath)}`,
`lock_dir=${shellQuote(remoteLockDir)}`,
`expected_sha=${shellQuote(sha256)}`,
"hash_file() {",
" if command -v sha256sum >/dev/null 2>&1; then",
" sha256sum \"$1\" | awk '{print $1}'",
" return 0",
" fi",
" if command -v shasum >/dev/null 2>&1; then",
" shasum -a 256 \"$1\" | awk '{print $1}'",
" return 0",
" fi",
" return 127",
"}",
"mkdir -p \"$remote_dir\"",
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge upload lock."),
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
"rm -f \"$remote_upload\" \"$remote_partial\"",
]),
"current_sha=\"\"",
"if [ -f \"$remote_path\" ]; then",
" current_sha=\"$(hash_file \"$remote_path\" 2>/dev/null)\" || current_sha=\"\"",
"fi",
"if [ -n \"$current_sha\" ] && [ \"$current_sha\" = \"$expected_sha\" ]; then",
" printf '{\"uploaded\":false}\\n'",
" exit 0",
"fi",
"rm -f \"$remote_upload\" \"$remote_partial\"",
"cat > \"$remote_upload\"",
"base64 -d < \"$remote_upload\" > \"$remote_partial\"",
// Verify upload integrity. If neither sha256sum nor shasum is on PATH
// (minimal Alpine/scratch images), surface the missing-tool error
// instead of a misleading "sha mismatch" — the verify step is then
// best-effort and we trust base64-decode + atomic rename below.
"if partial_sha=\"$(hash_file \"$remote_partial\" 2>/dev/null)\"; then",
" if [ \"$partial_sha\" != \"$expected_sha\" ]; then",
" echo \"Sandbox callback bridge entrypoint upload sha mismatch.\" >&2",
" exit 1",
" fi",
"else",
" echo \"Sandbox callback bridge entrypoint sha verify skipped: no sha256sum/shasum on remote.\" >&2",
"fi",
"mv \"$remote_partial\" \"$remote_path\"",
"printf '{\"uploaded\":true}\\n'",
].join("\n"),
timeoutMs,
shellCommand,
entrypointBase64,
);
requireSuccessfulResult("sync sandbox callback bridge entrypoint", syncResult);
let uploaded = false;
try {
uploaded = JSON.parse(syncResult.stdout.trim())?.uploaded === true;
} catch (error) {
throw new Error(
`Sandbox callback bridge sync wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
return {
remoteEntrypoint,
sha256,
uploaded,
};
}
export async function startSandboxCallbackBridgeServer(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
@@ -537,17 +864,17 @@ export async function startSandboxCallbackBridgeServer(input: {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const shellCommand = preferredShellForSandbox(input.shellCommand);
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
let remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
if (input.bridgeAsset) {
const assetClient = createCommandManagedSandboxCallbackBridgeQueueClient({
const assetSync = await syncSandboxCallbackBridgeEntrypoint({
runner: input.runner,
remoteCwd: input.remoteCwd,
assetRemoteDir: input.assetRemoteDir,
bridgeAsset: input.bridgeAsset,
timeoutMs,
shellCommand,
});
await assetClient.makeDir(input.assetRemoteDir);
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
await assetClient.writeTextFile(remoteEntrypoint, entrypointSource);
remoteEntrypoint = assetSync.remoteEntrypoint;
}
const env = buildSandboxCallbackBridgeEnv({
queueDir: input.queueDir,
+4 -46
View File
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
import { redactCommandText } from "./command-redaction.js";
import type {
@@ -1039,54 +1040,11 @@ function quoteForCmd(arg: string) {
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
}
const SSH_REMOTE_ENV_IDENTITY_KEYS = new Set([
"PATH",
"HOME",
"PWD",
"SHELL",
"USER",
"LOGNAME",
"NVM_DIR",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_RUNTIME_DIR",
]);
function readEnvValueCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined {
const direct = env[key];
if (typeof direct === "string") return direct;
const upper = key.toUpperCase();
for (const [candidateKey, candidateValue] of Object.entries(env)) {
if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") {
return candidateValue;
}
}
return undefined;
}
export function sanitizeSshRemoteEnv(
env: Record<string, string>,
inheritedEnv: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
const normalizedKey = key.toUpperCase();
if (!SSH_REMOTE_ENV_IDENTITY_KEYS.has(normalizedKey)) {
sanitized[key] = value;
continue;
}
const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key);
if (typeof inheritedValue === "string" && inheritedValue === value) {
continue;
}
sanitized[key] = value;
}
return sanitized;
return sanitizeRemoteExecutionEnv(env, inheritedEnv);
}
function resolveWindowsCmdShell(env: NodeJS.ProcessEnv): string {
@@ -1114,9 +1072,9 @@ async function resolveSpawnTarget(
spec: remote,
command,
args,
env: sanitizeSshRemoteEnv(Object.fromEntries(
env: Object.fromEntries(
Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)),
),
});
return {
command: sshResolved,
@@ -70,6 +70,42 @@ describe("ssh env-lab fixture", () => {
expect(stopped.running).toBe(false);
});
it("forwards stdin to remote SSH commands", async () => {
const support = await getSshEnvLabSupport();
if (!support.supported) {
console.warn(
`Skipping SSH stdin forwarding test: ${support.reason ?? "unsupported environment"}`,
);
return;
}
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixture({ statePath });
const config = await buildSshEnvLabFixtureConfig(started);
const remotePath = path.posix.join(started.workspaceDir, "stdin-forwarded.txt");
await runSshCommand(
config,
`sh -lc 'cat > ${JSON.stringify(remotePath)}'`,
{
stdin: "hello over ssh stdin\n",
timeoutMs: 30_000,
maxBuffer: 256 * 1024,
},
);
const result = await runSshCommand(
config,
`sh -lc 'cat ${JSON.stringify(remotePath)}'`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
expect(result.stdout).toBe("hello over ssh stdin\n");
});
it("does not treat an unrelated reused pid as the running fixture", async () => {
const support = await getSshEnvLabSupport();
if (!support.supported) {
+142 -5
View File
@@ -61,6 +61,7 @@ export function createSshCommandManagedRuntimeRunner(input: {
try {
const result = await runSshCommand(input.spec, remoteCommand, {
stdin: commandInput.stdin,
timeoutMs: commandInput.timeoutMs,
maxBuffer: maxBufferBytes,
});
@@ -205,6 +206,113 @@ async function execFileText(
});
}
async function spawnText(
file: string,
args: string[],
options: {
stdin?: string;
timeout?: number;
maxBuffer?: number;
} = {},
): Promise<SshCommandResult> {
return await new Promise<SshCommandResult>((resolve, reject) => {
const child = spawn(file, args, {
stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
});
const maxBuffer = options.maxBuffer ?? 1024 * 128;
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const finishReject = (error: Error & { stdout?: string; stderr?: string; code?: number | null; killed?: boolean }) => {
if (settled) return;
settled = true;
error.stdout = stdout;
error.stderr = stderr;
error.killed = timedOut;
reject(error);
};
const append = (
streamName: "stdout" | "stderr",
chunk: unknown,
) => {
const text = String(chunk);
if (streamName === "stdout") {
stdout += text;
} else {
stderr += text;
}
if (Buffer.byteLength(stdout, "utf8") > maxBuffer || Buffer.byteLength(stderr, "utf8") > maxBuffer) {
child.kill("SIGTERM");
finishReject(Object.assign(new Error(`Process output exceeded maxBuffer of ${maxBuffer} bytes.`), {
code: null,
}));
}
};
let killEscalation: NodeJS.Timeout | null = null;
const timeout = options.timeout && options.timeout > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
// Escalate to SIGKILL after a 5s grace window so a hung remote
// command that ignores SIGTERM cannot keep the child alive
// indefinitely.
killEscalation = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// child may have already exited between the SIGTERM and the
// escalation — that's fine.
}
}, 5_000);
killEscalation.unref?.();
}, options.timeout)
: null;
const clearTimers = () => {
if (timeout) clearTimeout(timeout);
if (killEscalation) clearTimeout(killEscalation);
};
child.stdout?.on("data", (chunk) => {
append("stdout", chunk);
});
child.stderr?.on("data", (chunk) => {
append("stderr", chunk);
});
child.on("error", (error) => {
clearTimers();
finishReject(Object.assign(error, { code: null }));
});
child.on("close", (code, signal) => {
clearTimers();
if (settled) return;
settled = true;
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(Object.assign(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code ?? -1}`), {
stdout,
stderr,
code,
signal,
killed: timedOut,
}));
});
if (options.stdin != null && child.stdin) {
child.stdin.end(options.stdin);
}
});
}
async function runLocalGit(
localDir: string,
args: string[],
@@ -721,6 +829,8 @@ export async function runSshCommand(
config: SshConnectionConfig,
remoteCommand: string,
options: {
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
maxBuffer?: number;
} = {},
@@ -730,18 +840,45 @@ export async function runSshCommand(
const auth = await createSshAuthArgs(config);
cleanup = auth.cleanup;
const sshArgs = [...auth.args];
const envEntries = Object.entries(options.env ?? {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
for (const [key] of envEntries) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid SSH environment variable key: ${key}`);
}
}
// Mirror buildSshSpawnTarget: source login profiles first, then run
// `env KEY=VAL cmd` so user-supplied identity overrides win over anything
// a profile re-exports. Without this, a remote profile that resets HOME
// / NVM_DIR / etc. would silently undo the explicit env passed in here.
const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`);
const remoteScript = [
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
envArgs.length > 0
? `exec env ${envArgs.join(" ")} sh -c ${shellQuote(remoteCommand)}`
: `exec sh -c ${shellQuote(remoteCommand)}`,
].join(" && ");
sshArgs.push(
"-p",
String(config.port),
`${config.username}@${config.host}`,
remoteCommand,
`sh -lc ${shellQuote(remoteScript)}`,
);
return await execFileText("ssh", sshArgs, {
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
});
return options.stdin != null
? await spawnText("ssh", sshArgs, {
stdin: options.stdin,
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
})
: await execFileText("ssh", sshArgs, {
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
});
} finally {
await cleanup();
}
+10 -1
View File
@@ -1,3 +1,5 @@
import type { AdapterModel } from "@paperclipai/adapter-utils";
export const type = "acpx_local";
export const label = "ACPX (local)";
@@ -6,6 +8,7 @@ export const DEFAULT_ACPX_LOCAL_MODE = "persistent";
export const DEFAULT_ACPX_LOCAL_PERMISSION_MODE = "approve-all";
export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny";
export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0;
export const DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS = 0;
export const acpxAgentOptions = [
{ id: "claude", label: "Claude via ACPX" },
@@ -13,6 +16,8 @@ export const acpxAgentOptions = [
{ id: "custom", label: "Custom ACP command" },
] as const;
export const models: AdapterModel[] = [];
export const agentConfigurationDoc = `# acpx_local agent configuration
Adapter: acpx_local
@@ -30,7 +35,7 @@ Don't use when:
Core fields:
- agent (string, optional): claude, codex, or custom. Defaults to claude.
- agentCommand (string, optional): custom ACP command when agent=custom, or an override for a built-in ACP agent command.
- mode (string, optional): persistent or oneshot. Defaults to persistent.
- mode (string, optional): persistent or oneshot. Defaults to persistent. Paperclip keeps session state persistent and may close the live process between runs.
- cwd (string, optional): default absolute working directory fallback for the agent process.
- permissionMode (string, optional): defaults to approve-all, meaning ACPX permission requests are auto-approved.
- nonInteractivePermissions (string, optional): fallback behavior when ACPX cannot ask interactively. Supported values are deny and fail.
@@ -38,7 +43,11 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction.
- promptTemplate (string, optional): run prompt template.
- bootstrapPromptTemplate (string, optional): first-run bootstrap prompt template.
- model (string, optional): requested ACP model. Claude and Codex ACP agents both receive this through ACP session config.
- effort/modelReasoningEffort (string, optional): requested thinking effort. Claude uses effort; Codex uses modelReasoningEffort/reasoning_effort.
- fastMode (boolean, optional): for ACPX Codex, request Codex fast mode through ACP session config.
- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout.
- warmHandleIdleMs (number, optional): live ACPX process idle window after a successful persistent run. Defaults to 0, meaning Paperclip shuts the process down after each run while retaining ACPX session state.
- env (object, optional): KEY=VALUE environment variables or secret bindings.
Dependency decision:
@@ -1,10 +1,9 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_MODE,
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
acpxAgentOptions,
} from "../index.js";
@@ -26,27 +25,6 @@ export function getConfigSchema(): AdapterConfigSchema {
type: "text",
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
},
{
key: "mode",
label: "Session mode",
type: "select",
default: DEFAULT_ACPX_LOCAL_MODE,
options: [
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One shot" },
],
},
{
key: "permissionMode",
label: "Permission mode",
type: "select",
default: DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
options: [
{ value: "approve-all", label: "Approve all" },
{ value: "default", label: "Approve reads" },
],
hint: "Defaults to maximum permissions. Approve reads grants read-only requests and asks for approval on writes.",
},
{
key: "nonInteractivePermissions",
label: "Non-interactive permissions",
@@ -56,6 +34,7 @@ export function getConfigSchema(): AdapterConfigSchema {
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.",
},
{
key: "cwd",
@@ -70,20 +49,12 @@ export function getConfigSchema(): AdapterConfigSchema {
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
},
{
key: "instructionsFilePath",
label: "Instructions file",
type: "text",
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
},
{
key: "promptTemplate",
label: "Prompt template",
type: "textarea",
},
{
key: "bootstrapPromptTemplate",
label: "Bootstrap prompt template",
type: "textarea",
key: "fastMode",
label: "Codex fast mode",
type: "toggle",
default: false,
hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.",
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
},
{
key: "timeoutSec",
@@ -91,6 +62,13 @@ export function getConfigSchema(): AdapterConfigSchema {
type: "number",
default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
},
{
key: "warmHandleIdleMs",
label: "Warm process idle ms",
type: "number",
default: DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.",
},
{
key: "env",
label: "Environment JSON",
@@ -45,10 +45,10 @@ import {
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
} from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WARM_HANDLE_IDLE_MS = 15 * 60 * 1000;
const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
@@ -59,6 +59,7 @@ interface RuntimeCacheEntry {
handle: AcpRuntimeHandle;
fingerprint: string;
lastUsedAt: number;
cleanupTimer?: NodeJS.Timeout;
}
interface ExecuteDeps {
@@ -79,6 +80,9 @@ interface AcpxPreparedRuntime {
stateDir: string;
permissionMode: "approve-all" | "approve-reads" | "deny-all";
nonInteractivePermissions: "deny" | "fail";
requestedModel: string;
requestedThinkingEffort: string;
fastMode: boolean;
timeoutSec: number;
sessionKey: string;
fingerprint: string;
@@ -504,6 +508,15 @@ function normalizeNonInteractivePermissions(config: Record<string, unknown>): "d
: "deny";
}
function normalizeRequestedThinkingEffort(config: Record<string, unknown>): string {
return (
asString(config.modelReasoningEffort, "") ||
asString(config.reasoningEffort, "") ||
asString(config.thinkingEffort, "") ||
asString(config.effort, "")
).trim();
}
function isCompatibleSession(
params: Record<string, unknown>,
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
@@ -534,6 +547,9 @@ function buildSessionParams(input: {
mode: prepared.mode,
stateDir: prepared.stateDir,
configFingerprint: prepared.fingerprint,
...(prepared.requestedModel ? { model: prepared.requestedModel } : {}),
...(prepared.requestedThinkingEffort ? { thinkingEffort: prepared.requestedThinkingEffort } : {}),
...(prepared.fastMode ? { fastMode: true } : {}),
skills: prepared.skillsIdentity,
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
@@ -644,6 +660,9 @@ async function buildRuntime(input: {
const mode = normalizeMode(config);
const permissionMode = normalizePermissionMode(config);
const nonInteractivePermissions = normalizeNonInteractivePermissions(config);
const requestedModel = asString(config.model, "").trim();
const requestedThinkingEffort = normalizeRequestedThinkingEffort(config);
const fastMode = acpxAgent === "codex" && config.fastMode === true;
const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC);
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
await fs.mkdir(stateDir, { recursive: true });
@@ -741,6 +760,9 @@ async function buildRuntime(input: {
mode,
permissionMode,
nonInteractivePermissions,
requestedModel,
requestedThinkingEffort,
fastMode,
remoteExecutionIdentity,
skillsIdentity,
skillPromptInstructions,
@@ -766,13 +788,16 @@ async function buildRuntime(input: {
stateDir,
permissionMode,
nonInteractivePermissions,
requestedModel,
requestedThinkingEffort,
fastMode,
timeoutSec,
sessionKey,
fingerprint,
agentCommand,
agentRegistry,
remoteExecutionIdentity,
skillPromptInstructions,
skillPromptInstructions,
skillsIdentity: {
...skillsIdentity,
commandNotes: skillCommandNotes,
@@ -780,6 +805,51 @@ async function buildRuntime(input: {
};
}
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
const options: Array<{ key: string; value: string }> = [];
if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel });
if (prepared.requestedThinkingEffort) {
options.push({
key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort",
value: prepared.requestedThinkingEffort,
});
}
if (prepared.fastMode) {
options.push(
{ key: "service_tier", value: "fast" },
{ key: "features.fast_mode", value: "true" },
);
}
return options;
}
async function applySessionConfigOptions(input: {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
prepared: AcpxPreparedRuntime;
onLog: AdapterExecutionContext["onLog"];
}) {
const options = sessionConfigOptions(input.prepared);
if (options.length === 0) return;
if (!input.runtime.setConfigOption) {
const message =
"ACPX runtime does not expose session config controls; upgrade ACPX or remove configured model, effort, and fast mode overrides.";
await input.onLog("stderr", `[paperclip] ${message}\n`);
throw new Error(message);
}
for (const option of options) {
await input.runtime.setConfigOption({
handle: input.handle,
key: option.key,
value: option.value,
});
await input.onLog(
"stdout",
`[paperclip] Applied ACPX ${input.prepared.acpxAgent} config ${option.key}=${option.value}\n`,
);
}
}
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{
prompt: string;
promptMetrics: Record<string, number>;
@@ -951,20 +1021,77 @@ async function cleanupIdleHandles(input: {
now: number;
idleMs: number;
}) {
if (input.idleMs <= 0) return;
const stale: Array<[string, RuntimeCacheEntry]> = [];
for (const entry of input.handles.entries()) {
if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
}
for (const [key, entry] of stale) {
input.handles.delete(key);
await entry.runtime.close({
handle: entry.handle,
await closeWarmHandle({
handles: input.handles,
key,
entry,
reason: "paperclip idle cleanup",
discardPersistentState: false,
}).catch(() => {});
});
}
}
function clearWarmHandleTimer(entry: RuntimeCacheEntry) {
if (!entry.cleanupTimer) return;
clearTimeout(entry.cleanupTimer);
entry.cleanupTimer = undefined;
}
async function closeWarmHandle(input: {
handles: Map<string, RuntimeCacheEntry>;
key: string;
entry: RuntimeCacheEntry;
reason: string;
discardPersistentState?: boolean;
}) {
if (input.handles.get(input.key) === input.entry) {
input.handles.delete(input.key);
}
clearWarmHandleTimer(input.entry);
await input.entry.runtime.close({
handle: input.entry.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState ?? false,
}).catch(() => {});
}
function scheduleIdleHandleCleanup(input: {
handles: Map<string, RuntimeCacheEntry>;
key: string;
entry: RuntimeCacheEntry;
idleMs: number;
now: () => number;
}) {
clearWarmHandleTimer(input.entry);
if (input.idleMs <= 0) return;
const delayMs = Math.max(1, input.entry.lastUsedAt + input.idleMs - input.now());
input.entry.cleanupTimer = setTimeout(() => {
void (async () => {
const current = input.handles.get(input.key);
if (current !== input.entry) return;
const idleForMs = input.now() - input.entry.lastUsedAt;
if (idleForMs < input.idleMs) {
scheduleIdleHandleCleanup(input);
return;
}
await closeWarmHandle({
handles: input.handles,
key: input.key,
entry: input.entry,
reason: "paperclip idle cleanup",
});
})();
}, delayMs);
input.entry.cleanupTimer.unref?.();
}
function warmHandleMatches(
entry: RuntimeCacheEntry | undefined,
runtime: AcpRuntime,
@@ -980,7 +1107,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const prepared = await buildRuntime({ ctx });
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_WARM_HANDLE_IDLE_MS);
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS);
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
const previousParams = parseObject(ctx.runtime.sessionParams);
@@ -996,6 +1123,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
};
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
if (cached) clearWarmHandleTimer(cached);
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
await ctx.onLog(
"stdout",
@@ -1044,7 +1172,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorMessage: message,
...classified,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
clearSession,
resultJson: { phase: "ensure_session" },
summary: message,
@@ -1059,12 +1187,52 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorMessage: "ACPX did not return a runtime session handle.",
errorCode: "acpx_runtime_error",
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
resultJson: { phase: "ensure_session" },
summary: "ACPX did not return a runtime session handle.",
};
}
const sessionHandle = handle;
try {
await applySessionConfigOptions({
runtime,
handle: sessionHandle,
prepared,
onLog: ctx.onLog,
});
} catch (err) {
const classified = classifyError(err);
const message = err instanceof Error ? err.message : String(err);
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
await runtime.close({
handle: sessionHandle,
reason: "paperclip config cleanup",
discardPersistentState: false,
}).catch(() => {});
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: message,
...classified,
provider: "acpx",
model: prepared.requestedModel || null,
clearSession,
resultJson: {
phase: "configure_session",
agent: prepared.acpxAgent,
requestedModel: prepared.requestedModel || null,
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
},
summary: message,
};
}
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
await emitAcpxLog(ctx, {
@@ -1076,6 +1244,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
runtimeSessionName: sessionHandle.runtimeSessionName,
mode: prepared.mode,
permissionMode: prepared.permissionMode,
model: prepared.requestedModel || null,
thinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
});
if (ctx.onMeta) {
await ctx.onMeta({
@@ -1085,6 +1256,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
commandNotes: [
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []),
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
...(Array.isArray(prepared.skillsIdentity.commandNotes)
? prepared.skillsIdentity.commandNotes.filter((note): note is string => typeof note === "string")
: []),
@@ -1130,15 +1304,23 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
const terminal = await turn.result;
if (timeout) clearTimeout(timeout);
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
warmHandles.delete(prepared.sessionKey);
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
await closeWarmHandle({
handles: warmHandles,
key: prepared.sessionKey,
entry: existing,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
});
} else {
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
}).catch(() => {});
}
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
}).catch(() => {});
} else if (prepared.mode === "persistent") {
} else if (prepared.mode === "persistent" && warmIdleMs > 0) {
const existing = warmHandles.get(prepared.sessionKey);
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
await runtime.close({
@@ -1147,13 +1329,37 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
discardPersistentState: false,
}).catch(() => {});
} else {
warmHandles.set(prepared.sessionKey, {
const entry: RuntimeCacheEntry = {
runtime,
handle: sessionHandle,
fingerprint: prepared.fingerprint,
lastUsedAt: now(),
};
warmHandles.set(prepared.sessionKey, entry);
scheduleIdleHandleCleanup({
handles: warmHandles,
key: prepared.sessionKey,
entry,
idleMs: warmIdleMs,
now,
});
}
} else {
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
await closeWarmHandle({
handles: warmHandles,
key: prepared.sessionKey,
entry: existing,
reason: "paperclip completed turn cleanup",
});
} else {
await runtime.close({
handle: sessionHandle,
reason: "paperclip completed turn cleanup",
discardPersistentState: false,
}).catch(() => {});
}
}
const errorMessage = timedOut
@@ -1176,7 +1382,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
billingType: "unknown",
costUsd: null,
resultJson: {
@@ -1184,6 +1390,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
stopReason: terminalStopReason,
permissionMode: prepared.permissionMode,
mode: prepared.mode,
requestedModel: prepared.requestedModel || null,
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
},
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
clearSession,
@@ -1199,7 +1408,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
discardPersistentState: timedOut,
}).catch(() => {});
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
@@ -1211,7 +1422,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
errorMeta: classified.errorMeta,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
clearSession: clearSession || timedOut,
resultJson: { phase: "turn" },
summary: message,
@@ -5,6 +5,7 @@ import {
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
} from "../index.js";
function parseCommaArgs(value: string): string[] {
@@ -80,13 +81,15 @@ function readNumber(value: unknown, fallback: number): number {
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const schemaValues = v.adapterSchemaValues ?? {};
const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT);
const ac: Record<string, unknown> = {
agent: schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT,
agent,
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
nonInteractivePermissions:
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
warmHandleIdleMs: readNumber(schemaValues.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS),
};
for (const key of [
@@ -105,6 +108,11 @@ export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unkn
if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model?.trim()) ac.model = v.model.trim();
if (v.thinkingEffort) {
ac[agent === "codex" ? "modelReasoningEffort" : "effort"] = v.thinkingEffort;
}
if (schemaValues.fastMode === true) ac.fastMode = true;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
@@ -167,7 +167,7 @@ export async function testEnvironment(
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
const sandbox = asBoolean(config.sandbox, false);
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 10));
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 60));
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@@ -290,13 +290,6 @@ export async function testEnvironment(
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
// For remote targets, do NOT spread the host process.env into the
// probe env: it leaks macOS-only paths (HOME=/Users/..., host
// XDG_CONFIG_HOME, TMPDIR, etc.) into the remote shell, which causes
// opencode on the remote box to try to mkdir host paths like /Users.
// Match the pattern used by claude_local / codex_local / gemini_local
// probes: send only the user-configured adapter env across SSH.
const probeEnv = targetIsRemote ? preparedRuntimeConfig.env : runtimeEnv;
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
@@ -305,7 +298,7 @@ export async function testEnvironment(
args,
{
cwd,
env: probeEnv,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
stdin: "Respond with hello.",
+1 -12
View File
@@ -259,17 +259,6 @@ export async function testEnvironment(
args.push("--tools", "read");
if (extraArgs.length > 0) args.push(...extraArgs);
// For remote targets, do NOT spread the host process.env into the probe
// env: it leaks macOS-only PATH, HOME, TMPDIR, etc. into the remote shell.
// In particular the Mac PATH overrides the nvm-sourced PATH that
// buildSshSpawnTarget sets up, which on Linux SSH targets resolves `node`
// to /usr/bin/node (Node 18) instead of nvm's Node 22, causing pi-tui to
// crash with `Invalid regular expression flags` on its /v unicode regex.
// Match the pattern used by claude_local / codex_local / gemini_local /
// opencode_local probes: send only the user-configured adapter env across
// SSH. Local probes still get the full runtimeEnv.
const probeEnv = targetIsRemote ? normalizeEnv(env) : runtimeEnv;
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
@@ -278,7 +267,7 @@ export async function testEnvironment(
args,
{
cwd,
env: probeEnv,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
onLog: async () => {},
@@ -0,0 +1,3 @@
ALTER TABLE "issue_comments" ADD COLUMN "author_type" text;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "presentation" jsonb;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "metadata" jsonb;
File diff suppressed because it is too large. Load diff
+10 -3
View File
@@ -551,15 +551,22 @@
{
"idx": 78,
"version": "7",
"when": 1777821410992,
"tag": "0078_company_search_document_indexes",
"when": 1778004024976,
"tag": "0078_white_darwin",
"breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1777821410992,
"tag": "0079_company_search_document_indexes",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1777849000000,
"tag": "0079_company_search_fuzzystrmatch",
"tag": "0080_company_search_fuzzystrmatch",
"breakpoints": true
}
]
+5 -1
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import type { IssueCommentAuthorType, IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared";
import { pgTable, uuid, text, timestamp, index, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
@@ -12,8 +13,11 @@ export const issueComments = pgTable(
issueId: uuid("issue_id").notNull().references(() => issues.id),
authorAgentId: uuid("author_agent_id").references(() => agents.id),
authorUserId: text("author_user_id"),
authorType: text("author_type").$type<IssueCommentAuthorType>(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
body: text("body").notNull(),
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
+3
View File
@@ -1260,9 +1260,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
id: randomUUID(),
companyId: parentIssue.companyId,
issueId,
authorType: options?.authorAgentId ? "agent" : "system",
authorAgentId: options?.authorAgentId ?? null,
authorUserId: null,
body,
presentation: null,
metadata: null,
createdAt: now,
updatedAt: now,
};
+19
View File
@@ -148,6 +148,25 @@ export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
export const ISSUE_COMMENT_AUTHOR_TYPES = ["user", "agent", "system"] as const;
export type IssueCommentAuthorType = (typeof ISSUE_COMMENT_AUTHOR_TYPES)[number];
export const ISSUE_COMMENT_PRESENTATION_KINDS = ["message", "system_notice"] as const;
export type IssueCommentPresentationKind = (typeof ISSUE_COMMENT_PRESENTATION_KINDS)[number];
export const ISSUE_COMMENT_PRESENTATION_TONES = ["neutral", "info", "success", "warning", "danger"] as const;
export type IssueCommentPresentationTone = (typeof ISSUE_COMMENT_PRESENTATION_TONES)[number];
export const ISSUE_COMMENT_METADATA_ROW_TYPES = [
"text",
"code",
"key_value",
"issue_link",
"agent_link",
"run_link",
] as const;
export type IssueCommentMetadataRowType = (typeof ISSUE_COMMENT_METADATA_ROW_TYPES)[number];
export function clampIssueRequestDepth(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.min(MAX_ISSUE_REQUEST_DEPTH, Math.max(0, Math.floor(value)));
+26
View File
@@ -20,6 +20,10 @@ export {
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
MAX_ISSUE_REQUEST_DEPTH,
ISSUE_COMMENT_AUTHOR_TYPES,
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
clampIssueRequestDepth,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
@@ -130,6 +134,10 @@ export {
type AgentIconName,
type IssueStatus,
type IssuePriority,
type IssueCommentAuthorType,
type IssueCommentMetadataRowType,
type IssueCommentPresentationKind,
type IssueCommentPresentationTone,
type IssueThreadInteractionKind,
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
@@ -359,6 +367,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
@@ -373,6 +383,16 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
IssueCommentMetadataCodeRow,
IssueCommentMetadataKeyValueRow,
IssueCommentMetadataIssueLinkRow,
IssueCommentMetadataAgentLinkRow,
IssueCommentMetadataRunLinkRow,
IssueCommentPresentation,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
@@ -482,6 +502,7 @@ export type {
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
@@ -700,6 +721,11 @@ export {
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
issueCommentAuthorTypeSchema,
issueCommentPresentationSchema,
issueCommentMetadataRowSchema,
issueCommentMetadataSectionSchema,
issueCommentMetadataSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
@@ -1,5 +1,7 @@
import type { AgentEnvConfig } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
import type { IssueCommentAuthorType } from "../constants.js";
import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js";
export interface CompanyPortabilityInclude {
company: boolean;
@@ -94,6 +96,16 @@ export interface CompanyPortabilityIssueRoutineManifestEntry {
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
}
export interface CompanyPortabilityIssueCommentManifestEntry {
body: string;
authorType: IssueCommentAuthorType;
authorAgentSlug: string | null;
authorUserId: string | null;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
createdAt: string | null;
}
export interface CompanyPortabilityIssueManifestEntry {
slug: string;
identifier: string | null;
@@ -112,6 +124,7 @@ export interface CompanyPortabilityIssueManifestEntry {
billingCode: string | null;
executionWorkspaceSettings: Record<string, unknown> | null;
assigneeAdapterOverrides: Record<string, unknown> | null;
comments: CompanyPortabilityIssueCommentManifestEntry[];
metadata: Record<string, unknown> | null;
}
+13
View File
@@ -150,6 +150,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
@@ -165,6 +167,16 @@ export type {
IssueReviewRequest,
IssueExecutionDecision,
IssueComment,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
IssueCommentMetadataCodeRow,
IssueCommentMetadataKeyValueRow,
IssueCommentMetadataIssueLinkRow,
IssueCommentMetadataAgentLinkRow,
IssueCommentMetadataRunLinkRow,
IssueCommentPresentation,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
@@ -306,6 +318,7 @@ export type {
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
+86
View File
@@ -1,4 +1,8 @@
import type {
IssueCommentAuthorType,
IssueCommentMetadataRowType,
IssueCommentPresentationKind,
IssueCommentPresentationTone,
IssueExecutionMonitorClearReason,
IssueExecutionMonitorKind,
IssueExecutionMonitorRecoveryPolicy,
@@ -162,6 +166,18 @@ export interface IssueProductivityReview {
updatedAt: Date;
}
export type SuccessfulRunHandoffStateKind = "required" | "resolved" | "escalated";
export interface SuccessfulRunHandoffState {
state: SuccessfulRunHandoffStateKind;
required: boolean;
sourceRunId: string | null;
correctiveRunId: string | null;
assigneeAgentId: string | null;
detectedProgressSummary: string | null;
createdAt: Date | string | null;
}
export interface IssueRelation {
id: string;
companyId: string;
@@ -324,6 +340,7 @@ export interface Issue {
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;
@@ -346,14 +363,83 @@ export interface IssueComment {
id: string;
companyId: string;
issueId: string;
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
body: string;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
followUpRequested?: boolean;
createdAt: Date;
updatedAt: Date;
}
interface IssueCommentMetadataRowBase {
type: IssueCommentMetadataRowType;
label?: string | null;
}
export interface IssueCommentMetadataTextRow extends IssueCommentMetadataRowBase {
type: "text";
text: string;
}
export interface IssueCommentMetadataCodeRow extends IssueCommentMetadataRowBase {
type: "code";
code: string;
language?: string | null;
}
export interface IssueCommentMetadataKeyValueRow extends IssueCommentMetadataRowBase {
type: "key_value";
label: string;
value: string;
}
export interface IssueCommentMetadataIssueLinkRow extends IssueCommentMetadataRowBase {
type: "issue_link";
issueId?: string | null;
identifier?: string | null;
title?: string | null;
}
export interface IssueCommentMetadataAgentLinkRow extends IssueCommentMetadataRowBase {
type: "agent_link";
agentId: string;
name?: string | null;
}
export interface IssueCommentMetadataRunLinkRow extends IssueCommentMetadataRowBase {
type: "run_link";
runId: string;
title?: string | null;
}
export type IssueCommentMetadataRow =
| IssueCommentMetadataTextRow
| IssueCommentMetadataCodeRow
| IssueCommentMetadataKeyValueRow
| IssueCommentMetadataIssueLinkRow
| IssueCommentMetadataAgentLinkRow
| IssueCommentMetadataRunLinkRow;
export interface IssueCommentMetadataSection {
title?: string | null;
rows: IssueCommentMetadataRow[];
}
export interface IssueCommentMetadata {
version: 1;
sections: IssueCommentMetadataSection[];
}
export interface IssueCommentPresentation {
kind: IssueCommentPresentationKind;
tone: IssueCommentPresentationTone;
title?: string | null;
detailsDefaultOpen: boolean;
}
export interface IssueThreadInteractionActorFields {
createdByAgentId?: string | null;
createdByUserId?: string | null;
@@ -1,5 +1,10 @@
import { z } from "zod";
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
import {
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
} from "./issue.js";
import { routineVariableSchema } from "./routine.js";
export const portabilityIncludeSchema = z
@@ -131,6 +136,16 @@ export const portabilityIssueRoutineManifestEntrySchema = z.object({
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
});
export const portabilityIssueCommentManifestEntrySchema = z.object({
body: z.string().min(1),
authorType: issueCommentAuthorTypeSchema,
authorAgentSlug: z.string().min(1).nullable(),
authorUserId: z.string().nullable(),
presentation: issueCommentPresentationSchema.nullable(),
metadata: issueCommentMetadataSchema.nullable(),
createdAt: z.string().datetime().nullable(),
});
export const portabilityIssueManifestEntrySchema = z.object({
slug: z.string().min(1),
identifier: z.string().min(1).nullable(),
@@ -149,6 +164,7 @@ export const portabilityIssueManifestEntrySchema = z.object({
billingCode: z.string().nullable(),
executionWorkspaceSettings: z.record(z.unknown()).nullable(),
assigneeAdapterOverrides: z.record(z.unknown()).nullable(),
comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]),
metadata: z.record(z.unknown()).nullable(),
});
+5
View File
@@ -157,6 +157,11 @@ export {
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
issueCommentAuthorTypeSchema,
issueCommentPresentationSchema,
issueCommentMetadataRowSchema,
issueCommentMetadataSectionSchema,
issueCommentMetadataSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
@@ -54,6 +54,46 @@ describe("issue validators", () => {
expect(parsed.body).toBe("Progress update\n\nNext action.");
});
it("accepts structured issue comment presentation and metadata", () => {
const parsed = addIssueCommentSchema.parse({
body: "Paperclip needs a disposition before this issue can continue.",
authorType: "system",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Needs disposition",
},
metadata: {
version: 1,
sections: [
{
title: "Evidence",
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
{ type: "issue_link", label: "Source issue", identifier: "PAP-3440" },
{ type: "run_link", label: "Run", runId: "11111111-1111-4111-8111-111111111111" },
],
},
],
},
});
expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
});
it("rejects arbitrary issue comment metadata", () => {
const parsed = addIssueCommentSchema.safeParse({
body: "Hidden details",
metadata: {
version: 1,
transcript: "raw log dump",
},
});
expect(parsed.success).toBe(false);
});
it("normalizes escaped line breaks in generated task drafts", () => {
const parsed = suggestedTaskDraftSchema.parse({
clientKey: "task-1",
+91
View File
@@ -8,6 +8,10 @@ import {
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_COMMENT_AUTHOR_TYPES,
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_PRIORITIES,
clampIssueRequestDepth,
@@ -233,8 +237,95 @@ export const checkoutIssueSchema = z.object({
export type CheckoutIssue = z.infer<typeof checkoutIssueSchema>;
const commentMetadataLabelSchema = z.string().trim().min(1).max(120);
const commentMetadataTextSchema = z.string().trim().min(1).max(2000);
export const issueCommentAuthorTypeSchema = z.enum(ISSUE_COMMENT_AUTHOR_TYPES);
export const issueCommentPresentationSchema = z.object({
kind: z.enum(ISSUE_COMMENT_PRESENTATION_KINDS).default("message"),
tone: z.enum(ISSUE_COMMENT_PRESENTATION_TONES).default("neutral"),
title: z.string().trim().min(1).max(160).nullable().optional(),
detailsDefaultOpen: z.boolean().optional().default(false),
}).strict();
export type IssueCommentPresentation = z.infer<typeof issueCommentPresentationSchema>;
const issueCommentMetadataBaseRowSchema = z.object({
type: z.enum(ISSUE_COMMENT_METADATA_ROW_TYPES),
label: commentMetadataLabelSchema.nullable().optional(),
});
const issueCommentMetadataTextRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("text"),
text: commentMetadataTextSchema,
}).strict();
const issueCommentMetadataCodeRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("code"),
code: z.string().min(1).max(4000),
language: z.string().trim().min(1).max(40).nullable().optional(),
}).strict();
const issueCommentMetadataKeyValueRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("key_value"),
label: commentMetadataLabelSchema,
value: commentMetadataTextSchema,
}).strict();
const issueCommentMetadataIssueLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("issue_link"),
issueId: z.string().uuid().nullable().optional(),
identifier: z.string().trim().min(1).max(80).nullable().optional(),
title: z.string().trim().min(1).max(240).nullable().optional(),
}).strict();
const issueCommentMetadataAgentLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("agent_link"),
agentId: z.string().uuid(),
name: z.string().trim().min(1).max(160).nullable().optional(),
}).strict();
const issueCommentMetadataRunLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("run_link"),
runId: z.string().uuid(),
title: z.string().trim().min(1).max(160).nullable().optional(),
}).strict();
export const issueCommentMetadataRowSchema = z.discriminatedUnion("type", [
issueCommentMetadataTextRowSchema,
issueCommentMetadataCodeRowSchema,
issueCommentMetadataKeyValueRowSchema,
issueCommentMetadataIssueLinkRowSchema,
issueCommentMetadataAgentLinkRowSchema,
issueCommentMetadataRunLinkRowSchema,
]).superRefine((value, ctx) => {
if (value.type === "issue_link" && !value.issueId && !value.identifier) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Issue link rows require issueId or identifier",
path: ["issueId"],
});
}
});
export const issueCommentMetadataSectionSchema = z.object({
title: z.string().trim().min(1).max(160).nullable().optional(),
rows: z.array(issueCommentMetadataRowSchema).min(1).max(50),
}).strict();
export const issueCommentMetadataSchema = z.object({
version: z.literal(1),
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
}).strict();
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
export const addIssueCommentSchema = z.object({
body: multilineTextSchema.pipe(z.string().min(1)),
authorType: issueCommentAuthorTypeSchema.optional(),
presentation: issueCommentPresentationSchema.nullable().optional(),
metadata: issueCommentMetadataSchema.nullable().optional(),
reopen: z.boolean().optional(),
resume: z.boolean().optional(),
interrupt: z.boolean().optional(),
+169 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -215,6 +215,103 @@ describe("acpx_local execute", () => {
}
});
it("closes successful persistent runs by default while retaining session state", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-close-success-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root));
expect(result.exitCode).toBe(0);
expect(result.sessionParams).toMatchObject({
mode: "persistent",
acpSessionId: "acp-1",
});
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip completed turn cleanup",
discardPersistentState: false,
}),
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("applies requested Codex model, reasoning effort, and fast mode before starting the turn", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-config-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "codex",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
model: "gpt-5.4",
modelReasoningEffort: "xhigh",
fastMode: true,
},
}));
expect(result.exitCode).toBe(0);
expect(result.model).toBe("gpt-5.4");
expect(runtime.setConfigInputs).toEqual([
expect.objectContaining({ key: "model", value: "gpt-5.4" }),
expect.objectContaining({ key: "reasoning_effort", value: "xhigh" }),
expect.objectContaining({ key: "service_tier", value: "fast" }),
expect.objectContaining({ key: "features.fast_mode", value: "true" }),
]);
expect(runtime.startInputs).toHaveLength(1);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("logs a clear error when configured session options need unsupported runtime controls", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-missing-config-controls-"));
try {
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
Object.defineProperty(runtime, "setConfigOption", { value: undefined });
const logs: LogEntry[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "codex",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
model: "gpt-5.4",
},
onLog: async (stream, chunk) => logs.push({ stream, chunk }),
}));
expect(result.exitCode).toBe(1);
expect(result.errorMessage).toContain("does not expose session config controls");
expect(logs).toEqual(expect.arrayContaining([
expect.objectContaining({
stream: "stderr",
chunk: expect.stringContaining("upgrade ACPX or remove configured model"),
}),
]));
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip config cleanup",
discardPersistentState: false,
}),
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reuses a compatible warm session and starts fresh when cwd changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-"));
const other = path.join(root, "other");
@@ -228,8 +325,15 @@ describe("acpx_local execute", () => {
return runtime;
},
});
const warmConfig = {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
};
const first = await execute(buildContext(root));
const first = await execute(buildContext(root, { config: warmConfig }));
const second = await execute(buildContext(root, {
runtime: {
sessionId: first.sessionId ?? null,
@@ -237,6 +341,7 @@ describe("acpx_local execute", () => {
sessionDisplayId: first.sessionDisplayId ?? null,
taskKey: "PAP-1",
},
config: warmConfig,
}));
const third = await execute(buildContext(root, {
runtime: {
@@ -250,6 +355,7 @@ describe("acpx_local execute", () => {
cwd: other,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
}));
@@ -279,8 +385,26 @@ describe("acpx_local execute", () => {
});
const [first, second] = await Promise.all([
execute(buildContext(root, { runId: "run-1" })),
execute(buildContext(root, { runId: "run-2" })),
execute(buildContext(root, {
runId: "run-1",
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
})),
execute(buildContext(root, {
runId: "run-2",
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 60_000,
},
})),
]);
expect(first.exitCode).toBe(0);
@@ -295,6 +419,47 @@ describe("acpx_local execute", () => {
}
});
it("cleans configured warm handles after their idle window", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-warm-idle-"));
vi.useFakeTimers();
try {
let clock = 0;
const runtime = new FakeRuntime({} as AcpRuntimeOptions);
const warmHandles = new Map();
const execute = createAcpxLocalExecutor({
warmHandles,
now: () => clock,
createRuntime: () => runtime,
});
const result = await execute(buildContext(root, {
config: {
agent: "claude",
cwd: root,
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
warmHandleIdleMs: 1_000,
},
}));
expect(result.exitCode).toBe(0);
expect(warmHandles.size).toBe(1);
clock = 1_000;
await vi.advanceTimersByTimeAsync(1_000);
expect(warmHandles.size).toBe(0);
expect(runtime.closeInputs).toEqual([
expect.objectContaining({
reason: "paperclip idle cleanup",
discardPersistentState: false,
}),
]);
} finally {
vi.useRealTimers();
await fs.rm(root, { recursive: true, force: true });
}
});
it("retries with a fresh session when ACPX cannot resume the saved backend session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-"));
try {
+8 -1
View File
@@ -3,7 +3,7 @@ import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels, refreshAdapterModels } from "../adapters/index.js";
import { listAdapterModels, listServerAdapters, refreshAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
@@ -30,6 +30,13 @@ describe("adapter model listing", () => {
expect(models).toEqual([]);
});
it("uses provider-prefixed ACPX fallback model labels", () => {
const adapter = listServerAdapters().find((candidate) => candidate.type === "acpx_local");
expect(adapter?.models?.some((model) => model.label.startsWith("Claude: "))).toBe(true);
expect(adapter?.models?.some((model) => model.label.startsWith("Codex: "))).toBe(true);
});
it("returns codex fallback models when no OpenAI key is available", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const models = await listAdapterModels("codex_local");
+24 -2
View File
@@ -248,11 +248,33 @@ describe("adapter routes", () => {
]),
}),
expect.objectContaining({
key: "permissionMode",
default: "approve-all",
key: "fastMode",
default: false,
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
}),
expect.objectContaining({
key: "warmHandleIdleMs",
default: 0,
}),
]),
);
const keys = res.body.fields.map((field: { key: string }) => field.key);
expect(keys).not.toContain("mode");
expect(keys).not.toContain("permissionMode");
expect(keys).not.toContain("instructionsFilePath");
expect(keys).not.toContain("promptTemplate");
expect(keys).not.toContain("bootstrapPromptTemplate");
});
it("GET /api/adapters includes ACPX model availability", async () => {
const app = createApp();
const res = await request(app).get("/api/adapters");
expect(res.status, JSON.stringify(res.body)).toBe(200);
const acpxLocal = res.body.find((a: any) => a.type === "acpx_local");
expect(acpxLocal).toBeDefined();
expect(acpxLocal.modelsCount).toBeGreaterThan(0);
});
it("rejects signed-in users without org access", async () => {
@@ -12,6 +12,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunIssueSummaryForAgent: vi.fn(),
getRunLogAccess: vi.fn(),
readLog: vi.fn(),
wakeup: vi.fn(),
}));
const mockIssueService = vi.hoisted(() => ({
@@ -26,6 +27,8 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
listCompanyIds: vi.fn(),
}));
const routeAgentId = "11111111-1111-4111-8111-111111111111";
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
@@ -210,6 +213,14 @@ describe("agent live run routes", () => {
content: "chunk",
nextOffset: 5,
});
mockHeartbeatService.wakeup.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
status: "queued",
invocationSource: "on_demand",
triggerDetail: "manual",
});
});
it("returns a compact active run payload for issue polling", async () => {
@@ -524,4 +535,66 @@ describe("agent live run routes", () => {
expect(res.body).toHaveLength(4);
expect(db.select).toHaveBeenCalledTimes(2);
});
it("passes scoped wake fields through the legacy heartbeat invoke route", async () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl)
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
.send({
reason: "issue_assigned",
payload: {
issueId: "issue-1",
taskId: "issue-1",
taskKey: "issue-1",
},
forceFreshSession: true,
}),
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
// caller actually supplied so empty-body callers (e.g. e2e suites) match
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
// caller supplies reason / payload / forceFreshSession those are
// forwarded; idempotencyKey is omitted unless explicitly set.
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "issue_assigned",
payload: {
issueId: "issue-1",
taskId: "issue-1",
taskKey: "issue-1",
},
requestedByActorType: "user",
requestedByActorId: "local-board",
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
forceFreshSession: true,
},
});
});
it("calls heartbeat.wakeup with the legacy minimal shape when the body is empty", async () => {
const res = await requestApp(
await createApp(),
(baseUrl) => request(baseUrl)
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
.send({}),
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
requestedByActorType: "user",
requestedByActorId: "local-board",
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
},
});
});
});
@@ -35,9 +35,11 @@ const projectSvc = {
const issueSvc = {
list: vi.fn(),
listComments: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
};
const routineSvc = {
@@ -131,6 +133,14 @@ describe("company portability", () => {
config,
secretKeys: new Set<string>(),
}));
issueSvc.listComments.mockResolvedValue([]);
issueSvc.addComment.mockResolvedValue({
id: "comment-imported",
body: "Imported comment",
authorType: "system",
presentation: null,
metadata: null,
});
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@@ -2333,6 +2343,103 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("does not silently add local adapter permission bypasses on import", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.not.objectContaining({
dangerouslySkipPermissions: expect.anything(),
}),
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
},
},
},
}, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
extraArgs: ["--skip-git-repo-check"],
args: ["--legacy-arg"],
}),
}));
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.not.objectContaining({
dangerouslyBypassApprovalsAndSandbox: expect.anything(),
dangerouslyBypassSandbox: expect.anything(),
}),
}));
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
@@ -2399,6 +2506,85 @@ describe("company portability", () => {
);
});
it("preserves issue comment presentation fields through export and import", async () => {
const portability = companyPortabilityService({} as any);
const presentation = { kind: "system_notice", tone: "warning", detailsDefaultOpen: false };
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Needs disposition",
description: "System notice source",
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("comments:");
expect(extension).toContain("system_notice");
expect(extension).toContain("successful_run_missing_state");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Needs disposition" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation,
metadata,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);
@@ -208,6 +208,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(evaluations[0]).toMatchObject({
priority: "medium",
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
originId: runId,
originFingerprint: `stale_active_run:${companyId}:${runId}`,
});
@@ -13,6 +13,7 @@ import {
issues,
} from "@paperclipai/db";
import { heartbeatService } from "../services/heartbeat.ts";
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
@@ -543,8 +544,24 @@ describe("heartbeat comment wake batching", () => {
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "user-1",
body: "Queued follow-up",
presentation: {
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
],
},
],
},
})
.returning()
.then((rows) => rows[0]);
@@ -577,7 +594,15 @@ describe("heartbeat comment wake batching", () => {
comments: [
expect.objectContaining({
id: queuedComment.id,
authorType: "user",
body: "Queued follow-up",
presentation: expect.objectContaining({
kind: "system_notice",
tone: "warning",
}),
metadata: expect.objectContaining({
version: 1,
}),
}),
],
commentWindow: {
@@ -1130,6 +1155,7 @@ describe("heartbeat comment wake batching", () => {
expect(payloads).toHaveLength(2);
expect(runs[1]?.contextSnapshot).toMatchObject({
retryReason: "missing_issue_comment",
modelProfile: "cheap",
});
} finally {
gateway.releaseFirstWait();
@@ -1329,8 +1355,9 @@ describe("heartbeat comment wake batching", () => {
eq(agentWakeupRequests.agentId, primaryAgentId),
eq(agentWakeupRequests.reason, "missing_issue_comment"),
),
);
);
expect(missingCommentRetries).toHaveLength(1);
expect(missingCommentRetries[0]?.payload).toMatchObject({ modelProfile: "cheap" });
} finally {
gateway.releaseFirstWait();
await gateway.close();
@@ -1566,7 +1593,8 @@ describe("heartbeat comment wake batching", () => {
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied";
const sourceRun = runs.find((run) => run.id === firstRun?.id);
return sourceRun?.status === "succeeded" && sourceRun.issueCommentStatus === "satisfied";
});
const runs = await db
@@ -1574,9 +1602,26 @@ describe("heartbeat comment wake batching", () => {
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
expect(runs[0]?.issueCommentStatus).toBe("satisfied");
expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull();
const sourceRun = runs.find((run) => run.id === firstRun?.id);
expect(sourceRun?.issueCommentStatus).toBe("satisfied");
expect(sourceRun?.issueCommentSatisfiedByCommentId).not.toBeNull();
await waitFor(async () => {
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
const hasHandoffComment = comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
);
const hasHandoffWake = wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return hasHandoffComment && hasHandoffWake;
});
const comments = await db
.select()
@@ -1584,16 +1629,19 @@ describe("heartbeat comment wake batching", () => {
.where(eq(issueComments.issueId, issueId))
.orderBy(asc(issueComments.createdAt));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toBe("Manual completion comment from the run.");
expect(comments[0]?.createdByRunId).toBe(firstRun?.id);
expect(comments.some((comment) => comment.body === "Manual completion comment from the run.")).toBe(true);
expect(comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
)).toBe(true);
expect(comments.every((comment) => !comment.body.startsWith("## Run summary"))).toBe(true);
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
expect(wakeups).toHaveLength(1);
expect(wakeups.some((wakeup) => wakeup.reason === "missing_issue_comment")).toBe(false);
expect(wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff")).toBe(true);
} finally {
gateway.releaseFirstWait();
await gateway.close();
@@ -280,6 +280,23 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
unresolvedBlockerIssueIds: [blockerId],
});
let finishReadyRun!: () => void;
const readyRunCanFinish = new Promise<void>((resolve) => {
finishReadyRun = resolve;
});
mockAdapterExecute.mockImplementationOnce(async () => {
await readyRunCanFinish;
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Ready dependency scheduling run complete.",
provider: "test",
model: "test-model",
};
});
const readyWake = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
@@ -288,6 +305,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: readyIssueId, wakeReason: "issue_assigned" },
});
expect(readyWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyWake!.id,
body: "Ready dependency scheduling run complete.",
});
finishReadyRun();
await waitForCondition(async () => {
const run = await db
@@ -354,6 +380,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
expect(promotedBlockedRun?.status).toBe("succeeded");
expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2);
const noActiveRuns = await waitForCondition(async () => {
const rows = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
return rows.every((run) => run.status !== "queued" && run.status !== "running");
}, 10_000);
expect(noActiveRuns).toBe(true);
});
it("honors maxConcurrentRuns 1 by leaving a second assignment wake queued", async () => {
@@ -429,6 +463,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: firstIssueId, wakeReason: "issue_assigned" },
});
expect(firstWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: firstIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: firstWake!.id,
body: "First assignment run completed.",
});
const firstRunStarted = await waitForCondition(async () => {
const run = await db
@@ -439,7 +481,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "running";
});
expect(firstRunStarted).toBe(true);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000);
expect(firstAdapterStarted).toBe(true);
const secondWake = await heartbeat.wakeup(agentId, {
@@ -450,6 +492,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: secondIssueId, wakeReason: "issue_assigned" },
});
expect(secondWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: secondIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: secondWake!.id,
body: "Second assignment run completed.",
});
const secondRunWhileFirstRunning = await db
.select({ status: heartbeatRuns.status })
@@ -470,11 +520,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "succeeded";
});
expect(secondRunSucceeded).toBe(true);
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
finishFirstRun();
}
});
}, 40_000);
it("cancels stale queued runs when issue blockers are still unresolved", async () => {
const companyId = randomUUID();
@@ -598,6 +648,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
.update(agentWakeupRequests)
.set({ runId: readyRunId })
.where(eq(agentWakeupRequests.id, readyWakeupRequestId));
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyRunId,
body: "Ready queued run completed.",
});
await db
.update(issues)
.set({
@@ -665,7 +723,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
executionLockedAt: null,
});
expect(readyRun?.status).toBe("succeeded");
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
});
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {
@@ -320,6 +320,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(escalations[0]).toMatchObject({
parentId: blockerIssueId,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
status: expect.stringMatching(/^(todo|in_progress|done)$/),
originFingerprint: [
"harness_liveness_leaf",
@@ -568,6 +569,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
executionWorkspaceId: null,
executionWorkspacePreference: null,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
});
@@ -23,6 +23,7 @@ import {
issueRelations,
issueTreeHoldMembers,
issueTreeHolds,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import {
@@ -69,7 +70,15 @@ vi.mock("../adapters/index.ts", async () => {
};
});
import { heartbeatService } from "../services/heartbeat.ts";
import {
heartbeatService,
redactDetectedSuccessfulRunProgressSummaryForBoard,
} from "../services/heartbeat.ts";
import {
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
} from "../services/recovery/index.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -313,6 +322,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(costEvents);
await db.delete(environmentLeases);
await db.delete(environments);
await db.delete(issueWorkProducts);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
@@ -709,6 +719,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
originId: input.issueId,
originRunId: input.runId,
priority: "medium",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(recovery.title).toContain("Recover stalled issue");
expect(recovery.description).toContain(`Previous source status: \`${input.previousStatus}\``);
@@ -743,6 +754,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
companyId: input.companyId,
reason: "issue_assigned",
source: "assignment",
payload: expect.objectContaining({ modelProfile: "cheap" }),
});
const recoveryRun = recoveryWakeup?.runId
@@ -758,6 +770,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
source: "stranded_issue_recovery",
sourceIssueId: input.issueId,
strandedRunId: input.runId,
modelProfile: "cheap",
});
return recovery;
@@ -915,6 +928,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(retryRun?.status).toBe("queued");
expect(retryRun?.retryOfRunId).toBe(runId);
expect(retryRun?.processLossRetryCount).toBe(1);
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
const issue = await db
.select()
@@ -1227,7 +1241,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect((failedRun?.resultJson as Record<string, unknown> | null)?.errorFamily).toBe("transient_upstream");
expect(retryRun?.status).toBe("scheduled_retry");
expect(retryRun?.scheduledRetryReason).toBe("transient_failure");
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode).toBe("same_session");
expect(retryRun?.contextSnapshot).toMatchObject({
codexTransientFallbackMode: "same_session",
modelProfile: "cheap",
});
const issue = await db
.select()
@@ -1241,6 +1258,448 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(0);
});
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented the backend detector, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented the backend detector, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
expect(handoffWakeups[0]?.payload).toMatchObject({
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
handoffAttempt: 1,
maxHandoffAttempts: 1,
resumeIntent: true,
resumeFromRunId: runId,
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.authorType).toBe("system");
expect(handoffComment?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
});
expect(handoffComment?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Required action",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", runId }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
]),
}),
]),
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_required")).toBe(true);
});
it("requeues a missing-disposition handoff when the previous corrective wake was cancelled", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const idempotencyKey = `finish_successful_run_handoff:${issueId}:${runId}:1`;
await db.insert(agentWakeupRequests).values({
id: randomUUID(),
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "finish_successful_run_handoff",
payload: {
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
},
status: "cancelled",
idempotencyKey,
requestedAt: new Date("2026-03-19T00:00:01.000Z"),
finishedAt: new Date("2026-03-19T00:00:02.000Z"),
updatedAt: new Date("2026-03-19T00:00:02.000Z"),
});
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented recovery handling, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented recovery handling, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey));
const requeued = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return requeued.length > 1 ? requeued : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(2);
expect(handoffWakeups.filter((wakeup) => wakeup.status === "cancelled")).toHaveLength(1);
expect(handoffWakeups.some((wakeup) => wakeup.status !== "cancelled")).toBe(true);
});
it("queues one missing-disposition handoff for artifact-producing successful runs left in progress", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Drafted the Phase 3 test plan but did not choose a final issue disposition.",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Regression test plan",
format: "markdown",
latestBody: "# Regression test plan\n\n- Cover artifact-producing successful runs",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: agentId,
updatedByAgentId: agentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Regression test plan",
format: "markdown",
body: "# Regression test plan\n\n- Cover artifact-producing successful runs",
createdByAgentId: agentId,
createdByRunId: ctx.runId,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(issueWorkProducts).values({
companyId,
issueId,
type: "report",
provider: "test",
externalId: "phase-3-report",
title: "Phase 3 regression notes",
status: "ready",
summary: "Successful run produced a visible artifact.",
createdByRunId: ctx.runId,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Created comments, a plan document, and a work product without choosing a disposition.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const settledRun = await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
const classifiedRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
expect(classifiedRun?.status ?? settledRun?.status).toBe("succeeded");
expect(classifiedRun?.livenessState).toBe("advanced");
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("in_progress");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments.filter((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toHaveLength(1);
expect(comments.some((comment) => comment.body.startsWith("Drafted the Phase 3 test plan"))).toBe(true);
const workProducts = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId));
expect(workProducts).toHaveLength(1);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(0);
});
it("redacts secret-bearing successful-run detected progress before handoff disclosure", async () => {
const { agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const bearerSecret = "live-bearer-token-value";
const apiKeySecret = "sk-testsuccessfulhandoffsecret";
const redactedDetectedSummary = redactDetectedSuccessfulRunProgressSummaryForBoard(
`Next action noted: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
{ enabled: false },
);
expect(redactedDetectedSummary).toContain("***REDACTED***");
expect(redactedDetectedSummary).not.toContain(bearerSecret);
expect(redactedDetectedSummary).not.toContain(apiKeySecret);
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Made progress but left the issue open.",
resultJson: {
message: `Next action: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
},
provider: "test",
model: "test-model",
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
const wakeupPayloadText = JSON.stringify(handoffWakeups[0]?.payload ?? {});
expect(wakeupPayloadText).not.toContain(bearerSecret);
expect(wakeupPayloadText).not.toContain(apiKeySecret);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.body).not.toContain(bearerSecret);
expect(handoffComment?.body).not.toContain(apiKeySecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(bearerSecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(apiKeySecret);
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
const handoffActivity = activity.find((event) => event.action === "issue.successful_run_handoff_required");
expect(handoffActivity).toBeTruthy();
const activityDetailsText = JSON.stringify(handoffActivity?.details ?? {});
expect(activityDetailsText).not.toContain(bearerSecret);
expect(activityDetailsText).not.toContain(apiKeySecret);
});
it("escalates an exhausted failed successful-run handoff without using generic continuation recovery first", async () => {
const { companyId, agentId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
runErrorCode: "adapter_failed",
runError: "Authorization: Bearer sk-test-successful-handoff-secret",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.assigneeAgentId).toBe(agentId);
expect(recovery?.title).toContain("Recover missing next step");
expect(recovery?.description).toContain("Normalized cause: `successful_run_missing_state`");
expect(recovery?.description).toContain("not a runtime/adapter crash report");
expect(recovery?.description).toContain(`Source run: [\`${sourceRunId}\`]`);
expect(recovery?.description).toContain("Missing disposition: `clear_next_step`");
expect(recovery?.description).toContain("Source assignee: [CodexCoder]");
expect(recovery?.description).not.toContain("sk-test-successful-handoff-secret");
const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(sourceIssue?.status).toBe("blocked");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recovery?.id]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments[0]?.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
expect(comments[0]?.authorType).toBe("system");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "danger",
detailsDefaultOpen: false,
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: recovery?.identifier }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CodexCoder" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
]),
});
expect(comments[0]?.body).not.toContain("sk-test-successful-handoff-secret");
expect(JSON.stringify(comments[0]?.metadata ?? {})).not.toContain("sk-test-successful-handoff-secret");
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_escalated")).toBe(true);
});
it("escalates an exhausted successful handoff run that still leaves no disposition", async () => {
const { companyId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.successfulContinuationObserved).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.description).toContain("Latest handoff run status: `succeeded`");
expect(recovery?.description).toContain("Suggested");
});
it("clears the detached warning when the run reports activity again", async () => {
const { runId } = await seedRunFixture({
includeIssue: false,
@@ -1315,6 +1774,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
@@ -1326,6 +1786,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
taskId: issueId,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
modelProfile: "cheap",
});
expect((runs[0]?.contextSnapshot as Record<string, unknown>)?.retryReason).toBeUndefined();
@@ -1433,6 +1894,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId: unblocked.issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
const unblockedRuns = await db
@@ -1486,6 +1948,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -1524,6 +1987,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.continuation_recovery",
modelProfile: "cheap",
});
const recoveries = await db
@@ -1575,6 +2039,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -1738,6 +2203,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -2215,6 +2681,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
@@ -2281,6 +2748,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});
@@ -2336,6 +2804,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});
@@ -286,6 +286,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
retryOfRunId: sourceRunId,
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
contextSnapshot: expect.objectContaining({ modelProfile: "cheap" }),
});
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString());
@@ -38,7 +38,12 @@ const mockTxInsert = vi.hoisted(() => vi.fn(() => ({ values: mockTxInsertValues
const mockTx = vi.hoisted(() => ({
insert: mockTxInsert,
}));
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ orderBy: mockDbSelectOrderBy })));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
}));
const mockFeedbackService = vi.hoisted(() => ({
@@ -236,9 +241,17 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
mockDbSelectFrom.mockReset();
mockDbSelectWhere.mockReset();
mockDbSelectOrderBy.mockReset();
mockDb.transaction.mockReset();
mockTxInsertValues.mockResolvedValue(undefined);
mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues }));
mockDbSelectOrderBy.mockResolvedValue([]);
mockDbSelectWhere.mockImplementation(() => ({ orderBy: mockDbSelectOrderBy }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx));
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
@@ -545,6 +558,68 @@ describe.sequential("issue comment reopen routes", () => {
));
});
it("passes validated comment presentation fields to trusted board comment writes", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata: {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
},
createdAt: new Date(),
updatedAt: new Date(),
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
const presentation = { kind: "system_notice", tone: "warning" };
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
});
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: "local-board", runId: null },
{
authorType: "user",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata,
},
);
});
it("rejects invalid comment metadata before writing a comment", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Invalid metadata",
metadata: { version: 1, arbitrary: true },
});
expect(res.status).toBe(400);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("does not move dependency-blocked issues to todo via POST comments", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getDependencyReadiness.mockResolvedValue({
@@ -372,6 +372,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
issueId,
clearReason: "max_attempts_exhausted",
maxAttempts: 1,
modelProfile: "cheap",
});
const activity = await db
@@ -414,6 +415,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
expect(recoveryIssue).toMatchObject({
parentId: issueId,
priority: "high",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(["todo", "in_progress"]).toContain(recoveryIssue?.status);
});
@@ -91,6 +91,11 @@ const mockWorkProductService = vi.hoisted(() => ({
const mockEnvironmentService = vi.hoisted(() => ({}));
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
execute: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
@@ -130,7 +135,7 @@ function createApp() {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -186,6 +191,14 @@ describe.sequential("issue goal context routes", () => {
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockDb.select.mockReturnValue({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(async () => []),
})),
})),
});
mockDb.execute.mockResolvedValue([]);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",
@@ -201,6 +201,7 @@ describeEmbeddedPostgres("productivity review service", () => {
expect(reviews).toHaveLength(1);
expect(reviews[0]?.parentId).toBe(seeded.issueId);
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
expect(reviews[0]?.assigneeAdapterOverrides).toEqual({ modelProfile: "cheap" });
expect(reviews[0]?.originId).toBe(seeded.issueId);
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
@@ -76,10 +76,12 @@ describe("run liveness continuations", () => {
continuationAttempt: 1,
maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
instruction: "Take the first concrete action now.",
modelProfile: "cheap",
});
expect(decision.contextSnapshot).toMatchObject({
issueId,
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
modelProfile: "cheap",
livenessContinuationAttempt: 1,
livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
livenessContinuationSourceRunId: runId,
@@ -146,6 +146,7 @@ vi.mock("../services/index.js", () => ({
reconcileStrandedAssignedIssues: vi.fn(async () => ({
dispatchRequeued: 0,
continuationRequeued: 0,
successfulRunHandoffEscalated: 0,
escalated: 0,
skipped: 0,
issueIds: [],
+47 -2
View File
@@ -1,4 +1,9 @@
import type { AdapterModelProfileDefinition, AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js";
import type {
AdapterModel,
AdapterModelProfileDefinition,
AdapterRuntimeCommandSpec,
ServerAdapterModule,
} from "./types.js";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import {
execute as acpxExecute,
@@ -8,7 +13,10 @@ import {
listAcpxSkills,
syncAcpxSkills,
} from "@paperclipai/adapter-acpx-local/server";
import { agentConfigurationDoc as acpxAgentConfigurationDoc } from "@paperclipai/adapter-acpx-local";
import {
agentConfigurationDoc as acpxAgentConfigurationDoc,
models as acpxModels,
} from "@paperclipai/adapter-acpx-local";
import {
execute as claudeExecute,
listClaudeSkills,
@@ -182,6 +190,38 @@ function normalizeHermesConfig<T extends { config?: unknown; agent?: unknown }>(
return ctx;
}
function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const result: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
result.push({ ...model, id });
}
return result;
}
function prefixAdapterModelLabels(models: AdapterModel[], provider: "Claude" | "Codex"): AdapterModel[] {
const prefix = `${provider}: `;
return models.map((model) => ({
...model,
label: model.label.startsWith(prefix) ? model.label : `${prefix}${model.label}`,
}));
}
async function listAcpxModels(): Promise<AdapterModel[]> {
const [claude, codex] = await Promise.all([
listClaudeModels().catch(() => claudeModels),
listCodexModels().catch(() => codexModels),
]);
return dedupeAdapterModels([
...acpxModels,
...prefixAdapterModelLabels(claude, "Claude"),
...prefixAdapterModelLabels(codex, "Codex"),
]);
}
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: claudeExecute,
@@ -211,6 +251,11 @@ const acpxLocalAdapter: ServerAdapterModule = {
syncSkills: syncAcpxSkills,
sessionCodec: acpxSessionCodec,
sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined,
models: dedupeAdapterModels([
...prefixAdapterModelLabels(claudeModels, "Claude"),
...prefixAdapterModelLabels(codexModels, "Codex"),
]),
listModels: listAcpxModels,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
+2
View File
@@ -686,6 +686,7 @@ export async function startServer(): Promise<StartedServer> {
reconciled.assignmentDispatched > 0 ||
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.successfulRunHandoffEscalated > 0 ||
reconciled.escalated > 0
) {
logger.warn(
@@ -751,6 +752,7 @@ export async function startServer(): Promise<StartedServer> {
reconciled.assignmentDispatched > 0 ||
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.successfulRunHandoffEscalated > 0 ||
reconciled.escalated > 0
) {
logger.warn(
+66 -16
View File
@@ -2883,7 +2883,25 @@ export function agentRoutes(
res.json({ ok: true });
});
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
// Shared handler body for the wakeup-style endpoints. The two routes differ
// only in:
// - `source` — the modern /wakeup endpoint reads it from the request body
// (timer|assignment|on_demand|automation) while the legacy
// /heartbeat/invoke endpoint hardcodes "on_demand", since it has only
// ever produced on-demand invocations.
// - skipped-response shape — the modern endpoint surfaces the rich
// SkippedWakeupResponse; the legacy endpoint stays on the simpler
// { status: "skipped" } shape for backward compat.
type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation";
type WakeupRouteOpts = {
source: HeartbeatSource | undefined;
skippedResponse: (agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) => unknown | Promise<unknown>;
};
const handleWakeupRoute = async (
req: Request,
res: Response,
opts: WakeupRouteOpts,
): Promise<void> => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2902,7 +2920,7 @@ export function agentRoutes(
}
const run = await heartbeat.wakeup(id, {
source: req.body.source,
source: opts.source,
triggerDetail: req.body.triggerDetail ?? "manual",
reason: req.body.reason ?? null,
payload: req.body.payload ?? null,
@@ -2917,7 +2935,7 @@ export function agentRoutes(
});
if (!run) {
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
res.status(202).json(await opts.skippedResponse(agent));
return;
}
@@ -2935,9 +2953,23 @@ export function agentRoutes(
});
res.status(202).json(run);
};
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
await handleWakeupRoute(req, res, {
source: req.body.source,
skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null),
});
});
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
// Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior
// before the wakeup/invoke convergence). Reads scope fields directly off
// the body without `validate(wakeAgentSchema)` because callers — including
// the e2e suite — post an empty body, and the schema rejects undefined
// / missing bodies. Only forwards fields the caller actually supplied so
// an empty body produces the original fixed-arg `heartbeat.invoke()`
// shape exactly.
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2955,19 +2987,37 @@ export function agentRoutes(
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.invoke(
id,
"on_demand",
{
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
},
"manual",
{
actorType: req.actor.type === "agent" ? "agent" : "user",
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
},
);
const body = (req.body ?? {}) as Partial<{
reason: unknown;
payload: unknown;
idempotencyKey: unknown;
forceFreshSession: unknown;
triggerDetail: unknown;
}>;
const contextSnapshot: Record<string, unknown> = {
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
};
if (body.forceFreshSession === true) {
contextSnapshot.forceFreshSession = true;
}
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
source: "on_demand",
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
contextSnapshot,
};
if (typeof body.reason === "string" && body.reason.length > 0) {
wakeOpts.reason = body.reason;
}
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
wakeOpts.payload = body.payload as Record<string, unknown>;
}
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
wakeOpts.idempotencyKey = body.idempotencyKey;
}
const run = await heartbeat.wakeup(id, wakeOpts);
if (!run) {
res.status(202).json({ status: "skipped" });
+117 -2
View File
@@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
@@ -35,6 +36,7 @@ import {
type CompanySearchQuery,
type CompanySearchResponse,
type ExecutionWorkspace,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
@@ -82,6 +84,7 @@ import { executionWorkspaceService as executionWorkspaceServiceDirect } from "..
import { feedbackService } from "../services/feedback.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { environmentService } from "../services/environments.js";
import { redactSensitiveText } from "../redaction.js";
import {
createCompanySearchRateLimiter,
type CompanySearchRateLimiter,
@@ -124,6 +127,103 @@ type ExecutionStageWakeContext = {
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
type SuccessfulRunHandoffActivityRow = {
entityId: string;
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
};
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_required",
"issue.successful_run_handoff_resolved",
"issue.successful_run_handoff_escalated",
] as const;
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function successfulRunHandoffStateFromActivity(row: {
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}): SuccessfulRunHandoffState | null {
const details = row.details ?? {};
const state =
row.action === "issue.successful_run_handoff_required"
? "required"
: row.action === "issue.successful_run_handoff_resolved"
? "resolved"
: row.action === "issue.successful_run_handoff_escalated"
? "escalated"
: null;
if (!state) return null;
const detectedProgressSummary =
readNonEmptyString(details.detectedProgressSummary)
?? readNonEmptyString(details.detected_progress_summary)
?? null;
return {
state,
required: state === "required",
sourceRunId:
readNonEmptyString(details.sourceRunId)
?? readNonEmptyString(details.source_run_id)
?? readNonEmptyString(details.resumeFromRunId)
?? row.runId
?? null,
correctiveRunId:
readNonEmptyString(details.correctiveRunId)
?? readNonEmptyString(details.corrective_run_id)
?? (state !== "required" ? row.runId : null),
assigneeAgentId:
readNonEmptyString(details.assigneeAgentId)
?? readNonEmptyString(details.agentId)
?? row.agentId
?? null,
detectedProgressSummary: detectedProgressSummary
? redactSensitiveText(detectedProgressSummary)
: null,
createdAt: row.createdAt,
};
}
async function listSuccessfulRunHandoffStates(
db: Db,
companyId: string,
issueIds: string[],
): Promise<Map<string, SuccessfulRunHandoffState>> {
if (issueIds.length === 0) return new Map();
const result = await db.execute(sql`
SELECT DISTINCT ON (${activityLog.entityId})
${activityLog.entityId} AS "entityId",
${activityLog.action} AS "action",
${activityLog.agentId} AS "agentId",
${activityLog.runId} AS "runId",
${activityLog.details} AS "details",
${activityLog.createdAt} AS "createdAt"
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)})
AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)})
ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC
`);
const rows = Array.from(result as Iterable<SuccessfulRunHandoffActivityRow>);
const states = new Map<string, SuccessfulRunHandoffState>();
for (const row of rows) {
const state = successfulRunHandoffStateFromActivity(row);
if (state) states.set(row.entityId, state);
}
return states;
}
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
@@ -1088,7 +1188,15 @@ export function issueRoutes(
limit,
offset,
});
res.json(result);
const handoffStates = await listSuccessfulRunHandoffStates(
db,
companyId,
result.map((issue) => issue.id),
);
res.json(result.map((issue) => ({
...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
})));
});
router.get("/companies/:companyId/labels", async (req, res) => {
@@ -1276,6 +1384,7 @@ export function issueRoutes(
blockerAttention,
productivityReview,
referenceSummary,
successfulRunHandoffStates,
] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
@@ -1285,6 +1394,7 @@ export function issueRoutes(
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
issueReferencesSvc.listIssueReferenceSummary(issue.id),
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
@@ -1299,6 +1409,7 @@ export function issueRoutes(
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
relatedWork: referenceSummary,
@@ -3743,6 +3854,10 @@ export function issueRoutes(
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
metadata: req.body.metadata ?? null,
});
await issueReferencesSvc.syncComment(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
+139 -2
View File
@@ -16,6 +16,7 @@ import type {
CompanyPortabilityImportResult,
CompanyPortabilityInclude,
CompanyPortabilityManifest,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityPreview,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewResult,
@@ -42,6 +43,9 @@ import {
ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey,
envConfigSchema,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
normalizeAgentUrlKey,
} from "@paperclipai/shared";
import {
@@ -644,6 +648,96 @@ function asInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function hasOwn(record: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function readStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length === value.length ? entries : null;
}
function derivePortableCommentAuthorType(value: Record<string, unknown>) {
const explicit = issueCommentAuthorTypeSchema.safeParse(value.authorType);
if (explicit.success) return explicit.data;
return asString(value.authorAgentSlug) ? "agent" : asString(value.authorUserId) ? "user" : "system";
}
function readPortableIssueComments(
value: unknown,
warnings: string[],
sourceLabel: string,
): CompanyPortabilityIssueCommentManifestEntry[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
warnings.push(`${sourceLabel} comments were ignored because they are not an array.`);
return [];
}
const comments: CompanyPortabilityIssueCommentManifestEntry[] = [];
for (const [index, entry] of value.entries()) {
if (!isPlainRecord(entry)) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it is not an object.`);
continue;
}
const body = asString(entry.body);
if (!body) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it has no body.`);
continue;
}
const presentation = entry.presentation == null ? null : issueCommentPresentationSchema.safeParse(entry.presentation);
if (presentation && !presentation.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid presentation metadata and was ignored.`);
continue;
}
const metadata = entry.metadata == null ? null : issueCommentMetadataSchema.safeParse(entry.metadata);
if (metadata && !metadata.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid hidden metadata and was ignored.`);
continue;
}
const createdAt = asString(entry.createdAt);
comments.push({
body,
authorType: derivePortableCommentAuthorType(entry),
authorAgentSlug: asString(entry.authorAgentSlug),
authorUserId: asString(entry.authorUserId),
presentation: presentation ? presentation.data : null,
metadata: metadata ? metadata.data : null,
createdAt: createdAt && Number.isNaN(Date.parse(createdAt)) ? null : createdAt,
});
}
return comments;
}
function appendCodexImportArg(adapterConfig: Record<string, unknown>, arg: string) {
const extraArgs = readStringArray(adapterConfig.extraArgs);
if (extraArgs) {
if (!extraArgs.includes(arg)) adapterConfig.extraArgs = [...extraArgs, arg];
return;
}
const legacyArgs = readStringArray(adapterConfig.args);
if (legacyArgs && legacyArgs.length > 0) {
if (!legacyArgs.includes(arg)) adapterConfig.args = [...legacyArgs, arg];
return;
}
if (legacyArgs?.includes(arg)) return;
adapterConfig.extraArgs = [arg];
}
function applyImportAdapterRunDefaults(
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
const next = { ...adapterConfig };
if (adapterType === "codex_local") {
appendCodexImportArg(next, "--skip-git-repo-check");
}
return next;
}
function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null {
if (!isPlainRecord(value)) return null;
const kind = asString(value.kind);
@@ -2685,6 +2779,7 @@ function buildManifestFromPackageFiles(
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
? extension.assigneeAdapterOverrides
: null,
comments: readPortableIssueComments(extension.comments, warnings, `Task ${slug}`),
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
if (frontmatter.kind && frontmatter.kind !== "task") {
@@ -2804,7 +2899,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
const nextAdapterConfig = writePaperclipSkillSyncPreference(
applyImportAdapterRunDefaults(effectiveAdapterType, adapterConfig),
desiredSkills,
);
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
delete nextAdapterConfig.instructionsFilePath;
@@ -3380,6 +3478,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
});
}
}
const comments = await issuesSvc.listComments(issue.id, { order: "asc" });
files[taskPath] = buildMarkdown(
{
name: issue.title,
@@ -3397,6 +3496,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
projectWorkspaceKey: projectWorkspaceKey ?? undefined,
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
comments: comments.length > 0
? comments.map((comment) => ({
body: comment.body,
authorType: comment.authorType,
authorAgentSlug: comment.authorAgentId ? (idToSlug.get(comment.authorAgentId) ?? null) : null,
// Portable bundles preserve author kind, but not raw board user ids.
authorUserId: null,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt instanceof Date
? comment.createdAt.toISOString()
: new Date(comment.createdAt).toISOString(),
}))
: undefined,
});
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
}
@@ -4496,7 +4609,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
issueStatus = "todo";
}
await issues.create(targetCompany.id, {
const createdIssue = await issues.create(targetCompany.id, {
projectId,
projectWorkspaceId,
title: manifestIssue.title,
@@ -4511,6 +4624,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
labelIds: manifestIssue.labelIds ?? [],
});
for (const comment of manifestIssue.comments ?? []) {
const authorAgentId = comment.authorType === "agent" && comment.authorAgentSlug
? importedSlugToAgentId.get(comment.authorAgentSlug)
?? existingSlugToAgentId.get(comment.authorAgentSlug)
?? null
: null;
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
}
const authorType = authorAgentId
? "agent"
: comment.authorType === "user"
? "user"
: "system";
await issues.addComment(createdIssue.id, comment.body, {
agentId: authorAgentId ?? undefined,
userId: authorType === "user" ? actorUserId ?? undefined : undefined,
}, {
authorType,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt,
});
}
}
}
+21
View File
@@ -89,6 +89,9 @@ type FeedbackTargetRecord = {
createdAt: Date;
authorAgentId: string | null;
authorUserId: string | null;
authorType?: string | null;
presentation?: unknown;
metadata?: unknown;
createdByRunId: string | null;
documentId: string | null;
documentKey: string | null;
@@ -797,6 +800,9 @@ async function resolveFeedbackTarget(
companyId: issueComments.companyId,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
authorType: issueComments.authorType,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
body: issueComments.body,
createdAt: issueComments.createdAt,
@@ -820,6 +826,9 @@ async function resolveFeedbackTarget(
createdAt: targetComment.createdAt,
authorAgentId: targetComment.authorAgentId,
authorUserId: targetComment.authorUserId,
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
presentation: targetComment.presentation ?? null,
metadata: targetComment.metadata ?? null,
createdByRunId: targetComment.createdByRunId ?? null,
documentId: null,
documentKey: null,
@@ -833,6 +842,9 @@ async function resolveFeedbackTarget(
createdAt: targetComment.createdAt.toISOString(),
authorAgentId: targetComment.authorAgentId,
authorUserId: targetComment.authorUserId,
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
presentation: targetComment.presentation ?? null,
metadata: targetComment.metadata ?? null,
createdByRunId: targetComment.createdByRunId ?? null,
issuePath,
targetPath: issuePath ? `${issuePath}#comment-${targetComment.id}` : null,
@@ -918,6 +930,9 @@ async function listIssueContextItems(
createdAt: issueComments.createdAt,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
authorType: issueComments.authorType,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
})
.from(issueComments)
@@ -952,6 +967,9 @@ async function listIssueContextItems(
createdAt: row.createdAt,
authorAgentId: row.authorAgentId,
authorUserId: row.authorUserId,
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
createdByRunId: row.createdByRunId ?? null,
documentId: null,
documentKey: null,
@@ -1023,6 +1041,9 @@ async function buildIssueContext(
createdAt: item.createdAt.toISOString(),
authorAgentId: item.authorAgentId,
authorUserId: item.authorUserId,
authorType: item.authorType ?? null,
presentation: item.presentation ?? null,
metadata: item.metadata ?? null,
createdByRunId: item.createdByRunId,
documentKey: item.documentKey,
documentTitle: item.documentTitle,
+356 -26
View File
@@ -26,13 +26,16 @@ import {
agentTaskSessions,
agentWakeupRequests,
activityLog,
approvals,
companySkills as companySkillsTable,
documentRevisions,
issueDocuments,
heartbeatRunEvents,
heartbeatRuns,
issueApprovals,
issueComments,
issueRelations,
issueThreadInteractions,
issues,
issueWorkProducts,
projects,
@@ -119,18 +122,33 @@ import {
import { instanceSettingsService } from "./instance-settings.js";
import {
RECOVERY_ORIGIN_KINDS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
RUN_LIVENESS_CONTINUATION_REASON,
buildRunLivenessContinuationIdempotencyKey,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffRequiredNotice,
decideRunLivenessContinuation,
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
findExistingRunLivenessContinuationWake,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
readContinuationAttempt,
} from "./recovery/index.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./recovery/model-profile-hint.js";
import { recoveryService } from "./recovery/service.js";
import { productivityReviewService } from "./productivity-review.js";
import { withAgentStartLock } from "./agent-start-lock.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { redactEventPayload } from "../redaction.js";
import {
redactCurrentUserText,
redactCurrentUserValue,
type CurrentUserRedactionOptions,
} from "../log-redaction.js";
import { redactEventPayload, redactSensitiveText } from "../redaction.js";
import {
hasSessionCompactionThresholds,
resolveSessionCompactionPolicy,
@@ -150,6 +168,16 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50;
export function redactDetectedSuccessfulRunProgressSummaryForBoard(
summary: string,
currentUserRedactionOptions?: CurrentUserRedactionOptions,
) {
const normalized = summary.replace(/\s+/g, " ").trim();
const redacted = redactSensitiveText(redactCurrentUserText(normalized, currentUserRedactionOptions));
return redacted.length <= 280 ? redacted : `${redacted.slice(0, 277)}...`;
}
const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100;
const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS;
@@ -1837,8 +1865,11 @@ async function buildPaperclipWakePayload(input: {
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdAt: issueComments.createdAt,
})
.from(issueComments)
@@ -1882,8 +1913,11 @@ async function buildPaperclipWakePayload(input: {
comments.push({
id: row.id,
issueId: row.issueId,
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
body,
bodyTruncated,
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
createdAt: row.createdAt.toISOString(),
author: row.authorAgentId
? { type: "agent", id: row.authorAgentId }
@@ -2541,6 +2575,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
projectId: input.claimed.projectId,
goalId: input.claimed.goalId,
assigneeAgentId: input.claimed.assigneeAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
originId: input.claimed.id,
originFingerprint: `issue_monitor:${input.clearReason}`,
@@ -2554,15 +2589,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
reason: "issue_monitor_recovery_issue",
idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: { issueId: recoveryIssue.id, sourceIssueId: input.claimed.id },
payload: withRecoveryModelProfileHint({ issueId: recoveryIssue.id, sourceIssueId: input.claimed.id }),
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: recoveryIssue.id,
sourceIssueId: input.claimed.id,
source: "issue.monitor.recovery_issue",
wakeReason: "issue_monitor_recovery_issue",
},
}),
});
}
@@ -2615,7 +2650,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
reason: "issue_monitor_recovery",
idempotencyKey: `issue-monitor-recovery:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: {
payload: withRecoveryModelProfileHint({
issueId: input.claimed.id,
monitorAttemptCount: input.nextAttemptCount,
monitorNotes: input.claimed.monitorNotes ?? null,
@@ -2623,10 +2658,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
}),
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: input.claimed.id,
source: "issue.monitor.recovery",
wakeReason: "issue_monitor_recovery",
@@ -2636,7 +2671,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
}),
});
await logActivity(db, {
@@ -3817,6 +3852,287 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
}
function issueUiLink(issue: Pick<typeof issues.$inferSelect, "id" | "identifier">) {
const label = issue.identifier ?? issue.id;
const prefix = issue.identifier?.split("-")[0] || "PAP";
return `[${label}](/${prefix}/issues/${label})`;
}
async function buildDetectedSuccessfulRunProgressSummary(run: typeof heartbeatRuns.$inferSelect) {
const resultJson = parseObject(run.resultJson);
const candidates = [
readNonEmptyString(run.nextAction) ? `Next action noted: ${readNonEmptyString(run.nextAction)}` : null,
readNonEmptyString(run.livenessReason),
readNonEmptyString(resultJson.summary),
readNonEmptyString(resultJson.result),
readNonEmptyString(resultJson.message),
].filter((value): value is string => Boolean(value));
const summary = candidates[0];
if (!summary) return null;
return redactDetectedSuccessfulRunProgressSummaryForBoard(
summary,
await getCurrentUserRedactionOptions(),
);
}
async function addSuccessfulRunHandoffCommentOnce(input: {
issue: Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
run: typeof heartbeatRuns.$inferSelect;
agent: Pick<typeof agents.$inferSelect, "id" | "name">;
detectedProgressSummary: string;
}) {
const existing = await db
.select({ id: issueComments.id })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.run.companyId),
eq(issueComments.issueId, input.issue.id),
eq(issueComments.createdByRunId, input.run.id),
sql`(${issueComments.body} = ${SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY} or ${issueComments.body} like '## This issue still needs a next step%' or ${issueComments.body} like '## Successful run missing issue disposition%')`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
if (existing) return null;
const notice = buildSuccessfulRunHandoffRequiredNotice(input);
return issuesSvc.addComment(
input.issue.id,
notice.body,
{ runId: input.run.id },
{
authorType: "system",
presentation: notice.presentation,
metadata: notice.metadata,
},
);
}
async function handleSuccessfulRunHandoff(run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect) {
if (run.status !== "succeeded") return;
const context = parseObject(run.contextSnapshot);
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
if (!issueId) return;
const issue = await db
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
executionState: issues.executionState,
projectId: issues.projectId,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.then((rows) => rows[0] ?? null);
const idempotencyKey = issue
? buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: issue.id,
sourceRunId: run.id,
})
: null;
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
const detectedProgressSummary = await buildDetectedSuccessfulRunProgressSummary(run);
const [
activeExecutionPath,
queuedWake,
pendingInteraction,
pendingApproval,
explicitBlocker,
openRecoveryIssue,
existingWake,
budgetBlock,
pauseHold,
] = await Promise.all([
issue
? db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, issue.companyId),
eq(heartbeatRuns.agentId, run.agentId),
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
sql`(
${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}
or ${heartbeatRuns.contextSnapshot} ->> 'taskId' = ${issue.id}
)`,
sql`${heartbeatRuns.id} <> ${run.id}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, issue.companyId),
eq(agentWakeupRequests.agentId, run.agentId),
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
sql`(
${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}
or ${agentWakeupRequests.payload} ->> 'taskId' = ${issue.id}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issue.id}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issue.id}
)`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.status, "pending"),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueApprovals.approvalId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, issue.companyId),
eq(issueApprovals.issueId, issue.id),
inArray(approvals.status, ["pending", "revision_requested"]),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueRelations.issueId })
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, issue.companyId),
eq(issueRelations.relatedIssueId, issue.id),
eq(issueRelations.type, "blocks"),
sql`exists (
select 1
from issues blocker
where blocker.id = ${issueRelations.issueId}
and blocker.company_id = ${issue.companyId}
and blocker.status not in ('done', 'cancelled')
and blocker.hidden_at is null
)`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issues.id })
.from(issues)
.where(
and(
eq(issues.companyId, issue.companyId),
inArray(issues.originKind, [
RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
]),
eq(issues.originId, issue.id),
isNull(issues.hiddenAt),
notInArray(issues.status, ["done", "cancelled"]),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
idempotencyKey
? findExistingFinishSuccessfulRunHandoffWake(db, {
companyId: run.companyId,
idempotencyKey,
})
: Promise.resolve(null),
issue
? budgets.getInvocationBlock(issue.companyId, run.agentId, {
issueId: issue.id,
projectId: issue.projectId,
})
: Promise.resolve(null),
issue
? treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id)
: Promise.resolve(null),
]);
const decision = decideSuccessfulRunHandoff({
run,
issue,
agent,
livenessState: run.livenessState as RunLivenessState | null,
detectedProgressSummary,
taskKey,
hasActiveExecutionPath: Boolean(activeExecutionPath),
hasQueuedWake: Boolean(queuedWake),
hasPendingInteractionOrApproval: Boolean(pendingInteraction || pendingApproval),
hasExplicitBlockerPath: Boolean(explicitBlocker),
hasOpenRecoveryIssue: Boolean(openRecoveryIssue),
hasPauseHold: Boolean(pauseHold),
budgetBlocked: Boolean(budgetBlock),
idempotentWakeExists: Boolean(existingWake),
});
if (decision.kind !== "enqueue" || !issue) return;
const handoffRun = await enqueueWakeup(run.agentId, {
source: "automation",
triggerDetail: "system",
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
idempotencyKey: decision.idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
});
if (!handoffRun) return;
await addSuccessfulRunHandoffCommentOnce({
issue,
run,
agent,
detectedProgressSummary: detectedProgressSummary ?? "The run reported progress, but did not choose a next step.",
});
await logActivity(db, {
companyId: issue.companyId,
actorType: "system",
actorId: "heartbeat",
agentId: run.agentId,
runId: run.id,
action: "issue.successful_run_handoff_required",
entityType: "issue",
entityId: issue.id,
details: {
label: "Successful run missing issue disposition",
sourceRunId: run.id,
correctiveRunId: handoffRun.id,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
detectedProgressSummary,
issue: issueUiLink(issue),
},
});
}
async function appendRunEvent(
run: typeof heartbeatRuns.$inferSelect,
seq: number,
@@ -3998,13 +4314,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const contextSnapshot = parseObject(run.contextSnapshot);
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
const retryContextSnapshot = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "missing_issue_comment",
retryReason: "missing_issue_comment",
missingIssueCommentForRunId: run.id,
};
});
const now = new Date();
const retryRun = await db.transaction(async (tx) => {
@@ -4027,11 +4343,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: "missing_issue_comment",
payload: {
payload: withRecoveryModelProfileHint({
issueId,
retryOfRunId: run.id,
retryReason: "missing_issue_comment",
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -4219,12 +4535,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const issueId = readNonEmptyString(contextSnapshot.issueId);
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
const retryContextSnapshot = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "process_lost_retry",
retryReason: "process_lost",
};
});
const queued = await db.transaction(async (tx) => {
const wakeupRequest = await tx
@@ -4235,10 +4551,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: "process_lost_retry",
payload: {
payload: withRecoveryModelProfileHint({
...(issueId ? { issueId } : {}),
retryOfRunId: run.id,
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -4675,7 +4991,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot: Record<string, unknown> = {
const retryContextSnapshot: Record<string, unknown> = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason,
@@ -4685,7 +5001,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
scheduledRetryAt: schedule.dueAt.toISOString(),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
};
});
const maxTurnContinuationIdempotencyKey = retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}`
: null;
@@ -4846,7 +5162,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: wakeReason,
payload: {
payload: withRecoveryModelProfileHint({
...(issueId ? { issueId } : {}),
retryOfRunId: run.id,
retryReason,
@@ -4855,7 +5171,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
scheduledRetryAt: schedule.dueAt.toISOString(),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -6171,6 +6487,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.select({
id: issueComments.id,
body: issueComments.body,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
})
.from(issueComments)
.where(and(
@@ -7235,9 +7556,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
} else if (outcome === "failed" && readTransientRecoveryContractFromRun(livenessRun)) {
await scheduleBoundedRetryForRun(livenessRun, agent);
}
await finalizeIssueCommentPolicy(livenessRun, agent);
const issueCommentPolicyResult = await finalizeIssueCommentPolicy(livenessRun, agent);
await releaseIssueExecutionAndPromote(livenessRun);
await handleRunLivenessContinuation(livenessRun);
await handleSuccessfulRunHandoff(
issueCommentPolicyResult.outcome === "retry_queued" || issueCommentPolicyResult.outcome === "retry_exhausted"
? {
...livenessRun,
issueCommentStatus: issueCommentPolicyResult.outcome,
}
: livenessRun,
agent,
);
}
if (finalizedRun) {
@@ -7739,10 +8069,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: recoveryReason,
payload: {
payload: withRecoveryModelProfileHint({
issueId: issue.id,
retryOfRunId: run.id,
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -7760,14 +8090,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
wakeReason: recoveryReason,
retryReason,
source: recoverySource,
retryOfRunId: run.id,
},
}),
sessionIdBefore: recoverySessionBefore,
retryOfRunId: run.id,
updatedAt: now,
+61 -1
View File
@@ -28,6 +28,9 @@ import {
projects,
} from "@paperclipai/db";
import type {
IssueCommentAuthorType,
IssueCommentMetadata,
IssueCommentPresentation,
IssueBlockerAttention,
IssueProductivityReview,
IssueProductivityReviewTrigger,
@@ -37,6 +40,9 @@ import {
clampIssueRequestDepth,
extractAgentMentionIds,
extractProjectMentionIds,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
isUuidLike,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
} from "@paperclipai/shared";
@@ -1679,10 +1685,47 @@ export function issueService(db: Db) {
return enriched;
}
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
function deriveIssueCommentAuthorType(comment: {
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
}): IssueCommentAuthorType {
const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType);
if (explicit.success) return explicit.data;
if (comment.authorAgentId) return "agent";
if (comment.authorUserId) return "user";
return "system";
}
function assertIssueCommentAuthorTypeAllowed(
actor: { agentId?: string | null; userId?: string | null },
authorType: IssueCommentAuthorType,
) {
if (actor.agentId && authorType !== "agent") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (actor.userId && authorType !== "user") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (!actor.agentId && !actor.userId && authorType !== "system") {
throw unprocessable("System comments cannot use user or agent authorType without an author id");
}
}
function redactIssueComment<T extends { body: string; authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; presentation?: unknown; metadata?: unknown }>(
comment: T,
censorUsernameInLogs: boolean,
): T & {
authorType: IssueCommentAuthorType;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
} {
return {
...comment,
authorType: deriveIssueCommentAuthorType(comment),
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
presentation: issueCommentPresentationSchema.nullable().catch(null).parse(comment.presentation ?? null),
metadata: issueCommentMetadataSchema.nullable().catch(null).parse(comment.metadata ?? null),
};
}
@@ -3743,6 +3786,12 @@ export function issueService(db: Db) {
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
options?: {
authorType?: IssueCommentAuthorType | null;
presentation?: IssueCommentPresentation | null;
metadata?: IssueCommentMetadata | null;
createdAt?: Date | string | null;
},
) => {
const issue = await db
.select({ companyId: issues.companyId })
@@ -3756,6 +3805,13 @@ export function issueService(db: Db) {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const authorType = issueCommentAuthorTypeSchema.parse(
options?.authorType ?? (actor.agentId ? "agent" : actor.userId ? "user" : "system"),
);
assertIssueCommentAuthorTypeAllowed(actor, authorType);
const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null);
const metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? null);
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
const [comment] = await db
.insert(issueComments)
.values({
@@ -3763,8 +3819,12 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
authorType,
createdByRunId: actor.runId ?? null,
body: redactedBody,
presentation,
metadata,
...(createdAt && !Number.isNaN(createdAt.getTime()) ? { createdAt } : {}),
})
.returning();
+9 -4
View File
@@ -14,6 +14,10 @@ import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { budgetService } from "./budgets.js";
import { issueService } from "./issues.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./recovery/model-profile-hint.js";
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
@@ -687,6 +691,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
goalId: evidence.sourceIssue.goalId,
billingCode: evidence.sourceIssue.billingCode,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
originId: evidence.sourceIssue.id,
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
@@ -732,21 +737,21 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: review.id,
sourceIssueId: evidence.sourceIssue.id,
trigger: evidence.trigger,
},
}),
requestedByActorType: "system",
requestedByActorId: "productivity_review",
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: review.id,
taskId: review.id,
wakeReason: "issue_assigned",
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
sourceIssueId: evidence.sourceIssue.id,
productivityReviewTrigger: evidence.trigger,
},
}),
});
}
+20
View File
@@ -42,3 +42,23 @@ export {
export type {
RunContinuationDecision,
} from "./run-liveness-continuations.js";
export {
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_OPTIONS,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffExhaustedNotice,
buildSuccessfulRunHandoffInstruction,
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
isSuccessfulRunHandoffRequiredNoticeBody,
} from "./successful-run-handoff.js";
export type {
SuccessfulRunHandoffNotice,
SuccessfulRunHandoffDecision,
} from "./successful-run-handoff.js";
@@ -0,0 +1,14 @@
export const RECOVERY_MODEL_PROFILE_KEY = "cheap" as const;
export function withRecoveryModelProfileHint<T extends Record<string, unknown>>(
input: T,
): T & { modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY } {
return {
...input,
modelProfile: RECOVERY_MODEL_PROFILE_KEY,
};
}
export function recoveryAssigneeAdapterOverrides() {
return { modelProfile: RECOVERY_MODEL_PROFILE_KEY };
}
@@ -2,6 +2,7 @@ import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
import type { RunLivenessState } from "@paperclipai/shared";
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
import { RECOVERY_REASON_KINDS } from "./origins.js";
export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation;
@@ -155,7 +156,7 @@ export function decideRunLivenessContinuation(input: {
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
}
const payload = {
const payload = withRecoveryModelProfileHint({
issueId: issue.id,
sourceRunId: run.id,
livenessState,
@@ -165,14 +166,14 @@ export function decideRunLivenessContinuation(input: {
instruction:
nextAction ??
"The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.",
};
});
return {
kind: "enqueue",
nextAttempt,
idempotencyKey,
payload,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
taskKey: issue.id,
@@ -183,6 +184,6 @@ export function decideRunLivenessContinuation(input: {
livenessContinuationState: livenessState,
livenessContinuationReason: livenessReason,
livenessContinuationInstruction: payload.instruction,
},
}),
};
}
+196 -29
View File
@@ -32,6 +32,13 @@ import { instanceSettingsService } from "../instance-settings.js";
import { issueTreeControlService } from "../issue-tree-control.js";
import { issueService } from "../issues.js";
import { getRunLogStore } from "../run-log-store.js";
import {
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildSuccessfulRunHandoffExhaustedNotice,
type SuccessfulRunHandoffNotice,
} from "./successful-run-handoff.js";
import {
RECOVERY_ORIGIN_KINDS,
buildIssueGraphLivenessLeafKey,
@@ -42,6 +49,10 @@ import {
classifyIssueGraphLiveness,
type IssueLivenessFinding,
} from "./issue-graph-liveness.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./model-profile-hint.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
@@ -76,6 +87,16 @@ type LatestIssueRun = Pick<
> | null;
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
type SuccessfulRunHandoffRecoveryEvidence = {
sourceRunId: string | null;
correctiveRunId: string;
missingDisposition: string;
handoffAttempt: number;
maxHandoffAttempts: number;
};
type WatchdogDecisionActor =
| { type: "board"; userId?: string | null; runId?: string | null }
| { type: "agent"; agentId?: string | null; runId?: string | null }
@@ -123,6 +144,39 @@ function didAutomaticRecoveryFail(
);
}
function successfulRunHandoffRecoveryEvidence(latestRun: LatestIssueRun): SuccessfulRunHandoffRecoveryEvidence | null {
if (!latestRun) return null;
const context = parseObject(latestRun.contextSnapshot);
const wakeReason = readNonEmptyString(context.wakeReason);
const handoffReason = readNonEmptyString(context.handoffReason);
const isSuccessfulRunHandoff =
wakeReason === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON ||
handoffReason === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
asBoolean(context.handoffRequired, false) === true;
if (!isSuccessfulRunHandoff) return null;
const handoffAttempt = asNumber(context.handoffAttempt, 1);
const maxHandoffAttempts = asNumber(
context.maxHandoffAttempts,
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
);
return {
sourceRunId: readNonEmptyString(context.sourceRunId) ?? readNonEmptyString(context.resumeFromRunId),
correctiveRunId: latestRun.id,
missingDisposition: readNonEmptyString(context.missingDisposition) ?? "clear_next_step",
handoffAttempt,
maxHandoffAttempts,
};
}
function isExhaustedSuccessfulRunHandoff(latestRun: LatestIssueRun) {
const evidence = successfulRunHandoffRecoveryEvidence(latestRun);
if (!evidence) return null;
if (evidence.handoffAttempt < evidence.maxHandoffAttempts) return { ...evidence, exhausted: false };
return { ...evidence, exhausted: true };
}
function issueIdFromRunContext(contextSnapshot: unknown) {
const context = parseObject(contextSnapshot);
return readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
@@ -145,6 +199,11 @@ function runUiLink(run: { id: string; agentId: string }, prefix: string) {
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
}
function agentUiLink(agent: { id: string; name: string | null } | null, prefix: string) {
if (!agent) return "unknown";
return `[${agent.name ?? agent.id}](/${prefix}/agents/${agent.id})`;
}
function formatDuration(ms: number | null) {
if (ms === null) return "unknown";
const minutes = Math.floor(ms / 60_000);
@@ -391,20 +450,20 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "automation",
triggerDetail: "system",
reason: input.reason,
payload: {
payload: withRecoveryModelProfileHint({
issueId: input.issueId,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: input.issueId,
taskId: input.issueId,
wakeReason: input.reason,
retryReason: input.retryReason,
source: input.source,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
}),
});
if (queued && input.retryOfRunId) {
@@ -427,18 +486,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: issue.id,
mutation: "assigned_todo_liveness_dispatch",
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
},
}),
});
}
@@ -542,18 +601,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "automation",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: candidate.id,
mutation: "unassigned_blocker_recovery",
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: candidate.id,
taskId: candidate.id,
wakeReason: "issue_assigned",
source: "issue.unassigned_blocker_recovery",
},
}),
});
if (queued) {
@@ -995,6 +1054,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
goalId: sourceIssue?.goalId ?? null,
billingCode: sourceIssue?.billingCode ?? null,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
originId: input.run.id,
originRunId: input.run.id,
@@ -1036,21 +1096,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: evaluation.id,
staleRunId: input.run.id,
sourceIssueId: sourceIssue?.id ?? null,
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: evaluation.id,
taskId: evaluation.id,
wakeReason: "issue_assigned",
source: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
staleRunId: input.run.id,
sourceIssueId: sourceIssue?.id ?? null,
},
}),
});
}
return { kind: "created" as const, evaluationIssueId: evaluation.id };
@@ -1294,11 +1354,45 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
prefix: string;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
sourceAssignee?: Pick<typeof agents.$inferSelect, "id" | "name"> | null;
}) {
const sourceIssue = issueUiLink({ identifier: input.issue.identifier, id: input.issue.id }, input.prefix);
const runLink = input.latestRun
? `[\`${input.latestRun.id}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${input.latestRun.id})`
: "none";
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON) {
const sourceRunId = input.successfulRunHandoffEvidence?.sourceRunId;
const sourceRunLink = sourceRunId && input.latestRun
? `[\`${sourceRunId}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${sourceRunId})`
: "unknown";
const missingDisposition = input.successfulRunHandoffEvidence?.missingDisposition ?? "clear_next_step";
return [
"Paperclip exhausted the bounded corrective handoff for a successful run that still has no valid issue disposition.",
"",
"This is not a runtime/adapter crash report. The source run succeeded; the remaining problem is the missing `done`, `in_review`, `blocked`, delegated follow-up, or explicit continuation path.",
"",
"## Safe Evidence",
"",
`- Source issue: ${sourceIssue}`,
`- Source run: ${sourceRunLink}`,
`- Corrective handoff run: ${runLink}`,
`- Source assignee: ${agentUiLink(input.sourceAssignee ?? null, input.prefix)}`,
`- Latest issue status: \`${input.issue.status}\``,
`- Latest handoff run status: \`${input.latestRun?.status ?? "unknown"}\``,
`- Normalized cause: \`${SUCCESSFUL_RUN_MISSING_STATE_REASON}\``,
`- Missing disposition: \`${missingDisposition}\``,
"- Suggested manager action: choose and record a valid issue disposition without copying transcript content.",
"",
"## Required Action",
"",
"- Inspect the source issue and run metadata, not raw transcript excerpts.",
"- Choose a valid issue disposition: `done`/`cancelled`, `in_review` with an owner, `blocked` with first-class blockers, delegated follow-up work, or an explicit continuation path.",
"- When the source issue has a clear owner and disposition, mark this recovery issue done.",
].join("\n");
}
const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "unknown";
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
@@ -1331,6 +1425,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) return null;
@@ -1341,15 +1437,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (!ownerAgentId) return null;
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
let recovery: Awaited<ReturnType<typeof issuesSvc.create>>;
try {
recovery = await issuesSvc.create(input.issue.companyId, {
title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
title: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? `Recover missing next step ${input.issue.identifier ?? input.issue.title}`
: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
description: buildStrandedIssueRecoveryDescription({
issue: input.issue,
latestRun: input.latestRun,
previousStatus: input.previousStatus,
prefix,
recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
sourceAssignee,
}),
status: "todo",
priority: input.issue.priority,
@@ -1357,6 +1460,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
projectId: input.issue.projectId,
goalId: input.issue.goalId,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
originId: input.issue.id,
originRunId: input.latestRun?.id ?? null,
@@ -1364,6 +1468,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
input.issue.companyId,
input.issue.id,
recoveryCause,
input.latestRun?.id ?? "no-run",
].join(":"),
billingCode: input.issue.billingCode,
@@ -1380,21 +1485,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: recovery.id,
sourceIssueId: input.issue.id,
strandedRunId: input.latestRun?.id ?? null,
},
recoveryCause,
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: recovery.id,
taskId: recovery.id,
wakeReason: "issue_assigned",
source: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
sourceIssueId: input.issue.id,
strandedRunId: input.latestRun?.id ?? null,
},
recoveryCause,
}),
});
return recovery;
@@ -1512,7 +1619,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
latestRun: LatestIssueRun;
comment: string;
comment?: string;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) {
return escalateStrandedRecoveryIssueInPlace({
@@ -1526,6 +1635,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
const nextBlockerIds = recoveryIssue
@@ -1538,10 +1649,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (!updated) return null;
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const recoveryOwner = recoveryIssue?.assigneeAgentId ? await getAgent(recoveryIssue.assigneeAgentId) : null;
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
let notice: SuccessfulRunHandoffNotice | null = null;
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) {
notice = buildSuccessfulRunHandoffExhaustedNotice({
issue: input.issue,
sourceRun: input.successfulRunHandoffEvidence.sourceRunId
? { id: input.successfulRunHandoffEvidence.sourceRunId, status: "succeeded" }
: null,
correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null,
sourceAssignee,
recoveryIssue,
recoveryOwner,
latestIssueStatus: input.issue.status,
latestHandoffRunStatus: input.latestRun?.status ?? "unknown",
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
});
}
const recoveryLine = recoveryIssue
? [
"",
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.",
].join("\n")
: [
@@ -1550,7 +1680,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
].join("\n");
await issuesSvc.addComment(input.issue.id, `${input.comment}${recoveryLine}`, {});
if (notice) {
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
authorType: "system",
presentation: notice.presentation,
metadata: notice.metadata,
});
} else {
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {});
}
await logActivity(db, {
companyId: input.issue.companyId,
@@ -1558,14 +1696,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
actorId: "system",
agentId: null,
runId: null,
action: "issue.updated",
action: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "issue.successful_run_handoff_escalated"
: "issue.updated",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
status: "blocked",
previousStatus: input.previousStatus,
source: "recovery.reconcile_stranded_assigned_issue",
source: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "recovery.reconcile_successful_run_handoff_missing_state"
: "recovery.reconcile_stranded_assigned_issue",
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
latestRunId: input.latestRun?.id ?? null,
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
@@ -1596,6 +1739,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
productiveContinuationObserved: 0,
successfulContinuationObserved: 0,
orphanBlockersAssigned: 0,
successfulRunHandoffEscalated: 0,
escalated: 0,
skipped: 0,
issueIds: [] as string[],
@@ -1713,6 +1857,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
result.skipped += 1;
continue;
}
const handoffEvidence = isExhaustedSuccessfulRunHandoff(latestRun);
if (handoffEvidence) {
if (!handoffEvidence.exhausted) {
result.skipped += 1;
continue;
}
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
successfulRunHandoffEvidence: handoffEvidence,
});
if (updated) {
result.successfulRunHandoffEscalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (isSuccessfulInProgressContinuationRun(latestRun)) {
const successfulRun = latestRun;
@@ -2393,6 +2559,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
projectId: recoveryIssue.projectId,
goalId: recoveryIssue.goalId,
assigneeAgentId: ownerSelection.agentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
originId: input.finding.incidentKey,
originFingerprint: livenessRecoveryLeafFingerprint(input.finding),
@@ -2473,15 +2640,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: escalation.id,
sourceIssueId: issue.id,
recoveryIssueId: recoveryIssue.id,
incidentKey: input.finding.incidentKey,
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: escalation.id,
taskId: escalation.id,
wakeReason: "issue_assigned",
@@ -2489,7 +2656,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
sourceIssueId: issue.id,
recoveryIssueId: recoveryIssue.id,
incidentKey: input.finding.incidentKey,
},
}),
});
logger.warn({
@@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import {
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffExhaustedNotice,
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
isIdempotentFinishSuccessfulRunHandoffWakeStatus,
isSuccessfulRunHandoffRequiredNoticeBody,
} from "./successful-run-handoff.js";
const run = {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
status: "succeeded",
contextSnapshot: { issueId: "issue-1" },
} as any;
const issue = {
id: "issue-1",
companyId: "company-1",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
assigneeAgentId: "agent-1",
assigneeUserId: null,
executionState: null,
} as any;
const agent = {
id: "agent-1",
companyId: "company-1",
status: "idle",
} as any;
function decide(overrides: Partial<Parameters<typeof decideSuccessfulRunHandoff>[0]> = {}) {
return decideSuccessfulRunHandoff({
run,
issue,
agent,
livenessState: "advanced",
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
taskKey: "issue-1",
hasActiveExecutionPath: false,
hasQueuedWake: false,
hasPendingInteractionOrApproval: false,
hasExplicitBlockerPath: false,
hasOpenRecoveryIssue: false,
hasPauseHold: false,
budgetBlocked: false,
idempotentWakeExists: false,
...overrides,
});
}
describe("successful run handoff decision", () => {
it("queues one corrective handoff wake for a successful progress run without a visible next action", () => {
const decision = decide();
expect(decision.kind).toBe("enqueue");
if (decision.kind !== "enqueue") return;
expect(decision.idempotencyKey).toBe("finish_successful_run_handoff:issue-1:run-1:1");
expect(decision.payload).toMatchObject({
issueId: "issue-1",
sourceRunId: "run-1",
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
resumeIntent: true,
resumeFromRunId: "run-1",
modelProfile: "cheap",
});
expect(decision.contextSnapshot).toMatchObject({
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
handoffRequired: true,
modelProfile: "cheap",
});
expect(decision.instruction).toContain("Resolve the missing disposition before creating or revising any new artifacts");
expect(decision.instruction).toContain("Choose **exactly one** outcome");
expect(decision.instruction).toContain("record an explicit continuation path");
});
it("does not queue when the issue already has a valid disposition", () => {
expect(decide({ issue: { ...issue, status: "done" } as any })).toEqual({
kind: "skip",
reason: "issue status done is a valid disposition",
});
});
it("does not queue when a successful run records an accepted next-action path", () => {
expect(decide({ issue: { ...issue, status: "in_review" } as any })).toEqual({
kind: "skip",
reason: "issue status in_review is a valid disposition",
});
expect(decide({ issue: { ...issue, status: "blocked" } as any })).toEqual({
kind: "skip",
reason: "issue status blocked is a valid disposition",
});
expect(decide({ hasPendingInteractionOrApproval: true })).toEqual({
kind: "skip",
reason: "pending interaction or approval owns the next action",
});
expect(decide({ hasActiveExecutionPath: true })).toEqual({
kind: "skip",
reason: "issue already has an active execution path",
});
});
it("does not queue when another wake or dependency path already owns the next action", () => {
expect(decide({ hasQueuedWake: true })).toEqual({
kind: "skip",
reason: "issue already has a queued or deferred wake",
});
expect(decide({ hasExplicitBlockerPath: true })).toEqual({
kind: "skip",
reason: "explicit blocker path owns the next action",
});
});
it("does not queue when a successful run has no progress signal", () => {
expect(decide({ livenessState: null, detectedProgressSummary: null })).toEqual({
kind: "skip",
reason: "successful run did not produce handoff-relevant progress",
});
});
it("does not treat adapter or runtime failures as missing-disposition handoffs", () => {
expect(decide({ run: { ...run, status: "failed", errorCode: "adapter_failed" } as any })).toEqual({
kind: "skip",
reason: "source run did not succeed",
});
});
it("does not queue on missing-comment retry bookkeeping runs", () => {
expect(decide({ run: { ...run, issueCommentStatus: "retry_exhausted" } as any })).toEqual({
kind: "skip",
reason: "missing issue comment retry owns the next action",
});
});
it("does not loop from a corrective handoff run", () => {
expect(decide({
run: {
...run,
id: "run-2",
contextSnapshot: {
issueId: "issue-1",
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
handoffRequired: true,
},
} as any,
})).toEqual({
kind: "skip",
reason: "source run is already a corrective handoff run",
});
});
it("does not queue for issue monitor maintenance runs", () => {
expect(decide({
run: {
...run,
contextSnapshot: {
issueId: "issue-1",
source: "issue.monitor",
wakeReason: "issue_monitor_due",
},
} as any,
})).toEqual({
kind: "skip",
reason: "issue monitor run owns its own recovery path",
});
});
it("uses a stable one-attempt idempotency key", () => {
expect(buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: "issue-1",
sourceRunId: "run-1",
})).toBe("finish_successful_run_handoff:issue-1:run-1:1");
});
it("allows failed or cancelled corrective wakes to be retried", () => {
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("queued")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("claimed")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("completed")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("failed")).toBe(false);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("cancelled")).toBe(false);
});
it("builds the required system notice with hidden structured metadata", () => {
const notice = buildSuccessfulRunHandoffRequiredNotice({
issue: {
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
} as any,
run: {
id: "22222222-2222-4222-8222-222222222222",
status: "succeeded",
} as any,
agent: {
id: "33333333-3333-4333-8333-333333333333",
name: "CodexCoder",
} as any,
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
});
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(notice.presentation).toEqual({
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
});
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Required action",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: "PAP-1" }),
expect.objectContaining({ type: "agent_link", name: "CodexCoder" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", runId: "22222222-2222-4222-8222-222222222222" }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Detected progress" }),
]),
}),
]));
});
it("builds the exhausted system notice with recovery metadata", () => {
const notice = buildSuccessfulRunHandoffExhaustedNotice({
issue: {
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
} as any,
sourceRun: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded" } as any,
correctiveRun: { id: "44444444-4444-4444-8444-444444444444", status: "failed" } as any,
sourceAssignee: { id: "33333333-3333-4333-8333-333333333333", name: "CodexCoder" } as any,
recoveryIssue: {
id: "55555555-5555-4555-8555-555555555555",
identifier: "PAP-2",
title: "Recover missing next step PAP-1",
status: "todo",
} as any,
recoveryOwner: { id: "66666666-6666-4666-8666-666666666666", name: "CTO" } as any,
latestIssueStatus: "in_progress",
latestHandoffRunStatus: "failed",
missingDisposition: "clear_next_step",
});
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
expect(notice.presentation).toMatchObject({
kind: "system_notice",
tone: "danger",
detailsDefaultOpen: false,
});
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: "PAP-2" }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CTO" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", label: "Source run" }),
expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
]));
});
it("recognizes new notices and legacy markdown headings for fallback deduplication", () => {
expect(isSuccessfulRunHandoffRequiredNoticeBody(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("## Successful run missing issue disposition\n\nold body")).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("## This issue still needs a next step\n\nold body")).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("Unrelated comment")).toBe(false);
});
});
@@ -0,0 +1,405 @@
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
import type { IssueCommentMetadata, IssueCommentPresentation, RunLivenessState } from "@paperclipai/shared";
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
export const FINISH_SUCCESSFUL_RUN_HANDOFF_REASON = "finish_successful_run_handoff";
export const SUCCESSFUL_RUN_MISSING_STATE_REASON = "successful_run_missing_state";
export const DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS = 1;
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY =
"Paperclip needs a disposition before this issue can continue.";
export const SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY =
"Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.";
export const LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES = [
"## This issue still needs a next step",
"## Successful run missing issue disposition",
] as const;
export const SUCCESSFUL_RUN_HANDOFF_OPTIONS = [
"mark_done_or_cancelled",
"send_for_review_or_ask_for_input",
"mark_blocked",
"delegate_or_continue_from_checkpoint",
] as const;
const PRODUCTIVE_SUCCESS_LIVENESS_STATES = new Set<RunLivenessState>([
"advanced",
"completed",
"blocked",
"needs_followup",
]);
const IDEMPOTENT_HANDOFF_WAKE_STATUSES = [
"queued",
"deferred_issue_execution",
"claimed",
"completed",
];
const IDEMPOTENT_HANDOFF_WAKE_STATUS_SET = new Set<string>(IDEMPOTENT_HANDOFF_WAKE_STATUSES);
export function isIdempotentFinishSuccessfulRunHandoffWakeStatus(status: string) {
return IDEMPOTENT_HANDOFF_WAKE_STATUS_SET.has(status);
}
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
type IssueRow = Pick<
typeof issues.$inferSelect,
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId" | "executionState"
>;
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
type NoticeIssue = Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
type NoticeRun = Pick<typeof heartbeatRuns.$inferSelect, "id" | "status">;
type NoticeAgent = Pick<typeof agents.$inferSelect, "id" | "name">;
type NullableNoticeAgent = NoticeAgent | null | undefined;
type NullableNoticeIssue = NoticeIssue | null | undefined;
type NullableNoticeRun = NoticeRun | null | undefined;
export type SuccessfulRunHandoffNotice = {
body: string;
presentation: IssueCommentPresentation;
metadata: IssueCommentMetadata;
};
export type SuccessfulRunHandoffDecision =
| {
kind: "enqueue";
idempotencyKey: string;
payload: Record<string, unknown>;
contextSnapshot: Record<string, unknown>;
instruction: string;
}
| {
kind: "skip";
reason: string;
};
function metadataText(value: unknown, fallback = "unknown") {
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
const resolved = text.length > 0 ? text : fallback;
return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved;
}
function keyValueRow(label: string, value: unknown): IssueCommentMetadata["sections"][number]["rows"][number] {
return { type: "key_value", label, value: metadataText(value) };
}
function issueLinkRow(
label: string,
issue: NullableNoticeIssue,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!issue) return keyValueRow(label, "unknown");
return {
type: "issue_link",
label,
issueId: issue.id,
identifier: issue.identifier,
title: issue.title,
};
}
function runLinkRow(
label: string,
run: NullableNoticeRun,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!run) return keyValueRow(label, "unknown");
return { type: "run_link", label, runId: run.id, title: run.status };
}
function agentLinkRow(
label: string,
agent: NullableNoticeAgent,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!agent) return keyValueRow(label, "unknown");
return { type: "agent_link", label, agentId: agent.id, name: agent.name };
}
function systemNoticePresentation(input: {
tone: IssueCommentPresentation["tone"];
title: string;
}): IssueCommentPresentation {
return {
kind: "system_notice",
tone: input.tone,
title: input.title,
detailsDefaultOpen: false,
};
}
export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) {
const trimmed = body.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY ||
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
}
export function buildSuccessfulRunHandoffRequiredNotice(input: {
issue: NoticeIssue;
run: NoticeRun;
agent: NoticeAgent;
detectedProgressSummary: string;
}): SuccessfulRunHandoffNotice {
return {
body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
presentation: systemNoticePresentation({
tone: "warning",
title: "Missing issue disposition",
}),
metadata: {
version: 1,
sections: [
{
title: "Required action",
rows: [
issueLinkRow("Source issue", input.issue),
agentLinkRow("Assignee", input.agent),
keyValueRow("Missing disposition", "clear_next_step"),
keyValueRow(
"Valid dispositions",
"done, cancelled, in_review with an owner, blocked with blockers, delegated follow-up, or explicit continuation",
),
],
},
{
title: "Run evidence",
rows: [
runLinkRow("Successful run", input.run),
keyValueRow("Run status", input.run.status),
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
keyValueRow("Detected progress", input.detectedProgressSummary),
keyValueRow("Automatic retry", "one corrective handoff wake queued"),
],
},
],
},
};
}
export function buildSuccessfulRunHandoffExhaustedNotice(input: {
issue: NoticeIssue;
sourceRun: NullableNoticeRun;
correctiveRun: NullableNoticeRun;
sourceAssignee: NullableNoticeAgent;
recoveryIssue: NullableNoticeIssue;
recoveryOwner: NullableNoticeAgent;
latestIssueStatus: string;
latestHandoffRunStatus: string;
missingDisposition: string;
}): SuccessfulRunHandoffNotice {
return {
body: SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
presentation: systemNoticePresentation({
tone: "danger",
title: "Missing disposition recovery blocked",
}),
metadata: {
version: 1,
sections: [
{
title: "Recovery owner",
rows: [
issueLinkRow("Source issue", input.issue),
issueLinkRow("Recovery issue", input.recoveryIssue),
agentLinkRow("Recovery owner", input.recoveryOwner),
agentLinkRow("Source assignee", input.sourceAssignee),
keyValueRow("Suggested action", "choose and record a valid issue disposition without copying transcript content"),
],
},
{
title: "Run evidence",
rows: [
runLinkRow("Source run", input.sourceRun),
runLinkRow("Corrective handoff run", input.correctiveRun),
keyValueRow("Latest issue status", input.latestIssueStatus),
keyValueRow("Latest handoff run status", input.latestHandoffRunStatus),
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
keyValueRow("Missing disposition", input.missingDisposition),
],
},
],
},
};
}
export function buildFinishSuccessfulRunHandoffIdempotencyKey(input: {
issueId: string;
sourceRunId: string;
attempt?: number;
}) {
return [
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
input.issueId,
input.sourceRunId,
String(input.attempt ?? 1),
].join(":");
}
export async function findExistingFinishSuccessfulRunHandoffWake(
db: Db,
input: {
companyId: string;
idempotencyKey: string;
},
) {
return db
.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, input.companyId),
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
inArray(agentWakeupRequests.status, IDEMPOTENT_HANDOFF_WAKE_STATUSES),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
}
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isCorrectiveHandoffRun(run: HeartbeatRunRow) {
const context = readRecord(run.contextSnapshot);
return context.handoffRequired === true ||
readString(context.wakeReason) === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON;
}
function isIssueMonitorMaintenanceRun(run: HeartbeatRunRow) {
const context = readRecord(run.contextSnapshot);
const wakeReason = readString(context.wakeReason);
const source = readString(context.source);
return Boolean(wakeReason?.startsWith("issue_monitor") || source?.startsWith("issue.monitor"));
}
function isProductiveSuccessfulRun(input: {
livenessState: RunLivenessState | null;
detectedProgressSummary: string | null;
}) {
if (input.livenessState && PRODUCTIVE_SUCCESS_LIVENESS_STATES.has(input.livenessState)) return true;
return Boolean(input.detectedProgressSummary);
}
export function buildSuccessfulRunHandoffInstruction(input: {
issueIdentifier: string | null;
sourceRunId: string;
}) {
const issueLabel = input.issueIdentifier ?? "this issue";
return [
`Your previous run on ${issueLabel} succeeded, but the issue is still in \`in_progress\` and Paperclip cannot identify a valid issue disposition.`,
"",
"Resolve the missing disposition before creating or revising any new artifacts. Choose **exactly one** outcome and perform the matching Paperclip action:",
"",
"**Is the issue finished?**",
"1. Mark it `done` (scope complete) or `cancelled` (intentionally stopped).",
"",
"**Does someone else need to look at it?**",
"2. Move it to `in_review` with a real reviewer path — `executionState.currentParticipant`, a human owner via `assigneeUserId`, a pending issue-thread interaction, or a linked pending approval.",
"",
"**Can it not continue right now?**",
"3. Mark it `blocked` with first-class blockers (`blockedByIssueIds`) or a clearly named unblock owner/action.",
"",
"**Is there more work to do?**",
`4. Either delegate follow-up work (create/link a follow-up issue and block this one on it, or close this issue if its scope is independently complete) or record an explicit continuation path with \`resumeIntent: true\`, \`resumeFromRunId: ${input.sourceRunId}\`, and a concrete next action.`,
"",
"Comments, document revisions, work-product writes, and continuation summaries are supporting evidence only — they do not satisfy this handoff unless the issue state/path also records one valid disposition.",
].join("\n");
}
export function decideSuccessfulRunHandoff(input: {
run: HeartbeatRunRow;
issue: IssueRow | null;
agent: AgentRow | null;
livenessState: RunLivenessState | null;
detectedProgressSummary: string | null;
taskKey: string | null;
hasActiveExecutionPath: boolean;
hasQueuedWake: boolean;
hasPendingInteractionOrApproval: boolean;
hasExplicitBlockerPath: boolean;
hasOpenRecoveryIssue: boolean;
hasPauseHold: boolean;
budgetBlocked: boolean;
idempotentWakeExists: boolean;
}): SuccessfulRunHandoffDecision {
const { run, issue, agent } = input;
if (run.status !== "succeeded") return { kind: "skip", reason: "source run did not succeed" };
if (isCorrectiveHandoffRun(run)) return { kind: "skip", reason: "source run is already a corrective handoff run" };
if (isIssueMonitorMaintenanceRun(run)) return { kind: "skip", reason: "issue monitor run owns its own recovery path" };
if (run.issueCommentStatus === "retry_queued" || run.issueCommentStatus === "retry_exhausted") {
return { kind: "skip", reason: "missing issue comment retry owns the next action" };
}
if (!issue) return { kind: "skip", reason: "issue not found" };
if (!agent) return { kind: "skip", reason: "agent not found" };
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
return { kind: "skip", reason: "company scope mismatch" };
}
if (issue.assigneeAgentId !== run.agentId) {
return { kind: "skip", reason: "issue is no longer assigned to the source run agent" };
}
if (issue.assigneeUserId) return { kind: "skip", reason: "issue is human-owned" };
if (issue.status !== "in_progress") return { kind: "skip", reason: `issue status ${issue.status} is a valid disposition` };
if (issue.executionState) return { kind: "skip", reason: "issue has execution policy state" };
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
}
if (!isProductiveSuccessfulRun(input)) {
return { kind: "skip", reason: "successful run did not produce handoff-relevant progress" };
}
if (input.hasActiveExecutionPath) return { kind: "skip", reason: "issue already has an active execution path" };
if (input.hasQueuedWake) return { kind: "skip", reason: "issue already has a queued or deferred wake" };
if (input.hasPendingInteractionOrApproval) {
return { kind: "skip", reason: "pending interaction or approval owns the next action" };
}
if (input.hasExplicitBlockerPath) return { kind: "skip", reason: "explicit blocker path owns the next action" };
if (input.hasOpenRecoveryIssue) return { kind: "skip", reason: "open recovery issue owns the ambiguity" };
if (input.hasPauseHold) return { kind: "skip", reason: "issue is under an active pause hold" };
if (input.budgetBlocked) return { kind: "skip", reason: "budget hard stop blocks corrective wake" };
if (input.idempotentWakeExists) {
return { kind: "skip", reason: "corrective handoff wake already exists for this source run" };
}
const instruction = buildSuccessfulRunHandoffInstruction({
issueIdentifier: issue.identifier,
sourceRunId: run.id,
});
const payload = withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
sourceIssueId: issue.id,
sourceRunId: run.id,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
validDispositionOptions: [...SUCCESSFUL_RUN_HANDOFF_OPTIONS],
detectedProgressSummary: input.detectedProgressSummary,
handoffAttempt: 1,
maxHandoffAttempts: DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
resumeIntent: true,
followUpRequested: true,
resumeFromRunId: run.id,
...(input.taskKey ? { taskKey: input.taskKey } : {}),
instruction,
});
return {
kind: "enqueue",
idempotencyKey: buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: issue.id,
sourceRunId: run.id,
}),
payload,
instruction,
contextSnapshot: withRecoveryModelProfileHint({
...payload,
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
livenessState: input.livenessState,
}),
};
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { AdapterConfigSchema, ConfigFieldSchema } from "@paperclipai/adapter-utils";
import { fieldMatchesVisibleWhen } from "./schema-config-fields";
const sourceField: ConfigFieldSchema = {
key: "provider",
label: "Provider",
type: "select",
options: [
{ label: "Claude", value: "claude" },
{ label: "Codex", value: "codex" },
],
};
const schema: AdapterConfigSchema = {
fields: [sourceField],
};
function targetWithVisibleWhen(visibleWhen: Record<string, unknown>): ConfigFieldSchema {
return {
key: "model",
label: "Model",
type: "text",
meta: { visibleWhen },
};
}
describe("fieldMatchesVisibleWhen", () => {
it("treats an empty values array as no match", () => {
const field = targetWithVisibleWhen({ key: "provider", values: [] });
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(false);
});
it("treats all non-string values as no match", () => {
const field = targetWithVisibleWhen({ key: "provider", values: [null, 42] });
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(false);
});
it("matches non-empty string values", () => {
const field = targetWithVisibleWhen({ key: "provider", values: ["claude"] });
expect(fieldMatchesVisibleWhen(field, () => "claude", schema)).toBe(true);
expect(fieldMatchesVisibleWhen(field, () => "codex", schema)).toBe(false);
});
});
+129 -95
View File
@@ -283,6 +283,38 @@ function getDefaultValue(field: ConfigFieldSchema): unknown {
}
}
export function fieldMatchesVisibleWhen(
field: ConfigFieldSchema,
readValue: (field: ConfigFieldSchema) => unknown,
schema: AdapterConfigSchema,
): boolean {
const visibleWhen = field.meta?.visibleWhen;
if (!visibleWhen || typeof visibleWhen !== "object" || Array.isArray(visibleWhen)) return true;
const condition = visibleWhen as {
key?: unknown;
value?: unknown;
values?: unknown;
notValues?: unknown;
};
if (typeof condition.key !== "string" || condition.key.length === 0) return true;
const sourceField = schema.fields.find((candidate) => candidate.key === condition.key);
if (!sourceField) return true;
const actual = String(readValue(sourceField) ?? "");
if (typeof condition.value === "string") return actual === condition.value;
if (Array.isArray(condition.values)) {
const values = condition.values.filter((value): value is string => typeof value === "string");
return values.length > 0 && values.includes(actual);
}
if (Array.isArray(condition.notValues)) {
const values = condition.notValues.filter((value): value is string => typeof value === "string");
return !values.includes(actual);
}
return true;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -369,111 +401,113 @@ export function SchemaConfigFields({
return (
<>
{schema.fields.map((field) => {
switch (field.type) {
case "select": {
const currentVal = String(readValue(field) ?? "");
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<SelectField
value={currentVal}
options={field.options ?? []}
{schema.fields
.filter((field) => fieldMatchesVisibleWhen(field, readValue, schema))
.map((field) => {
switch (field.type) {
case "select": {
const currentVal = String(readValue(field) ?? "");
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<SelectField
value={currentVal}
options={field.options ?? []}
onChange={(v) => writeValue(field, v)}
/>
</Field>
);
}
case "toggle":
return (
<ToggleField
key={field.key}
label={field.label}
hint={field.hint}
checked={readValue(field) === true}
onChange={(v) => writeValue(field, v)}
/>
</Field>
);
}
);
case "toggle":
return (
<ToggleField
key={field.key}
label={field.label}
hint={field.hint}
checked={readValue(field) === true}
onChange={(v) => writeValue(field, v)}
/>
);
case "number":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftNumberInput
value={Number(readValue(field) ?? 0)}
onCommit={(v) => writeValue(field, v)}
immediate
className={inputClass}
/>
</Field>
);
case "number":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftNumberInput
value={Number(readValue(field) ?? 0)}
onCommit={(v) => writeValue(field, v)}
immediate
className={inputClass}
/>
</Field>
);
case "textarea":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftTextarea
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
/>
</Field>
);
case "textarea":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftTextarea
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
/>
</Field>
);
case "combobox": {
const currentVal = String(readValue(field) ?? "");
// Dynamic options: if meta.providerModels exists, compute options
// based on the current provider value
let comboboxOptions = field.options ?? [];
if (field.meta?.providerModels) {
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
if (providerVal === "auto") {
// Auto: show all models from all providers, grouped by provider
const providerLabel = schema.fields.find((f) => f.key === "provider");
const providerOptions = providerLabel?.options ?? [];
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
models.map((m) => ({
case "combobox": {
const currentVal = String(readValue(field) ?? "");
// Dynamic options: if meta.providerModels exists, compute options
// based on the current provider value
let comboboxOptions = field.options ?? [];
if (field.meta?.providerModels) {
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
if (providerVal === "auto") {
// Auto: show all models from all providers, grouped by provider
const providerLabel = schema.fields.find((f) => f.key === "provider");
const providerOptions = providerLabel?.options ?? [];
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
models.map((m) => ({
label: m,
value: m,
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
})),
);
} else {
const providerModels = modelsByProvider[providerVal] ?? [];
const providerLabel = schema.fields.find((f) => f.key === "provider");
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
comboboxOptions = providerModels.map((m) => ({
label: m,
value: m,
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
})),
);
} else {
const providerModels = modelsByProvider[providerVal] ?? [];
const providerLabel = schema.fields.find((f) => f.key === "provider");
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
comboboxOptions = providerModels.map((m) => ({
label: m,
value: m,
group: provName,
}));
group: provName,
}));
}
}
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<ComboboxField
value={currentVal}
options={comboboxOptions}
onChange={(v) => writeValue(field, v || undefined)}
placeholder={field.hint}
/>
</Field>
);
}
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<ComboboxField
value={currentVal}
options={comboboxOptions}
onChange={(v) => writeValue(field, v || undefined)}
placeholder={field.hint}
/>
</Field>
);
}
case "text":
default:
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftInput
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
className={inputClass}
/>
</Field>
);
}
})}
case "text":
default:
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftInput
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
className={inputClass}
/>
</Field>
);
}
})}
</>
);
}
+12 -8
View File
@@ -69,6 +69,15 @@ export interface AgentPermissionUpdate {
canAssignTasks: boolean;
}
export interface AgentWakeRequest {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
forceFreshSession?: boolean;
}
function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?";
@@ -204,16 +213,11 @@ export const agentsApi = {
`/companies/${companyId}/adapters/${type}/test-environment`,
data,
),
invoke: (id: string, companyId?: string) => api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), {}),
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
wakeup: (
id: string,
data: {
source?: "timer" | "assignment" | "on_demand" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string | null;
payload?: Record<string, unknown> | null;
idempotencyKey?: string | null;
},
data: AgentWakeRequest,
companyId?: string,
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>
+44 -17
View File
@@ -57,6 +57,7 @@ import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { filterAcpxModelsByAgent } from "../lib/acpx-model-filter";
/* ---- Create mode values ---- */
@@ -360,9 +361,21 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
});
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
const models = fetchedModels ?? externalModels ?? [];
const rawModels = fetchedModels ?? externalModels ?? [];
const adapterCommandField =
adapterType === "hermes_local" ? "hermesCommand" : "command";
const acpxAgent =
adapterType === "acpx_local"
? isCreate
? String(val!.adapterSchemaValues?.agent ?? "claude")
: eff("adapterConfig", "agent", String(config.agent ?? "claude"))
: "";
const models = useMemo(
() => adapterType === "acpx_local"
? filterAcpxModelsByAgent(rawModels, acpxAgent)
: rawModels,
[adapterType, rawModels, acpxAgent],
);
const {
data: detectedModelData,
refetch: refetchDetectedModel,
@@ -527,19 +540,23 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const thinkingEffortKey =
adapterType === "codex_local"
? "modelReasoningEffort"
: adapterType === "cursor"
? "mode"
: adapterType === "opencode_local"
? "variant"
: "effort";
: adapterType === "acpx_local" && acpxAgent === "codex"
? "modelReasoningEffort"
: adapterType === "cursor"
? "mode"
: adapterType === "opencode_local"
? "variant"
: "effort";
const thinkingEffortOptions =
adapterType === "codex_local"
? codexThinkingEffortOptions
: adapterType === "cursor"
? cursorModeOptions
: adapterType === "opencode_local"
? openCodeThinkingEffortOptions
: claudeThinkingEffortOptions;
: adapterType === "acpx_local" && acpxAgent === "codex"
? codexThinkingEffortOptions
: adapterType === "cursor"
? cursorModeOptions
: adapterType === "opencode_local"
? openCodeThinkingEffortOptions
: claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: adapterType === "codex_local"
@@ -548,11 +565,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
: adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", "effort", String(config.effort ?? ""));
: adapterType === "acpx_local" && acpxAgent === "codex"
? eff(
"adapterConfig",
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? ""),
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
: adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", "effort", String(config.effort ?? ""));
const showThinkingEffort = adapterType !== "gemini_local";
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
@@ -982,7 +1005,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}}
onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined}
onRefreshModels={
adapterType === "codex_local" || adapterType === "acpx_local"
? handleRefreshModels
: undefined
}
refreshingModels={refreshingModels}
detectModelLabel="Detect model"
emptyDetectHint="No model detected. Select or enter one manually."
+6
View File
@@ -192,6 +192,9 @@ describe("CommentThread", () => {
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
authorType: "user",
presentation: null,
metadata: null,
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
@@ -349,6 +352,9 @@ describe("CommentThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Hello from the comment body",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-11T11:00:00.000Z"),
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
}]}
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("IssueBlockedNotice", () => {
it("renders a successful-run next-step notice without requiring blockers", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
});
+83 -29
View File
@@ -1,5 +1,6 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
@@ -7,12 +8,17 @@ export function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
agentName,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
agentName?: string | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -61,39 +67,87 @@ export function IssueBlockedNotice({
return (
<div
data-blocker-attention-state={blockerAttention?.state}
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff ? (
<>
<p className="font-medium leading-5">This issue still needs a next step.</p>
<p className="leading-5">
A run finished successfully, but this issue is still open in{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">
in_progress
</code>{" "}
with no clear owner for the next action.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
<li>Mark it done or cancelled.</li>
<li>Send it for review or ask for input.</li>
<li>Mark it blocked with a blocker owner.</li>
<li>Delegate follow-up work or queue a continuation.</li>
</ul>
<div className="flex flex-wrap gap-1.5 text-xs">
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
<Link
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</Link>
) : successfulRunHandoff.sourceRunId ? (
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</span>
) : null}
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
Corrective wake queued for {agentName ?? "the assignee"}
</span>
</div>
{successfulRunHandoff.detectedProgressSummary ? (
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
</>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
) : null}
{blockers.length > 0 || issueStatus === "blocked" ? (
<>
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</>
) : null}
</div>
</div>
@@ -836,6 +836,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-perf-codex",
authorUserId: null,
body: "Older loaded comment",
authorType: "agent" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
};
@@ -1056,6 +1059,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary with **markdown**",
authorType: "agent" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}];
@@ -1135,6 +1141,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
authorType: "user",
presentation: null,
metadata: null,
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
@@ -1588,6 +1597,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary",
authorType: "agent",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}]}
@@ -1624,6 +1636,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Need a quick update",
authorType: "user",
presentation: null,
metadata: null,
queueState: "queued",
queueReason: "hold",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
@@ -1653,6 +1668,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Queue behind active run",
authorType: "user",
presentation: null,
metadata: null,
queueState: "queued",
queueReason: "active_run",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
@@ -1997,6 +2015,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "hello",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-22T12:00:00.000Z"),
updatedAt: new Date("2026-04-22T12:00:00.000Z"),
}]}
+187
View File
@@ -36,6 +36,7 @@ import type {
IssueAttachment,
IssueBlockerAttention,
IssueRelationIssueSummary,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
@@ -93,6 +94,16 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
import { useOptionalToastActions } from "../context/ToastContext";
import type { CompanyUserProfile } from "../lib/company-members";
import { timeAgo } from "../lib/timeAgo";
import {
isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment,
} from "../lib/successful-run-handoff";
import { SystemNotice } from "./SystemNotice";
import { buildSystemNoticeProps } from "../lib/system-notice-comment";
import type {
IssueCommentMetadata,
IssueCommentPresentation,
} from "@paperclipai/shared";
import {
describeToolInput,
displayToolName,
@@ -264,6 +275,7 @@ interface IssueChatThreadProps {
activeRun?: ActiveRunForIssue | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
@@ -583,6 +595,9 @@ function commentDateLabel(date: Date | string | undefined): string {
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
const { onImageClick } = useContext(IssueChatCtx);
if (isSuccessfulRunHandoffComment(text)) {
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
}
return (
<MarkdownBody
className="text-sm leading-6"
@@ -595,6 +610,41 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: {
);
});
export function SuccessfulRunHandoffCommentCallout({
text,
recessed,
onImageClick,
}: {
text: string;
recessed?: boolean;
onImageClick?: (src: string) => void;
}) {
const escalated = isSuccessfulRunHandoffEscalationComment(text);
return (
<div
className={cn(
"rounded-md border px-3 py-2.5 text-sm shadow-sm",
escalated
? "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100"
: "border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
)}
style={recessed ? { opacity: 0.55 } : undefined}
>
<div className="flex items-start gap-2">
<AlertTriangle
className={cn(
"mt-1 h-4 w-4 shrink-0",
escalated ? "text-red-600 dark:text-red-300" : "text-amber-600 dark:text-amber-300",
)}
/>
<MarkdownBody className="min-w-0 text-sm leading-6" softBreaks onImageClick={onImageClick}>
{text}
</MarkdownBody>
</div>
</div>
);
}
function humanizeValue(value: string | null) {
if (!value) return "None";
return value.replace(/_/g, " ");
@@ -1901,6 +1951,127 @@ function ExpiredRequestConfirmationActivity({
);
}
function isIssueCommentPresentation(value: unknown): value is IssueCommentPresentation {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.kind === "system_notice" || v.kind === "message";
}
function isIssueCommentMetadata(value: unknown): value is IssueCommentMetadata {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.version === 1 && Array.isArray(v.sections);
}
function SystemNoticeCommentRow({
message,
anchorId,
}: {
message: ThreadMessage;
anchorId?: string;
}) {
const { onImageClick, agentMap } = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null;
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null;
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
const runId = typeof custom.runId === "string" ? custom.runId : null;
const authorType = typeof custom.authorType === "string" ? custom.authorType : null;
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
const bodyText = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const source = (() => {
const runAgentName = runAgentId ? agentMap?.get(runAgentId)?.name ?? null : null;
if (authorType === "system") {
const label = runAgentName ?? "Paperclip";
if (runAgentId && runId) return { label, href: `/agents/${runAgentId}/runs/${runId}` };
return { label };
}
if (runAgentId && runId) {
return { label: authorName ?? runAgentName ?? "Paperclip", href: `/agents/${runAgentId}/runs/${runId}` };
}
if (authorName) return { label: authorName };
return undefined;
})();
const props = buildSystemNoticeProps({
presentation,
metadata: commentMetadata,
body: (
<MarkdownBody className="text-sm leading-6" softBreaks onImageClick={onImageClick}>
{bodyText}
</MarkdownBody>
),
timestamp: message.createdAt ? new Date(message.createdAt).toISOString() : undefined,
source,
runAgentId,
});
const handleCopy = () => {
void navigator.clipboard.writeText(bodyText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleCopyLink = () => {
if (!anchorId || typeof window === "undefined") return;
const url = `${window.location.origin}${window.location.pathname}#${anchorId}`;
void navigator.clipboard.writeText(url).then(() => {
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
});
};
return (
<div id={anchorId} className="group">
<div className="py-1">
<SystemNotice {...props} />
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
{anchorId ? (
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy link"
aria-label="Copy link to system notice"
onClick={handleCopyLink}
>
{copiedLink ? <Check className="h-3.5 w-3.5" /> : <Paperclip className="h-3.5 w-3.5" />}
</button>
) : null}
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy notice text"
aria-label="Copy system notice"
onClick={handleCopy}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
);
}
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
const {
agentMap,
@@ -1933,6 +2104,15 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
? custom.interaction
: null;
if (custom.kind === "system_notice") {
return (
<SystemNoticeCommentRow
message={message}
anchorId={anchorId}
/>
);
}
if (custom.kind === "interaction" && interaction) {
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
return (
@@ -3077,6 +3257,7 @@ export function IssueChatThread({
activeRun = null,
blockedBy = [],
blockerAttention = null,
successfulRunHandoff = null,
companyId,
projectId,
issueStatus,
@@ -3700,6 +3881,12 @@ export function IssueChatThread({
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
agentName={
successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
: null
}
/>
<IssueAssigneePausedNotice agent={assignedAgent} />
</div>
@@ -0,0 +1,398 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread } from "./IssueChatThread";
import type { IssueChatComment } from "../lib/issue-chat-messages";
import type { Agent } from "@paperclipai/shared";
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
useAui: () => ({ thread: () => ({ append: async () => undefined }) }),
}));
vi.mock("./transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({
transcriptByRun: new Map(),
hasOutputForRun: () => false,
}),
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: () => <textarea aria-label="Issue chat editor" />,
}));
vi.mock("./InlineEntitySelector", () => ({ InlineEntitySelector: () => null }));
vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => <span>{name}</span> }));
vi.mock("./OutputFeedbackButtons", () => ({ OutputFeedbackButtons: () => null }));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null }));
vi.mock("./StatusBadge", () => ({ StatusBadge: ({ status }: { status: string }) => <span>{status}</span> }));
vi.mock("./IssueLinkQuicklook", () => ({
IssueLinkQuicklook: ({
children,
to,
}: {
children: ReactNode;
to: string;
}) => <a href={to}>{children}</a>,
}));
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
usePaperclipIssueRuntime: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
window.scrollTo = vi.fn();
root = createRoot(container);
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
});
function renderThread(comments: IssueChatComment[], agentMap?: Map<string, Agent>) {
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={comments}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
agentMap={agentMap}
/>
</MemoryRouter>,
);
});
}
const baseTimestamps = {
createdAt: new Date("2026-05-04T16:32:00.000Z"),
updatedAt: new Date("2026-05-04T16:32:00.000Z"),
};
describe("IssueChatThread system notice routing", () => {
it("renders authorType=system comments as a SystemNotice rather than a user bubble", () => {
const comment: IssueChatComment = {
id: "comment-system",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
{ type: "key_value", label: "Status before", value: "in_progress" },
],
},
],
},
...baseTimestamps,
};
renderThread([comment]);
const row = container.querySelector('[data-message-role="system"]');
expect(row).not.toBeNull();
const status = row?.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("Missing issue disposition");
expect(container.textContent).toContain("Paperclip needs a disposition");
// collapsed by default — metadata identifier should not be visible
expect(container.textContent).not.toContain("PAP-3440");
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement | null;
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelectorAll('[data-message-role="user"]').length).toBe(0);
});
it("expands metadata when detailsDefaultOpen is true", () => {
const comment: IssueChatComment = {
id: "comment-system-open",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Recovery escalated.",
presentation: {
kind: "system_notice",
tone: "danger",
title: null,
detailsDefaultOpen: true,
},
metadata: {
version: 1,
sections: [
{
rows: [
{ type: "agent_link", label: "Owner", agentId: "agent-cto", name: "CTO" },
],
},
],
},
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System alert");
expect(container.textContent).toContain("CTO");
const toggle = container.querySelector("button[aria-expanded]");
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
});
it("falls back to legacy user bubble + handoff callout for old text-only comments", () => {
const comment: IssueChatComment = {
id: "comment-legacy",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "## Successful run missing issue disposition\n\nFix this.",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
const userRow = container.querySelector('[data-message-role="user"]');
expect(userRow).not.toBeNull();
expect(container.textContent).toContain("Successful run missing issue disposition");
});
it("keeps regular user comments rendering as user bubbles", () => {
const comment: IssueChatComment = {
id: "comment-user",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "Standard user message.",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="user"]')).not.toBeNull();
expect(container.textContent).toContain("Standard user message.");
});
it("keeps agent-authored comments rendering as assistant bubbles even with system_notice presentation absent", () => {
const comment: IssueChatComment = {
id: "comment-agent",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent reply",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
});
it("labels system notice source as the originating run agent name when runAgentId is available", () => {
const codexAgent = {
id: "agent-codex",
name: "CodexCoder",
} as unknown as Agent;
const agentMap = new Map<string, Agent>([[codexAgent.id, codexAgent]]);
const comment: IssueChatComment = {
id: "comment-system-runagent",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-issue-chat-01",
runAgentId: "agent-codex",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment], agentMap);
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-codex/runs/run-issue-chat-01");
expect(sourceLink?.textContent).toBe("CodexCoder");
expect(sourceLink?.textContent).not.toBe("You");
});
it("shows copy-link feedback on the link button only", async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const comment: IssueChatComment = {
id: "comment-copy-link",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "System recovery completed.",
presentation: {
kind: "system_notice",
tone: "success",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const copyLink = container.querySelector('button[aria-label="Copy link to system notice"]') as HTMLButtonElement;
const copyText = container.querySelector('button[aria-label="Copy system notice"]') as HTMLButtonElement;
await act(async () => {
copyLink.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("#comment-comment-copy-link"));
expect(copyLink.querySelector(".lucide-check")).not.toBeNull();
expect(copyText.querySelector(".lucide-check")).toBeNull();
});
it("labels system notice source as Paperclip when no run agent can be resolved", () => {
const comment: IssueChatComment = {
id: "comment-system-no-author",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: null,
runAgentId: null,
body: "System recovery completed.",
presentation: {
kind: "system_notice",
tone: "info",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
expect(status?.textContent).toContain("Paperclip");
expect(status?.textContent).not.toContain("You");
});
it("falls back to Paperclip in the system notice header when run agent is unknown to agentMap", () => {
const comment: IssueChatComment = {
id: "comment-system-unknown-agent",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-xyz",
runAgentId: "agent-unknown",
body: "Disposition required.",
presentation: {
kind: "system_notice",
tone: "warning",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-unknown/runs/run-xyz");
expect(sourceLink?.textContent).toBe("Paperclip");
});
it("keeps agent-authored comments as assistant bubbles even when presentation requests system_notice", () => {
const comment: IssueChatComment = {
id: "comment-agent-system",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: "Reassigned to ClaudeFixer.",
presentation: {
kind: "system_notice",
tone: "neutral",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
});
});
+11
View File
@@ -66,6 +66,7 @@ import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
import { workflowSort } from "../lib/workflow-sort";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
@@ -1528,6 +1529,16 @@ export function IssuesList({
</span>
)
) : null}
{isSuccessfulRunHandoffRequired(issue) ? (
<span
className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
aria-label="Needs next step"
title="This issue needs a next step"
>
<CircleDot className="h-3 w-3" />
Needs next step
</span>
) : null}
</>
)}
className={isMutedIssue ? "opacity-70" : undefined}
+12
View File
@@ -21,6 +21,8 @@ import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import type { Issue } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
const boardStatuses = [
"backlog",
@@ -159,6 +161,16 @@ function KanbanCard({
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isSuccessfulRunHandoffRequired(issue) ? (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title="This issue needs a next step"
aria-label="Needs next step"
>
<AlertTriangle className="h-3 w-3" />
Next step
</span>
) : null}
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
+197
View File
@@ -0,0 +1,197 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ReactElement } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { SystemNotice } from "./SystemNotice";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("SystemNotice", () => {
it("renders the warning tone label and body in a single status container", () => {
const node = render(
<SystemNotice
tone="warning"
body="Paperclip needs a disposition before this issue can continue."
/>,
);
const status = node.querySelectorAll('[role="status"]');
expect(status.length).toBe(1);
expect(status[0]?.getAttribute("aria-label")).toBe("System warning");
expect(node.textContent).toContain(
"Paperclip needs a disposition before this issue can continue.",
);
});
it("uses System alert label for danger tone", () => {
const node = render(
<SystemNotice tone="danger" body="Recovery escalated to CTO." />,
);
const status = node.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System alert");
});
it("uses neutral System notice label by default", () => {
const node = render(
<SystemNotice tone="neutral" body="Reassigned to ClaudeFixer." />,
);
const status = node.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System notice");
});
it("collapses metadata details by default and toggles aria-expanded on click", () => {
const node = render(
<SystemNotice
tone="warning"
body="Needs a disposition."
metadata={[
{
title: "Required action",
rows: [
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
},
],
},
]}
/>,
);
const button = node.querySelector("button[aria-expanded]");
expect(button).not.toBeNull();
expect(button?.getAttribute("aria-expanded")).toBe("false");
expect(button?.getAttribute("aria-controls")).not.toBeNull();
expect(node.textContent).not.toContain("PAP-3440");
act(() => {
(button as HTMLButtonElement).click();
});
const reopened = node.querySelector("button[aria-expanded]");
expect(reopened?.getAttribute("aria-expanded")).toBe("true");
expect(node.textContent).toContain("PAP-3440");
});
it("renders metadata expanded when detailsDefaultOpen is true", () => {
const node = render(
<SystemNotice
tone="warning"
body="Needs a disposition."
detailsDefaultOpen
metadata={[
{
rows: [{ kind: "text", label: "Suggested action", value: "Pick a disposition" }],
},
]}
/>,
);
const button = node.querySelector("button[aria-expanded]");
expect(button?.getAttribute("aria-expanded")).toBe("true");
expect(node.textContent).toContain("Suggested action");
expect(node.textContent).toContain("Pick a disposition");
});
it("hides the details affordance when no metadata is provided", () => {
const node = render(<SystemNotice tone="warning" body="Short notice." />);
expect(node.querySelector("button[aria-expanded]")).toBeNull();
});
it("renders typed metadata rows with hrefs when present", () => {
const node = render(
<SystemNotice
tone="danger"
body="Recovery blocked"
detailsDefaultOpen
metadata={[
{
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Disposition recovery",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892",
status: "succeeded",
},
],
},
]}
/>,
);
const links = Array.from(node.querySelectorAll("a")).map((a) => a.getAttribute("href"));
expect(links).toContain("/PAP/issues/PAP-3440");
expect(links).toContain("/PAP/agents/cto");
expect(links).toContain("/PAP/agents/codexcoder/runs/9cdba892");
expect(node.textContent).toContain("PAP-3440");
expect(node.textContent).toContain("Disposition recovery");
expect(node.textContent).toContain("CTO");
expect(node.textContent).toContain("succeeded");
});
it("renders metadata link rows as plain text when href is missing", () => {
const node = render(
<SystemNotice
tone="neutral"
body="Reassigned"
detailsDefaultOpen
metadata={[
{
rows: [
{ kind: "agent", label: "Reassigned to", name: "ClaudeFixer" },
{ kind: "run", label: "Run", runId: "abc12345" },
{ kind: "issue", label: "Issue", identifier: "PAP-1" },
],
},
]}
/>,
);
expect(node.querySelectorAll("a").length).toBe(0);
expect(node.textContent).toContain("ClaudeFixer");
expect(node.textContent).toContain("abc12345");
expect(node.textContent).toContain("PAP-1");
});
});
+337
View File
@@ -0,0 +1,337 @@
import { useId, useState, type ReactNode } from "react";
import {
ChevronDown,
CircleAlert,
CircleCheck,
Info,
OctagonAlert,
TriangleAlert,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type SystemNoticeTone = "neutral" | "info" | "success" | "warning" | "danger";
export type SystemNoticeMetadataRow =
| { kind: "text"; label: string; value: string }
| { kind: "code"; label: string; value: string }
| { kind: "issue"; label: string; identifier: string; href?: string; title?: string }
| { kind: "agent"; label: string; name: string; href?: string }
| { kind: "run"; label: string; runId: string; href?: string; status?: string };
export type SystemNoticeMetadataSection = {
title?: string;
rows: SystemNoticeMetadataRow[];
};
export type SystemNoticeProps = {
tone?: SystemNoticeTone;
/** Short label that names the system actor + tone, e.g. "System warning". Required so tone is not color-only. */
label?: string;
/** Short visible body — one or two sentences from the system perspective. */
body: ReactNode;
/** Optional small chip for the originating run link. */
source?: { label: string; href?: string };
/** Hidden-by-default metadata. Renders the Details affordance only when present. */
metadata?: SystemNoticeMetadataSection[];
/** Force the details panel open initially. Defaults to false (collapsed). */
detailsDefaultOpen?: boolean;
/** Optional ISO timestamp shown next to the label. */
timestamp?: string;
className?: string;
};
type ToneTokens = {
container: string;
iconWrap: string;
icon: LucideIcon;
iconClass: string;
label: string;
divider: string;
};
const TONE_TOKENS: Record<SystemNoticeTone, ToneTokens> = {
neutral: {
container:
"border-border bg-muted/35 dark:bg-muted/20",
iconWrap: "bg-muted text-foreground/70",
icon: Info,
iconClass: "text-muted-foreground",
label: "text-muted-foreground",
divider: "border-border/70",
},
info: {
container:
"border-sky-300/70 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10",
iconWrap: "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200",
icon: Info,
iconClass: "text-sky-700 dark:text-sky-300",
label: "text-sky-800 dark:text-sky-200",
divider: "border-sky-300/50 dark:border-sky-500/30",
},
success: {
container:
"border-emerald-300/70 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10",
iconWrap: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200",
icon: CircleCheck,
iconClass: "text-emerald-700 dark:text-emerald-300",
label: "text-emerald-800 dark:text-emerald-200",
divider: "border-emerald-300/50 dark:border-emerald-500/30",
},
warning: {
container:
"border-amber-300/70 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10",
iconWrap: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
icon: TriangleAlert,
iconClass: "text-amber-700 dark:text-amber-300",
label: "text-amber-900 dark:text-amber-200",
divider: "border-amber-300/60 dark:border-amber-500/30",
},
danger: {
container:
"border-red-400/60 bg-red-50/80 dark:border-red-500/35 dark:bg-red-500/10",
iconWrap: "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-200",
icon: OctagonAlert,
iconClass: "text-red-700 dark:text-red-300",
label: "text-red-900 dark:text-red-200",
divider: "border-red-400/50 dark:border-red-500/30",
},
};
function formatTimestamp(ts: string) {
try {
return new Date(ts).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return ts;
}
}
function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTokens }) {
return (
<div className="grid grid-cols-[7.5rem_1fr] gap-x-3 gap-y-0.5 px-3 py-1.5 text-xs">
<div className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{row.label}
</div>
<div className="min-w-0 break-words text-foreground/90">
{(() => {
switch (row.kind) {
case "text":
return <span>{row.value}</span>;
case "code":
return (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
{row.value}
</code>
);
case "issue": {
const issueLabel = (
<>
<span>{row.identifier}</span>
{row.title ? (
<span className="text-muted-foreground">— {row.title}</span>
) : null}
</>
);
if (row.href) {
return (
<a
href={row.href}
className={cn(
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
tone.label,
)}
>
{issueLabel}
</a>
);
}
return (
<span className={cn("inline-flex items-center gap-1 font-medium", tone.label)}>
{issueLabel}
</span>
);
}
case "agent":
if (row.href) {
return (
<a
href={row.href}
className={cn(
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
tone.label,
)}
>
{row.name}
</a>
);
}
return (
<span className={cn("font-medium", tone.label)}>{row.name}</span>
);
case "run": {
const runShort = row.runId.length > 12 ? `${row.runId.slice(0, 8)}…` : row.runId;
const inner = (
<>
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground/80">{runShort}</code>
{row.status ? (
<span className={cn("font-sans", tone.label)}>{row.status}</span>
) : null}
</>
);
if (row.href) {
return (
<a
href={row.href}
className="inline-flex items-center gap-2 rounded-sm font-mono text-[11px] underline-offset-2 hover:underline"
>
{inner}
</a>
);
}
return (
<span className="inline-flex items-center gap-2 font-mono text-[11px]">
{inner}
</span>
);
}
}
})()}
</div>
</div>
);
}
export function SystemNotice({
tone = "neutral",
label,
body,
source,
metadata,
detailsDefaultOpen = false,
timestamp,
className,
}: SystemNoticeProps) {
const tokens = TONE_TOKENS[tone];
const ToneIcon = tokens.icon;
const [open, setOpen] = useState(detailsDefaultOpen);
const detailsId = useId();
const hasDetails = Boolean(metadata && metadata.length > 0);
const resolvedLabel =
label ??
{
neutral: "System notice",
info: "System notice",
success: "System notice",
warning: "System warning",
danger: "System alert",
}[tone];
return (
<section
role="status"
aria-label={resolvedLabel}
className={cn(
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
tokens.container,
className,
)}
>
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
tokens.iconWrap,
)}
aria-hidden
>
<ToneIcon className={cn("h-4 w-4", tokens.iconClass)} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
<span className={tokens.label}>{resolvedLabel}</span>
{source ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
{source.href ? (
<a
href={source.href}
className="rounded-sm font-medium normal-case tracking-normal text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{source.label}
</a>
) : (
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{source.label}
</span>
)}
</>
) : null}
{timestamp ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{formatTimestamp(timestamp)}
</span>
</>
) : null}
</div>
<div className="mt-1 break-words text-[14px] leading-6 text-foreground">{body}</div>
</div>
{hasDetails ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"ml-1 inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-transparent px-2 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground transition-[background-color,border-color,color]",
"hover:border-border/70 hover:bg-background/70 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<span>{open ? "Hide details" : "Details"}</span>
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform duration-150",
open && "rotate-180",
)}
/>
</button>
) : null}
</header>
{hasDetails && open ? (
<div
id={detailsId}
className={cn(
"border-t bg-background/50 dark:bg-background/30",
tokens.divider,
)}
>
<div className="divide-y divide-border/50 px-1 py-1">
{metadata!.map((section, sectionIdx) => (
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
{section.title ? (
<div className="px-3 pb-1 pt-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{section.title}
</div>
) : null}
<div>
{section.rows.map((row, rowIdx) => (
<MetadataRow key={rowIdx} row={row} tone={tokens} />
))}
</div>
</div>
))}
</div>
</div>
) : null}
</section>
);
}
export default SystemNotice;
@@ -94,6 +94,7 @@ function createComment(index: number): IssueChatComment {
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
companyId: "company-long-thread",
issueId: "issue-long-thread",
authorType: authorAgentId ? "agent" : "user",
authorAgentId,
authorUserId: authorAgentId ? null : "user-board",
body: isMarkdown
@@ -101,6 +102,8 @@ function createComment(index: number): IssueChatComment {
: authorAgentId
? plainAssistantBody(index + 1)
: plainUserBody(index + 1),
presentation: null,
metadata: null,
createdAt: atMinute(index),
updatedAt: atMinute(index),
};
+5 -1
View File
@@ -43,17 +43,21 @@ function createAgent(
}
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
return {
const merged: IssueChatComment = {
id: "comment-default",
companyId: "company-ux",
issueId: "issue-ux",
authorType: overrides.authorAgentId ? "agent" : "user",
authorAgentId: null,
authorUserId: "user-1",
body: "",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
return merged;
}
const primaryAgent = createAgent("agent-1", "CodexCoder", "code", "codexcoder");
@@ -23,9 +23,12 @@ function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
id: "comment-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
authorType: overrides.authorAgentId ? "agent" : "user",
authorAgentId: null,
authorUserId: issueThreadInteractionFixtureMeta.currentUserId,
body: "",
presentation: null,
metadata: null,
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
...overrides,
+204
View File
@@ -0,0 +1,204 @@
import type {
SystemNoticeMetadataSection,
SystemNoticeProps,
} from "../components/SystemNotice";
export type SystemNoticeFixture = {
id: string;
caption: string;
} & SystemNoticeProps;
const HANDOFF_METADATA: SystemNoticeMetadataSection[] = [
{
title: "Recovery owner",
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
{
kind: "text",
label: "Suggested action",
value: "Reassign to a recovery agent and pick a disposition.",
},
],
},
{
title: "Run evidence",
rows: [
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
{
kind: "run",
label: "Recovery run",
runId: "61fdb79b-8012-4676-ac71-2971830e126a",
href: "/PAP/agents/codexcoder/runs/61fdb79b-8012-4676-ac71-2971830e126a",
status: "failed",
},
{
kind: "text",
label: "Normalized cause",
value: "Run completed without issuing a disposition for an in_progress task.",
},
],
},
];
const REQUIRED_METADATA: SystemNoticeMetadataSection[] = [
{
title: "Required action",
rows: [
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Assignee",
name: "CodexCoder",
href: "/PAP/agents/codexcoder",
},
{
kind: "text",
label: "Next step",
value: "Pick done, blocked, or in_review and post a one-line rationale.",
},
],
},
{
title: "Run context",
rows: [
{
kind: "run",
label: "Successful run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
{
kind: "code",
label: "Status before",
value: "in_progress",
},
],
},
];
const NEUTRAL_METADATA: SystemNoticeMetadataSection[] = [
{
rows: [
{
kind: "agent",
label: "Reassigned to",
name: "ClaudeFixer",
href: "/PAP/agents/claudefixer",
},
{
kind: "agent",
label: "From",
name: "CodexCoder",
href: "/PAP/agents/codexcoder",
},
{
kind: "text",
label: "Reason",
value: "Manual reassignment requested by Board.",
},
],
},
];
export const systemNoticeFixtures: readonly SystemNoticeFixture[] = [
{
id: "warning-collapsed",
caption: "Warning · collapsed (default)",
tone: "warning",
label: "System warning",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:32:00.000Z",
body: "Paperclip needs a disposition before this issue can continue.",
metadata: REQUIRED_METADATA,
detailsDefaultOpen: false,
},
{
id: "warning-expanded",
caption: "Warning · expanded",
tone: "warning",
label: "System warning",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:32:00.000Z",
body: "Paperclip needs a disposition before this issue can continue.",
metadata: REQUIRED_METADATA,
detailsDefaultOpen: true,
},
{
id: "danger-collapsed",
caption: "Danger · collapsed (default)",
tone: "danger",
label: "System alert",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:48:00.000Z",
body: "Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.",
metadata: HANDOFF_METADATA,
detailsDefaultOpen: false,
},
{
id: "danger-expanded",
caption: "Danger · expanded",
tone: "danger",
label: "System alert",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:48:00.000Z",
body: "Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.",
metadata: HANDOFF_METADATA,
detailsDefaultOpen: true,
},
{
id: "neutral-collapsed",
caption: "Neutral · collapsed (default)",
tone: "neutral",
label: "System notice",
source: { label: "Paperclip" },
timestamp: "2026-05-04T15:10:00.000Z",
body: "Reassigned to ClaudeFixer.",
metadata: NEUTRAL_METADATA,
detailsDefaultOpen: false,
},
{
id: "neutral-expanded",
caption: "Neutral · expanded",
tone: "neutral",
label: "System notice",
source: { label: "Paperclip" },
timestamp: "2026-05-04T15:10:00.000Z",
body: "Reassigned to ClaudeFixer.",
metadata: NEUTRAL_METADATA,
detailsDefaultOpen: true,
},
{
id: "warning-no-details",
caption: "Warning · no metadata (Details affordance hidden)",
tone: "warning",
label: "System warning",
source: { label: "Paperclip" },
timestamp: "2026-05-04T17:02:00.000Z",
body: "This run paused while waiting on board approval.",
},
];
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { filterAcpxModelsByAgent } from "./acpx-model-filter";
const mixedModels = [
{ id: "claude-sonnet-4-6", label: "Claude: Claude Sonnet 4.6" },
{ id: "gpt-5.3-codex", label: "Codex: gpt-5.3-codex" },
{ id: "provider/custom-model", label: "Custom model" },
];
describe("filterAcpxModelsByAgent", () => {
it("keeps only Claude models when ACPX Claude is selected", () => {
expect(filterAcpxModelsByAgent(mixedModels, "claude").map((model) => model.id)).toEqual([
"claude-sonnet-4-6",
]);
});
it("keeps only Codex models when ACPX Codex is selected", () => {
expect(filterAcpxModelsByAgent(mixedModels, "codex").map((model) => model.id)).toEqual([
"gpt-5.3-codex",
]);
});
it("does not show built-in provider models for custom ACP commands", () => {
expect(filterAcpxModelsByAgent(mixedModels, "custom")).toEqual([]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { AdapterModel } from "../api/agents";
import { models as CLAUDE_LOCAL_MODELS } from "@paperclipai/adapter-claude-local";
import { models as CODEX_LOCAL_MODELS } from "@paperclipai/adapter-codex-local";
const claudeModelIds = new Set(CLAUDE_LOCAL_MODELS.map((model) => model.id));
const codexModelIds = new Set(CODEX_LOCAL_MODELS.map((model) => model.id));
export function filterAcpxModelsByAgent(models: AdapterModel[], acpxAgent: string): AdapterModel[] {
if (acpxAgent === "claude") {
return models.filter((model) => claudeModelIds.has(model.id) || model.label.startsWith("Claude: "));
}
if (acpxAgent === "codex") {
return models.filter((model) => codexModelIds.has(model.id) || model.label.startsWith("Codex: "));
}
return [];
}
+9
View File
@@ -65,4 +65,13 @@ describe("activity formatting", () => {
expect(formatIssueActivityAction("issue.monitor_cleared")).toBe("cleared a monitor");
expect(formatIssueActivityAction("issue.monitor_recovery_issue_created")).toBe("created a monitor recovery issue");
});
it("uses plain next-step copy for successful-run handoff activity", () => {
expect(formatActivityVerb("issue.successful_run_handoff_required")).toBe("flagged missing next step on");
expect(formatIssueActivityAction("issue.successful_run_handoff_required")).toBe("Run finished without a clear next step");
expect(formatIssueActivityAction("issue.successful_run_handoff_resolved")).toBe("Next step chosen");
expect(formatIssueActivityAction("issue.successful_run_handoff_escalated")).toBe(
"Run finished without a next step - recovery escalated",
);
});
});
+6
View File
@@ -43,6 +43,9 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.monitor_escalated_to_board": "escalated monitor for",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"issue.successful_run_handoff_required": "flagged missing next step on",
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
"issue.successful_run_handoff_escalated": "escalated missing next step on",
"agent.created": "created",
"agent.updated": "updated",
"agent.paused": "paused",
@@ -92,6 +95,9 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
"issue.deleted": "deleted the issue",
"issue.successful_run_handoff_required": "Run finished without a clear next step",
"issue.successful_run_handoff_resolved": "Next step chosen",
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",
+4
View File
@@ -39,6 +39,7 @@ function createAgent(id: string, name: string): Agent {
}
function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComment {
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-1",
companyId: "company-1",
@@ -46,6 +47,9 @@ function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComm
authorAgentId: null,
authorUserId: "user-1",
body: "Hello",
authorType: authorAgentId ? "agent" : "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
+16 -1
View File
@@ -305,11 +305,13 @@ function createCommentMessage(args: {
const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args;
const createdAt = toDate(comment.createdAt);
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap);
const isSystemNotice = comment.authorType === "system";
const custom = {
kind: "comment",
kind: isSystemNotice ? "system_notice" : "comment",
commentId: comment.id,
anchorId: `comment-${comment.id}`,
authorName,
authorType: comment.authorType,
authorAgentId: comment.authorAgentId,
authorUserId: comment.authorUserId,
companyId: companyId ?? comment.companyId,
@@ -322,8 +324,21 @@ function createCommentMessage(args: {
queueReason: comment.queueReason ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
followUpRequested: comment.followUpRequested === true,
presentation: comment.presentation ?? null,
commentMetadata: comment.metadata ?? null,
};
if (isSystemNotice) {
const message: ThreadSystemMessage = {
id: comment.id,
role: "system",
createdAt,
content: [{ type: "text", text: comment.body }],
metadata: { custom },
};
return message;
}
if (comment.authorAgentId) {
const message: ThreadAssistantMessage = {
id: comment.id,
@@ -83,6 +83,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@@ -97,6 +100,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "First",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@@ -140,6 +146,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Original",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
@@ -151,6 +160,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Updated",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:05.000Z"),
},
@@ -170,6 +182,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@@ -182,6 +197,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@@ -192,6 +210,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@@ -216,6 +237,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@@ -226,6 +250,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "First",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@@ -310,6 +337,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@@ -322,6 +352,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@@ -334,6 +367,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Brand new",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:04.000Z"),
updatedAt: new Date("2026-03-28T14:00:04.000Z"),
},
@@ -354,6 +390,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@@ -366,6 +405,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@@ -376,6 +418,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@@ -827,6 +872,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
@@ -853,6 +901,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
@@ -874,6 +925,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
+3
View File
@@ -57,9 +57,12 @@ export function createOptimisticIssueComment(params: {
clientId,
companyId: params.companyId,
issueId: params.issueId,
authorType: "user",
authorAgentId: null,
authorUserId: params.authorUserId,
body: params.body,
presentation: null,
metadata: null,
clientStatus: params.clientStatus ?? "pending",
queueTargetRunId: params.queueTargetRunId ?? null,
createdAt: now,
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION,
isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment,
successfulRunHandoffActivityTone,
} from "./successful-run-handoff";
describe("successful run handoff UI helpers", () => {
it("matches both required and escalated production comments", () => {
expect(isSuccessfulRunHandoffComment(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
expect(isSuccessfulRunHandoffComment("## This issue still needs a next step\n\n- Source run: abc")).toBe(true);
expect(isSuccessfulRunHandoffComment("## Successful run missing issue disposition\n\n- Source run: abc")).toBe(true);
expect(isSuccessfulRunHandoffComment(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY)).toBe(true);
expect(
isSuccessfulRunHandoffComment(
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no clear next-step disposition.",
),
).toBe(true);
expect(
isSuccessfulRunHandoffEscalationComment(
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no clear next-step disposition.",
),
).toBe(true);
expect(isSuccessfulRunHandoffComment("Ordinary issue comment")).toBe(false);
});
it("returns shared tones for required, escalated, and neutral activity", () => {
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION).className).toContain("amber");
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION).className).toContain("red");
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION).className).toContain("border");
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { ActivityEvent, Issue, SuccessfulRunHandoffState } from "@paperclipai/shared";
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION = "issue.successful_run_handoff_required";
export const SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION = "issue.successful_run_handoff_resolved";
export const SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION = "issue.successful_run_handoff_escalated";
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY =
"Paperclip needs a disposition before this issue can continue.";
export const SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY =
"Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.";
export function isSuccessfulRunHandoffActivity(action: string) {
return action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|| action === SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION
|| action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
}
export function isSuccessfulRunHandoffRequired(issue: Pick<Issue, "successfulRunHandoff">) {
return issue.successfulRunHandoff?.required === true;
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function successfulRunHandoffFromActivity(event: ActivityEvent): SuccessfulRunHandoffState | null {
if (!isSuccessfulRunHandoffActivity(event.action)) return null;
const details = event.details ?? {};
const state = event.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
? "required"
: event.action === SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION
? "resolved"
: "escalated";
return {
state,
required: state === "required",
sourceRunId:
readString(details.sourceRunId)
?? readString(details.source_run_id)
?? readString(details.resumeFromRunId)
?? event.runId
?? null,
correctiveRunId:
readString(details.correctiveRunId)
?? readString(details.corrective_run_id)
?? (state !== "required" ? event.runId : null),
assigneeAgentId:
readString(details.assigneeAgentId)
?? readString(details.agentId)
?? event.agentId
?? null,
detectedProgressSummary:
readString(details.detectedProgressSummary)
?? readString(details.detected_progress_summary)
?? null,
createdAt: event.createdAt,
};
}
export function isSuccessfulRunHandoffComment(text: string) {
const trimmed = text.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
|| /^##\s+(This issue still needs a next step|Run finished without a next step|Successful run missing issue disposition)/i.test(trimmed)
|| isSuccessfulRunHandoffEscalationComment(trimmed);
}
export function isSuccessfulRunHandoffEscalationComment(text: string) {
const trimmed = text.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY
|| /^Paperclip exhausted the bounded successful-run handoff correction\b/i.test(trimmed);
}
export function successfulRunHandoffActivityTone(action: string) {
if (action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION) {
return {
className: "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100",
iconClassName: "text-red-600 dark:text-red-300",
};
}
if (action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION) {
return {
className: "border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
iconClassName: "text-amber-600 dark:text-amber-300",
};
}
return {
className: "border-border/60 text-muted-foreground",
iconClassName: "text-muted-foreground",
};
}
+143
View File
@@ -0,0 +1,143 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildSystemNoticeProps, mapCommentMetadataToSystemNoticeSections } from "./system-notice-comment";
describe("mapCommentMetadataToSystemNoticeSections", () => {
it("maps server metadata row types to SystemNotice rows", () => {
const sections = mapCommentMetadataToSystemNoticeSections(
{
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
{ type: "agent_link", label: "Assignee", agentId: "agent-1", name: "CodexCoder" },
{ type: "key_value", label: "Status before", value: "in_progress" },
{ type: "code", label: "Cause code", code: "missing_disposition" },
{ type: "text", label: "Notes", text: "Pick a disposition." },
{ type: "run_link", label: "Source run", runId: "9cdba892-c7ca-4d93-8604-4843873b127c", title: "succeeded" },
],
},
],
},
{ runAgentId: "agent-1" },
);
expect(sections).toHaveLength(1);
expect(sections[0]?.title).toBe("Required action");
const rows = sections[0]!.rows;
expect(rows).toEqual([
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/issues/PAP-3440",
title: "Recovery",
},
{ kind: "agent", label: "Assignee", name: "CodexCoder", href: "/agents/agent-1" },
{ kind: "text", label: "Status before", value: "in_progress" },
{ kind: "code", label: "Cause code", value: "missing_disposition" },
{ kind: "text", label: "Notes", value: "Pick a disposition." },
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/agents/agent-1/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
]);
});
it("omits run href when no runAgentId is available", () => {
const sections = mapCommentMetadataToSystemNoticeSections(
{
version: 1,
sections: [
{
rows: [
{ type: "run_link", label: "Run", runId: "abc12345" },
],
},
],
},
{},
);
expect(sections[0]?.rows[0]).toEqual({
kind: "run",
label: "Run",
runId: "abc12345",
href: undefined,
status: undefined,
});
});
it("returns an empty array for null metadata", () => {
expect(mapCommentMetadataToSystemNoticeSections(null)).toEqual([]);
expect(mapCommentMetadataToSystemNoticeSections(undefined)).toEqual([]);
});
});
describe("buildSystemNoticeProps", () => {
it("derives tone, label, and metadata from a system_notice presentation", () => {
const props = buildSystemNoticeProps({
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required",
rows: [{ type: "key_value", label: "Status", value: "in_progress" }],
},
],
},
body: "Body text",
runAgentId: "agent-1",
});
expect(props.tone).toBe("warning");
expect(props.label).toBe("Missing disposition");
expect(props.detailsDefaultOpen).toBe(false);
expect(props.metadata?.[0]?.rows[0]).toEqual({
kind: "text",
label: "Status",
value: "in_progress",
});
});
it("falls back to neutral tone with default label when presentation is null", () => {
const props = buildSystemNoticeProps({
presentation: null,
metadata: null,
body: "Hello",
});
expect(props.tone).toBe("neutral");
expect(props.label).toBe("System notice");
expect(props.metadata).toBeUndefined();
});
it("uses the danger default label when presentation lacks a title", () => {
const props = buildSystemNoticeProps({
presentation: {
kind: "system_notice",
tone: "danger",
title: null,
detailsDefaultOpen: true,
},
metadata: null,
body: "boom",
});
expect(props.label).toBe("System alert");
expect(props.detailsDefaultOpen).toBe(true);
});
});
+125
View File
@@ -0,0 +1,125 @@
import type {
IssueCommentMetadata,
IssueCommentMetadataRow,
IssueCommentPresentation,
} from "@paperclipai/shared";
import type {
SystemNoticeMetadataRow,
SystemNoticeMetadataSection,
SystemNoticeProps,
SystemNoticeTone,
} from "../components/SystemNotice";
const TONE_LABEL: Record<SystemNoticeTone, string> = {
neutral: "System notice",
info: "System notice",
success: "System notice",
warning: "System warning",
danger: "System alert",
};
function metadataRowText(row: { label?: string | null }, fallback: string) {
const label = row.label?.trim();
return label && label.length > 0 ? label : fallback;
}
function mapMetadataRow(
row: IssueCommentMetadataRow,
ctx: { runAgentId?: string | null },
): SystemNoticeMetadataRow | null {
switch (row.type) {
case "text":
return { kind: "text", label: metadataRowText(row, "Detail"), value: row.text };
case "code":
return { kind: "code", label: metadataRowText(row, "Code"), value: row.code };
case "key_value":
return { kind: "text", label: row.label, value: row.value };
case "issue_link": {
const identifier = row.identifier ?? null;
if (!identifier) {
return { kind: "text", label: metadataRowText(row, "Issue"), value: row.title ?? "unknown" };
}
return {
kind: "issue",
label: metadataRowText(row, "Issue"),
identifier,
href: `/issues/${identifier}`,
title: row.title ?? undefined,
};
}
case "agent_link": {
const name = row.name?.trim() || row.agentId.slice(0, 8);
return {
kind: "agent",
label: metadataRowText(row, "Agent"),
name,
href: `/agents/${row.agentId}`,
};
}
case "run_link": {
const runAgentId = ctx.runAgentId ?? null;
const href = runAgentId ? `/agents/${runAgentId}/runs/${row.runId}` : undefined;
return {
kind: "run",
label: metadataRowText(row, "Run"),
runId: row.runId,
href,
status: row.title ?? undefined,
};
}
default:
return null;
}
}
export function mapCommentMetadataToSystemNoticeSections(
metadata: IssueCommentMetadata | null | undefined,
ctx: { runAgentId?: string | null } = {},
): SystemNoticeMetadataSection[] {
if (!metadata || !Array.isArray(metadata.sections)) return [];
return metadata.sections
.map((section) => {
const rows = section.rows
.map((row) => mapMetadataRow(row, ctx))
.filter((r): r is SystemNoticeMetadataRow => r !== null);
if (rows.length === 0) return null;
const out: SystemNoticeMetadataSection = { rows };
if (section.title) out.title = section.title;
return out;
})
.filter((s): s is SystemNoticeMetadataSection => s !== null);
}
export function systemNoticeLabelForTone(
tone: SystemNoticeTone,
presentationTitle?: string | null,
): string {
const trimmed = presentationTitle?.trim();
if (trimmed && trimmed.length > 0) return trimmed;
return TONE_LABEL[tone];
}
export function buildSystemNoticeProps(input: {
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
body: import("react").ReactNode;
timestamp?: string;
source?: SystemNoticeProps["source"];
runAgentId?: string | null;
}): SystemNoticeProps {
const tone: SystemNoticeTone = input.presentation?.tone ?? "neutral";
const label = systemNoticeLabelForTone(tone, input.presentation?.title);
const detailsDefaultOpen = Boolean(input.presentation?.detailsDefaultOpen);
const sections = mapCommentMetadataToSystemNoticeSections(input.metadata, {
runAgentId: input.runAgentId ?? null,
});
return {
tone,
label,
body: input.body,
metadata: sections.length > 0 ? sections : undefined,
detailsDefaultOpen,
timestamp: input.timestamp,
source: input.source,
};
}
+28 -9
View File
@@ -104,8 +104,14 @@ import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"
import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
import { filterIssueDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
successfulRunHandoffActivityTone,
} from "../lib/successful-run-handoff";
import {
Activity as ActivityIcon,
AlertTriangle,
Archive,
ArrowLeft,
Check,
@@ -578,6 +584,7 @@ type IssueDetailChatTabProps = {
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@@ -635,6 +642,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
executionRunId,
blockedBy,
blockerAttention,
successfulRunHandoff,
comments,
locallyQueuedCommentRunIds,
interactions,
@@ -836,6 +844,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
activeRun={resolvedActiveRun}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@@ -1065,16 +1074,25 @@ function IssueDetailActivityTab({
agentMap={agentMap}
hasLiveRuns={hasLiveRuns}
activityEvents={activity ?? []}
renderActivityEvent={(evt) => (
<div className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
renderActivityEvent={(evt) => {
const tone = successfulRunHandoffActivityTone(evt.action);
const isHandoffWarning =
evt.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|| evt.action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
return (
<div className={cn("space-y-1.5 rounded-lg border px-3 py-2 text-xs", tone.className)}>
<div className="flex items-center gap-1.5">
{isHandoffWarning ? (
<AlertTriangle className={cn("h-3.5 w-3.5 shrink-0", tone.iconClassName)} />
) : null}
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
)}
);
}}
/>
</div>
{linkedApprovals && linkedApprovals.length > 0 && (
@@ -3662,6 +3680,7 @@ export function IssueDetail() {
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
+403
View File
@@ -0,0 +1,403 @@
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SystemNotice } from "@/components/SystemNotice";
import { systemNoticeFixtures } from "@/fixtures/systemNoticeFixtures";
import { cn } from "@/lib/utils";
import {
CircleDashed,
FlaskConical,
Layers,
ListChecks,
Sparkles,
} from "lucide-react";
function LabSection({
id,
eyebrow,
title,
description,
accentClassName,
children,
}: {
id?: string;
eyebrow: string;
title: string;
description: string;
accentClassName?: string;
children: ReactNode;
}) {
return (
<section
id={id}
className={cn(
"rounded-[28px] border border-border/70 bg-background/85 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
accentClassName,
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
{eyebrow}
</div>
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
</section>
);
}
function FixtureFrame({ caption, children }: { caption: string; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5" />
{caption}
</div>
{children}
</div>
);
}
function MockUserBubble({
authorName,
body,
alignEnd,
}: {
authorName: string;
body: string;
alignEnd?: boolean;
}) {
return (
<div className={cn("flex items-start gap-2.5", alignEnd && "justify-end")}>
{!alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
<div className={cn("flex min-w-0 max-w-[85%] flex-col", alignEnd && "items-end")}>
<div
className={cn(
"mb-1 px-1 text-sm font-medium text-foreground",
alignEnd ? "text-right" : "text-left",
)}
>
{authorName}
</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
{alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
function MockAgentBubble({ agentName, body }: { agentName: string; body: string }) {
return (
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{agentName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">{agentName}</div>
<div className="min-w-0 max-w-full rounded-2xl border border-border/70 bg-background px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
</div>
);
}
const checklist = [
"One container per system notice — no nested chat bubble",
"Tone communicated by icon + label, never color alone",
"Operational evidence hidden behind Details, expanded only on demand",
"Issue, agent, and run metadata render as typed link rows, not raw markdown",
"Hierarchy visibly distinct from user (right-aligned) and agent (left-aligned) bubbles",
];
export function SystemNoticeUxLab() {
const fixtureById = new Map(systemNoticeFixtures.map((f) => [f.id, f] as const));
const warningCollapsed = fixtureById.get("warning-collapsed")!;
const warningExpanded = fixtureById.get("warning-expanded")!;
const dangerCollapsed = fixtureById.get("danger-collapsed")!;
const dangerExpanded = fixtureById.get("danger-expanded")!;
const neutralCollapsed = fixtureById.get("neutral-collapsed")!;
const neutralExpanded = fixtureById.get("neutral-expanded")!;
const warningNoDetails = fixtureById.get("warning-no-details")!;
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(245,158,11,0.10),transparent_28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="p-6 sm:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-500/25 bg-amber-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">
<FlaskConical className="h-3.5 w-3.5" />
System Notice Lab
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight">
First-class system notice treatment
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
Replaces the current pattern where a Paperclip-authored warning renders inside a user-style
chat bubble. The notice is one container, system-styled, with hidden-by-default operational
metadata. Tone is conveyed by icon, label, and color together so it stays accessible.
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
PAP-3525 plan
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
phase 1 — UX
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
tones: warning · danger · neutral
</Badge>
</div>
</div>
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
<div className="mb-4 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
<ListChecks className="h-4 w-4 text-amber-700 dark:text-amber-300" />
What this lab proves
</div>
<div className="space-y-3">
{checklist.map((line) => (
<div
key={line}
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
>
{line}
</div>
))}
</div>
</aside>
</div>
</div>
<LabSection
id="tones"
eyebrow="Tone matrix"
title="Three tones, two states"
description="Each tone pairs a unique icon and tone label so the notice is recognizable without color. Collapsed is the default; the Details affordance reveals operational metadata only when reviewers ask for it."
accentClassName="bg-[linear-gradient(180deg,rgba(245,158,11,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<FixtureFrame caption={warningCollapsed.caption}>
<SystemNotice {...warningCollapsed} />
</FixtureFrame>
<FixtureFrame caption={warningExpanded.caption}>
<SystemNotice {...warningExpanded} />
</FixtureFrame>
<FixtureFrame caption={dangerCollapsed.caption}>
<SystemNotice {...dangerCollapsed} />
</FixtureFrame>
<FixtureFrame caption={dangerExpanded.caption}>
<SystemNotice {...dangerExpanded} />
</FixtureFrame>
<FixtureFrame caption={neutralCollapsed.caption}>
<SystemNotice {...neutralCollapsed} />
</FixtureFrame>
<FixtureFrame caption={neutralExpanded.caption}>
<SystemNotice {...neutralExpanded} />
</FixtureFrame>
<FixtureFrame caption={warningNoDetails.caption}>
<SystemNotice {...warningNoDetails} />
</FixtureFrame>
</div>
</LabSection>
<LabSection
id="hierarchy"
eyebrow="Hierarchy in thread"
title="Distinct from user and agent comments"
description="Side-by-side with adjacent comment types so reviewers can confirm the system row reads as a system row — full width, no avatar gutter, no chat bubble — while user and agent comments keep their existing rounded bubbles."
accentClassName="bg-[linear-gradient(180deg,rgba(8,145,178,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4">
<MockUserBubble
authorName="Riley Board"
body="Why does this issue keep waking back up without a clear next step?"
alignEnd
/>
<MockAgentBubble
agentName="CodexCoder"
body="The previous run completed without picking a disposition. I'll wait for the new system notice to surface so the recovery owner is unambiguous."
/>
<SystemNotice
tone="danger"
label="System alert"
source={{ label: "Paperclip", href: "/PAP/agents" }}
timestamp="2026-05-04T16:48:00.000Z"
body="Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner."
metadata={[
{
title: "Recovery owner",
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
],
},
{
title: "Run evidence",
rows: [
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
],
},
]}
/>
<MockUserBubble
authorName="Riley Board"
body="Thanks — assigning the recovery owner now."
alignEnd
/>
</div>
</LabSection>
<div className="grid gap-5 xl:grid-cols-2">
<LabSection
eyebrow="Before"
title="Today's nested treatment"
description="The same content rendered through the existing user-bubble + warning-callout path. Two containers, same gray background as user comments, and the warning icon is forced inside a chat row."
accentClassName="bg-[linear-gradient(180deg,rgba(244,63,94,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>YO</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">You</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
<div className="rounded-md border border-red-500/35 bg-red-500/10 px-3 py-2.5 text-sm text-red-950 dark:text-red-100">
<div className="flex items-start gap-2">
<Sparkles className="mt-1 h-4 w-4 shrink-0 text-red-600 dark:text-red-300" />
<div className="min-w-0">
<p className="m-0 font-semibold">Successful run handoff missing</p>
<ul className="mt-1.5 list-disc space-y-0.5 pl-4 text-[13px] leading-5">
<li>Source issue: PAP-3440</li>
<li>Source run: 9cdba892-c7ca-4d93-8604-4843873b127c</li>
<li>Recovery run: 61fdb79b-8012-4676-ac71-2971830e126a</li>
<li>Status before: in_progress</li>
<li>Normalized cause: Run completed without disposition</li>
<li>Recovery owner: CTO</li>
<li>Suggested action: Reassign to recovery agent</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<p className="px-1 text-xs text-muted-foreground">
Author reads as <span className="font-medium text-foreground">You</span> even though the
author is the Paperclip system. Two containers stack the warning inside a user-style
bubble, and operational evidence is always visible.
</p>
</div>
</LabSection>
<LabSection
eyebrow="After"
title="System notice replacement"
description="One container, system-authored label, hidden details. The chat surface keeps user and agent bubbles unchanged."
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<SystemNotice {...dangerCollapsed} />
<p className="px-1 text-xs text-muted-foreground">
Same content. The visible body is one short system sentence; reviewers expand{" "}
<span className="font-medium text-foreground">Details</span> only when they need run
evidence. Tone is reinforced by the octagon icon and the &quot;System alert&quot; label,
not just red.
</p>
</div>
</LabSection>
</div>
<Card className="gap-4 border-border/70 bg-background/85 py-0">
<CardHeader className="px-5 pt-5 pb-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<Layers className="h-4 w-4 text-amber-700 dark:text-amber-300" />
Implementation notes
</div>
<CardTitle className="text-lg">Handoff to engineering</CardTitle>
<CardDescription>
What the Phase 4 UI implementation should preserve from this design.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5 pt-0 text-sm text-muted-foreground">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Component</div>
Use <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">{`<SystemNotice />`}</code>{" "}
from <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">@/components/SystemNotice</code>.
It accepts <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">tone</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">label</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">body</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">metadata</code>, and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">detailsDefaultOpen</code>.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Routing in IssueChatThread</div>
Comments where{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">authorType === &quot;system&quot;</code>{" "}
or{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation.kind === &quot;system_notice&quot;</code>{" "}
should render as a SystemNotice row at full content width — never inside an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">IssueChatUserMessage</code>{" "}
or assistant bubble.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Accessibility</div>
The Details button has{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-expanded</code>{" "}
and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-controls</code>{" "}
wired to the panel id. The container exposes{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">role=&quot;status&quot;</code>{" "}
and an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-label</code>{" "}
equal to the visible tone label so screen readers announce tone with text.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Legacy fallback</div>
Existing comments without{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation</code>{" "}
keep rendering through the current{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">SuccessfulRunHandoffCommentCallout</code>{" "}
string-detector. The new contract is opt-in for the system generators in Phase 5.
</div>
</CardContent>
</Card>
</div>
);
}
export default SystemNoticeUxLab;
+14 -27
View File
@@ -43,27 +43,6 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
type: "text",
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
},
{
key: "mode",
label: "Session mode",
type: "select",
default: "persistent",
options: [
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One shot" },
],
},
{
key: "permissionMode",
label: "Permission mode",
type: "select",
default: "approve-all",
options: [
{ value: "approve-all", label: "Approve all" },
{ value: "default", label: "ACP default" },
],
hint: "Defaults to maximum permissions: ACPX permission requests are auto-approved.",
},
{
key: "nonInteractivePermissions",
label: "Non-interactive permissions",
@@ -73,6 +52,7 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.",
},
{
key: "cwd",
@@ -87,14 +67,21 @@ const acpxLocalConfigSchema: AdapterConfigSchema = {
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
},
{
key: "instructionsFilePath",
label: "Instructions file",
type: "text",
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
key: "fastMode",
label: "Codex fast mode",
type: "toggle",
default: false,
hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.",
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
},
{ key: "promptTemplate", label: "Prompt template", type: "textarea" },
{ key: "bootstrapPromptTemplate", label: "Bootstrap prompt template", type: "textarea" },
{ key: "timeoutSec", label: "Timeout seconds", type: "number", default: 0 },
{
key: "warmHandleIdleMs",
label: "Warm process idle ms",
type: "number",
default: 0,
hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.",
},
{
key: "env",
label: "Environment JSON",
@@ -84,6 +84,7 @@ function ScenarioCard({
function createComment(overrides: Partial<StoryComment>): StoryComment {
const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z");
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-default",
companyId,
@@ -91,6 +92,9 @@ function createComment(overrides: Partial<StoryComment>): StoryComment {
authorAgentId: null,
authorUserId: currentUserId,
body: "",
authorType: authorAgentId ? "agent" : "user",
presentation: null,
metadata: null,
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
...overrides,
@@ -384,6 +388,42 @@ const issueChatComments: IssueChatComment[] = [
runId: "run-issue-chat-01",
runAgentId: codexAgent.id,
}),
createComment({
id: "comment-issue-system-warning",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-issue-chat-01",
runAgentId: codexAgent.id,
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: issueId, identifier: "PAP-3440", title: "Successful run handoff" },
{ type: "agent_link", label: "Assignee", agentId: codexAgent.id, name: codexAgent.name },
{ type: "key_value", label: "Status before", value: "in_progress" },
],
},
{
title: "Run evidence",
rows: [
{ type: "run_link", label: "Successful run", runId: "run-issue-chat-01", title: "succeeded" },
{ type: "key_value", label: "Normalized cause", value: "Run completed without disposition" },
],
},
],
},
createdAt: new Date("2026-04-20T13:54:00.000Z"),
}),
createComment({
id: "comment-issue-queued",
body: "@QAChecker please do a quick visual pass after the Storybook build is green.",
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more