mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Merge public-gh/master into search page branch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
18e54fa87f
102 files changed
+24118
-455
No files matched your search
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 renamed without changes.
File renamed without changes.
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
}]}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "System alert" 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 === "system"</code>{" "}
|
||||
or{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation.kind === "system_notice"</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="status"</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;
|
||||
@@ -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
Reference in new issue
Block a user