mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip lets operators assign tools and credentials to agents. > - Legacy Codex starts provider processes and builds a separate shell environment policy. > - Both ACP and CLI removed every variable whose name contained KEY, SECRET, or TOKEN. > - This also removed service credentials that the operator explicitly assigned, such as SLACK_BOT_TOKEN. > - This pull request uses the resolved configuration to admit assigned service secrets by name. > - Every Codex launch keeps a bounded shell allowlist, including launches without an agent identity. > - Assigned scripts can use their credentials without adding unassigned host secrets to shell tools. ## Linked Issues or Issue Description Related: #16255. That PR fixes project-level exclude patterns. This PR fixes the removal of explicitly assigned service secrets and the fallback to an inherited policy when no agent identity is present. **What happened?** A Codex task could not run a Slack posting script. SLACK_BOT_TOKEN was present in its resolved launch environment, but Paperclip removed its name from shell_environment_policy.include_only. A live removal check also showed that an identity-free launch without an explicit policy could inherit an unassigned host token. **Expected behavior** An explicitly assigned service credential reaches shell commands. Removing that binding removes its shell access on the next launch. Unassigned host credentials stay outside the allowlist. Codex login credentials and observability headers keep their separate provider paths. **Steps to reproduce** 1. Configure a Legacy Codex agent with a synthetic SLACK_BOT_TOKEN value. 2. Run a shell command that checks whether the variable exists without printing its value. 3. Before this fix, the variable is absent. Repeat through ACP and CLI. 4. Remove the binding and run a second turn. Check that neither the removed token nor an unassigned host token reaches the command. **Paperclip version or commit** Reproduced in the executor regressions before the fix on master at4dfcb4c9b8. The final change is rebased on25f2d05282. **Deployment mode** Source checkout. Local macOS arm64 provider acceptance with Codex CLI 0.160.0 and Codex ACP 1.6.2, gpt-5.6-sol, and an existing ChatGPT login. ## What Changed - Added one shared Codex shell-name policy for Legacy ACP and CLI execution. - Admit explicitly resolved service-secret bindings. Keep Codex login keys, observability credentials, and private runtime transport tokens outside that opt-in. - Reject glob-shaped environment names so one configured name cannot match other credentials. - Always emit the bounded policy. Do not fall back to host inheritance after a binding is removed. - Updated executor regressions, the identity contract, and the paired verification maps. ## Verification - Red before the fix: executor regressions failed because SLACK_BOT_TOKEN was absent in ACP and CLI, with and without agent identities. - Green: 267 tests passed across the two executor suites and the shared shell-policy suite. Coverage includes host/provider isolation, unresolved and removed bindings, glob names, identity delivery, and names-only metadata. - The complete Codex adapter source suite passed after the CI follow-up: 571 tests passed, 1 skipped across 31 files. Its typecheck also passed. - The first CI run caught four stale expectations in ACP config and SSH launch tests. Those now assert the bounded policy and the existing command/resume behavior. No production code changed in that follow-up. - Live CLI and ACP acceptance: run a Python presence check through each real adapter, remove the synthetic Slack binding, and run again. The assigned token was present only on the first turn. The unassigned host token and OpenAI API key were absent on both turns. CLI retained its session; ACP correctly invalidated its session after the configuration changed. Workspace proof files persisted. - An earlier live CLI check failed after binding removal because the no-policy fallback exposed the synthetic host token. The final change removes that fallback. Both live checks then passed. - Full pnpm -r typecheck and pnpm build passed. The changed packages were compiled again after the safety correction. - node scripts/check-maps.mjs and git diff --check passed. - The local pnpm test:run attempt reported failures in native warm-session tests and was interrupted. That full-suite result is not a pass; PR CI must establish the final repository-wide gate. One named native failure passed alone, and the complete native executor file then passed separately (774 tests). The original broad-run failure cause remains unconfirmed. - After rebase, one Gemini skill-root case timed out at five seconds while the broad run was active. The case passed alone, and the full focused set then passed again at the PR head (267 tests). The earlier timeout remains part of the verification record. - An initial broad adapter run failed because sandbox restrictions denied loopback listeners. Running the same suites with loopback access passed. ## Risks - Explicitly assigned service secrets now reach shell commands. This is the intended behavior change: assigning a credential authorizes the run to use it, and a model-directed command can read it. This is not a broker that hides assigned credentials from the model. Operators must assign only credentials and service permissions authorized for the run. Configuration and secret-access authorization remain with their existing owners. - Superagent initially flagged this intended assigned-credential exposure as a P2. The inline reply records the trust-model rationale and the documentation states it explicitly. The security scan on the final documented revision passed without any check override. - The launch policy contains names, not values. Log redaction remains in place. Provider login credentials and observability headers remain excluded from the service-secret opt-in. - Identity-free Codex runs now also receive a bounded policy. This removes reliance on the operator's inherited shell policy. - Existing explicit project exclude patterns retain their behavior; #16255 addresses that separate case. - The change has not been deployed to a shared instance. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and code execution. The exact served model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with Fixes / Closes / Refs OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run the focused tests locally and they pass; the full-suite limitation is recorded above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
8988 lines
375 KiB
TypeScript
8988 lines
375 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { summarizeStartupReuse } from "../startup-reuse.js";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { parse as parseToml } from "smol-toml";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createRuntimeStore, type AcpRuntimeOptions, type AcpSessionRecord, type AcpSessionStore } from "acpx/runtime";
|
|
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
|
|
import {
|
|
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
|
prepareAdapterExecutionTargetRuntime,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
startAdapterExecutionTargetProcessSessionBridge,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
|
|
// Wrap the staging seam + both sandbox bridges in call-recording spies that
|
|
// still delegate to the real implementations (a runner-backed sandbox test
|
|
// exercises them end-to-end against a local runner). This lets the staging
|
|
// tests assert the exact `runtimeRootDir`/`workspaceLocalDir`/`assets` the
|
|
// engine threads without changing any real behavior for the other tests.
|
|
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
|
|
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
|
|
return {
|
|
...actual,
|
|
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
|
|
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
|
|
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
|
|
};
|
|
});
|
|
import {
|
|
buildAcpxRunSummary,
|
|
createAcpxEngineExecutor,
|
|
findAncestorBin,
|
|
geminiVersionSupportsNativeAcpFlag,
|
|
parseGeminiVersionParts,
|
|
referencedSourceContentSignature,
|
|
resolveStalledToolTimeoutMs,
|
|
rewriteGeminiAcpFlagForVersion,
|
|
summarizeAcpStopActivity,
|
|
summarizeAcpxTurnUsage,
|
|
type AcpxEngineExecutorOptions,
|
|
} from "./execute.js";
|
|
import {
|
|
ACPX_CLAUDE_MCP_TOOL_TIMEOUT_MS,
|
|
ACPX_HANDSHAKE_TIMEOUT_MS,
|
|
ACPX_STALLED_TOOL_MARGIN_MS,
|
|
ACPX_STALLED_TOOL_TIMEOUT_MS,
|
|
} from "./constants.js";
|
|
import { sessionCodec } from "./session-codec.js";
|
|
import { runChildProcess } from "../server-utils.js";
|
|
import { createPromptContextFixture } from "../test-fixtures/prompt-context.js";
|
|
import { setExpensiveWorkspaceGitExecutor } from "../git-workspace-sync.js";
|
|
import { createWorkspaceRestoreTeardown } from "../workspace-restore-teardown.js";
|
|
import { withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep } from "../workspace-restore-diagnostics.js";
|
|
import { resolveReferencedSourceIgnore } from "../sandbox-managed-runtime.js";
|
|
import {
|
|
getActiveStepContext,
|
|
runWithRuntimeParent,
|
|
SANDBOX_STARTUP_SPAN_ATTRS,
|
|
} from "./startup-timing.js";
|
|
|
|
|
|
const tempRoots: string[] = [];
|
|
let defaultGeminiTestHome: string | undefined;
|
|
|
|
async function makeTempRoot() {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
|
|
tempRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
defaultGeminiTestHome = undefined;
|
|
// A remote run stages a process-session bridge whose detached event writer can
|
|
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
|
|
// when the run's own best-effort `client.remove(sessionDir)` (which production
|
|
// catch-wraps) has already returned. Under CI load that trailing write can land
|
|
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
|
|
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
|
|
// same way production tolerates it, instead of failing the just-passed test.
|
|
await Promise.all(
|
|
tempRoots.splice(0).map((root) =>
|
|
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
|
|
),
|
|
);
|
|
});
|
|
|
|
async function pathExists(candidate: string): Promise<boolean> {
|
|
return fs.access(candidate).then(() => true).catch(() => false);
|
|
}
|
|
|
|
async function onlyChildDir(parent: string): Promise<string> {
|
|
const entries = await fs.readdir(parent);
|
|
expect(entries).toHaveLength(1);
|
|
return path.join(parent, entries[0]!);
|
|
}
|
|
|
|
async function createSkill(root: string, name: string, body = `---\nrequired: false\n---\n# ${name}\n`) {
|
|
const skillDir = path.join(root, name);
|
|
await fs.mkdir(skillDir, { recursive: true });
|
|
await fs.writeFile(path.join(skillDir, "SKILL.md"), body, "utf8");
|
|
return {
|
|
key: `paperclipai/test/${name}`,
|
|
runtimeName: name,
|
|
source: skillDir,
|
|
required: false,
|
|
};
|
|
}
|
|
|
|
function createLocalSandboxRunner(
|
|
onExecute?: (input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
}) => void,
|
|
) {
|
|
let counter = 0;
|
|
return {
|
|
execute: async (input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdin?: string;
|
|
timeoutMs?: number;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
|
}) => {
|
|
counter += 1;
|
|
onExecute?.(input);
|
|
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
|
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
|
|
cwd: input.cwd ?? process.cwd(),
|
|
env: input.env ?? {},
|
|
stdin: input.stdin,
|
|
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
|
graceSec: 5,
|
|
onLog: input.onLog ?? (async () => {}),
|
|
onSpawn: input.onSpawn
|
|
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
|
|
: undefined,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildRuntime(
|
|
onSetConfigOption?: (input: { key: string; value: string }) => void,
|
|
onEnsureSession?: (input: Record<string, unknown>) => void,
|
|
onStartTurn?: (input: Record<string, unknown>) => void,
|
|
) {
|
|
return {
|
|
ensureSession: async (input: Record<string, unknown>) => {
|
|
onEnsureSession?.(input);
|
|
return ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
});
|
|
},
|
|
startTurn: (input: Record<string, unknown>) => {
|
|
onStartTurn?.(input);
|
|
return {
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
};
|
|
},
|
|
setConfigOption: async (input: { key: string; value: string }) => {
|
|
onSetConfigOption?.(input);
|
|
},
|
|
close: async () => {},
|
|
};
|
|
}
|
|
|
|
async function runExecutor(
|
|
config: Record<string, unknown>,
|
|
options: {
|
|
context?: Record<string, unknown>;
|
|
runtime?: Record<string, unknown>;
|
|
executionTransport?: Record<string, unknown>;
|
|
authToken?: string;
|
|
agentIdentity?: AdapterExecutionContext["agentIdentity"];
|
|
observability?: AdapterExecutionContext["observability"];
|
|
executionTarget?: Record<string, unknown>;
|
|
runtimeMcp?: AdapterRuntimeMcpAccess;
|
|
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
|
|
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
|
|
} = {},
|
|
) {
|
|
// Skill reconciliation must never inspect or modify the developer's real
|
|
// Gemini home. Keep one private home across this test's resumed turns.
|
|
const configuredEnv = config.env as Record<string, unknown> | undefined;
|
|
if (config.agent === "gemini" && !(typeof configuredEnv?.HOME === "string" && configuredEnv.HOME.trim())) {
|
|
defaultGeminiTestHome ??= await makeTempRoot();
|
|
config = { ...config, env: { ...configuredEnv, HOME: defaultGeminiTestHome } };
|
|
}
|
|
const runtimeOptions: Record<string, unknown>[] = [];
|
|
const configOptions: Array<{ key: string; value: string }> = [];
|
|
const sessionInputs: Record<string, unknown>[] = [];
|
|
const turnInputs: Record<string, unknown>[] = [];
|
|
const meta: Record<string, unknown>[] = [];
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
|
const receipts: unknown[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
...(options.prepareRemoteManagedHome
|
|
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
|
|
: {}),
|
|
createRuntime: (options) => {
|
|
runtimeOptions.push(options as unknown as Record<string, unknown>);
|
|
return buildRuntime(
|
|
({ key, value }) => configOptions.push({ key, value }),
|
|
(input) => sessionInputs.push(input),
|
|
(input) => turnInputs.push(input),
|
|
) as never;
|
|
},
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-1",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
},
|
|
runtime: options.runtime ?? {},
|
|
config,
|
|
context: options.context ?? {},
|
|
executionTransport: options.executionTransport,
|
|
authToken: options.authToken,
|
|
agentIdentity: options.agentIdentity,
|
|
observability: options.observability,
|
|
executionTarget: options.executionTarget,
|
|
runtimeMcp: options.runtimeMcp,
|
|
startupTraceContext: options.startupTraceContext,
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async (payload: unknown) => {
|
|
meta.push(payload as Record<string, unknown>);
|
|
},
|
|
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
|
events.push(event);
|
|
},
|
|
onUsage: async (receipt: unknown) => { receipts.push(receipt); },
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
return { logs, meta, events, receipts, runtimeOptions, configOptions, sessionInputs, turnInputs, result };
|
|
}
|
|
|
|
// Under `vi.useFakeTimers()`, setup before `ensureSession` still performs real
|
|
// filesystem work. Advancing the fake clock before that work reaches the
|
|
// handshake can leave the guard timer scheduled after the advance and hang the
|
|
// test. Track the exact call boundary so the deadline always advances only
|
|
// after the guard exists, regardless of runner load.
|
|
function trackEnsureSessionCall<T>(call: () => Promise<T>): {
|
|
call: () => Promise<T>;
|
|
started: Promise<void>;
|
|
} {
|
|
let markStarted!: () => void;
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
return {
|
|
call: () => {
|
|
markStarted();
|
|
return call();
|
|
},
|
|
started,
|
|
};
|
|
}
|
|
|
|
async function advanceHandshakeGuardAfterStart(started: Promise<void>, ms: number): Promise<void> {
|
|
await started;
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
}
|
|
|
|
// A recording span, used only in tests. It captures the span name, the parent
|
|
// span (resolved from the explicit parent-context token), the attribute map,
|
|
// the terminal status, and whether the span ended. The engine treats it purely
|
|
// through the structural `StartupSpan` contract.
|
|
interface RecordingSpan {
|
|
name: string;
|
|
attributes: Record<string, string | number | boolean>;
|
|
parent: RecordingSpan | null;
|
|
// The raw third `startSpan` argument — the parent-context token — before the
|
|
// recorder resolves it to a parent span. A root span receives `undefined`
|
|
// here, so a test asserts the trace-root shape from the argument itself.
|
|
parentContextArg: unknown;
|
|
status: { code: number } | null;
|
|
ended: boolean;
|
|
// The count of `end` calls. A guarded `end` closure ends the span at most
|
|
// once, so a test asserts this count equals one for the run root span.
|
|
endCalls: number;
|
|
setAttribute(key: string, value: string | number | boolean): void;
|
|
setStatus(status: { code: number; message?: string }): void;
|
|
end(): void;
|
|
}
|
|
|
|
// Build an in-memory startup trace context that records every span. It models
|
|
// the real OTel parenting contract: `startSpan(name, options, context)` reads
|
|
// the parent from the explicit `context` token that `contextWithSpan` produced,
|
|
// so a test asserts the exact parent of each child without an OTel package or
|
|
// ambient async-context propagation.
|
|
function createRecordingStartupTrace() {
|
|
const spans: RecordingSpan[] = [];
|
|
const traceContext = {
|
|
tracer: {
|
|
startSpan(
|
|
name: string,
|
|
options?: { attributes?: Record<string, string | number | boolean> },
|
|
context?: unknown,
|
|
) {
|
|
const parent =
|
|
context && typeof context === "object" && "span" in context
|
|
? ((context as { span: RecordingSpan }).span ?? null)
|
|
: null;
|
|
const span: RecordingSpan = {
|
|
name,
|
|
attributes: { ...(options?.attributes ?? {}) },
|
|
parent,
|
|
parentContextArg: context,
|
|
status: null,
|
|
ended: false,
|
|
endCalls: 0,
|
|
setAttribute(key: string, value: string | number | boolean) {
|
|
span.attributes[key] = value;
|
|
},
|
|
setStatus(status: { code: number }) {
|
|
span.status = { code: status.code };
|
|
},
|
|
end() {
|
|
span.ended = true;
|
|
span.endCalls += 1;
|
|
},
|
|
};
|
|
spans.push(span);
|
|
return span;
|
|
},
|
|
},
|
|
contextWithSpan(span: unknown) {
|
|
return { span };
|
|
},
|
|
} satisfies AdapterExecutionContext["startupTraceContext"];
|
|
return { traceContext, spans };
|
|
}
|
|
|
|
// Mirror the production host-to-sandbox exec seam for one execution. The real
|
|
// seam reads the runtime-parent store with `getActiveStepContext()` and opens a
|
|
// `sandbox.exec` span whose third `startSpan` argument is the stored
|
|
// parent-context token. This test copy issues that same span through the
|
|
// injected recorder, so a test asserts which context a startup-body exec parents
|
|
// to at the point the run issues it. The recorder pushes the span into `spans`.
|
|
function issueSandboxExecFromStore(
|
|
tracing: NonNullable<AdapterExecutionContext["startupTraceContext"]>,
|
|
): void {
|
|
const activeStep = getActiveStepContext();
|
|
tracing.tracer.startSpan("sandbox.exec", undefined, activeStep?.parentContext);
|
|
}
|
|
|
|
// The closed span-attribute allowlist for a sandbox-start span. A test asserts
|
|
// every recorded attribute key is in this set, so a command, path, id, or
|
|
// error-text key can never ride a span. Every key uses the closed
|
|
// `paperclip.sandbox.startup.` prefix from the attribute contract.
|
|
const A = SANDBOX_STARTUP_SPAN_ATTRS;
|
|
const ALLOWED_STARTUP_SPAN_ATTRIBUTE_KEYS = new Set<string>([
|
|
// Step-span keys.
|
|
A.provider,
|
|
A.stepWallMs,
|
|
A.outcome,
|
|
// Root-span keys.
|
|
A.rootWallMs,
|
|
A.rootWorkMs,
|
|
A.rootDiffMs,
|
|
A.coldStart,
|
|
A.warmth,
|
|
A.region,
|
|
A.imageId,
|
|
A.sandboxId,
|
|
A.leaseId,
|
|
// Handshake sub-times and the parallel-batch tag.
|
|
A.handshakeCreateRuntimeWallMs,
|
|
A.handshakeEnsureSessionWallMs,
|
|
A.batch,
|
|
]);
|
|
|
|
// The closed attribute allowlist for the run root span. It carries only a
|
|
// non-reversible run-id hash and its own wall time, so no command, path, id, or
|
|
// error text can ride the `task.run` span.
|
|
const ALLOWED_RUN_SPAN_ATTRIBUTE_KEYS = new Set<string>([
|
|
"paperclip.task.run.run_id",
|
|
"paperclip.task.run.wall_ms",
|
|
]);
|
|
|
|
// The closed attribute allowlist for the agent turn span. It carries only its
|
|
// own wall time, so no command, path, id, prompt, or error text can ride the
|
|
// `agent.turn` span.
|
|
const ALLOWED_TURN_SPAN_ATTRIBUTE_KEYS = new Set<string>([
|
|
"paperclip.agent.turn.wall_ms",
|
|
]);
|
|
|
|
describe("shared ACPX engine runtime behavior", () => {
|
|
it("passes the current prompt, session, cancellation and diagnostic inputs to the runtime turn", async () => {
|
|
const root = await makeTempRoot();
|
|
const { result, turnInputs, meta } = await runExecutor({
|
|
agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"),
|
|
cwd: root, timeoutSec: 120,
|
|
});
|
|
expect(turnInputs).toHaveLength(1);
|
|
expect(turnInputs[0]).toMatchObject({
|
|
handle: { backendSessionId: "backend-session", agentSessionId: "agent-session", runtimeSessionName: "runtime-session" },
|
|
text: meta[0]?.prompt, mode: "prompt", requestId: "run-1", timeoutMs: 120_000,
|
|
onTerminalSessionFailure: expect.any(Function),
|
|
});
|
|
expect(meta[0]?.prompt).toBeTypeOf("string");
|
|
expect(turnInputs[0]?.text).toBeTypeOf("string");
|
|
expect((meta[0]?.prompt as string).length).toBeGreaterThan(0);
|
|
expect((turnInputs[0]?.text as string).length).toBeGreaterThan(0);
|
|
expect(turnInputs[0]?.signal).toBeInstanceOf(AbortSignal);
|
|
expect((turnInputs[0]?.signal as AbortSignal).aborted).toBe(false);
|
|
expect(result.summary).toBe("end_turn");
|
|
expect(result.sessionParams?.configFingerprint).toBeTypeOf("string");
|
|
expect((result.sessionParams?.configFingerprint as string).length).toBeGreaterThan(0);
|
|
expect(result.sessionParams?.sessionKey).toBe(`paperclip:company-1:agent-1:default:${result.sessionParams?.configFingerprint}`);
|
|
});
|
|
|
|
it.each(["agentCommand", "cwd", "model", "thinkingEffort", "mode", "fastMode"] as const)(
|
|
"folds configured %s into the engine session fingerprint",
|
|
async (dimension) => {
|
|
const root = await makeTempRoot();
|
|
const config = {
|
|
agent: dimension === "fastMode" ? "codex" : "custom", agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"), cwd: root,
|
|
env: { CODEX_HOME: path.join(root, "codex-home") },
|
|
paperclipRuntimeSkills: [], paperclipSkillSync: { desiredSkills: [] },
|
|
};
|
|
const changed: Record<string, unknown> = {
|
|
agentCommand: "node ./other-acp.js", cwd: path.join(root, "other-workspace"), model: "test-model",
|
|
thinkingEffort: "high", mode: "oneshot", fastMode: true,
|
|
};
|
|
const first = await runExecutor(config);
|
|
const second = await runExecutor({ ...config, [dimension]: changed[dimension] });
|
|
expect(first.result.sessionParams?.configFingerprint).toBeTypeOf("string");
|
|
expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
|
|
expect((first.result.sessionParams?.configFingerprint as string).length).toBeGreaterThan(0);
|
|
expect((second.result.sessionParams?.configFingerprint as string).length).toBeGreaterThan(0);
|
|
expect(second.result.sessionParams?.configFingerprint).not.toBe(first.result.sessionParams?.configFingerprint);
|
|
},
|
|
);
|
|
|
|
it("integrates the env-free store while delivering current credentials and options to the provider", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const first = await runExecutor({ agent: "claude", agentCommand: "node ./fake-acp.js", cwd: root, stateDir,
|
|
env: { ANTHROPIC_API_KEY: "first-fixture-credential" } });
|
|
const liveEnvironment = (first.sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(liveEnvironment.ANTHROPIC_API_KEY).toBe("first-fixture-credential");
|
|
const store = first.runtimeOptions[0]!.sessionStore as AcpSessionStore;
|
|
const record: AcpSessionRecord = {
|
|
schema: "acpx.session.v1", acpxRecordId: "integration-record", acpSessionId: "integration-session",
|
|
agentCommand: "fixture-provider", cwd: root, name: "fixture", createdAt: "2026-10-03T00:00:00.000Z",
|
|
lastUsedAt: "2026-10-03T00:00:01.000Z", lastSeq: 0,
|
|
eventLog: { active_path: path.join(stateDir, "fixture.jsonl"), segment_count: 1, max_segment_bytes: 1024, max_segments: 2 },
|
|
messages: [], updated_at: "2026-10-03T00:00:01.000Z", cumulative_token_usage: {}, request_token_usage: {},
|
|
acpx: { session_options: { env: { ...liveEnvironment }, model: "retained-model", max_turns: 4 } },
|
|
};
|
|
await store.save(record);
|
|
const file = path.join(stateDir, "sessions", "integration-record.json");
|
|
const saved = await fs.readFile(file, "utf8");
|
|
expect(saved).not.toContain("first-fixture-credential");
|
|
expect(JSON.parse(saved).acpx.session_options).toEqual({ model: "retained-model", max_turns: 4 });
|
|
expect(record.acpx?.session_options?.env).toEqual(liveEnvironment);
|
|
// A file written by the prior engine is loaded with the current run's env,
|
|
// without altering the provider launch or deleting unrelated options.
|
|
record.acpx!.session_options!.env = { ANTHROPIC_API_KEY: "legacy-fixture-credential" };
|
|
await createRuntimeStore({ stateDir }).save(record);
|
|
const second = await runExecutor({ agent: "claude", agentCommand: "node ./fake-acp.js", cwd: root, stateDir,
|
|
env: { ANTHROPIC_API_KEY: "rotated-fixture-credential" } }, { runtime: { sessionParams: first.result.sessionParams } });
|
|
expect((second.sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env.ANTHROPIC_API_KEY).toBe("rotated-fixture-credential");
|
|
const rotatedStore = second.runtimeOptions[0]!.sessionStore as AcpSessionStore;
|
|
const loaded = await rotatedStore.load(record.acpxRecordId);
|
|
expect(loaded?.acpx?.session_options).toMatchObject({ env: { ANTHROPIC_API_KEY: "rotated-fixture-credential" }, model: "retained-model", max_turns: 4 });
|
|
await rotatedStore.save(loaded!);
|
|
const resaved = await fs.readFile(file, "utf8");
|
|
for (const value of ["first-fixture-credential", "legacy-fixture-credential", "rotated-fixture-credential"]) expect(resaved).not.toContain(value);
|
|
});
|
|
it.each(["claude", "codex", "gemini", "kimi", "custom"])("defaults the legacy %s engine to full auto on fresh and resumed runs", async (agent) => {
|
|
const root = await makeTempRoot();
|
|
const config = {
|
|
agent, cwd: root, stateDir: path.join(root, "state"),
|
|
...(agent === "custom" ? { agentCommand: "node ./fake-acp.js" } : {}),
|
|
};
|
|
const first = await runExecutor(config);
|
|
const resumed = await runExecutor(config, { runtime: { sessionParams: first.result.sessionParams } });
|
|
for (const run of [first, resumed]) {
|
|
expect(run.runtimeOptions[0]?.permissionMode).toBe("approve-all");
|
|
expect(run.result.resultJson?.permissionMode).toBe("approve-all");
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["default", "approve-all"], ["", "approve-all"],
|
|
["approve-reads", "approve-reads"], ["deny-all", "deny-all"],
|
|
])("resolves the legacy %j permission setting to %s", async (permissionMode, expected) => {
|
|
const root = await makeTempRoot();
|
|
const run = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state"), permissionMode });
|
|
expect(run.runtimeOptions[0]?.permissionMode).toBe(expected);
|
|
});
|
|
|
|
it("persists ACP agent process identity before prompting on each run (host lane re-creates, no warm reuse)", async () => {
|
|
const root = await makeTempRoot();
|
|
const startedAt = "2026-07-30T07:00:00.000Z";
|
|
const processPid = 43_210;
|
|
let runtimeCreateCount = 0;
|
|
let processIdentityPersisted = false;
|
|
let turnStartedBeforeProcessIdentity = false;
|
|
const warmHandles = new Map();
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles,
|
|
createRuntime: (options) => {
|
|
runtimeCreateCount += 1;
|
|
const patchedOptions = options as AcpRuntimeOptions & {
|
|
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
|
};
|
|
return {
|
|
ensureSession: async () => {
|
|
await patchedOptions.onAgentSpawn?.({ pid: processPid, startedAt });
|
|
return {
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
};
|
|
},
|
|
startTurn: () => {
|
|
turnStartedBeforeProcessIdentity = !processIdentityPersisted;
|
|
return {
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
};
|
|
},
|
|
close: async () => {},
|
|
} as never;
|
|
},
|
|
});
|
|
const config = {
|
|
agent: "codex",
|
|
cwd: root,
|
|
stateDir: path.join(root, "state"),
|
|
warmHandleIdleMs: 60_000,
|
|
};
|
|
const context = {
|
|
taskId: "issue-1",
|
|
paperclipWorkspace: { cwd: root },
|
|
};
|
|
const firstOnSpawn = vi.fn(async (meta: unknown) => {
|
|
expect(meta).toEqual({ pid: processPid, processGroupId: null, startedAt });
|
|
processIdentityPersisted = true;
|
|
});
|
|
const first = await execute({
|
|
runId: "run-process-identity-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config,
|
|
context,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onSpawn: firstOnSpawn,
|
|
} as never);
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(firstOnSpawn).toHaveBeenCalledOnce();
|
|
expect(turnStartedBeforeProcessIdentity).toBe(false);
|
|
|
|
processIdentityPersisted = false;
|
|
turnStartedBeforeProcessIdentity = false;
|
|
const secondOnSpawn = vi.fn(async (meta: unknown) => {
|
|
expect(meta).toEqual({ pid: processPid, processGroupId: null, startedAt });
|
|
processIdentityPersisted = true;
|
|
});
|
|
const second = await execute({
|
|
runId: "run-process-identity-2",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: { sessionParams: first.sessionParams },
|
|
config,
|
|
context,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onSpawn: secondOnSpawn,
|
|
} as never);
|
|
|
|
expect(second.exitCode).toBe(0);
|
|
// Amendment B: the host lane closes and relaunches (no warm reuse), so the
|
|
// second run re-creates the runtime and re-persists the process identity.
|
|
expect(runtimeCreateCount).toBe(2);
|
|
expect(secondOnSpawn).toHaveBeenCalledOnce();
|
|
expect(turnStartedBeforeProcessIdentity).toBe(false);
|
|
});
|
|
|
|
it("keeps company export config outside ACPX Codex argv and caller-owned home files", async () => {
|
|
const root = await makeTempRoot();
|
|
const home = path.join(root, "caller-codex-home");
|
|
await fs.mkdir(home, { recursive: true });
|
|
await fs.writeFile(path.join(home, "config.toml"), 'model = "caller-model"\n');
|
|
const { logs, meta } = await runExecutor({ agent: "codex", agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state"), env: { CODEX_HOME: home } }, {
|
|
observability: { kind: "codex", compatibilityKey: "fixture-revision", otelConfig: { exporter: { "otlp-http": { endpoint: "https://collector.example/v1/logs", protocol: "binary", headers: { authorization: "Bearer export-secret-canary" } } } }, environment: { OTEL_EXPORTER_OTLP_LOGS_TIMEOUT: "3000" } },
|
|
});
|
|
expect(await fs.readFile(path.join(home, "config.toml"), "utf8")).toBe('model = "caller-model"\n');
|
|
expect(logs.some(row => row.text.includes("caller-owned Codex home"))).toBe(true);
|
|
expect(JSON.stringify(meta)).not.toContain("export-secret-canary");
|
|
});
|
|
|
|
it.each([true, false])("applies company export to a server-managed Codex credential home (enabled=%s)", async (enabled) => {
|
|
const root = await makeTempRoot();
|
|
const home = path.join(root, "server-credential-projection", "provider");
|
|
const original = 'model = "managed-model"\n[model_providers.paperclip]\nname = "Managed provider"\n[mcp_servers.fixture]\nurl = "https://tools.example.test/mcp"\n';
|
|
await fs.mkdir(home, { recursive: true });
|
|
await fs.writeFile(path.join(home, "config.toml"), original);
|
|
await fs.writeFile(path.join(home, "auth.json"), '{"OPENAI_API_KEY":"fake-managed-provider-key"}');
|
|
const otelConfig = enabled ? {
|
|
log_user_prompt: true,
|
|
exporter: { "otlp-http": { endpoint: "https://collector.example.test/v1/logs", protocol: "binary", headers: { authorization: "Bearer export-secret-canary" } } },
|
|
} : { exporter: "none", trace_exporter: "none", metrics_exporter: "none", log_user_prompt: false };
|
|
const run = await runExecutor({
|
|
agent: "codex", agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state"),
|
|
env: { CODEX_HOME: home }, managedAiConnection: { provider: "openai", method: "api_key", identity: "server-resolved-credential" },
|
|
paperclipRuntimeSkills: [], paperclipSkillSync: { desiredSkills: [] },
|
|
}, { observability: { kind: "codex", compatibilityKey: enabled ? "enabled" : "disabled", otelConfig, environment: {} } });
|
|
expect(run.result.exitCode).toBe(0);
|
|
const env = (run.sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(env.CODEX_HOME).not.toBe(home);
|
|
const config = parseToml(await fs.readFile(path.join(env.CODEX_HOME!, "config.toml"), "utf8"));
|
|
expect(config.otel).toEqual(otelConfig);
|
|
expect(config.model).toBe("managed-model");
|
|
expect(config.model_providers).toEqual({ paperclip: { name: "Managed provider" } });
|
|
expect(config.mcp_servers).toEqual({ fixture: { url: "https://tools.example.test/mcp" } });
|
|
expect(await fs.readFile(path.join(home, "config.toml"), "utf8")).toBe(original);
|
|
expect(await fs.realpath(path.join(env.CODEX_HOME!, "auth.json"))).toBe(await fs.realpath(path.join(home, "auth.json")));
|
|
expect(JSON.stringify(run.meta)).not.toContain("export-secret-canary");
|
|
expect(run.logs.some(row => row.text.includes("caller-owned Codex home"))).toBe(false);
|
|
});
|
|
|
|
it("changes the ACPX Codex configuration home while resuming the same conversation after export changes", async () => {
|
|
const root = await makeTempRoot();
|
|
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "paperclip"));
|
|
vi.stubEnv("CODEX_HOME", path.join(root, "source"));
|
|
try {
|
|
const config = { agent: "codex", agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state"), paperclipRuntimeSkills: [], paperclipSkillSync: { desiredSkills: [] } };
|
|
const launch = (key: string) => ({ kind: "codex" as const, compatibilityKey: key, otelConfig: { exporter: "none", trace_exporter: "none", metrics_exporter: "none" }, environment: {} });
|
|
const first = await runExecutor(config, { observability: launch("first") });
|
|
const second = await runExecutor(config, { observability: launch("second"), runtime: { sessionParams: first.result.sessionParams } });
|
|
const firstEnv = (first.sessionInputs[0]?.sessionOptions as { env: Record<string, string> }).env;
|
|
const secondEnv = (second.sessionInputs[0]?.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(firstEnv.CODEX_HOME).not.toBe(secondEnv.CODEX_HOME);
|
|
expect(second.sessionInputs[0]?.resumeSessionId).toBe(first.result.sessionId);
|
|
expect(second.result.sessionParams?.configFingerprint).toBe(first.result.sessionParams?.configFingerprint);
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("sets Codex model, effort, and fast mode through CODEX_CONFIG without session config calls", async () => {
|
|
const { configOptions, meta, receipts, result } = await runExecutor({
|
|
agent: "codex",
|
|
model: "gpt-5.6-sol",
|
|
modelReasoningEffort: "high",
|
|
fastMode: true,
|
|
});
|
|
|
|
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toMatchObject({
|
|
model: "gpt-5.6-sol",
|
|
model_reasoning_effort: "high",
|
|
service_tier: "fast",
|
|
features: { fast_mode: true },
|
|
});
|
|
expect(configOptions).toEqual([]);
|
|
expect(result.pricingContext).toEqual({ serviceTier: "fast" });
|
|
expect(receipts).toContainEqual(expect.objectContaining({ pricingContext: { serviceTier: "fast" }, complete: true }));
|
|
expect(meta[0]?.commandNotes).toContain(
|
|
"Requested ACPX model: gpt-5.6-sol (set via CODEX_CONFIG at startup).",
|
|
);
|
|
});
|
|
|
|
it.each([true, false])("delivers configured service tokens to Codex shells while isolating host and provider credentials (identity: %s)", async (withIdentity) => {
|
|
const root = await makeTempRoot();
|
|
vi.stubEnv("PAPERCLIP_HOME", root);
|
|
vi.stubEnv("HOST_SERVICE_TOKEN", "host-token-canary");
|
|
vi.stubEnv("DATABASE_URL", "postgres://host/private");
|
|
try {
|
|
const { meta } = await runExecutor({ agent: "codex", stateDir: path.join(root, "state"), env: {
|
|
CODEX_HOME: path.join(root, "codex-home"),
|
|
SLACK_BOT_TOKEN: "assigned-tool-token", OPENAI_API_KEY: "provider-key-canary",
|
|
OTEL_EXPORTER_OTLP_HEADERS: "authorization=export-secret-canary",
|
|
} }, {
|
|
...(withIdentity ? {
|
|
authToken: "assigned-run-token",
|
|
agentIdentity: { keyId: "sha256:test", publicKeyPem: "public", privateKeyPem: "private" },
|
|
} : {}),
|
|
});
|
|
const config = JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG ?? "{}"));
|
|
expect(config.shell_environment_policy?.include_only).toContain("SLACK_BOT_TOKEN");
|
|
if (withIdentity) expect(config.shell_environment_policy.include_only).toEqual(expect.arrayContaining([
|
|
"PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
|
|
]));
|
|
for (const key of ["HOST_SERVICE_TOKEN", "DATABASE_URL", "OPENAI_API_KEY", "OTEL_EXPORTER_OTLP_HEADERS"]) {
|
|
expect(config.shell_environment_policy.include_only).not.toContain(key);
|
|
}
|
|
expect(JSON.stringify(config)).not.toContain("assigned-run-token");
|
|
expect(JSON.stringify(config)).not.toContain("assigned-tool-token");
|
|
} finally { vi.unstubAllEnvs(); }
|
|
});
|
|
|
|
it("forwards arbitrary Codex model IDs verbatim without picker-dependent session config", async () => {
|
|
const arbitraryModel = "gpt-999-test-does-not-exist";
|
|
const { configOptions, meta } = await runExecutor({
|
|
agent: "codex",
|
|
model: arbitraryModel,
|
|
reasoningEffort: "xhigh",
|
|
fastMode: true,
|
|
});
|
|
|
|
const codexConfig = JSON.parse(
|
|
String((meta[0]?.env as Record<string, string>).CODEX_CONFIG),
|
|
) as Record<string, unknown>;
|
|
expect(codexConfig.model).toBe(arbitraryModel);
|
|
expect(codexConfig.model_reasoning_effort).toBe("xhigh");
|
|
expect(configOptions).toEqual([]);
|
|
});
|
|
|
|
it("merges user CODEX_CONFIG while runtime model settings win", async () => {
|
|
const { meta } = await runExecutor({
|
|
agent: "codex",
|
|
model: "gpt-runtime",
|
|
fastMode: true,
|
|
env: {
|
|
CODEX_CONFIG: JSON.stringify({
|
|
model: "gpt-user",
|
|
approval_policy: "never",
|
|
features: { experimental_feature: true, fast_mode: false },
|
|
}),
|
|
},
|
|
});
|
|
|
|
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toMatchObject({
|
|
model: "gpt-runtime",
|
|
approval_policy: "never",
|
|
service_tier: "fast",
|
|
features: { experimental_feature: true, fast_mode: true },
|
|
});
|
|
});
|
|
|
|
it("warns when runtime settings replace malformed user CODEX_CONFIG", async () => {
|
|
const { logs, meta } = await runExecutor({
|
|
agent: "codex",
|
|
model: "gpt-runtime",
|
|
env: { CODEX_CONFIG: "not-json" },
|
|
});
|
|
|
|
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toMatchObject({
|
|
model: "gpt-runtime",
|
|
});
|
|
expect(logs).toContainEqual({
|
|
stream: "stderr",
|
|
text: "[paperclip] Ignoring invalid user CODEX_CONFIG while applying runtime Codex settings; expected a JSON object.\n",
|
|
});
|
|
});
|
|
|
|
it("sets Claude and Gemini models at startup without unsupported picker calls", async () => {
|
|
const claude = await runExecutor({ agent: "claude", model: "claude-opus-4-7" });
|
|
expect((claude.meta[0]?.env as Record<string, string>).ANTHROPIC_MODEL).toBe(
|
|
"claude-opus-4-7",
|
|
);
|
|
expect(claude.configOptions).toEqual([]);
|
|
|
|
const gemini = await runExecutor({
|
|
agent: "gemini",
|
|
model: "gemini-2.5-pro",
|
|
thinkingEffort: "high",
|
|
env: { GEMINI_MODEL: "stale-model" },
|
|
});
|
|
expect((gemini.meta[0]?.env as Record<string, string>).GEMINI_MODEL).toBe("gemini-2.5-pro");
|
|
expect(gemini.configOptions).toEqual([
|
|
{ key: "effort", value: "high" },
|
|
]);
|
|
});
|
|
|
|
it("bounds Codex shell inheritance even without model overrides or an agent identity", async () => {
|
|
const { configOptions, meta } = await runExecutor({ agent: "codex" });
|
|
|
|
const config = JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG));
|
|
expect(config.model).toBeUndefined();
|
|
expect(config.features.shell_snapshot).toBe(false);
|
|
expect(config.shell_environment_policy).toMatchObject({ inherit: "all", ignore_default_excludes: true });
|
|
expect(config.shell_environment_policy.include_only).toContain("PATH");
|
|
expect(config.shell_environment_policy.include_only).not.toContain("HOST_SERVICE_TOKEN");
|
|
expect(configOptions).toEqual([]);
|
|
});
|
|
|
|
it.each(["claude", "codex", "gemini", "kimi", "custom"])("delivers the shared owned sections at the %s ACP turn boundary", async (agent) => {
|
|
const root = await makeTempRoot();
|
|
const context = createPromptContextFixture();
|
|
const config = { agent, agentCommand: "node ./fixture-acp.js", cwd: root, stateDir: path.join(root, "state"), mode: "persistent" };
|
|
const fresh = await runExecutor(config, { context });
|
|
expect(fresh.turnInputs).toHaveLength(1);
|
|
const prompt = String(fresh.turnInputs[0]?.text ?? "");
|
|
expect(prompt).toContain("You are agent agent-1");
|
|
expect(prompt).toContain("Connection tools:");
|
|
expect(prompt).not.toContain("Execution contract:");
|
|
expect(prompt).toContain(context.paperclipTaskMarkdownAssignment);
|
|
expect(prompt).toContain(context.paperclipTaskCommunicationGuidance);
|
|
expect(prompt).not.toContain('"objective":');
|
|
expect(prompt).not.toContain("### Issue description");
|
|
expect(prompt).toContain('"id":"comment-first"');
|
|
expect(prompt).toContain('"id":"comment-second"');
|
|
expect(prompt).toContain('"id":"comment-scope"');
|
|
expect(prompt.indexOf('"id":"comment-first"')).toBeLessThan(prompt.indexOf('"id":"comment-second"'));
|
|
expect(prompt).toContain("Untrusted continuation evidence");
|
|
expect(prompt).toContain("receipt-1");
|
|
const resumed = await runExecutor(config, { context, runtime: { sessionParams: fresh.result.sessionParams } });
|
|
expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId);
|
|
const resumedPrompt = String(resumed.turnInputs[0]?.text ?? "");
|
|
expect(resumedPrompt).not.toContain("Execution contract:");
|
|
expect(resumedPrompt).toContain(context.paperclipTaskMarkdownAssignmentCompact);
|
|
expect(resumedPrompt).not.toContain(context.paperclipTaskCommunicationGuidance);
|
|
expect(resumedPrompt).not.toContain('"id":"comment-first"');
|
|
expect(resumedPrompt).toContain('"id":"comment-second"');
|
|
const reset = await runExecutor(config, { context });
|
|
expect(reset.sessionInputs[0]?.resumeSessionId).toBeUndefined();
|
|
const resetPrompt = String(reset.turnInputs[0]?.text ?? "");
|
|
expect(resetPrompt).toContain("You are agent agent-1");
|
|
expect(resetPrompt).toContain("Connection tools:");
|
|
expect(resetPrompt).toContain(context.paperclipTaskMarkdownAssignment);
|
|
expect(resetPrompt).not.toContain("Execution contract:");
|
|
});
|
|
|
|
it("includes Paperclip env and API access notes in the ACPX prompt without leaking the token", async () => {
|
|
const { meta } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
|
|
{
|
|
authToken: "runtime-secret-token",
|
|
context: {
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_assigned",
|
|
paperclipWake: {
|
|
reason: "issue_assigned",
|
|
issue: { id: "issue-1", identifier: "TEST-1" },
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
const promptMetrics = meta[0]?.promptMetrics as Record<string, number> | undefined;
|
|
expect(prompt).toContain("Paperclip runtime note:");
|
|
expect(prompt).toContain("PAPERCLIP_AGENT_ID");
|
|
expect(prompt).toContain("PAPERCLIP_API_KEY");
|
|
expect(prompt).not.toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
|
expect(prompt).toContain("## Paperclip Wake Payload");
|
|
expect(prompt).toContain("TEST-1");
|
|
expect(prompt).toContain("Paperclip API access note:");
|
|
expect(prompt).toContain('PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"');
|
|
expect(prompt).toContain("$PAPERCLIP_API_BASE/api/agents/me");
|
|
expect(prompt).toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID");
|
|
expect(prompt).toContain("X-Paperclip-Run-Id");
|
|
expect(prompt).not.toContain("$PAPERCLIP_API_URL/api/");
|
|
expect(prompt).not.toContain("/api/issues/{id}");
|
|
expect(prompt).not.toContain("-d '{...}'");
|
|
expect(prompt).not.toContain("runtime-secret-token");
|
|
expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("keeps large continuation history in ACP turns and preserves resume deltas", async () => {
|
|
const root = await makeTempRoot();
|
|
const config = {
|
|
agent: "claude", cwd: root, stateDir: path.join(root, "state"), mode: "persistent",
|
|
env: { PAPERCLIP_WAKE_PAYLOAD_JSON: "stale configured wake" },
|
|
};
|
|
const messages = Array.from({ length: 50 }, (_, index) => ({
|
|
id: `message-${index}`, authorType: "user", authorId: "user-1",
|
|
body: `Message ${index}: ${"context ".repeat(500)} End ${index}.`,
|
|
createdAt: "2020-01-01T00:00:00.000Z", updatedAt: "2020-01-01T00:00:00.000Z",
|
|
deleted: false, sourceTrust: { kind: "authenticated_user" },
|
|
}));
|
|
const completedActions = Array.from({ length: 50 }, (_, index) => ({
|
|
runId: "prior-run", receiptId: `receipt-${index}`, operationId: `operation-${index}`,
|
|
result: { text: `Completed action ${index}` },
|
|
}));
|
|
const continuation = {
|
|
version: 1, companyId: "company-1", issueId: "issue-1",
|
|
trigger: { reason: "issue_commented", interactionId: null, sourceRunId: null },
|
|
originCommentIds: [messages[49]!.id], objective: "Preserve all context", messages,
|
|
interactionOutcomes: [], unresolvedInteractionIds: [], completedWork: null,
|
|
completedActions, coverage: { kind: "full_task_history", throughCommentId: messages[49]!.id, summaryThroughCommentId: null },
|
|
};
|
|
expect(Buffer.byteLength(JSON.stringify(continuation))).toBeGreaterThan(128 * 1024);
|
|
const context = { taskId: "issue-1", paperclipWake: {
|
|
reason: "issue_commented", issue: { id: "issue-1" }, executionContinuation: continuation,
|
|
} };
|
|
const fresh = await runExecutor(config, { context });
|
|
const changedMessage = { ...messages[49]!, body: "Updated direction: preserve approval gates." };
|
|
const resumed = await runExecutor(config, {
|
|
runtime: { sessionParams: fresh.result.sessionParams },
|
|
context: { ...context, paperclipWake: { ...context.paperclipWake, executionContinuation: {
|
|
...continuation, resumeDelta: { baseRunId: "run-1", messages: [changedMessage] },
|
|
} } },
|
|
});
|
|
expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId);
|
|
for (const run of [fresh, resumed]) {
|
|
const sessionOptions = run.sessionInputs[0]?.sessionOptions as Record<string, unknown>;
|
|
expect(sessionOptions.env).not.toHaveProperty("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
|
const prompt = String(run.turnInputs[0]?.text);
|
|
expect(prompt).not.toContain("stale configured wake");
|
|
for (const action of completedActions) expect(prompt).toContain(JSON.stringify(action));
|
|
}
|
|
const freshPrompt = String(fresh.turnInputs[0]?.text);
|
|
for (const message of messages) expect(freshPrompt).toContain(JSON.stringify(message));
|
|
const resumedPrompt = String(resumed.turnInputs[0]?.text);
|
|
expect(resumedPrompt).toContain(JSON.stringify(changedMessage));
|
|
expect(resumedPrompt).not.toContain(messages[0]!.body);
|
|
expect(resumedPrompt).toContain('"kind":"task_history_delta"');
|
|
});
|
|
|
|
it.each([
|
|
["claude", false], ["codex", false], ["claude", true], ["codex", true],
|
|
] as const)("keeps %s ACP conversation policy on fresh, resumed, and reset turns (custom=%s)", async (agent, custom) => {
|
|
const root = await makeTempRoot();
|
|
const config = { agent, cwd: root, stateDir: path.join(root, "state"), mode: "persistent",
|
|
...(custom ? { promptTemplate: "Custom agent instructions." } : {}),
|
|
};
|
|
const chatDirective = "Chat mode: clarify goals and hand accepted plans off to ordinary project tasks.";
|
|
const context = {
|
|
conversationMode: true,
|
|
taskId: "chat-1",
|
|
paperclipTaskMarkdown: chatDirective,
|
|
paperclipTaskMarkdownCompact: chatDirective,
|
|
paperclipTaskCommunicationGuidance: "Frozen Slack communication preference.",
|
|
paperclipWake: {
|
|
reason: "issue_commented",
|
|
issue: { id: "chat-1", workMode: "planning", status: "in_progress" },
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
comments: [],
|
|
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
|
fallbackFetchNeeded: false,
|
|
},
|
|
};
|
|
const fresh = await runExecutor(config, { context });
|
|
const resumed = await runExecutor(config, {
|
|
context,
|
|
runtime: { sessionParams: fresh.result.sessionParams },
|
|
});
|
|
expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId);
|
|
const reset = await runExecutor(config, { context });
|
|
expect(reset.sessionInputs[0]?.resumeSessionId).toBeUndefined();
|
|
for (const run of [fresh, reset]) {
|
|
expect(String(run.meta[0]?.prompt).match(/Frozen Slack communication preference\./g)).toHaveLength(1);
|
|
}
|
|
expect(String(resumed.meta[0]?.prompt)).not.toContain("Frozen Slack communication preference.");
|
|
for (const { meta } of [fresh, resumed, reset]) {
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toContain(chatDirective);
|
|
expect(prompt).not.toContain("Execution contract:");
|
|
expect(prompt).not.toContain("clear final disposition");
|
|
expect(prompt).not.toContain("Create child issues");
|
|
expect(prompt).not.toContain("Use child issues");
|
|
}
|
|
expect(String(fresh.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "You are agent agent-1");
|
|
expect(String(reset.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "You are agent agent-1");
|
|
const ordinary = await runExecutor({ ...config, promptTemplate: "" }, { context: { ...context, conversationMode: false } });
|
|
expect(String(ordinary.meta[0]?.prompt)).not.toContain("Execution contract:");
|
|
expect(String(ordinary.meta[0]?.prompt)).toContain("Create child issues from the approved plan");
|
|
});
|
|
|
|
it("uses only the guarded external-chat contract for a default ACPX prompt", async () => {
|
|
const { meta } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
|
|
{
|
|
authToken: "runtime-secret-token",
|
|
context: {
|
|
taskId: "issue-chat-1",
|
|
paperclipTaskMarkdown: "# CHAT-1 — Answer the provider message",
|
|
paperclipWake: {
|
|
reason: "External chat message received",
|
|
externalChatProvider: "discord",
|
|
checkedOutByHarness: true,
|
|
issue: {
|
|
id: "issue-chat-1",
|
|
identifier: "CHAT-1",
|
|
title: "Discord conversation",
|
|
status: "in_progress",
|
|
workMode: "standard",
|
|
},
|
|
continuationSummary: {
|
|
key: "summary",
|
|
body: "Earlier chat context.",
|
|
},
|
|
commentWindow: {
|
|
requestedCount: 1,
|
|
includedCount: 1,
|
|
missingCount: 0,
|
|
},
|
|
comments: [
|
|
{
|
|
id: "comment-chat-1",
|
|
issueId: "issue-chat-1",
|
|
body: "Reply with CHAT-OK.",
|
|
},
|
|
],
|
|
fallbackFetchNeeded: false,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
const promptMetrics = meta[0]?.promptMetrics as Record<string, number> | undefined;
|
|
expect(prompt).toContain("## External chat response contract");
|
|
expect(prompt).toContain("# CHAT-1 — Answer the provider message");
|
|
expect(prompt).toContain("Make zero Paperclip API calls");
|
|
expect(prompt).not.toContain("Paperclip API access note:");
|
|
expect(prompt).not.toContain("Paperclip runtime note:");
|
|
expect(prompt).not.toContain(
|
|
"Leave durable progress in comments, documents, or work products",
|
|
);
|
|
expect(promptMetrics?.runtimeNoteChars).toBe(0);
|
|
expect(promptMetrics?.heartbeatPromptChars).toBe(0);
|
|
});
|
|
|
|
it("keeps the authenticated API fallback when ACPX has no native wake reader", async () => {
|
|
const { meta } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
|
|
{
|
|
authToken: "runtime-secret-token",
|
|
context: {
|
|
taskId: "issue-chat-overflow",
|
|
paperclipTaskMarkdown: "# CHAT-2 — Answer every queued message",
|
|
paperclipWake: {
|
|
reason: "External chat message received",
|
|
externalChatProvider: "slack",
|
|
checkedOutByHarness: true,
|
|
issue: {
|
|
id: "issue-chat-overflow",
|
|
identifier: "CHAT-2",
|
|
title: "Slack conversation",
|
|
status: "in_progress",
|
|
workMode: "standard",
|
|
},
|
|
commentWindow: {
|
|
requestedCount: 2,
|
|
includedCount: 1,
|
|
missingCount: 1,
|
|
},
|
|
commentIds: ["comment-chat-1", "comment-chat-2"],
|
|
latestCommentId: "comment-chat-2",
|
|
comments: [
|
|
{
|
|
id: "comment-chat-2",
|
|
issueId: "issue-chat-overflow",
|
|
body: "Answer both queued messages.",
|
|
},
|
|
],
|
|
fallbackFetchNeeded: true,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).not.toContain("read_current_wake_comments");
|
|
expect(prompt).not.toContain("## External chat response contract");
|
|
expect(prompt).toContain("Paperclip API access note:");
|
|
expect(prompt).toContain("Only fetch the API thread");
|
|
});
|
|
|
|
it("preserves a configured agent prompt template on a guarded external-chat turn", async () => {
|
|
const { meta } = await runExecutor(
|
|
{
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
promptTemplate: "Custom agent instruction for {{agent.id}}.",
|
|
},
|
|
{
|
|
context: {
|
|
taskId: "issue-chat-1",
|
|
paperclipWake: {
|
|
reason: "External chat message received",
|
|
externalChatProvider: "telegram",
|
|
checkedOutByHarness: true,
|
|
issue: {
|
|
id: "issue-chat-1",
|
|
identifier: "CHAT-1",
|
|
title: "Telegram conversation",
|
|
status: "in_progress",
|
|
workMode: "standard",
|
|
},
|
|
commentWindow: {
|
|
requestedCount: 1,
|
|
includedCount: 1,
|
|
missingCount: 0,
|
|
},
|
|
comments: [{ id: "comment-chat-1", body: "Hello" }],
|
|
fallbackFetchNeeded: false,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toContain("## External chat response contract");
|
|
expect(prompt).toContain("Custom agent instruction for agent-1.");
|
|
expect(prompt).not.toContain(
|
|
"Leave durable progress in comments, documents, or work products",
|
|
);
|
|
});
|
|
|
|
it("does not show a scoped issue API command when the task id is unavailable", async () => {
|
|
const { meta } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js" },
|
|
{ authToken: "runtime-secret-token" },
|
|
);
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toContain("Paperclip API access note:");
|
|
expect(prompt).toContain("Use a real issue id from the current context before making issue write requests.");
|
|
expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID");
|
|
});
|
|
|
|
it("emits ACP text deltas as stdout transcript records", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "text_delta",
|
|
text: " streamed ",
|
|
stream: "output",
|
|
tag: "agent_message_chunk",
|
|
};
|
|
yield { type: "text_delta", text: "hello ", stream: "output", tag: "agent_message_chunk" };
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-streaming-text-delta",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
},
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.summary).toBe("streamed hello");
|
|
for (const text of [" streamed ", "hello "]) {
|
|
expect(logs).toContainEqual({
|
|
stream: "stdout",
|
|
text: `${JSON.stringify({ type: "acpx.text_delta", text, channel: "output", tag: "agent_message_chunk" })}\n`,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("defaults run summaries to the final output segment without thought text", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "text_delta",
|
|
text: "Let me get oriented and inspect the PRs…",
|
|
stream: "output",
|
|
tag: "agent_message_chunk",
|
|
};
|
|
yield {
|
|
type: "text_delta",
|
|
text: "hidden chain of thought",
|
|
stream: "thought",
|
|
tag: "agent_thought_chunk",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "Bash (pending)",
|
|
title: "Bash",
|
|
status: "pending",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: 'tool call (in_progress): {"command":"',
|
|
title: "tool call",
|
|
status: "in_progress",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "tool call (completed): apps",
|
|
title: "tool call",
|
|
status: "completed",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "text_delta",
|
|
text: "## Update\n\n- Checked PR status\n- Continue burn-in",
|
|
stream: "output",
|
|
tag: "agent_message_chunk",
|
|
};
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-default-traits-pin",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.summary).toBe("## Update\n\n- Checked PR status\n- Continue burn-in");
|
|
expect(result.summary).not.toContain("Let me get oriented");
|
|
expect(result.summary).not.toContain("hidden chain of thought");
|
|
const toolCallEvents = logs
|
|
.map((entry) => {
|
|
try {
|
|
return JSON.parse(entry.text) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((parsed): parsed is Record<string, unknown> => parsed?.type === "acpx.tool_call");
|
|
// Every tool_call update reaches the run log — including in-progress
|
|
// updates with the unresolved placeholder title (their name is restored
|
|
// from the pending announcement). Nothing is coalesced or dropped for
|
|
// agents without the coalescePlaceholderToolUpdates trait.
|
|
expect(toolCallEvents.map((event) => [event.name, event.status])).toEqual([
|
|
["Bash", "pending"],
|
|
["Bash", "in_progress"],
|
|
["Bash", "completed"],
|
|
]);
|
|
});
|
|
|
|
it("does not allow configuration to include thought text in run summaries", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "text_delta",
|
|
text: "Let me get oriented and inspect the PRs…",
|
|
stream: "output",
|
|
tag: "agent_message_chunk",
|
|
};
|
|
yield {
|
|
type: "text_delta",
|
|
text: "hidden chain of thought",
|
|
stream: "thought",
|
|
tag: "agent_thought_chunk",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "Bash (pending)",
|
|
title: "Bash",
|
|
status: "pending",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: 'tool call (in_progress): {"command":"',
|
|
title: "tool call",
|
|
status: "in_progress",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "tool call (completed): apps",
|
|
title: "tool call",
|
|
status: "completed",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "text_delta",
|
|
text: "## Update\n\n- Checked PR status\n- Continue burn-in",
|
|
stream: "output",
|
|
tag: "agent_message_chunk",
|
|
};
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-summary-last-segment",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
summaryStrategy: "full",
|
|
},
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.summary).toBe("## Update\n\n- Checked PR status\n- Continue burn-in");
|
|
expect(result.summary).not.toContain("Let me get oriented");
|
|
expect(result.summary).not.toContain("hidden chain of thought");
|
|
});
|
|
|
|
it("treats a statusless initial tool call as an output-segment boundary", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "text_delta",
|
|
text: "Intermediate setup that must not be published",
|
|
stream: "output",
|
|
tag: "agent_message_chunk",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "Bash",
|
|
title: "Bash",
|
|
toolCallId: "tool-without-status",
|
|
tag: "tool_call",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "Bash (completed)",
|
|
title: "Bash",
|
|
status: "completed",
|
|
toolCallId: "tool-without-status",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "text_delta",
|
|
text: "## Final update\n\n- Remediation verified",
|
|
stream: "output",
|
|
tag: "agent_message_chunk",
|
|
};
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-summary-statusless-tool-call",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.summary).toBe("## Final update\n\n- Remediation verified");
|
|
expect(result.summary).not.toContain("Intermediate setup");
|
|
});
|
|
|
|
it("buildAcpxRunSummary prefers the last non-empty segment", () => {
|
|
expect(
|
|
buildAcpxRunSummary({
|
|
outputSegments: ["first plan", "second plan", " final update "],
|
|
fallback: "end_turn",
|
|
}),
|
|
).toBe("final update");
|
|
expect(buildAcpxRunSummary({ outputSegments: ["", " "], fallback: "end_turn" })).toBe("end_turn");
|
|
});
|
|
|
|
it("coalesces placeholder-title tool updates when the adapter opts in", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "tool_call",
|
|
text: "Bash (pending)",
|
|
title: "Bash",
|
|
status: "pending",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: 'tool call (in_progress): {"command":"',
|
|
title: "tool call",
|
|
status: "in_progress",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: 'tool call (in_progress): {"command":"ls',
|
|
title: "tool call",
|
|
status: "in_progress",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "Running: ls apps (in_progress)",
|
|
title: "Running: ls apps",
|
|
status: "in_progress",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield {
|
|
type: "tool_call",
|
|
text: "tool call (completed): apps\\npackage.json",
|
|
title: "tool call",
|
|
status: "completed",
|
|
toolCallId: "tool-1",
|
|
tag: "tool_call_update",
|
|
};
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-tool-call-coalesce",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
coalescePlaceholderToolUpdates: true,
|
|
},
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
const toolCallEvents = logs
|
|
.map((entry) => {
|
|
try {
|
|
return JSON.parse(entry.text) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((parsed): parsed is Record<string, unknown> => parsed?.type === "acpx.tool_call");
|
|
// The two placeholder-title in-progress updates are dropped; the pending
|
|
// announcement, the resolved-title in-progress update, and the terminal
|
|
// completed update all survive.
|
|
expect(toolCallEvents.map((event) => [event.name, event.status])).toEqual([
|
|
["Bash", "pending"],
|
|
["Running: ls apps", "in_progress"],
|
|
["Bash", "completed"],
|
|
]);
|
|
});
|
|
|
|
it("captures per-run usage, cost deltas, and billing identity from the ACP runtime", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
let statusCalls = 0;
|
|
const onUsage = vi.fn();
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
getStatus: async () => {
|
|
statusCalls += 1;
|
|
return statusCalls === 1
|
|
? { models: { currentModelId: "claude-observed-default", availableModelIds: [] }, usage: { cost: { amount: 0.4, currency: "USD" } } }
|
|
: {
|
|
models: { currentModelId: "claude-observed-default", availableModelIds: [] },
|
|
usage: {
|
|
cumulative: {
|
|
inputTokens: 120,
|
|
outputTokens: 4500,
|
|
cachedReadTokens: 900,
|
|
cachedWriteTokens: 30,
|
|
},
|
|
cost: { amount: 1.15, currency: "USD" },
|
|
},
|
|
};
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "status",
|
|
text: "usage",
|
|
tag: "usage_update",
|
|
used: 5550,
|
|
size: 200000,
|
|
cost: { amount: 1.1, currency: "USD" },
|
|
};
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
resolveBillingIdentity: () => ({ provider: "anthropic", biller: "anthropic", billingType: "api" }),
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-usage-capture",
|
|
onUsage,
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
},
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(statusCalls).toBe(2);
|
|
// Cache-write tokens count as input tokens; cached reads stay separate.
|
|
expect(result.usage).toEqual({ inputTokens: 150, outputTokens: 4500, cachedInputTokens: 900 });
|
|
expect(result.usageBasis).toBe("per_run");
|
|
// Agent-reported cost is cumulative; this run pays the delta.
|
|
expect(result.costUsd).toBeCloseTo(0.75);
|
|
expect(result.provider).toBe("anthropic");
|
|
expect(result.biller).toBe("anthropic");
|
|
expect(result.billingType).toBe("api");
|
|
expect(result.model).toBe("claude-observed-default");
|
|
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ model: "claude-observed-default", complete: true }));
|
|
expect((result.resultJson as Record<string, unknown>)?.cumulativeCostUsd).toBeCloseTo(1.15);
|
|
expect((result.resultJson as Record<string, unknown>)?.usage).toEqual({
|
|
inputTokens: 120,
|
|
outputTokens: 4500,
|
|
cachedReadTokens: 900,
|
|
cachedWriteTokens: 30,
|
|
});
|
|
const statusLine = logs.find((entry) => entry.text.includes('"acpx.status"'));
|
|
expect(statusLine?.text).toContain('"cost"');
|
|
});
|
|
|
|
it.each(["checkpoint", "terminal"])("returns received usage after the %s receipt sink rejects and still closes the runtime", async (failedSave) => {
|
|
const root = await makeTempRoot();
|
|
const close = vi.fn(async () => {});
|
|
const cancel = vi.fn(async () => {});
|
|
let reads = 0;
|
|
const execute = createAcpxEngineExecutor({
|
|
resolveBillingIdentity: () => ({ provider: "anthropic", biller: "anthropic", billingType: "api" }),
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
getStatus: async () => ++reads === 1
|
|
? { usage: { cost: { amount: 0.4, currency: "USD" } } }
|
|
: { usage: { cumulative: { inputTokens: 120, outputTokens: 4500, cachedReadTokens: 900, cachedWriteTokens: 30 }, cost: { amount: 1.15, currency: "USD" } } },
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "status", text: "usage", tag: "usage_update",
|
|
breakdown: { inputTokens: 100, outputTokens: 4000, cachedReadTokens: 800, cachedWriteTokens: 20 }, cost: { amount: 1.1, currency: "USD" } };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), cancel,
|
|
}), close,
|
|
}) as never,
|
|
});
|
|
const onUsage = vi.fn(async (receipt: { complete: boolean }) => {
|
|
if (failedSave === "checkpoint" || receipt.complete) throw new Error("Receipt storage unavailable");
|
|
});
|
|
const result = await execute({ runId: `failed-${failedSave}-save`, agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {}, config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {}, onMeta: async () => {}, onLog: async () => {}, onUsage } as never);
|
|
expect(result).toMatchObject({ exitCode: 1, usageBasis: "per_run", usageComplete: failedSave === "terminal",
|
|
provider: "anthropic", biller: "anthropic", billingType: "api" });
|
|
expect(result.errorMessage).toContain("Receipt storage unavailable");
|
|
expect(result.usage).toEqual(failedSave === "terminal"
|
|
? { inputTokens: 150, outputTokens: 4500, cachedInputTokens: 900 }
|
|
: { inputTokens: 120, outputTokens: 4000, cachedInputTokens: 800 });
|
|
expect(result.costUsd).toBeCloseTo(failedSave === "terminal" ? 0.75 : 0.7);
|
|
expect(onUsage).toHaveBeenCalledTimes(failedSave === "terminal" ? 2 : 1);
|
|
expect(cancel).toHaveBeenCalledTimes(1);
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("falls back to usage_update events when the runtime lacks getStatus", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "status",
|
|
text: "usage",
|
|
tag: "usage_update",
|
|
cost: { amount: 0.31, currency: "USD" },
|
|
breakdown: { inputTokens: 40, outputTokens: 700, cachedReadTokens: 60 },
|
|
};
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-usage-event-fallback",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
},
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
|
|
expect(result.usageBasis).toBe("per_run");
|
|
expect(result.costUsd).toBeCloseTo(0.31);
|
|
expect(result.provider).toBe("acpx");
|
|
expect(result.billingType).toBe("unknown");
|
|
});
|
|
|
|
it.each(["failed", "cancelled"] as const)("keeps %s in-stream usage provisional without a final receipt", async (status) => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield {
|
|
type: "status",
|
|
text: "usage",
|
|
tag: "usage_update",
|
|
cost: { amount: 0.31, currency: "USD" },
|
|
breakdown: { inputTokens: 40, outputTokens: 700, cachedReadTokens: 60 },
|
|
};
|
|
|
|
})(),
|
|
result: Promise.resolve({ status, error: { code: "INTERNAL", message: "fixture failure" } }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const onUsage = vi.fn();
|
|
const result = await execute({
|
|
runId: "run-usage-event-fallback",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
},
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onUsage,
|
|
} as never);
|
|
|
|
expect(result.exitCode).not.toBe(0);
|
|
if (status === "failed") {
|
|
expect(result.errorCode).toBe("acpx_turn_failed");
|
|
expect(result.summary).toBe("fixture failure");
|
|
expect(result.resultJson?.stopReason).toBe("fixture failure");
|
|
}
|
|
expect(result.usageComplete).toBe(false);
|
|
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: false, costUsd: 0.31 }));
|
|
expect(result.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
|
|
expect(result.usageBasis).toBe("per_run");
|
|
expect(result.costUsd).toBeCloseTo(0.31);
|
|
expect(result.provider).toBe("acpx");
|
|
expect(result.billingType).toBe("unknown");
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => {
|
|
const root = await makeTempRoot();
|
|
const skillRoot = path.join(root, "skills");
|
|
const outsideRoot = path.join(root, "outside");
|
|
await fs.mkdir(outsideRoot, { recursive: true });
|
|
await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8");
|
|
const skill = await createSkill(skillRoot, "danger");
|
|
await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(skill.source, "leak.txt"));
|
|
await fs.symlink(outsideRoot, path.join(skill.source, "leak-dir"));
|
|
|
|
const stateDir = path.join(root, "state");
|
|
const { meta } = await runExecutor({
|
|
agent: "claude",
|
|
stateDir,
|
|
paperclipRuntimeSkills: [skill],
|
|
paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
});
|
|
|
|
const mountedRoot = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
|
|
const skillsHome = path.join(mountedRoot, ".claude", "skills");
|
|
const materializedSkill = path.join(skillsHome, skill.runtimeName);
|
|
expect(await fs.readFile(path.join(materializedSkill, "SKILL.md"), "utf8")).toContain("# danger");
|
|
expect(await pathExists(path.join(materializedSkill, "leak.txt"))).toBe(false);
|
|
expect(await pathExists(path.join(materializedSkill, "leak-dir"))).toBe(false);
|
|
expect(String(meta[0]?.prompt ?? "")).toContain(`Skill root: ${skillsHome}`);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")("revokes removed ACPX Codex skills and skips symlinked descendants", async () => {
|
|
const root = await makeTempRoot();
|
|
const skillRoot = path.join(root, "skills");
|
|
const outsideRoot = path.join(root, "outside");
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(outsideRoot, { recursive: true });
|
|
await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8");
|
|
const keep = await createSkill(skillRoot, "keep");
|
|
const remove = await createSkill(skillRoot, "remove");
|
|
await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(keep.source, "leak.txt"));
|
|
await fs.symlink(outsideRoot, path.join(keep.source, "leak-dir"));
|
|
|
|
const baseConfig = {
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state"),
|
|
env: { CODEX_HOME: codexHome },
|
|
paperclipRuntimeSkills: [keep, remove],
|
|
};
|
|
|
|
await runExecutor({
|
|
...baseConfig,
|
|
paperclipSkillSync: { desiredSkills: [keep.key, remove.key] },
|
|
});
|
|
expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName, "SKILL.md"))).toBe(true);
|
|
|
|
await runExecutor({
|
|
...baseConfig,
|
|
paperclipSkillSync: { desiredSkills: [keep.key] },
|
|
});
|
|
|
|
expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "SKILL.md"))).toBe(true);
|
|
expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak.txt"))).toBe(false);
|
|
expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak-dir"))).toBe(false);
|
|
expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName))).toBe(false);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")("keeps the operational skill in an ACPX Codex home after an empty replacement", async () => {
|
|
const root = await makeTempRoot();
|
|
const skillRoot = path.join(root, "skills");
|
|
const codexHome = path.join(root, "codex-home");
|
|
const operational = {
|
|
...await createSkill(skillRoot, "paperclip"),
|
|
key: "paperclipai/paperclip/paperclip",
|
|
};
|
|
|
|
await runExecutor({
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state"),
|
|
env: { CODEX_HOME: codexHome },
|
|
paperclipRuntimeSkills: [operational],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
|
|
expect(await pathExists(path.join(codexHome, "skills", operational.runtimeName, "SKILL.md"))).toBe(true);
|
|
});
|
|
|
|
it.each(["codex", "gemini"])("refreshes the %s skill root when a connection home changes between turns", async (agent) => {
|
|
const root = await makeTempRoot();
|
|
const operational = {
|
|
...await createSkill(path.join(root, "skills"), "paperclip"),
|
|
key: "paperclipai/paperclip/paperclip",
|
|
};
|
|
const homes = [path.join(root, "first-home"), path.join(root, "next-home")];
|
|
for (const home of homes) {
|
|
const { meta } = await runExecutor({
|
|
agent,
|
|
stateDir: path.join(root, "state"),
|
|
env: agent === "codex" ? { CODEX_HOME: home } : { HOME: home },
|
|
paperclipRuntimeSkills: [operational],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
const skillsHome = agent === "codex" ? path.join(home, "skills") : path.join(home, ".gemini", "skills");
|
|
expect(String(meta[0]?.prompt ?? "")).toContain(`Skill root for this run: ${skillsHome}`);
|
|
expect(String(meta[0]?.prompt ?? "")).toContain("Resolve referenced scripts and files relative to that skill");
|
|
if (home === homes[1]) expect(String(meta[0]?.prompt ?? "")).not.toContain(homes[0]!);
|
|
await fs.rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => {
|
|
const root = await makeTempRoot();
|
|
const skillRoot = path.join(root, "skills");
|
|
const codexHome = path.join(root, "codex-home");
|
|
const legacy = await createSkill(skillRoot, "legacy");
|
|
const skillsHome = path.join(codexHome, "skills");
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
await fs.symlink(legacy.source, path.join(skillsHome, legacy.runtimeName));
|
|
|
|
await runExecutor({
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state"),
|
|
env: { CODEX_HOME: codexHome },
|
|
paperclipRuntimeSkills: [legacy],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
|
|
expect(await pathExists(path.join(skillsHome, legacy.runtimeName))).toBe(false);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")("replaces stale managed Codex auth files with source symlinks", async () => {
|
|
const root = await makeTempRoot();
|
|
const sourceCodexHome = path.join(root, "source-codex-home");
|
|
const paperclipHome = path.join(root, "paperclip-home");
|
|
const paperclipInstanceId = "test-instance";
|
|
const managedCodexHome = path.join(
|
|
paperclipHome,
|
|
"instances",
|
|
paperclipInstanceId,
|
|
"companies",
|
|
"company-1",
|
|
"codex-home",
|
|
);
|
|
await fs.mkdir(sourceCodexHome, { recursive: true });
|
|
await fs.mkdir(managedCodexHome, { recursive: true });
|
|
const sourceAuth = path.join(sourceCodexHome, "auth.json");
|
|
const managedAuth = path.join(managedCodexHome, "auth.json");
|
|
await fs.writeFile(sourceAuth, "{\"source\":true}", "utf8");
|
|
await fs.writeFile(managedAuth, "{\"stale\":true}", "utf8");
|
|
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
|
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
|
try {
|
|
process.env.CODEX_HOME = sourceCodexHome;
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = paperclipInstanceId;
|
|
await runExecutor({
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state"),
|
|
paperclipRuntimeSkills: [],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
} finally {
|
|
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = previousCodexHome;
|
|
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
|
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
|
}
|
|
|
|
const authStat = await fs.lstat(managedAuth);
|
|
expect(authStat.isSymbolicLink()).toBe(true);
|
|
expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth);
|
|
});
|
|
|
|
it.each(["OPENAI_API_KEY", "CODEX_API_KEY"] as const)(
|
|
"uses isolated API-key auth instead of the host ChatGPT login for %s",
|
|
async (keyName) => {
|
|
const root = await makeTempRoot();
|
|
const sourceCodexHome = path.join(root, "source-codex-home");
|
|
const paperclipHome = path.join(root, "paperclip-home");
|
|
await fs.mkdir(sourceCodexHome, { recursive: true });
|
|
const sourceAuth = path.join(sourceCodexHome, "auth.json");
|
|
await fs.writeFile(sourceAuth, JSON.stringify({ tokens: "host-login" }), "utf8");
|
|
const managedHome = path.join(
|
|
paperclipHome, "instances", "test-instance", "companies", "company-1",
|
|
"acp-engine", "agents", "agent-1", "codex-home",
|
|
);
|
|
await fs.mkdir(managedHome, { recursive: true });
|
|
const managedAuth = path.join(managedHome, "auth.json");
|
|
if (process.platform === "win32") {
|
|
await fs.writeFile(managedAuth, JSON.stringify({ tokens: "stale-login" }), "utf8");
|
|
} else {
|
|
await fs.symlink(sourceAuth, managedAuth);
|
|
}
|
|
|
|
vi.stubEnv("CODEX_HOME", sourceCodexHome);
|
|
vi.stubEnv("PAPERCLIP_HOME", paperclipHome);
|
|
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "test-instance");
|
|
vi.stubEnv("OPENAI_API_KEY", "");
|
|
vi.stubEnv("CODEX_API_KEY", "");
|
|
try {
|
|
const { sessionInputs } = await runExecutor({
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state"),
|
|
env: { [keyName]: "sk-acp-test-key" },
|
|
paperclipRuntimeSkills: [],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
const sessionEnv = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(sessionEnv.CODEX_HOME).toBe(managedHome);
|
|
expect(sessionEnv.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" }));
|
|
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(false);
|
|
expect(JSON.parse(await fs.readFile(managedAuth, "utf8"))).toEqual({ OPENAI_API_KEY: "sk-acp-test-key" });
|
|
expect(await fs.readFile(sourceAuth, "utf8")).toBe(JSON.stringify({ tokens: "host-login" }));
|
|
if (process.platform !== "win32") {
|
|
expect((await fs.stat(managedAuth)).mode & 0o777).toBe(0o600);
|
|
}
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("sets GROK_HOME for a Grok run from the company Grok home, and leaves CODEX_HOME unchanged for a Codex run", async () => {
|
|
const root = await makeTempRoot();
|
|
const paperclipHome = path.join(root, "paperclip-home");
|
|
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
|
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
|
try {
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
|
|
|
const grokRun = await runExecutor({
|
|
agent: "grok",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state-grok"),
|
|
});
|
|
expect(grokRun.sessionInputs[0]?.sessionOptions).toMatchObject({
|
|
env: expect.objectContaining({
|
|
GROK_HOME: path.join(
|
|
paperclipHome,
|
|
"instances",
|
|
"default",
|
|
"companies",
|
|
"company-1",
|
|
"grok-home",
|
|
),
|
|
}),
|
|
});
|
|
|
|
const codexHome = path.join(root, "codex-home");
|
|
const codexRun = await runExecutor({
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state-codex"),
|
|
env: { CODEX_HOME: codexHome },
|
|
paperclipRuntimeSkills: [],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
const codexEnv = (codexRun.sessionInputs[0]?.sessionOptions as { env: Record<string, string> })
|
|
.env;
|
|
expect(codexEnv.CODEX_HOME).toBe(codexHome);
|
|
expect(codexEnv.GROK_HOME).toBeUndefined();
|
|
} finally {
|
|
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
|
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
|
}
|
|
});
|
|
|
|
it("uses direct registry commands and per-session env across ACPX agent changes", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const baseConfig = {
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
};
|
|
|
|
const first = await runExecutor(
|
|
{ ...baseConfig, agent: "custom-a" },
|
|
{ authToken: "old-key" },
|
|
);
|
|
const second = await runExecutor(
|
|
{ ...baseConfig, agent: "custom-b" },
|
|
{ authToken: "new-key" },
|
|
);
|
|
|
|
expect(
|
|
(first.runtimeOptions[0]!.agentRegistry as { resolve(name: string): string }).resolve(
|
|
"custom-a",
|
|
),
|
|
).toBe("node ./fake-acp.js");
|
|
expect(
|
|
(second.sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env
|
|
.PAPERCLIP_API_KEY,
|
|
).toBe("new-key");
|
|
await expect(fs.access(path.join(stateDir, "wrappers"))).rejects.toThrow();
|
|
});
|
|
|
|
it("forwards resolved adapter env through session options without overriding runtime vars", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const { sessionInputs } = await runExecutor(
|
|
{
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
env: {
|
|
OOGA_BOOGA_123: "plain-value",
|
|
// Server-resolved secret_ref values arrive here as plain strings.
|
|
OPENROUTER_API_KEY: "resolved-secret-value",
|
|
// Reserved-namespace config keys must not clobber runtime identity/wake.
|
|
PAPERCLIP_TASK_ID: "attacker-issue",
|
|
// PAPERCLIP_API_KEY is never accepted from config.
|
|
PAPERCLIP_API_KEY: "config-key",
|
|
// A PAPERCLIP_*-named key the harness does not assign flows through.
|
|
PAPERCLIP_CLOUD_PROVIDER_TOKEN: "cloud-token",
|
|
},
|
|
},
|
|
{
|
|
authToken: "runtime-secret-token",
|
|
context: { taskId: "issue-real", wakeReason: "issue_assigned" },
|
|
},
|
|
);
|
|
const env = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(env.OOGA_BOOGA_123).toBe("plain-value");
|
|
expect(env.OPENROUTER_API_KEY).toBe("resolved-secret-value");
|
|
expect(env.PAPERCLIP_TASK_ID).toBe("issue-real");
|
|
expect(env.PAPERCLIP_API_KEY).toBe("runtime-secret-token");
|
|
expect(env.PAPERCLIP_CLOUD_PROVIDER_TOKEN).toBe("cloud-token");
|
|
});
|
|
|
|
it.each(["OPENAI_API_KEY", "CODEX_API_KEY"] as const)(
|
|
"selects Codex ACP API-key authentication when %s is configured",
|
|
async (apiKeyName) => {
|
|
const root = await makeTempRoot();
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
|
|
const { sessionInputs } = await runExecutor({
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state"),
|
|
env: {
|
|
CODEX_HOME: codexHome,
|
|
[apiKeyName]: "sk-acp-test-key",
|
|
},
|
|
paperclipRuntimeSkills: [],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
|
|
const env = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(env[apiKeyName]).toBe("sk-acp-test-key");
|
|
expect(env.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" }));
|
|
},
|
|
);
|
|
|
|
it.each(["OPENAI_API_KEY", "CODEX_API_KEY"] as const)(
|
|
"selects Codex ACP API-key authentication when only the host process provides %s",
|
|
async (apiKeyName) => {
|
|
const root = await makeTempRoot();
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
|
|
// Simulate a local launch that inherits a provider key from the host
|
|
// process environment. No adapter config sets the key directly, so the
|
|
// launched env only receives it through host projection.
|
|
vi.stubEnv(apiKeyName, "sk-host-inherited-key");
|
|
try {
|
|
const { sessionInputs } = await runExecutor({
|
|
agent: "codex",
|
|
stateDir: path.join(root, "state"),
|
|
env: { CODEX_HOME: codexHome },
|
|
paperclipRuntimeSkills: [],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
});
|
|
|
|
const env = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(env[apiKeyName]).toBe("sk-host-inherited-key");
|
|
expect(env.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" }));
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("busts the session fingerprint when resolved adapter env changes but not across wakes", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir };
|
|
|
|
const first = await runExecutor(
|
|
{ ...baseConfig, env: { OPENROUTER_API_KEY: "value-1" } },
|
|
{ context: { taskId: "issue-1", wakeReason: "issue_assigned" } },
|
|
);
|
|
const changedEnv = await runExecutor(
|
|
{ ...baseConfig, env: { OPENROUTER_API_KEY: "value-2" } },
|
|
{ context: { taskId: "issue-1", wakeReason: "issue_assigned" } },
|
|
);
|
|
const sameEnvNewWake = await runExecutor(
|
|
{ ...baseConfig, env: { OPENROUTER_API_KEY: "value-1" } },
|
|
{ context: { taskId: "issue-1", wakeReason: "comment", wakeCommentId: "c-9" } },
|
|
);
|
|
|
|
const fp = (r: { result: { sessionParams?: unknown } }) =>
|
|
(r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
|
|
|
// A changed forwarded env value invalidates warm-handle / session reuse so
|
|
// the next launch sources the latest env.
|
|
expect(fp(first)).toBeDefined();
|
|
expect(fp(changedEnv)).not.toBe(fp(first));
|
|
// A new heartbeat with the same config env keeps the fingerprint stable, so
|
|
// per-wake PAPERCLIP_* churn does not needlessly reset the session.
|
|
expect(fp(sameEnvNewWake)).toBe(fp(first));
|
|
});
|
|
|
|
it("keeps rotated run scratch paths out of session identity while retaining user temp overrides", async () => {
|
|
const root = await makeTempRoot();
|
|
const config = { agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") };
|
|
async function withScratch(dir: string, userTemp: string) {
|
|
return runExecutor({ ...config, env: {
|
|
PAPERCLIP_RUN_SCRATCH_DIR: dir, PAPERCLIP_TASK_SCRATCH_DIR: dir,
|
|
PAPERCLIP_SCRATCH_DIR: dir, PAPERCLIP_TMPDIR: dir, TEMP: dir, TMP: dir, TMPDIR: userTemp,
|
|
} }, { context: { taskId: "issue-1", paperclipScratch: { type: "heartbeat_run", dir, tempKeysApplied: ["TEMP", "TMP"] } } });
|
|
}
|
|
const first = await withScratch(path.join(root, "run-1"), "/custom/tmp-1");
|
|
const second = await withScratch(path.join(root, "run-2"), "/custom/tmp-1");
|
|
const changed = await withScratch(path.join(root, "run-3"), "/custom/tmp-2");
|
|
expect(second.result.sessionParams?.configFingerprint).toBe(first.result.sessionParams?.configFingerprint);
|
|
expect(changed.result.sessionParams?.configFingerprint).not.toBe(first.result.sessionParams?.configFingerprint);
|
|
});
|
|
|
|
it("busts the session fingerprint when a stable configured PAPERCLIP_* value rotates", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir };
|
|
|
|
// A configured PAPERCLIP_*-named value the harness does not assign (e.g. a
|
|
// cloud provider token binding) is stable per-run config: rotating it must
|
|
// invalidate a warm/resumable session so the next launch sources the new
|
|
// value, even across an otherwise-identical wake context.
|
|
const context = { taskId: "issue-1", wakeReason: "issue_assigned" };
|
|
const withKey = await runExecutor(
|
|
{ ...baseConfig, env: { PAPERCLIP_CLOUD_PROVIDER_TOKEN: "explicit-key-1" } },
|
|
{ context },
|
|
);
|
|
const rotatedKey = await runExecutor(
|
|
{ ...baseConfig, env: { PAPERCLIP_CLOUD_PROVIDER_TOKEN: "explicit-key-2" } },
|
|
{ context },
|
|
);
|
|
|
|
const fp = (r: { result: { sessionParams?: unknown } }) =>
|
|
(r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
|
|
|
expect(fp(withKey)).toBeDefined();
|
|
expect(fp(rotatedKey)).not.toBe(fp(withKey));
|
|
});
|
|
|
|
it("busts the session fingerprint when the referenced-project set or pinned checkout changes", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "workspace");
|
|
const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir };
|
|
|
|
// A compatible resume reuses the already-staged referenced-project trees, so
|
|
// the fingerprint must fold in the referenced-project set. Otherwise a resume
|
|
// reuses a stale tree after the set or a project's pinned checkout changes.
|
|
const withReferenced = (additional: unknown[]) => ({
|
|
context: {
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_assigned",
|
|
paperclipWorkspace: { cwd, realization: { additional } },
|
|
},
|
|
});
|
|
const projectA = {
|
|
path: "/host/project-a",
|
|
projectId: "a",
|
|
projectWorkspaceId: "ws-a",
|
|
repoUrl: "https://example.test/a.git",
|
|
repoRef: "ref-a-1",
|
|
};
|
|
const projectB = {
|
|
path: "/host/project-b",
|
|
projectId: "b",
|
|
projectWorkspaceId: "ws-b",
|
|
repoUrl: "https://example.test/b.git",
|
|
repoRef: "ref-b-1",
|
|
};
|
|
|
|
const one = await runExecutor(baseConfig, withReferenced([projectA]));
|
|
const two = await runExecutor(baseConfig, withReferenced([projectA, projectB]));
|
|
const reordered = await runExecutor(baseConfig, withReferenced([projectB, projectA]));
|
|
const repointed = await runExecutor(
|
|
baseConfig,
|
|
withReferenced([projectA, { ...projectB, repoRef: "ref-b-2" }]),
|
|
);
|
|
|
|
const fp = (r: { result: { sessionParams?: unknown } }) =>
|
|
(r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
|
|
|
// Adding a referenced project changes the set identity, so the fingerprint
|
|
// busts and the next launch stages the current set.
|
|
expect(fp(one)).toBeDefined();
|
|
expect(fp(two)).not.toBe(fp(one));
|
|
// The identity depends on the set, not the order the records arrive in.
|
|
expect(fp(reordered)).toBe(fp(two));
|
|
// Re-pinning one project's checkout ref busts the fingerprint, so the resume
|
|
// re-stages instead of reusing a stale tree.
|
|
expect(fp(repointed)).not.toBe(fp(two));
|
|
});
|
|
|
|
it("routes every referenced project's Git-ignore scan through the registered scheduler, not an unbounded direct spawn", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "workspace");
|
|
const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir };
|
|
// The paths need not exist: the scheduler hook intercepts the scan before
|
|
// any real `git` process (or even a directory-existence check) runs.
|
|
const projectPaths = ["/host/project-a", "/host/project-b", "/host/project-c"];
|
|
|
|
const scannedPaths: string[] = [];
|
|
setExpensiveWorkspaceGitExecutor(async (input) => {
|
|
scannedPaths.push(input.localDir);
|
|
const error = new Error("fatal: not a git repository (or any of the parent directories): .git");
|
|
throw Object.assign(error, { stdout: "", stderr: error.message });
|
|
});
|
|
|
|
try {
|
|
await runExecutor(baseConfig, {
|
|
context: {
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_assigned",
|
|
paperclipWorkspace: {
|
|
cwd,
|
|
realization: {
|
|
additional: projectPaths.map((localPath, index) => ({
|
|
path: localPath,
|
|
projectId: String.fromCharCode(97 + index),
|
|
})),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
setExpensiveWorkspaceGitExecutor(null);
|
|
}
|
|
|
|
// Every referenced project's scan reached the registered scheduler hook.
|
|
// The `Promise.all` fan-out over projects can no longer bypass it by
|
|
// spawning `git` directly, so a host process that bounds concurrent scans
|
|
// there bounds referenced-project scans too, however many projects a run
|
|
// configures.
|
|
expect(scannedPaths.sort()).toEqual([...projectPaths].sort());
|
|
});
|
|
|
|
it("busts the session fingerprint when referenced-project files change at the same host path", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "workspace");
|
|
const projectDir = path.join(root, "project-a");
|
|
await fs.mkdir(path.join(projectDir, "src"), { recursive: true });
|
|
await fs.writeFile(path.join(projectDir, "src", "index.ts"), "export const value = 1;\n");
|
|
const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir };
|
|
|
|
// Same host path and same metadata across runs. Only the file bytes change,
|
|
// which mirrors a branch that moved or a re-checkout in place. The metadata
|
|
// identity stays constant, so without the content signature a compatible
|
|
// resume would reuse a stale staged tree.
|
|
const referencedProject = {
|
|
path: projectDir,
|
|
projectId: "a",
|
|
projectWorkspaceId: "ws-a",
|
|
repoUrl: "https://example.test/a.git",
|
|
repoRef: "main",
|
|
};
|
|
const withProject = () => ({
|
|
context: {
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_assigned",
|
|
paperclipWorkspace: { cwd, realization: { additional: [referencedProject] } },
|
|
},
|
|
});
|
|
const fp = (r: { result: { sessionParams?: unknown } }) =>
|
|
(r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
|
|
|
const before = await runExecutor(baseConfig, withProject());
|
|
// Change the tree content and its size while the host path and metadata stay
|
|
// identical.
|
|
await fs.writeFile(path.join(projectDir, "src", "index.ts"), "export const value = 2; // changed\n");
|
|
const afterEdit = await runExecutor(baseConfig, withProject());
|
|
// Add a new file to the tree.
|
|
await fs.writeFile(path.join(projectDir, "src", "extra.ts"), "export const extra = true;\n");
|
|
const afterAdd = await runExecutor(baseConfig, withProject());
|
|
|
|
expect(fp(before)).toBeDefined();
|
|
// A content-only edit busts the fingerprint, so the resume re-stages.
|
|
expect(fp(afterEdit)).not.toBe(fp(before));
|
|
// A new file in the tree busts the fingerprint too.
|
|
expect(fp(afterAdd)).not.toBe(fp(afterEdit));
|
|
});
|
|
|
|
it("busts the session fingerprint when referenced-project bytes change but size and mtime stay equal", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "workspace");
|
|
const projectDir = path.join(root, "project-a");
|
|
const filePath = path.join(projectDir, "src", "index.ts");
|
|
await fs.mkdir(path.join(projectDir, "src"), { recursive: true });
|
|
// The two contents have the same byte length, so a stat-only signature (size
|
|
// and modification time) would collide once the modification time also matches.
|
|
const firstContent = "export const value = 1;\n";
|
|
const secondContent = "export const value = 2;\n";
|
|
expect(secondContent.length).toBe(firstContent.length);
|
|
await fs.writeFile(filePath, firstContent);
|
|
|
|
// A re-checkout can restore the same modification time, so pin it explicitly
|
|
// and reapply it after the edit. Only the file bytes change between runs.
|
|
const pinnedTime = new Date("2026-01-01T00:00:00.000Z");
|
|
await fs.utimes(filePath, pinnedTime, pinnedTime);
|
|
|
|
const referencedProject = {
|
|
path: projectDir,
|
|
projectId: "a",
|
|
projectWorkspaceId: "ws-a",
|
|
repoUrl: "https://example.test/a.git",
|
|
repoRef: "main",
|
|
};
|
|
const withProject = () => ({
|
|
context: {
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_assigned",
|
|
paperclipWorkspace: { cwd, realization: { additional: [referencedProject] } },
|
|
},
|
|
});
|
|
const fp = (r: { result: { sessionParams?: unknown } }) =>
|
|
(r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
|
|
|
const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir };
|
|
const before = await runExecutor(baseConfig, withProject());
|
|
// Replace the bytes with an equal-length string and restore the same
|
|
// modification time, so path, size, and mtime all stay identical across the two runs.
|
|
await fs.writeFile(filePath, secondContent);
|
|
await fs.utimes(filePath, pinnedTime, pinnedTime);
|
|
const after = await runExecutor(baseConfig, withProject());
|
|
|
|
expect(fp(before)).toBeDefined();
|
|
// The content signature reads bytes, so an equal-size, equal-mtime edit busts
|
|
// the fingerprint and the resume re-stages instead of reusing a stale tree.
|
|
expect(fp(after)).not.toBe(fp(before));
|
|
});
|
|
|
|
describe("referencedSourceContentSignature", () => {
|
|
it("returns a stable marker without walking the tree when the ignore resolution failed", async () => {
|
|
const root = await makeTempRoot();
|
|
// A directory that does not exist: a walk would throw ENOENT. The
|
|
// `failed` resolution must short-circuit before any `fs.readdir` call.
|
|
const localPath = path.join(root, "does-not-exist");
|
|
|
|
const signature = await referencedSourceContentSignature(localPath, { kind: "failed", reason: "git status timed out" });
|
|
|
|
expect(signature).toBe("unreadable:git status timed out");
|
|
});
|
|
|
|
it("never leaks a raw absolute path into the signature, even when the underlying scan embedded one", async () => {
|
|
const root = await makeTempRoot();
|
|
const localPath = path.join(root, "does-not-exist");
|
|
|
|
// A raw toplevel string that makes `localPath` a non-descendant, carrying
|
|
// a sensitive absolute path — exactly the shape a caught Git diagnostic
|
|
// could embed. `resolveReferencedSourceIgnore` is the single choke point
|
|
// that must reduce it to the fixed category before the signature (which
|
|
// embeds `reason` verbatim as `unreadable:${reason}`) ever sees it.
|
|
const sensitivePath = "/home/alice/project";
|
|
let ignoreResolution;
|
|
try {
|
|
setExpensiveWorkspaceGitExecutor(async (input) => {
|
|
if (input.operation === "referenced_source.toplevel") {
|
|
return { stdout: `${sensitivePath}\n`, stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
ignoreResolution = await resolveReferencedSourceIgnore(localPath);
|
|
} finally {
|
|
setExpensiveWorkspaceGitExecutor(null);
|
|
}
|
|
expect(ignoreResolution.kind).toBe("failed");
|
|
|
|
const signature = await referencedSourceContentSignature(localPath, ignoreResolution);
|
|
|
|
expect(signature).toBe("unreadable:git-toplevel-not-descendant");
|
|
expect(signature).not.toContain(sensitivePath);
|
|
expect(signature).not.toContain(localPath);
|
|
});
|
|
|
|
it("skips a Git-ignored file (exact match) so its content never affects the signature", async () => {
|
|
const root = await makeTempRoot();
|
|
const localPath = path.join(root, "project");
|
|
await fs.mkdir(localPath, { recursive: true });
|
|
await fs.writeFile(path.join(localPath, "kept.txt"), "kept\n", "utf8");
|
|
await fs.writeFile(path.join(localPath, "secret.env"), "TOKEN=1\n", "utf8");
|
|
|
|
const resolution = { kind: "git" as const, ignoredPaths: ["secret.env"] };
|
|
const before = await referencedSourceContentSignature(localPath, resolution);
|
|
await fs.writeFile(path.join(localPath, "secret.env"), "TOKEN=2\n", "utf8");
|
|
const afterIgnoredEdit = await referencedSourceContentSignature(localPath, resolution);
|
|
await fs.writeFile(path.join(localPath, "kept.txt"), "kept, changed\n", "utf8");
|
|
const afterKeptEdit = await referencedSourceContentSignature(localPath, resolution);
|
|
|
|
// Editing the ignored file never busts the signature; editing the kept file does.
|
|
expect(afterIgnoredEdit).toBe(before);
|
|
expect(afterKeptEdit).not.toBe(before);
|
|
});
|
|
|
|
it("skips every file under a Git-ignored directory (prefix match)", async () => {
|
|
const root = await makeTempRoot();
|
|
const localPath = path.join(root, "project");
|
|
await fs.mkdir(path.join(localPath, "build", "nested"), { recursive: true });
|
|
await fs.writeFile(path.join(localPath, "kept.txt"), "kept\n", "utf8");
|
|
await fs.writeFile(path.join(localPath, "build", "nested", "artifact.js"), "v1\n", "utf8");
|
|
|
|
const resolution = { kind: "git" as const, ignoredPaths: ["build"] };
|
|
const before = await referencedSourceContentSignature(localPath, resolution);
|
|
await fs.writeFile(path.join(localPath, "build", "nested", "artifact.js"), "v2\n", "utf8");
|
|
const after = await referencedSourceContentSignature(localPath, resolution);
|
|
|
|
expect(after).toBe(before);
|
|
});
|
|
});
|
|
|
|
it("shapes ACPX session env for remote execution identities", async () => {
|
|
const root = await makeTempRoot();
|
|
const localCwd = path.join(root, "local");
|
|
const remoteCwd = "/workspace/remote";
|
|
const { sessionInputs, runtimeOptions } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", cwd: localCwd, stateDir: path.join(root, "state") },
|
|
{ context: { paperclipWorkspace: { cwd: localCwd, workspaceWorktreePath: localCwd } }, executionTarget: { kind: "remote", transport: "ssh", remoteCwd } },
|
|
);
|
|
const env = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(env.PAPERCLIP_WORKSPACE_CWD).toBe(localCwd);
|
|
// The ssh remote transport is NOT the runner-backed process-session lane, so
|
|
// it stays byte-identical: no host-spawn redirect. `cwd` is the host cwd and
|
|
// `spawnCwd` is unset.
|
|
expect(runtimeOptions[0]!.cwd).toBe(localCwd);
|
|
expect(runtimeOptions[0]!.spawnCwd).toBeUndefined();
|
|
});
|
|
|
|
it("does not materialize credential wrapper scripts", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir });
|
|
await expect(fs.access(path.join(stateDir, "wrappers"))).rejects.toThrow();
|
|
});
|
|
|
|
it("keeps concurrent credentials isolated in their session options", async () => {
|
|
const [first, second] = await Promise.all([
|
|
runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js" }, { authToken: "first" }),
|
|
runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js" }, { authToken: "second" }),
|
|
]);
|
|
expect(
|
|
(first.sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env
|
|
.PAPERCLIP_API_KEY,
|
|
).toBe("first");
|
|
expect(
|
|
(second.sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env
|
|
.PAPERCLIP_API_KEY,
|
|
).toBe("second");
|
|
});
|
|
|
|
it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const runStderrDir = path.join(stateDir, "run-stderr");
|
|
await fs.mkdir(runStderrDir, { recursive: true });
|
|
const stderrTail = "claude-agent-acp: SDK init failed (auth missing)";
|
|
await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8");
|
|
|
|
class FakeAcpRuntimeError extends Error {
|
|
readonly code = "ACP_SESSION_INIT_FAILED";
|
|
readonly cause: Error;
|
|
readonly retryable = false;
|
|
constructor(message: string, cause: Error) {
|
|
super(message);
|
|
this.name = "AcpRuntimeError";
|
|
this.cause = cause;
|
|
}
|
|
}
|
|
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => {
|
|
throw new FakeAcpRuntimeError(
|
|
"session/new failed: backend rejected initialize",
|
|
new Error("upstream timeout"),
|
|
);
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
},
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).toBe("acpx_session_init_failed");
|
|
const meta = result.errorMeta ?? {};
|
|
expect(meta.errorName).toBe("AcpRuntimeError");
|
|
expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED");
|
|
expect(meta.causeMessage).toBe("upstream timeout");
|
|
expect(meta.retryable).toBe(false);
|
|
expect(typeof meta.stackPreview).toBe("string");
|
|
expect(meta.phase).toBe("ensure_session");
|
|
|
|
const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\""));
|
|
expect(errorLogLine).toBeTruthy();
|
|
const errorPayload = JSON.parse(errorLogLine!.text.trim());
|
|
expect(errorPayload.phase).toBe("ensure_session");
|
|
expect(errorPayload.errorName).toBe("AcpRuntimeError");
|
|
expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED");
|
|
expect(errorPayload.causeMessage).toBe("upstream timeout");
|
|
expect(errorPayload.childStderrTail).toContain("SDK init failed");
|
|
|
|
const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail"));
|
|
expect(stderrLog).toBeTruthy();
|
|
expect(stderrLog!.text).toContain(stderrTail);
|
|
});
|
|
|
|
it("configures in-process child stderr capture without forcing verbose mode", async () => {
|
|
const root = await makeTempRoot();
|
|
const { runtimeOptions } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") });
|
|
expect(runtimeOptions[0]!.verbose).toBe(false);
|
|
expect(runtimeOptions[0]!.onAgentStderr).toBeTypeOf("function");
|
|
// Local lane is byte-identical: no host-spawn redirect, so `spawnCwd` is
|
|
// unset and acpx falls back to `cwd`.
|
|
expect(runtimeOptions[0]!.spawnCwd).toBeUndefined();
|
|
});
|
|
|
|
it("starts sandbox ACP process sessions in the remote execution cwd", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
|
|
let sessionPayload: Record<string, unknown> | null = null;
|
|
const runner = createLocalSandboxRunner(
|
|
(input: { args?: string[]; env?: Record<string, string> }) => {
|
|
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
|
|
const script = input.args?.[1] ?? "";
|
|
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
|
|
if (match) {
|
|
sessionPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<string, unknown>;
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
const { runtimeOptions, sessionInputs } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(sessionPayload).toMatchObject({
|
|
command: "sh",
|
|
args: ["-lc", "exec node ./fake-acp.js"],
|
|
cwd: remoteCwd,
|
|
});
|
|
|
|
// Host-spawn cwd decoupling: on the remote process-session lane the acpx
|
|
// runtime host-spawns the relay proxy, whose `chdir` must land in a
|
|
// HOST-valid dir — the engine's host `cwd` (`localCwd`) — while the advertised
|
|
// ACP `session/new` cwd and the in-sandbox `commandPayload.cwd` stay
|
|
// `remoteCwd`. `spawnCwd` carries the host-only redirect; it must differ from
|
|
// the advertised session cwd. (Threading proof; the acpx runtime honoring
|
|
// `spawnCwd ?? cwd` at the real host spawn is proven in remote-spawn-smoke.)
|
|
expect(runtimeOptions[0]!.cwd).toBe(remoteCwd);
|
|
expect(sessionInputs[0]!.cwd).toBe(remoteCwd);
|
|
expect(runtimeOptions[0]!.spawnCwd).toBe(localCwd);
|
|
const proxyCommand = (runtimeOptions[0]!.agentRegistry as { resolve(name: string): string }).resolve("custom");
|
|
expect(proxyCommand.startsWith(`${JSON.stringify(process.execPath.replaceAll("\\", "/"))} `)).toBe(true);
|
|
expect(proxyCommand).toContain("paperclip-process-session-proxy.mjs");
|
|
expect(runtimeOptions[0]!.spawnCwd).not.toBe(sessionInputs[0]!.cwd);
|
|
const payloadEnv = ((sessionPayload as Record<string, unknown> | null)?.env ?? {}) as Record<string, unknown>;
|
|
expect(payloadEnv).toMatchObject({
|
|
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
|
});
|
|
expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(
|
|
/^http:\/\/127\.0\.0\.1:\d+$/,
|
|
);
|
|
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
|
|
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
|
|
});
|
|
|
|
it("keeps the session fingerprint stable when only the host spawn cwd changes", async () => {
|
|
// `spawnCwd` (the host-only spawn redirect = the host `cwd`) must NOT enter
|
|
// the session fingerprint or compat key: two runs of the same session that
|
|
// stage into the same in-sandbox `remoteCwd` from DIFFERENT host worktrees
|
|
// must reuse — not invalidate — the staged runtime. So the fingerprint has to
|
|
// ignore the host cwd and key only on the advertised session cwd (`remoteCwd`).
|
|
const root = await makeTempRoot();
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
|
|
const runOnce = async (hostWorktree: string) => {
|
|
const localCwd = path.join(root, hostWorktree);
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
return runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, hostWorktree, "state"), cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const first = await runOnce("worktree-a");
|
|
const second = await runOnce("worktree-b");
|
|
|
|
// Host cwd (and therefore `spawnCwd`) differs between the two runs...
|
|
expect(first.runtimeOptions[0]!.spawnCwd).not.toBe(second.runtimeOptions[0]!.spawnCwd);
|
|
// ...but the advertised session cwd — and thus the fingerprint — is identical.
|
|
expect(first.sessionInputs[0]!.cwd).toBe(remoteCwd);
|
|
expect(second.sessionInputs[0]!.cwd).toBe(remoteCwd);
|
|
const fp = (r: { result: { sessionParams?: unknown } }) =>
|
|
(r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
|
expect(fp(first)).toBeDefined();
|
|
expect(fp(second)).toBe(fp(first));
|
|
});
|
|
|
|
it("routes child stderr in-process while keeping the unfiltered run log", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return buildRuntime() as never;
|
|
},
|
|
});
|
|
const writes: string[] = [];
|
|
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
writes.push(String(chunk));
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-nes-close-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
expect(result.exitCode).toBe(0);
|
|
runtimeOptions?.onAgentStderr?.("Error handling request { method: 'nes/cl");
|
|
runtimeOptions?.onAgentStderr?.("ose' } { code: -32601 }\n");
|
|
runtimeOptions?.onAgentStderr?.("some genuine crash: TypeError: x is not a function\n");
|
|
} finally {
|
|
process.stderr.write = originalWrite;
|
|
}
|
|
expect(writes.join("")).not.toContain("nes/close");
|
|
expect(writes.join("")).toContain("some genuine crash");
|
|
const runLog = await fs.readFile(path.join(stateDir, "run-stderr", "run-nes-close-1.log"), "utf8");
|
|
expect(runLog).toContain("nes/close");
|
|
expect(runLog).toContain("some genuine crash");
|
|
});
|
|
|
|
it("routes reused warm-runtime stderr to the current run log", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const warmHandles = new Map();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles,
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return buildRuntime() as never;
|
|
},
|
|
});
|
|
const config = {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
mode: "persistent",
|
|
warmHandleIdleMs: 60_000,
|
|
};
|
|
const first = await execute({
|
|
runId: "run-warm-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
const second = await execute({
|
|
runId: "run-warm-2",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: { sessionParams: first.sessionParams },
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
expect(second.exitCode).toBe(0);
|
|
runtimeOptions?.onAgentStderr?.("current-run-stderr\n");
|
|
await expect(fs.readFile(path.join(stateDir, "run-stderr", "run-warm-1.log"), "utf8")).rejects.toThrow();
|
|
await expect(fs.readFile(path.join(stateDir, "run-stderr", "run-warm-2.log"), "utf8")).resolves.toContain("current-run-stderr");
|
|
});
|
|
|
|
it("passes Paperclip env through ACPX session options instead of process.env", async () => {
|
|
let observedSessionEnv: Record<string, string> | undefined;
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async (input: { sessionOptions?: { env?: Record<string, string> } }) => {
|
|
observedSessionEnv = input.sessionOptions?.env;
|
|
return { backendSessionId: "backend-session", agentSessionId: "agent-session", runtimeSessionName: "runtime-session" };
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () { yield { type: "done", stopReason: "end_turn" }; })(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
const previousApiKey = process.env.PAPERCLIP_API_KEY;
|
|
try {
|
|
delete process.env.PAPERCLIP_API_KEY;
|
|
const result = await execute({
|
|
runId: "run-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js" },
|
|
context: {},
|
|
authToken: "runtime-key",
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(observedSessionEnv?.PAPERCLIP_API_KEY).toBe("runtime-key");
|
|
expect(process.env.PAPERCLIP_API_KEY).toBeUndefined();
|
|
} finally {
|
|
if (previousApiKey === undefined) delete process.env.PAPERCLIP_API_KEY;
|
|
else process.env.PAPERCLIP_API_KEY = previousApiKey;
|
|
}
|
|
});
|
|
|
|
it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const { meta } = await runExecutor(
|
|
{ agent: "claude", stateDir, cwd },
|
|
{ context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } },
|
|
);
|
|
|
|
const settingsPath = path.join(cwd, ".claude", "settings.local.json");
|
|
const written = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
|
|
permissions?: {
|
|
allow?: unknown;
|
|
additionalDirectories?: unknown;
|
|
defaultMode?: unknown;
|
|
};
|
|
};
|
|
expect(written.permissions?.defaultMode).toBe("default");
|
|
const allow = written.permissions?.allow;
|
|
expect(Array.isArray(allow)).toBe(true);
|
|
expect(allow).toContain("Bash(curl:*)");
|
|
expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`);
|
|
const additionalDirectories = written.permissions?.additionalDirectories as string[] | undefined;
|
|
expect(Array.isArray(additionalDirectories)).toBe(true);
|
|
expect(additionalDirectories).toContain(stateDir);
|
|
expect(additionalDirectories).toContain(path.join(root, "agent-home"));
|
|
|
|
const note = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
|
|
entry.includes("Paperclip-managed Claude settings"),
|
|
);
|
|
expect(note).toBeTruthy();
|
|
});
|
|
|
|
it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(cwd, ".claude", "settings.local.json"),
|
|
JSON.stringify(
|
|
{
|
|
statusLine: { type: "command", command: "preserve-me" },
|
|
permissions: {
|
|
allow: ["Bash(npm test:*)"],
|
|
additionalDirectories: ["/Users/example/custom"],
|
|
defaultMode: "acceptEdits",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
await runExecutor(
|
|
{ agent: "claude", stateDir, cwd },
|
|
{ context: { paperclipWorkspace: { cwd } } },
|
|
);
|
|
|
|
const written = JSON.parse(
|
|
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
|
|
) as {
|
|
statusLine?: unknown;
|
|
permissions?: {
|
|
allow?: string[];
|
|
additionalDirectories?: string[];
|
|
defaultMode?: string;
|
|
};
|
|
};
|
|
expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" });
|
|
expect(written.permissions?.defaultMode).toBe("acceptEdits");
|
|
expect(written.permissions?.allow).toContain("Bash(npm test:*)");
|
|
expect(written.permissions?.allow).toContain("Bash(curl:*)");
|
|
expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom");
|
|
expect(written.permissions?.additionalDirectories).toContain(stateDir);
|
|
});
|
|
|
|
it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(cwd, ".claude", "settings.local.json"),
|
|
JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2),
|
|
"utf8",
|
|
);
|
|
|
|
const { meta } = await runExecutor(
|
|
{ agent: "claude", stateDir, cwd },
|
|
{ context: { paperclipWorkspace: { cwd } } },
|
|
);
|
|
|
|
const written = JSON.parse(
|
|
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
|
|
) as { permissions?: { defaultMode?: string } };
|
|
expect(written.permissions?.defaultMode).toBe("default");
|
|
|
|
const overrideNote = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
|
|
entry.includes("overrode user dontAsk"),
|
|
);
|
|
expect(overrideNote).toBeTruthy();
|
|
});
|
|
|
|
it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const verboseByAgent: Record<string, boolean | undefined> = {};
|
|
for (const agent of ["claude", "codex", "custom"] as const) {
|
|
const runtimeOptions: AcpRuntimeOptions[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: (options) => {
|
|
runtimeOptions.push(options as AcpRuntimeOptions);
|
|
return buildRuntime() as never;
|
|
},
|
|
});
|
|
const result = await execute({
|
|
runId: `run-${agent}`,
|
|
agent: { id: `agent-${agent}`, companyId: "company-1" },
|
|
runtime: {},
|
|
config:
|
|
agent === "custom"
|
|
? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd }
|
|
: { agent, stateDir: path.join(root, `state-${agent}`), cwd },
|
|
context: { paperclipWorkspace: { cwd } },
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
expect(result.exitCode).toBe(0);
|
|
verboseByAgent[agent] = (runtimeOptions[0] as { verbose?: boolean } | undefined)?.verbose;
|
|
}
|
|
|
|
expect(verboseByAgent.claude).toBe(true);
|
|
expect(verboseByAgent.codex).toBe(false);
|
|
expect(verboseByAgent.custom).toBe(false);
|
|
});
|
|
|
|
it("does not touch .claude/settings.local.json for the codex agent", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
await runExecutor(
|
|
{ agent: "codex", stateDir, cwd },
|
|
{ context: { paperclipWorkspace: { cwd } } },
|
|
);
|
|
|
|
expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false);
|
|
});
|
|
|
|
it("changes the ACPX session fingerprint when the resolved secret manifest rotates", async () => {
|
|
const root = await makeTempRoot();
|
|
const baseConfig = {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
};
|
|
|
|
const first = await runExecutor(baseConfig, {
|
|
context: {
|
|
paperclipSecrets: {
|
|
manifest: [
|
|
{
|
|
configPath: "env.API_TOKEN",
|
|
envKey: "API_TOKEN",
|
|
secretId: "secret-1",
|
|
bindingId: "binding-1",
|
|
secretKey: "api-token",
|
|
version: 1,
|
|
provider: "local_encrypted",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const second = await runExecutor(baseConfig, {
|
|
context: {
|
|
paperclipSecrets: {
|
|
manifest: [
|
|
{
|
|
configPath: "env.API_TOKEN",
|
|
envKey: "API_TOKEN",
|
|
secretId: "secret-1",
|
|
bindingId: "binding-1",
|
|
secretKey: "api-token",
|
|
version: 2,
|
|
provider: "local_encrypted",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(first.result.sessionParams?.configFingerprint).toBeTypeOf("string");
|
|
expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
|
|
expect(first.result.sessionParams?.configFingerprint).not.toBe(second.result.sessionParams?.configFingerprint);
|
|
});
|
|
|
|
it.each(["claude", "codex", "grok", "gemini", "kimi"])("resumes %s with added and removed MCP servers and current credentials", async (agent) => {
|
|
const root = await makeTempRoot();
|
|
// This fixture injects its runtime. Keep session identity independent of
|
|
// host CLI discovery/version probes while testing MCP credential changes.
|
|
const config = { agent, agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state"), paperclipRuntimeSkills: [], paperclipSkillSync: { desiredSkills: [] } };
|
|
const first = await runExecutor(config);
|
|
const server = { name: "github", url: "https://example.test/github/mcp", connectionId: "github", token: "current-token" };
|
|
const second = await runExecutor(config, {
|
|
runtime: { sessionParams: sessionCodec.deserialize(first.result.sessionParams), taskKey: "default" },
|
|
runtimeMcp: { getServers: () => [server] }, context: { refreshTools: true },
|
|
});
|
|
expect(second.sessionInputs[0]?.resumeSessionId).toBe("backend-session");
|
|
expect(second.result.sessionParams?.configFingerprint).toBe(first.result.sessionParams?.configFingerprint);
|
|
expect(second.runtimeOptions[0]?.mcpServers).toEqual([{ type: "http", name: "github", url: server.url, headers: [{ name: "Authorization", value: "Bearer current-token" }] }]);
|
|
expect(JSON.stringify(sessionCodec.serialize(second.result.sessionParams ?? null))).not.toContain("current-token");
|
|
const third = await runExecutor(config, { runtime: { sessionParams: sessionCodec.deserialize(second.result.sessionParams) }, context: { refreshTools: true } });
|
|
expect(third.sessionInputs[0]?.resumeSessionId).toBe("backend-session");
|
|
expect(third.runtimeOptions[0]?.mcpServers).toEqual([]);
|
|
});
|
|
|
|
it("bounds Claude MCP tool calls only when runtime MCP servers are attached", async () => {
|
|
const server = { name: "paperclip-assigned", url: "https://example.test/mcp/gateways/g", connectionId: "assignment:x", token: "t" };
|
|
const runtimeMcp = { getServers: () => [server] };
|
|
const envOf = (run: { meta: Array<Record<string, unknown>> }) => run.meta[0]?.env as Record<string, string>;
|
|
|
|
const bounded = await runExecutor({ agent: "claude" }, { runtimeMcp });
|
|
expect(envOf(bounded).MCP_TOOL_TIMEOUT).toBe(String(ACPX_CLAUDE_MCP_TOOL_TIMEOUT_MS));
|
|
|
|
const withoutServers = await runExecutor({ agent: "claude" });
|
|
expect(envOf(withoutServers).MCP_TOOL_TIMEOUT).toBeUndefined();
|
|
|
|
const userConfigured = await runExecutor({ agent: "claude", env: { MCP_TOOL_TIMEOUT: "120000" } }, { runtimeMcp });
|
|
expect(envOf(userConfigured).MCP_TOOL_TIMEOUT).toBe("120000");
|
|
|
|
const codex = await runExecutor({ agent: "codex" }, { runtimeMcp });
|
|
expect(envOf(codex).MCP_TOOL_TIMEOUT).toBeUndefined();
|
|
});
|
|
|
|
it("injects runtime MCP servers and fingerprints their identity without persisting bearer tokens", async () => {
|
|
const root = await makeTempRoot();
|
|
const baseConfig = {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
};
|
|
const server = {
|
|
name: "github",
|
|
url: "https://paperclip.example/api/tool-gateway/gateways/github/mcp",
|
|
connectionId: "connection-1",
|
|
};
|
|
const first = await runExecutor(baseConfig, {
|
|
runtimeMcp: { getServers: () => [{ ...server, token: "token-one" }] },
|
|
});
|
|
const rotatedToken = await runExecutor(baseConfig, {
|
|
runtimeMcp: { getServers: () => [{ ...server, token: "token-two" }] },
|
|
});
|
|
const changedSet = await runExecutor(baseConfig, {
|
|
runtimeMcp: {
|
|
getServers: () => [{ ...server, connectionId: "connection-2", token: "token-two" }],
|
|
},
|
|
});
|
|
|
|
expect(first.runtimeOptions[0]?.mcpServers).toEqual([{
|
|
type: "http",
|
|
name: "github",
|
|
url: server.url,
|
|
headers: [{ name: "Authorization", value: "Bearer token-one" }],
|
|
}]);
|
|
expect(first.result.sessionParams?.mcpServers).toEqual([{
|
|
name: "github",
|
|
url: server.url,
|
|
connectionId: "connection-1",
|
|
}]);
|
|
expect(JSON.stringify(first.result.sessionParams)).not.toContain("token-one");
|
|
expect(first.result.sessionParams?.configFingerprint).toBe(rotatedToken.result.sessionParams?.configFingerprint);
|
|
expect(first.result.sessionParams?.configFingerprint).not.toBe(changedSet.result.sessionParams?.configFingerprint);
|
|
});
|
|
});
|
|
|
|
describe("findAncestorBin", () => {
|
|
async function writeFakeBin(dir: string, name: string) {
|
|
const binDir = path.join(dir, "node_modules", ".bin");
|
|
await fs.mkdir(binDir, { recursive: true });
|
|
const binPath = path.join(binDir, name);
|
|
await fs.writeFile(binPath, "#!/usr/bin/env bash\necho ok\n", { mode: 0o755 });
|
|
return binPath;
|
|
}
|
|
|
|
it("finds the binary in the start directory's own node_modules/.bin", async () => {
|
|
const root = await makeTempRoot();
|
|
const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
|
|
await fs.mkdir(packageDir, { recursive: true });
|
|
const expectedBin = await writeFakeBin(packageDir, "claude-agent-acp");
|
|
|
|
const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
|
|
|
|
expect(resolved).toBe(expectedBin);
|
|
});
|
|
|
|
it("finds the binary hoisted to an ancestor node_modules/.bin", async () => {
|
|
const root = await makeTempRoot();
|
|
const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
|
|
await fs.mkdir(packageDir, { recursive: true });
|
|
const expectedBin = await writeFakeBin(root, "claude-agent-acp");
|
|
|
|
const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
|
|
|
|
expect(resolved).toBe(expectedBin);
|
|
});
|
|
|
|
it("returns null when the binary is not present in any ancestor", async () => {
|
|
const root = await makeTempRoot();
|
|
const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils");
|
|
await fs.mkdir(packageDir, { recursive: true });
|
|
|
|
const resolved = await findAncestorBin(packageDir, "claude-agent-acp");
|
|
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("terminates at the filesystem root instead of looping forever", async () => {
|
|
const resolved = await findAncestorBin("/", "definitely-not-a-real-bin-name-xyz");
|
|
expect(resolved).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("gemini ACP flag selection", () => {
|
|
it("uses the canonical local Gemini workspace for ACP filesystem requests", async () => {
|
|
const root = await makeTempRoot();
|
|
const workspace = path.join(root, "workspace");
|
|
const alias = path.join(root, "workspace-alias");
|
|
await fs.mkdir(workspace);
|
|
await fs.symlink(workspace, alias, process.platform === "win32" ? "junction" : "dir");
|
|
const { runtimeOptions, sessionInputs } = await runExecutor({
|
|
agent: "gemini", agentCommand: "node ./fake-acp.js", cwd: alias,
|
|
stateDir: path.join(root, "state"),
|
|
});
|
|
const canonical = await fs.realpath(workspace);
|
|
expect(runtimeOptions[0]!.cwd).toBe(canonical);
|
|
expect(sessionInputs[0]!.cwd).toBe(canonical);
|
|
});
|
|
|
|
it("parses semantic version parts from gemini --version output", () => {
|
|
expect(parseGeminiVersionParts("0.30.0")).toEqual([0, 30, 0]);
|
|
expect(parseGeminiVersionParts("gemini-cli v1.2.3\n")).toEqual([1, 2, 3]);
|
|
expect(parseGeminiVersionParts("no version here")).toBeNull();
|
|
expect(parseGeminiVersionParts(null)).toBeNull();
|
|
});
|
|
|
|
it("keeps --acp for gemini >= 0.33.0 and unknown versions", () => {
|
|
expect(geminiVersionSupportsNativeAcpFlag([0, 33, 0])).toBe(true);
|
|
expect(geminiVersionSupportsNativeAcpFlag([0, 34, 1])).toBe(true);
|
|
expect(geminiVersionSupportsNativeAcpFlag([1, 0, 0])).toBe(true);
|
|
expect(geminiVersionSupportsNativeAcpFlag(null)).toBe(true);
|
|
expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 33, 0])).toBe("gemini --acp");
|
|
});
|
|
|
|
it("downgrades --acp to --experimental-acp for gemini < 0.33.0", () => {
|
|
expect(geminiVersionSupportsNativeAcpFlag([0, 30, 0])).toBe(false);
|
|
expect(geminiVersionSupportsNativeAcpFlag([0, 32, 9])).toBe(false);
|
|
expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 30, 0])).toBe("gemini --experimental-acp");
|
|
expect(rewriteGeminiAcpFlagForVersion("/opt/bin/gemini --acp", [0, 30, 0])).toBe(
|
|
"/opt/bin/gemini --experimental-acp",
|
|
);
|
|
});
|
|
|
|
async function writeFakeGemini(binDir: string, version: string) {
|
|
await fs.mkdir(binDir, { recursive: true });
|
|
const binPath = path.join(binDir, "gemini");
|
|
await fs.writeFile(binPath, `#!/bin/sh\necho "${version}"\n`, { mode: 0o755 });
|
|
}
|
|
|
|
function pathWithFakeBin(binDir: string): string {
|
|
return [binDir, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter);
|
|
}
|
|
|
|
it("registers the gemini multi-word command directly", async () => {
|
|
const root = await makeTempRoot();
|
|
const binDir = path.join(root, "bin");
|
|
await writeFakeGemini(binDir, "0.33.0");
|
|
const { runtimeOptions } = await runExecutor({ agent: "gemini", stateDir: path.join(root, "state"), env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) } });
|
|
expect((runtimeOptions[0]!.agentRegistry as { resolve(name: string): string }).resolve("gemini")).toBe("gemini --acp");
|
|
});
|
|
|
|
it("downgrades the registered gemini command when the local CLI predates --acp", async () => {
|
|
const root = await makeTempRoot();
|
|
const binDir = path.join(root, "bin");
|
|
await writeFakeGemini(binDir, "0.30.0");
|
|
const { runtimeOptions } = await runExecutor({ agent: "gemini", stateDir: path.join(root, "state"), env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) } });
|
|
expect((runtimeOptions[0]!.agentRegistry as { resolve(name: string): string }).resolve("gemini")).toBe("gemini --experimental-acp");
|
|
});
|
|
|
|
it("applies the 4h sandbox backstop when timeoutSec is unset on a sandbox execution target", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const { logs, runtimeOptions } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd },
|
|
{
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "acme-sandbox",
|
|
environmentId: "env-1",
|
|
leaseId: "lease-1",
|
|
remoteCwd: cwd,
|
|
},
|
|
},
|
|
);
|
|
|
|
// The sandbox default flows into the ACPX runtime wall-clock timer.
|
|
expect(runtimeOptions[0]?.timeoutMs).toBe(DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000);
|
|
// The effective timeout and its source are stated at run start so a later
|
|
// timeout is diagnosable from the run log alone.
|
|
const startLine = logs.find(
|
|
(entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"),
|
|
);
|
|
expect(startLine).toBeTruthy();
|
|
expect(startLine!.text).toContain(
|
|
`[paperclip] Adapter execution timeout: timeoutSec=${DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC} ` +
|
|
"(sandbox default; set adapterConfig.timeoutSec to override).",
|
|
);
|
|
});
|
|
|
|
it("keeps local execution unlimited by default and logs the unlimited timeout", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const { logs, runtimeOptions } = await runExecutor({
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd,
|
|
});
|
|
|
|
expect(runtimeOptions[0]?.timeoutMs).toBeUndefined();
|
|
const startLine = logs.find(
|
|
(entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"),
|
|
);
|
|
expect(startLine).toBeTruthy();
|
|
expect(startLine!.text).toContain("Adapter execution timeout: none");
|
|
});
|
|
|
|
it("prefers a configured timeoutSec over the sandbox default", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const { logs, runtimeOptions } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 90 },
|
|
{
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: cwd,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(runtimeOptions[0]?.timeoutMs).toBe(90 * 1000);
|
|
const startLine = logs.find(
|
|
(entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"),
|
|
);
|
|
expect(startLine!.text).toContain(
|
|
"Adapter execution timeout: timeoutSec=90 (configured via adapterConfig.timeoutSec; set adapterConfig.timeoutSec to override).",
|
|
);
|
|
});
|
|
|
|
it("keeps the sandbox backstop for an explicit timeoutSec of 0 but honors a negative opt-out", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
const sandboxContext = {
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
remoteCwd: cwd,
|
|
},
|
|
};
|
|
|
|
// The config UI persists the schema default of 0 for untouched fields, so
|
|
// an explicit 0 cannot mean "no timeout" — it keeps the 4h backstop.
|
|
const explicitZero = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 0 },
|
|
sandboxContext,
|
|
);
|
|
expect(explicitZero.runtimeOptions[0]?.timeoutMs).toBe(
|
|
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC * 1000,
|
|
);
|
|
expect(explicitZero.result.resultJson?.adapterExecutionTimeout).toEqual({
|
|
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default",
|
|
});
|
|
|
|
// A negative timeoutSec is the documented opt-out from any adapter
|
|
// wall-clock timeout, sandbox targets included.
|
|
const negativeOptOut = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: -1 },
|
|
sandboxContext,
|
|
);
|
|
expect(negativeOptOut.runtimeOptions[0]?.timeoutMs).toBeUndefined();
|
|
expect(negativeOptOut.result.resultJson?.adapterExecutionTimeout).toEqual({ timeoutSec: 0, source: "configured" });
|
|
const startLine = negativeOptOut.logs.find(
|
|
(entry) => entry.stream === "stderr" && entry.text.includes("Adapter execution timeout:"),
|
|
);
|
|
expect(startLine!.text).toContain(
|
|
"Adapter execution timeout: none (explicitly disabled via adapterConfig.timeoutSec; " +
|
|
"set it to a positive value to add one).",
|
|
);
|
|
});
|
|
|
|
it("reports a self-describing timeout error when the wall-clock timer kills a turn", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
|
|
const cancelReasons: string[] = [];
|
|
let releaseTurn: (() => void) | null = null;
|
|
const turnCancelled = new Promise<void>((resolve) => {
|
|
releaseTurn = resolve;
|
|
});
|
|
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
// Never yields on its own: only the Paperclip wall-clock timer's
|
|
// cancel unblocks the turn, simulating a hung run.
|
|
events: (async function* () {
|
|
await turnCancelled;
|
|
})(),
|
|
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
|
cancel: async ({ reason }: { reason: string }) => {
|
|
cancelReasons.push(reason);
|
|
releaseTurn?.();
|
|
},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-timeout-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd,
|
|
timeoutSec: 1,
|
|
},
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
const expectedMessage =
|
|
"Run exceeded the adapter execution timeout (timeoutSec=1, configured via adapterConfig.timeoutSec). " +
|
|
"Set adapterConfig.timeoutSec to raise it.";
|
|
expect(result.timedOut).toBe(true);
|
|
expect(result.signal).toBe("SIGTERM");
|
|
expect(result.errorCode).toBe("acpx_timeout");
|
|
expect(result.resultJson?.adapterExecutionTimeout).toEqual({ timeoutSec: 1, source: "configured" });
|
|
expect(result.errorMessage).toBe(expectedMessage);
|
|
expect(cancelReasons).toContain(expectedMessage);
|
|
expect(result.resultJson).toMatchObject({ acpObservedEventCount: 0, acpPendingToolCount: 0, acpToolInventoryComplete: true });
|
|
expect(result.resultJson).not.toHaveProperty("acpLastEventAgeMs");
|
|
}, 15_000);
|
|
});
|
|
|
|
describe("ACP activity diagnostics", () => {
|
|
it("classifies rejected definitions before redaction and overrides a transient adapter label", async () => {
|
|
const root = await makeTempRoot(), cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
const execute = createAcpxEngineExecutor({
|
|
classifyTerminalSessionFailure: () => ({ errorCode: "claude_transient_upstream", errorFamily: "transient_upstream" }),
|
|
createRuntime: () => ({ ...buildRuntime(), startTurn: (options: { onTerminalSessionFailure: (failure: unknown) => void }) => {
|
|
options.onTerminalSessionFailure({ category: "service", details: "API Error: 400 tools.17.custom.name: String should have at most 128 characters" });
|
|
return { events: (async function* () {})(), result: Promise.resolve({ status: "failed", error: new Error("turn failed") }), cancel: async () => {} };
|
|
} }) as never,
|
|
});
|
|
const result = await execute({ runId: "invalid-definition", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, env: { SECRET: "128" } },
|
|
context: {}, onMeta: async () => {}, onLog: async () => {} } as never);
|
|
expect(result).toMatchObject({ errorCode: "provider_tool_definition_invalid", errorFamily: "configuration" });
|
|
expect(JSON.stringify(result.resultJson?.terminalSessionFailure)).not.toContain("128");
|
|
});
|
|
it.each(["terminal", "relay_error", "no_events"])(
|
|
"snapshots %s activity before usage reads, failure logging, and cleanup", async (outcome) => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
let statusReads = 0;
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
getStatus: async () => {
|
|
if (++statusReads > 1) currentNow += 100_000;
|
|
return null;
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
if (outcome !== "no_events") {
|
|
yield { type: "tool_call", toolCallId: "private-pending-id", title: "private command", kind: "execute", status: "in_progress" };
|
|
yield { type: "tool_call", toolCallId: "private-done-id", title: "private read", kind: "read", status: "in_progress" };
|
|
yield { type: "tool_call", toolCallId: "private-done-id", status: "completed" };
|
|
yield { type: "tool_call", toolCallId: "private-cancelled-id", status: "cancelled" };
|
|
yield { type: "tool_call", toolCallId: "private-failed-id", status: "failed" };
|
|
yield { type: "status", text: "terminal/create private receipt" };
|
|
}
|
|
currentNow = 10_000;
|
|
if (outcome === "relay_error") throw new Error("relay failed");
|
|
})(),
|
|
result: Promise.resolve({ status: "failed", error: new Error("turn failed") }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => { currentNow += 100_000; },
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "activity-run", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd },
|
|
context: {}, onMeta: async () => {},
|
|
onLog: async (_stream: string, text: string) => {
|
|
if (text.includes('"type":"acpx.error"')) currentNow += 100_000;
|
|
},
|
|
} as never);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.resultJson).toMatchObject({
|
|
acpObservedEventCount: outcome === "no_events" ? 0 : 6,
|
|
acpPendingToolCount: outcome === "no_events" ? 0 : 1,
|
|
acpToolInventoryComplete: outcome === "no_events",
|
|
});
|
|
if (outcome === "no_events") expect(result.resultJson).not.toHaveProperty("acpLastEventAgeMs");
|
|
else expect(result.resultJson?.acpLastEventAgeMs).toBe(9000);
|
|
const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp")));
|
|
expect(JSON.stringify(diagnostics)).not.toContain("private");
|
|
expect(currentNow).toBeGreaterThan(10_000);
|
|
},
|
|
);
|
|
|
|
it("snapshots a silent turn's activity when the wall-clock timeout fires, before cancel events", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
let releaseTurn: (() => void) | null = null;
|
|
const turnCancelled = new Promise<void>((resolve) => { releaseTurn = resolve; });
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "tool_call", toolCallId: "private-id", title: "private command", kind: "execute", status: "in_progress" };
|
|
// The agent goes silent with the tool still pending.
|
|
currentNow = 9000;
|
|
await turnCancelled;
|
|
// The cancel makes the agent emit a closing event, so the
|
|
// finalize-time age alone would make this turn look active.
|
|
currentNow = 9500;
|
|
yield { type: "status", text: "private closing status" };
|
|
})(),
|
|
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
|
cancel: async () => { releaseTurn?.(); },
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "silent-timeout", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, timeoutSec: 1 },
|
|
context: {}, onMeta: async () => {}, onLog: async () => {},
|
|
} as never);
|
|
expect(result.timedOut).toBe(true);
|
|
expect(result.resultJson).toMatchObject({
|
|
acpObservedEventCount: 2,
|
|
acpLastEventAgeMs: 0,
|
|
acpPendingToolCount: 1,
|
|
acpLastEventAgeAtStopMs: 8000,
|
|
acpOldestPendingToolAgeAtStopMs: 8000,
|
|
acpPendingToolKinds: "execute",
|
|
});
|
|
const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp")));
|
|
expect(JSON.stringify(diagnostics)).not.toContain("private");
|
|
}, 15_000);
|
|
|
|
it("keeps the stalled tool's stop-time age when the cancel closes that tool", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
let releaseTurn: (() => void) | null = null;
|
|
const turnCancelled = new Promise<void>((resolve) => { releaseTurn = resolve; });
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "tool_call", toolCallId: "private-id", title: "private command", kind: "execute", status: "in_progress" };
|
|
currentNow = 9000;
|
|
await turnCancelled;
|
|
// The cancel closes the stalled tool before finalization runs.
|
|
currentNow = 9500;
|
|
yield { type: "tool_call", tag: "tool_call_update", toolCallId: "private-id", status: "cancelled" };
|
|
})(),
|
|
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
|
cancel: async () => { releaseTurn?.(); },
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "cancel-closes-tool", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, timeoutSec: 1 },
|
|
context: {}, onMeta: async () => {}, onLog: async () => {},
|
|
} as never);
|
|
expect(result.timedOut).toBe(true);
|
|
expect(result.resultJson).toMatchObject({
|
|
acpPendingToolCount: 0,
|
|
acpLastEventAgeAtStopMs: 8000,
|
|
acpOldestPendingToolAgeAtStopMs: 8000,
|
|
acpPendingToolKinds: "execute",
|
|
});
|
|
const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp")));
|
|
expect(JSON.stringify(diagnostics)).not.toContain("private");
|
|
}, 15_000);
|
|
|
|
it("ends the turn as a failure when a pending tool call stops receiving updates", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
let releaseTurn: (() => void) | null = null;
|
|
const turnCancelled = new Promise<void>((resolve) => { releaseTurn = resolve; });
|
|
const cancelReasons: Array<string | undefined> = [];
|
|
const logs: string[] = [];
|
|
const permissionDecisions: unknown[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
stalledToolTimeoutMs: 5000,
|
|
stalledToolCheckIntervalMs: 5,
|
|
createRuntime: (options) => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
// A live subagent keeps its Task call fresh, but one of its tool
|
|
// calls never gets another update. Only the Task call's permission
|
|
// request reaches the host.
|
|
permissionDecisions.push(await options.onPermissionRequest?.({
|
|
sessionId: "session-1",
|
|
raw: { sessionId: "session-1", toolCall: { toolCallId: "private-task" }, options: [] },
|
|
inferredKind: "think",
|
|
} as never, { signal: new AbortController().signal }));
|
|
yield { type: "tool_call", toolCallId: "private-task", title: "private task", kind: "think", status: "in_progress" };
|
|
for (const toolCallId of ["private-peer-asked", "private-peer-shell"]) {
|
|
await options.onPermissionRequest?.({
|
|
sessionId: "session-1",
|
|
raw: { sessionId: "session-1", toolCall: { toolCallId }, options: [] },
|
|
inferredKind: "other",
|
|
} as never, { signal: new AbortController().signal });
|
|
}
|
|
yield { type: "tool_call", toolCallId: "private-peer-asked", kind: "other", status: "completed" };
|
|
yield { type: "tool_call", toolCallId: "private-peer-silent", kind: "other", status: "failed" };
|
|
yield { type: "tool_call", toolCallId: "private-peer-shell", kind: "execute", status: "completed" };
|
|
yield { type: "tool_call", toolCallId: "private-call", title: "private lookup", kind: "other", status: "pending" };
|
|
currentNow = 7000;
|
|
yield { type: "tool_call", tag: "tool_call_update", toolCallId: "private-task", status: "in_progress" };
|
|
await turnCancelled;
|
|
yield { type: "tool_call", tag: "tool_call_update", toolCallId: "private-call", status: "cancelled" };
|
|
})(),
|
|
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
|
cancel: async ({ reason }: { reason?: string }) => {
|
|
cancelReasons.push(reason);
|
|
releaseTurn?.();
|
|
},
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "stalled-tool", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd },
|
|
context: {}, onMeta: async () => {}, onLog: async (_stream: string, text: string) => { logs.push(text); },
|
|
} as never);
|
|
expect(cancelReasons).toEqual(["paperclip stalled tool watchdog"]);
|
|
expect(result.timedOut).toBe(false);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorCode).toBe("acpx_tool_stalled");
|
|
expect(result.errorMessage).toContain("kind other");
|
|
expect(result.resultJson).toMatchObject({
|
|
status: "failed",
|
|
acpStalledToolKind: "other",
|
|
acpStalledToolSilentMs: 6000,
|
|
acpLastEventAgeAtStopMs: 0,
|
|
acpOldestPendingToolAgeAtStopMs: 6000,
|
|
acpPendingToolKinds: "other,think",
|
|
acpStalledToolPermissionRequested: false,
|
|
acpPendingToolPermissionRequestedCount: 1,
|
|
acpSettledSameKindToolCount: 2,
|
|
acpSettledSameKindToolPermissionRequestedCount: 1,
|
|
});
|
|
// The host only observes the request; ACPX's permission mode decides it.
|
|
expect(permissionDecisions).toEqual([undefined]);
|
|
expect(logs.join("")).toContain(
|
|
"[paperclip] A pending tool call (kind other) had no update for 6s; ending the turn. Its permission request did not reach Paperclip.",
|
|
);
|
|
const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp")));
|
|
expect(JSON.stringify(diagnostics)).not.toContain("private");
|
|
expect(result.errorMessage).not.toContain("private");
|
|
}, 15_000);
|
|
|
|
it("ends a stalled turn within the cancel deadline when the agent ignores the cancel", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
const closeStreamReasons: Array<string | undefined> = [];
|
|
let endEvents: (() => void) | null = null;
|
|
const eventsEnded = new Promise<void>((resolve) => { endEvents = resolve; });
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
stalledToolTimeoutMs: 5000,
|
|
stalledToolCheckIntervalMs: 5,
|
|
duplexLossCancelDeadlineMs: 25,
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "tool_call", toolCallId: "call-1", kind: "other", status: "pending" };
|
|
currentNow = 9000;
|
|
await eventsEnded;
|
|
})(),
|
|
// The agent never answers: neither the cancel nor the result settles.
|
|
result: new Promise<never>(() => {}),
|
|
cancel: async () => {},
|
|
closeStream: async ({ reason }: { reason?: string }) => {
|
|
closeStreamReasons.push(reason);
|
|
endEvents?.();
|
|
},
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "stalled-tool-silent-agent", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd },
|
|
context: {}, onMeta: async () => {}, onLog: async () => {},
|
|
} as never);
|
|
expect(closeStreamReasons).toEqual(["paperclip stalled tool cancel deadline"]);
|
|
expect(result.errorCode).toBe("acpx_tool_stalled");
|
|
expect(result.resultJson).toMatchObject({
|
|
status: "failed",
|
|
stopReason: "paperclip_stalled_tool_deadline",
|
|
acpStalledToolKind: "other",
|
|
});
|
|
}, 15_000);
|
|
|
|
it("records that a stalled tool call's permission request reached the host", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
let releaseTurn: (() => void) | null = null;
|
|
const turnCancelled = new Promise<void>((resolve) => { releaseTurn = resolve; });
|
|
const logs: string[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
stalledToolTimeoutMs: 5000,
|
|
stalledToolCheckIntervalMs: 5,
|
|
createRuntime: (options) => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "tool_call", toolCallId: "call-1", kind: "other", status: "pending" };
|
|
// The request can reach the host after the relay saw the call.
|
|
await options.onPermissionRequest?.({
|
|
sessionId: "session-1",
|
|
raw: { sessionId: "session-1", toolCall: { toolCallId: "call-1" }, options: [] },
|
|
inferredKind: "other",
|
|
} as never, { signal: new AbortController().signal });
|
|
currentNow = 7000;
|
|
await turnCancelled;
|
|
})(),
|
|
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
|
cancel: async () => { releaseTurn?.(); },
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "stalled-tool-after-permission", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd },
|
|
context: {}, onMeta: async () => {}, onLog: async (_stream: string, text: string) => { logs.push(text); },
|
|
} as never);
|
|
expect(result.errorCode).toBe("acpx_tool_stalled");
|
|
expect(result.resultJson).toMatchObject({
|
|
acpStalledToolKind: "other",
|
|
acpStalledToolPermissionRequested: true,
|
|
acpPendingToolPermissionRequestedCount: 1,
|
|
acpSettledSameKindToolCount: 0,
|
|
acpSettledSameKindToolPermissionRequestedCount: 0,
|
|
});
|
|
expect(logs.join("")).toContain("Its permission request reached Paperclip.");
|
|
}, 15_000);
|
|
|
|
it("leaves a turn alone while its long-running tool keeps receiving updates", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
const cancelReasons: Array<string | undefined> = [];
|
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
stalledToolTimeoutMs: 5000,
|
|
stalledToolCheckIntervalMs: 5,
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "tool_call", toolCallId: "task-1", kind: "think", status: "in_progress" };
|
|
// The tool runs far past the bound in total, but each heartbeat
|
|
// arrives within it, and the watchdog checks between them.
|
|
for (let step = 0; step < 4; step += 1) {
|
|
currentNow += 4000;
|
|
await tick();
|
|
yield { type: "tool_call", tag: "tool_call_update", toolCallId: "task-1", status: "in_progress" };
|
|
}
|
|
yield { type: "tool_call", tag: "tool_call_update", toolCallId: "task-1", status: "completed" };
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async ({ reason }: { reason?: string }) => { cancelReasons.push(reason); },
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "live-tool", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd },
|
|
context: {}, onMeta: async () => {}, onLog: async () => {},
|
|
} as never);
|
|
expect(cancelReasons).toEqual([]);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.errorCode).toBeNull();
|
|
expect(result.resultJson).not.toHaveProperty("acpStalledToolKind");
|
|
}, 15_000);
|
|
|
|
it("leaves a queued tool call alone while earlier tool calls keep finishing", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
const cancelReasons: Array<string | undefined> = [];
|
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
stalledToolTimeoutMs: 5000,
|
|
stalledToolCheckIntervalMs: 5,
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
// The queued call gets no update until the slow calls ahead of it
|
|
// finish, one by one, each within the bound.
|
|
yield { type: "tool_call", toolCallId: "queued", kind: "edit", status: "pending" };
|
|
for (let step = 0; step < 4; step += 1) {
|
|
yield { type: "tool_call", toolCallId: `slow-${step}`, kind: "execute", status: "in_progress" };
|
|
currentNow += 4000;
|
|
await tick();
|
|
yield { type: "tool_call", tag: "tool_call_update", toolCallId: `slow-${step}`, status: "completed" };
|
|
}
|
|
yield { type: "tool_call", tag: "tool_call_update", toolCallId: "queued", status: "completed" };
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async ({ reason }: { reason?: string }) => { cancelReasons.push(reason); },
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "queued-tool", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd },
|
|
context: {}, onMeta: async () => {}, onLog: async () => {},
|
|
} as never);
|
|
expect(cancelReasons).toEqual([]);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.errorCode).toBeNull();
|
|
}, 15_000);
|
|
|
|
it("does not watch tool calls when the stalled-tool bound is disabled", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
let currentNow = 1000;
|
|
let releaseTurn: (() => void) | null = null;
|
|
const turnCancelled = new Promise<void>((resolve) => { releaseTurn = resolve; });
|
|
const cancelReasons: Array<string | undefined> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
stalledToolCheckIntervalMs: 5,
|
|
createRuntime: () => ({
|
|
...buildRuntime(),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "tool_call", toolCallId: "call-1", kind: "other", status: "pending" };
|
|
currentNow = 10_000_000;
|
|
await turnCancelled;
|
|
})(),
|
|
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
|
cancel: async ({ reason }: { reason?: string }) => {
|
|
cancelReasons.push(reason);
|
|
releaseTurn?.();
|
|
},
|
|
}),
|
|
}) as never,
|
|
});
|
|
const result = await execute({
|
|
runId: "stall-watchdog-off", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
|
config: {
|
|
agent: "claude", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd,
|
|
stalledToolTimeoutSec: 0, timeoutSec: 1,
|
|
},
|
|
context: {}, onMeta: async () => {}, onLog: async () => {},
|
|
} as never);
|
|
// Only the wall-clock timeout ended this turn.
|
|
expect(cancelReasons).toHaveLength(1);
|
|
expect(cancelReasons[0]).not.toBe("paperclip stalled tool watchdog");
|
|
expect(result.timedOut).toBe(true);
|
|
expect(result.errorCode).toBe("acpx_timeout");
|
|
}, 15_000);
|
|
});
|
|
|
|
describe("resolveStalledToolTimeoutMs", () => {
|
|
it("watches Claude turns with a bound above Claude's own tool bounds", () => {
|
|
expect(resolveStalledToolTimeoutMs({}, {}, "claude")).toBe(ACPX_STALLED_TOOL_TIMEOUT_MS);
|
|
expect(resolveStalledToolTimeoutMs({}, { MCP_TOOL_TIMEOUT: String(ACPX_CLAUDE_MCP_TOOL_TIMEOUT_MS) }, "claude"))
|
|
.toBe(ACPX_STALLED_TOOL_TIMEOUT_MS);
|
|
expect(resolveStalledToolTimeoutMs({}, { MCP_TOOL_TIMEOUT: "3600000" }, "claude"))
|
|
.toBe(3_600_000 + ACPX_STALLED_TOOL_MARGIN_MS);
|
|
expect(resolveStalledToolTimeoutMs({}, { BASH_MAX_TIMEOUT_MS: "7200000", MCP_TOOL_TIMEOUT: "not-a-number" }, "claude"))
|
|
.toBe(7_200_000 + ACPX_STALLED_TOOL_MARGIN_MS);
|
|
});
|
|
|
|
it("leaves other agents unwatched unless the adapter config sets a bound", () => {
|
|
expect(resolveStalledToolTimeoutMs({}, {}, "codex")).toBe(0);
|
|
expect(resolveStalledToolTimeoutMs({ stalledToolTimeoutSec: 600 }, {}, "codex")).toBe(600_000);
|
|
});
|
|
|
|
it("lets the adapter config override or disable the bound", () => {
|
|
expect(resolveStalledToolTimeoutMs({ stalledToolTimeoutSec: 120 }, { MCP_TOOL_TIMEOUT: "3600000" }, "claude")).toBe(120_000);
|
|
expect(resolveStalledToolTimeoutMs({ stalledToolTimeoutSec: 0 }, {}, "claude")).toBe(0);
|
|
expect(resolveStalledToolTimeoutMs({ stalledToolTimeoutSec: -1 }, {}, "claude")).toBe(0);
|
|
expect(resolveStalledToolTimeoutMs({ stalledToolTimeoutSec: "120" }, {}, "claude")).toBe(ACPX_STALLED_TOOL_TIMEOUT_MS);
|
|
});
|
|
});
|
|
|
|
describe("summarizeAcpStopActivity", () => {
|
|
it("reports nothing for an unstopped turn with no pending tools", () => {
|
|
expect(summarizeAcpStopActivity(null, 1000, [])).toEqual({});
|
|
});
|
|
|
|
it("reports pending kinds without a stop, and collapses unknown kinds", () => {
|
|
expect(summarizeAcpStopActivity(null, 1000, [
|
|
{ kind: "fetch", firstSeenAt: 10 }, { kind: "private_tool_name" }, {}, { kind: "fetch" },
|
|
])).toEqual({ acpPendingToolKinds: "fetch,unknown" });
|
|
});
|
|
|
|
it("measures ages from the stop and ignores tools first seen after it", () => {
|
|
expect(summarizeAcpStopActivity(10_000, 4000, [
|
|
{ kind: "execute", firstSeenAt: 3000 }, { kind: "read", firstSeenAt: 12_000 },
|
|
])).toEqual({
|
|
acpLastEventAgeAtStopMs: 6000,
|
|
acpOldestPendingToolAgeAtStopMs: 7000,
|
|
acpPendingToolKinds: "execute,read",
|
|
});
|
|
});
|
|
|
|
it("omits the event age when no event preceded the stop", () => {
|
|
expect(summarizeAcpStopActivity(10_000, null, [])).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("summarizeAcpxTurnUsage", () => {
|
|
it("uses the post-turn amount alone when the cumulative cost counter reset", () => {
|
|
const summary = summarizeAcpxTurnUsage({
|
|
preStatus: { usage: { cost: { amount: 2.5, currency: "USD" } } },
|
|
postStatus: {
|
|
usage: {
|
|
cumulative: { inputTokens: 10, outputTokens: 20 },
|
|
cost: { amount: 0.3, currency: "USD" },
|
|
},
|
|
},
|
|
eventBreakdown: null,
|
|
eventCostUsd: null,
|
|
});
|
|
expect(summary.costUsd).toBeCloseTo(0.3);
|
|
expect(summary.cumulativeCostUsd).toBeCloseTo(0.3);
|
|
});
|
|
|
|
it("ignores non-USD cost amounts", () => {
|
|
const summary = summarizeAcpxTurnUsage({
|
|
preStatus: null,
|
|
postStatus: { usage: { cost: { amount: 4, currency: "EUR" } } },
|
|
eventBreakdown: null,
|
|
eventCostUsd: null,
|
|
});
|
|
expect(summary.costUsd).toBeNull();
|
|
expect(summary.cumulativeCostUsd).toBeNull();
|
|
});
|
|
|
|
it("returns no usage when nothing was reported", () => {
|
|
const summary = summarizeAcpxTurnUsage({
|
|
preStatus: null,
|
|
postStatus: null,
|
|
eventBreakdown: null,
|
|
eventCostUsd: null,
|
|
});
|
|
expect(summary.usage).toBeNull();
|
|
expect(summary.costUsd).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("summarizeAcpxTurnUsage no-report turns", () => {
|
|
it("suppresses usage when the turn reported nothing and the persisted breakdown is unchanged", () => {
|
|
const stale = { inputTokens: 10, outputTokens: 500, cachedReadTokens: 30 };
|
|
const summary = summarizeAcpxTurnUsage({
|
|
preStatus: { usage: { cumulative: stale, cost: { amount: 0.5, currency: "USD" } } },
|
|
postStatus: { usage: { cumulative: { ...stale }, cost: { amount: 0.5, currency: "USD" } } },
|
|
eventBreakdown: null,
|
|
eventCostUsd: null,
|
|
});
|
|
expect(summary.usage).toBeNull();
|
|
expect(summary.usageDetail).toBeNull();
|
|
expect(summary.costUsd).toBeCloseTo(0);
|
|
});
|
|
|
|
it("prefers current event usage when the persisted breakdown is stale", () => {
|
|
const stale = { inputTokens: 10, outputTokens: 500, cachedReadTokens: 30 };
|
|
const current = { inputTokens: 25, outputTokens: 75, cachedReadTokens: 5 };
|
|
const summary = summarizeAcpxTurnUsage({
|
|
preStatus: { usage: { cumulative: stale } },
|
|
postStatus: { usage: { cumulative: { ...stale } } },
|
|
eventBreakdown: current,
|
|
eventCostUsd: null,
|
|
});
|
|
expect(summary.usage).toEqual({
|
|
inputTokens: 25,
|
|
outputTokens: 75,
|
|
cachedInputTokens: 5,
|
|
});
|
|
expect(summary.usageDetail).toMatchObject(current);
|
|
});
|
|
|
|
it("treats omitted and explicit zero fields as the same stale breakdown", () => {
|
|
const current = { inputTokens: 25, outputTokens: 75, cachedReadTokens: 5 };
|
|
const summary = summarizeAcpxTurnUsage({
|
|
preStatus: { usage: { cumulative: { inputTokens: 10, outputTokens: 500 } } },
|
|
postStatus: {
|
|
usage: {
|
|
cumulative: {
|
|
inputTokens: 10,
|
|
outputTokens: 500,
|
|
cachedReadTokens: 0,
|
|
cachedWriteTokens: 0,
|
|
thoughtTokens: 0,
|
|
totalTokens: 0,
|
|
},
|
|
},
|
|
},
|
|
eventBreakdown: current,
|
|
eventCostUsd: null,
|
|
});
|
|
expect(summary.usage).toEqual({
|
|
inputTokens: 25,
|
|
outputTokens: 75,
|
|
cachedInputTokens: 5,
|
|
});
|
|
});
|
|
|
|
it("does not reuse stale tokens when the turn reports cost only", () => {
|
|
const stale = { inputTokens: 10, outputTokens: 500, cachedReadTokens: 30 };
|
|
const summary = summarizeAcpxTurnUsage({
|
|
preStatus: { usage: { cumulative: stale, cost: { amount: 0.5, currency: "USD" } } },
|
|
postStatus: {
|
|
usage: { cumulative: { ...stale }, cost: { amount: 0.5, currency: "USD" } },
|
|
},
|
|
eventBreakdown: null,
|
|
eventCostUsd: 0.75,
|
|
});
|
|
expect(summary.usage).toBeNull();
|
|
expect(summary.usageDetail).toBeNull();
|
|
expect(summary.costUsd).toBeCloseTo(0.25);
|
|
expect(summary.cumulativeCostUsd).toBeCloseTo(0.75);
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function setupRemoteSandbox() {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
// A file present only in the HOST worktree proves the workspace is shipped
|
|
// into the sandbox: the local runner extracts the staged tar into remoteCwd.
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
const runner = createLocalSandboxRunner();
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner,
|
|
};
|
|
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
|
}
|
|
|
|
it("test_remote_buildRuntime_crosses_staging_seam", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { sessionInputs, events } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
|
|
// Crossing the staging seam emits a per-step timing event for the sync.
|
|
const stageEvent = events.find(
|
|
(event) => event.eventType === "run.startup.step" && event.payload?.step === "stage.sync",
|
|
);
|
|
expect(stageEvent).toBeTruthy();
|
|
expect(typeof stageEvent!.payload?.durationMs).toBe("number");
|
|
|
|
// Staging seam crossed exactly once, shipping the HOST worktree.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
|
|
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
|
|
expect(stageArgs.workspaceLocalDir).toBe(localCwd);
|
|
expect(stageArgs.target).toMatchObject({ kind: "remote", transport: "sandbox" });
|
|
// No credential/home asset staged in PR 1 (that is PR 2's per-adapter seed).
|
|
expect(stageArgs.assets ?? []).toEqual([]);
|
|
expect(stageArgs.installCommand ?? null).toBeNull();
|
|
|
|
// Both bridges receive the real (non-null) runtimeRootDir from staging.
|
|
const paperclipArgs = vi.mocked(startAdapterExecutionTargetPaperclipBridge).mock.calls[0]![0];
|
|
const processArgs = vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mock.calls[0]![0];
|
|
expect(paperclipArgs.runtimeRootDir).toBeTruthy();
|
|
expect(processArgs.runtimeRootDir).toBeTruthy();
|
|
expect(String(paperclipArgs.runtimeRootDir)).toContain(".paperclip-runtime");
|
|
expect(processArgs.runtimeRootDir).toBe(paperclipArgs.runtimeRootDir);
|
|
|
|
// The workspace really landed in the sandbox workspace dir.
|
|
await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
|
|
// And session/new is created on the in-sandbox workspace cwd.
|
|
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
|
|
});
|
|
|
|
it("hands the merged paperclip env to the process-session launch when the setups overlap", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
// Decode the process-session LAUNCH payload (the base64 command blob) — the
|
|
// in-sandbox process env is carried there, NOT in the exec's own `env`.
|
|
let launchPayload: Record<string, unknown> | null = null;
|
|
let bridgeExecEnv: Record<string, string> | undefined;
|
|
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
|
|
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
|
|
bridgeExecEnv = input.env;
|
|
const script = input.args?.[1] ?? "";
|
|
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
|
|
if (match) {
|
|
launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
}
|
|
}
|
|
});
|
|
|
|
await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
|
|
// The process-session bridge receives its launch env as a DEFERRED thunk —
|
|
// the seam that lets its env-independent setup overlap the paperclip bridge
|
|
// start instead of running strictly after it.
|
|
const processArgs = vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mock.calls[0]![0];
|
|
expect(typeof processArgs.env).toBe("function");
|
|
|
|
// ...and despite the overlap the launch still observes the MERGED paperclip
|
|
// env: the paperclip-`env` → process-session-launch hand-off stays sequenced
|
|
// under concurrency (bridge base URL + minted bridge token both present, and
|
|
// the token is NOT the host run JWT).
|
|
const payloadEnv = ((launchPayload as Record<string, unknown> | null)?.env ?? {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(payloadEnv).toMatchObject({ PAPERCLIP_API_BRIDGE_MODE: "queue_v1" });
|
|
expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
|
|
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
|
|
expect(bridgeExecEnv?.PAPERCLIP_SANDBOX_EXEC_CHANNEL).toBe("bridge");
|
|
expect(bridgeExecEnv?.PAPERCLIP_API_KEY).toBeUndefined();
|
|
});
|
|
|
|
it("publishes referenced-project workspace hints repointed at their staged sandbox directories", async () => {
|
|
const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
// A referenced project with a real host tree, so the sandbox transport stages it and returns a
|
|
// `project-<projectId>` directory for it.
|
|
const referencedProjectDir = path.join(root, "referenced-project-a");
|
|
await fs.mkdir(referencedProjectDir, { recursive: true });
|
|
await fs.writeFile(path.join(referencedProjectDir, "note.txt"), "referenced", "utf8");
|
|
|
|
// Decode the process-session LAUNCH payload — the in-sandbox process env is carried there.
|
|
let launchPayload: Record<string, unknown> | null = null;
|
|
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
|
|
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
|
|
const script = input.args?.[1] ?? "";
|
|
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
|
|
if (match) {
|
|
launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
}
|
|
}
|
|
});
|
|
|
|
await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
context: {
|
|
taskId: "issue-1",
|
|
wakeReason: "issue_assigned",
|
|
paperclipWorkspace: {
|
|
cwd: localCwd,
|
|
realization: {
|
|
additional: [
|
|
{
|
|
path: referencedProjectDir,
|
|
projectId: "a",
|
|
projectWorkspaceId: "ws-a",
|
|
repoUrl: "https://example.test/a.git",
|
|
repoRef: "main",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// The plural workspace-hints channel the agent reads. The referenced hint points at the
|
|
// host path today; on a remote target the run must repoint it at the staged directory.
|
|
paperclipWorkspaces: [
|
|
{
|
|
workspaceId: "ws-a",
|
|
cwd: referencedProjectDir,
|
|
repoUrl: "https://example.test/a.git",
|
|
repoRef: "main",
|
|
projectId: "a",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
const payloadEnv = ((launchPayload as Record<string, unknown> | null)?.env ?? {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const workspacesJson = payloadEnv.PAPERCLIP_WORKSPACES_JSON;
|
|
expect(typeof workspacesJson).toBe("string");
|
|
const hints = JSON.parse(String(workspacesJson)) as Array<Record<string, unknown>>;
|
|
const referencedHint = hints.find((hint) => hint.projectId === "a");
|
|
expect(referencedHint).toBeTruthy();
|
|
// The referenced hint repoints from the host path to its staged in-sandbox directory.
|
|
expect(String(referencedHint!.cwd)).toContain("project-a");
|
|
expect(referencedHint!.cwd).not.toBe(referencedProjectDir);
|
|
});
|
|
|
|
it("stops the process-session bridge when the paperclip bridge fails under concurrency", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
// The paperclip bridge fails; the process-session bridge — started CONCURRENTLY
|
|
// with it — still resolves a live handle. The abandon path must stop that
|
|
// handle so no started bridge leaks on partial failure.
|
|
const stop = vi.fn(async () => {});
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
|
|
throw new Error("paperclip bridge boom");
|
|
});
|
|
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
|
async () => ({ agentCommand: null, stop }) as never,
|
|
);
|
|
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => buildRuntime() as never,
|
|
});
|
|
|
|
await expect(
|
|
execute({
|
|
runId: "run-bridge-fail",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never),
|
|
).rejects.toThrow("paperclip bridge boom");
|
|
|
|
// The concurrently-started process-session bridge was stopped exactly once.
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("test_remote_session_new_uses_in_sandbox_cwd", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { sessionInputs, runtimeOptions } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
|
|
// The ACP runtime + session/new both bind to the in-sandbox workspace dir,
|
|
// not the HOST worktree path.
|
|
expect(runtimeOptions[0]?.cwd).toBe(remoteCwd);
|
|
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
|
|
expect(sessionInputs[0]?.cwd).not.toBe(localCwd);
|
|
});
|
|
|
|
it("preserves Gemini's in-sandbox workspace without host canonicalization", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { sessionInputs, runtimeOptions } = await runExecutor(
|
|
{ agent: "gemini", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
expect(runtimeOptions[0]?.cwd).toBe(remoteCwd);
|
|
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
|
|
});
|
|
|
|
it("recreates the remote runtime while resuming after the cwd is staged", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
let created = 0;
|
|
const warmHandles = new Map();
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles,
|
|
createRuntime: () => {
|
|
created += 1;
|
|
return buildRuntime(undefined, (input) => ensureInputs.push(input)) as never;
|
|
},
|
|
});
|
|
const base = {
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
mode: "persistent",
|
|
warmHandleIdleMs: 60_000,
|
|
},
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
};
|
|
|
|
const first = await execute({ runId: "run-remote-a", runtime: {}, ...base } as never);
|
|
const second = await execute({
|
|
runId: "run-remote-b",
|
|
runtime: { sessionParams: first.sessionParams },
|
|
...base,
|
|
} as never);
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
expect(created).toBe(2);
|
|
expect(warmHandles.size).toBe(0);
|
|
expect(ensureInputs).toHaveLength(2);
|
|
// Both runs resolve session/new to the in-sandbox cwd...
|
|
expect(ensureInputs[0]?.cwd).toBe(remoteCwd);
|
|
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
|
|
// ...and the second run RESUMES the first session: fingerprint/compat/persist
|
|
// all read the same in-sandbox `sessionCwd`, so a handle created with the
|
|
// in-sandbox cwd is reused, not invalidated, after the HOST→sandbox cwd swap.
|
|
expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId);
|
|
});
|
|
|
|
it("test_local_foundation_unchanged", async () => {
|
|
const root = await makeTempRoot();
|
|
const localCwd = path.join(root, "worktree");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
const { sessionInputs, runtimeOptions } = await runExecutor({
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
cwd: localCwd,
|
|
});
|
|
|
|
// A local (non-remote) run never crosses the staging seam or starts a
|
|
// bridge, and session/new stays on the HOST cwd — byte-identical to today.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(startAdapterExecutionTargetPaperclipBridge)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(startAdapterExecutionTargetProcessSessionBridge)).not.toHaveBeenCalled();
|
|
expect(sessionInputs[0]?.cwd).toBe(localCwd);
|
|
expect(runtimeOptions[0]?.cwd).toBe(localCwd);
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function setupRemoteSandbox() {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
|
}
|
|
|
|
it("test_remote_seam_receives_adapter_agnostic_context", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
let captured: Record<string, unknown> | null = null;
|
|
const { sessionInputs, events } = await runExecutor(
|
|
{
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
// A user/adapter-config env value proves the seam sees the resolved run env.
|
|
env: { SEAM_MARKER: "seam-marker-value" },
|
|
},
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
captured = input as unknown as Record<string, unknown>;
|
|
const stagedRuntime = await input.stage([]);
|
|
return { stagedRuntime };
|
|
},
|
|
},
|
|
);
|
|
|
|
// The managed-home seam runs inside the timed stage.sync boundary, so a
|
|
// per-step timing event is emitted for it.
|
|
const stageEvent = events.find(
|
|
(event) => event.eventType === "run.startup.step" && event.payload?.step === "stage.sync",
|
|
);
|
|
expect(stageEvent).toBeTruthy();
|
|
expect(typeof stageEvent!.payload?.durationMs).toBe("number");
|
|
|
|
// The engine invoked the seam and used the runtime it staged (session/new
|
|
// binds to the in-sandbox workspace dir the seam returned).
|
|
expect(captured).not.toBeNull();
|
|
const context = captured as unknown as Record<string, unknown>;
|
|
// Only generic, adapter-agnostic inputs cross the boundary...
|
|
expect(context.acpxAgent).toBe("custom");
|
|
expect(context.companyId).toBe("company-1");
|
|
expect(context.runId).toBe("run-1");
|
|
expect(context.workspaceLocalDir).toBe(localCwd);
|
|
expect(context.executionTarget).toMatchObject({ kind: "remote", transport: "sandbox" });
|
|
expect(typeof context.stage).toBe("function");
|
|
expect(typeof context.timeoutSec).toBe("number");
|
|
// ...including the resolved run env (adapter config env folded in).
|
|
expect((context.env as Record<string, string>).SEAM_MARKER).toBe("seam-marker-value");
|
|
// ...and NOTHING scoped to a single adapter leaks across the seam. This locks
|
|
// the boundary: the engine must not hand a Gemini/Claude/Codex-specific field
|
|
// (e.g. the former `geminiSkillsHome`) to the generic seam context.
|
|
expect(context).not.toHaveProperty("geminiSkillsHome");
|
|
expect(Object.keys(context).some((key) => /gemini|claude|codex/i.test(key))).toBe(false);
|
|
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
|
|
});
|
|
|
|
it("test_remote_seam_stages_assets_and_env_remap_reaches_process", async () => {
|
|
const { root, stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
// A managed-home dir the seam ships as an asset (mirrors a per-adapter home).
|
|
const managedHomeDir = path.join(root, "managed-home");
|
|
await fs.mkdir(managedHomeDir, { recursive: true });
|
|
await fs.writeFile(path.join(managedHomeDir, "config.json"), "{}", "utf8");
|
|
|
|
const { meta } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const stagedRuntime = await input.stage([
|
|
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
|
|
]);
|
|
// Repoint an adapter home env var onto the in-sandbox asset dir; the
|
|
// engine must forward this mutated run env to the spawned process.
|
|
input.env.MANAGED_HOME = stagedRuntime.assetDirs.home ?? "";
|
|
return { stagedRuntime };
|
|
},
|
|
},
|
|
);
|
|
|
|
// The seam's asset was threaded through the shared staging seam...
|
|
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
|
|
expect(stageArgs.workspaceLocalDir).toBe(localCwd);
|
|
expect(stageArgs.assets).toEqual([
|
|
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
|
|
]);
|
|
// ...it really landed in the sandbox (local runner extracts to the asset dir)...
|
|
const remoteAssetDir = String((meta[0]?.env as Record<string, string>).MANAGED_HOME);
|
|
expect(remoteAssetDir).toBeTruthy();
|
|
await expect(fs.readFile(path.join(remoteAssetDir, "config.json"), "utf8")).resolves.toBe("{}");
|
|
// ...the staged asset dir resolves under the run's managed runtime root (an
|
|
// in-sandbox path), not the host managed-home dir.
|
|
expect(remoteAssetDir).toContain(".paperclip-runtime");
|
|
expect(remoteAssetDir).not.toBe(managedHomeDir);
|
|
expect(path.isAbsolute(remoteAssetDir)).toBe(true);
|
|
});
|
|
|
|
it("retains bounded restore diagnostics through real settlement and result reproduction", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const failure = Object.assign(new Error("private-restore-path and command"), { code: 1, stderr: "private-restore-stderr" });
|
|
const { result } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt", executionTarget,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const stagedRuntime = await input.stage([]);
|
|
return {
|
|
stagedRuntime,
|
|
teardown: createWorkspaceRestoreTeardown({
|
|
stagedRuntime: { restoreWorkspace: () => withWorkspaceRestoreDiagnostics("workspace", () =>
|
|
withWorkspaceRestoreStep("git_integration", async () => { throw failure; })) },
|
|
onLog: async () => {}, startMessage: "Restoring workspace", failurePrefix: "Restore failed",
|
|
}),
|
|
};
|
|
},
|
|
},
|
|
);
|
|
expect(result.resultJson).toMatchObject({
|
|
workspaceRestoreFailure: "restore_failed",
|
|
workspaceRestoreDiagnostic: { phase: "workspace", step: "git_integration", errorCode: "unknown", exitCode: 1 },
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("private-restore-");
|
|
});
|
|
|
|
it("test_remote_seam_teardown_fires_once_on_exit", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
let teardownCalls = 0;
|
|
await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const stagedRuntime = await input.stage([]);
|
|
return {
|
|
stagedRuntime,
|
|
teardown: async () => {
|
|
teardownCalls += 1;
|
|
return { ok: true };
|
|
},
|
|
};
|
|
},
|
|
},
|
|
);
|
|
|
|
// The engine fires the seam's teardown exactly once on the exit/cleanup path
|
|
// (mirrors the codex auth copy-back + staged-temp cleanup finally).
|
|
expect(teardownCalls).toBe(1);
|
|
});
|
|
|
|
it("test_remote_seam_absent_stages_workspace_only", async () => {
|
|
// Without a seam (custom agents / adapters with no home seed), the remote lane
|
|
// stages the workspace with no home asset — byte-identical to PR-1 behavior.
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { sessionInputs } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
|
|
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
|
|
expect(stageArgs.assets ?? []).toEqual([]);
|
|
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
|
|
});
|
|
|
|
it("test_remote_seam_surfaces_referenced_project_staging_failure_reason", async () => {
|
|
// A referenced project that failed to stage into the sandbox is a first-class
|
|
// run outcome. The run result carries the failure reason for each dropped
|
|
// project, and the run log gains one stderr line per failure that names the
|
|
// project id and the reason. The run stays successful (per-project isolation).
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { result, logs } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const stagedRuntime = await input.stage([]);
|
|
// Inject a per-project staging failure the coordinator would record on a
|
|
// real sandbox extract failure.
|
|
stagedRuntime.additionalSourceFailures = [
|
|
{ projectId: "proj-x", error: "extract failed: boom" },
|
|
];
|
|
return { stagedRuntime };
|
|
},
|
|
},
|
|
);
|
|
|
|
// The run result carries the failure with its reason.
|
|
expect(result.referencedProjectStagingFailures).toEqual([
|
|
{ projectId: "proj-x", error: "extract failed: boom" },
|
|
]);
|
|
// The run log gains one stderr line that names the project id and the reason.
|
|
const failureLine = logs.find(
|
|
(entry) => entry.stream === "stderr" && entry.text.includes("proj-x"),
|
|
);
|
|
expect(failureLine?.text).toBe(
|
|
"[paperclip] Referenced project proj-x failed to stage; the run continues without it: extract failed: boom\n",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine Claude skill bundle staging (remote ACP lane)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function setupRemoteSandbox() {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
|
}
|
|
|
|
// A stand-in for the real Claude seam (`claude-local/server/acp.ts`): stage
|
|
// the bundle the engine hands it, at the same asset key and
|
|
// `followSymlinks` value the real seam uses. This isolates the ENGINE's own
|
|
// contract — computing and threading `skillsBundleDir`, then rewriting the
|
|
// prompt/identity once staging resolves — from the real seam, which has its
|
|
// own test in `claude-local/server/acp.test.ts`.
|
|
function stagingClaudeSeam(): AcpxEngineExecutorOptions["prepareRemoteManagedHome"] {
|
|
return async (input) => {
|
|
const stagedRuntime = await input.stage(
|
|
input.skillsBundleDir
|
|
? [{ key: "skills", localDir: input.skillsBundleDir, followSymlinks: false }]
|
|
: [],
|
|
);
|
|
return { stagedRuntime };
|
|
};
|
|
}
|
|
|
|
it("advertises folded routing metadata and exact staged paths without injecting skill bodies", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
const stateDir = path.join(root, "state");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
const skill = await createSkill(path.join(root, "sources"), "paperclip--versioned", [
|
|
"---", "description: >-", " Use for Paperclip-managed tasks", " and durable task documents.",
|
|
"---", "PRIVATE_SKILL_BODY_RECIPE", "",
|
|
].join("\n"));
|
|
const { meta, result } = await runExecutor({
|
|
agent: "claude", agentCommand: "node ./fake-acp.js", stateDir, cwd,
|
|
paperclipRuntimeSkills: [skill], paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
});
|
|
const bundle = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
|
|
const stagedFile = path.join(bundle, ".claude", "skills", skill.runtimeName, "SKILL.md");
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toContain(`- ${skill.runtimeName}: Use for Paperclip-managed tasks and durable task documents. (file: ${stagedFile})`);
|
|
expect(prompt).not.toContain("PRIVATE_SKILL_BODY_RECIPE");
|
|
expect(prompt).not.toContain(skill.source);
|
|
expect(result.sessionParams?.skills).toMatchObject({ selectedSkills: [skill.runtimeName] });
|
|
await expect(fs.readFile(stagedFile, "utf8")).resolves.toContain("PRIVATE_SKILL_BODY_RECIPE");
|
|
});
|
|
|
|
it("bounds routing descriptions and keeps files discoverable when metadata is absent or malformed", async () => {
|
|
const root = await makeTempRoot();
|
|
const cwd = path.join(root, "worktree");
|
|
const stateDir = path.join(root, "state");
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
const sources = path.join(root, "sources");
|
|
const bounded = await createSkill(sources, "bounded", `---\ndescription: ${"x".repeat(600)}\n---\nBOUNDED_BODY\n`);
|
|
const absent = await createSkill(sources, "absent", "# ABSENT_BODY\n");
|
|
const malformed = await createSkill(sources, "malformed", "---\ndescription: incomplete header\nMALFORMED_BODY\n");
|
|
const skills = [bounded, absent, malformed];
|
|
const { meta } = await runExecutor({
|
|
agent: "claude", agentCommand: "node ./fake-acp.js", stateDir, cwd,
|
|
paperclipRuntimeSkills: skills, paperclipSkillSync: { desiredSkills: skills.map((skill) => skill.key) },
|
|
});
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toContain(`- bounded: ${"x".repeat(512)} (file: `);
|
|
expect(prompt).not.toContain("x".repeat(513));
|
|
expect(prompt).toMatch(/- absent \(file: .*\/absent\/SKILL\.md\)/);
|
|
expect(prompt).toMatch(/- malformed \(file: .*\/malformed\/SKILL\.md\)/);
|
|
for (const body of ["BOUNDED_BODY", "ABSENT_BODY", "MALFORMED_BODY"]) expect(prompt).not.toContain(body);
|
|
});
|
|
|
|
it("rewrites the prompt and skill identity onto the in-sandbox skill root once the bundle is staged", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const skill = await createSkill(path.join(localCwd, "skills"), "review", "---\ndescription: Review requested task documents.\n---\n# review\nREMOTE_BODY_ONLY\n");
|
|
|
|
const { meta, result } = await runExecutor(
|
|
{
|
|
agent: "claude",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
paperclipRuntimeSkills: [skill],
|
|
paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget, prepareRemoteManagedHome: stagingClaudeSeam() },
|
|
);
|
|
|
|
const hostBundleDir = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
|
|
const hostSkillsHome = path.join(hostBundleDir, ".claude", "skills");
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toMatch(/Skill root: (\S+)/);
|
|
expect(prompt).not.toContain(hostSkillsHome);
|
|
|
|
const inSandboxSkillsRoot = prompt.match(/Skill root: (\S+)/)![1]!;
|
|
expect(inSandboxSkillsRoot).not.toBe(hostSkillsHome);
|
|
expect(prompt).toContain(`- review: Review requested task documents. (file: ${path.join(inSandboxSkillsRoot, "review", "SKILL.md")})`);
|
|
expect(prompt).not.toContain("REMOTE_BODY_ONLY");
|
|
await expect(
|
|
fs.readFile(path.join(inSandboxSkillsRoot, skill.runtimeName, "SKILL.md"), "utf8"),
|
|
).resolves.toContain("# review");
|
|
|
|
const skillsIdentity = result.sessionParams?.skills as { skillRoot?: string } | undefined;
|
|
expect(skillsIdentity?.skillRoot).toBe(inSandboxSkillsRoot);
|
|
});
|
|
|
|
it("keeps the session fingerprint stable across two different in-sandbox skill roots", async () => {
|
|
// Same session (same execution target, same config) both times, so the
|
|
// fingerprint's other 16 fields cannot explain a difference — only the
|
|
// seam's reported in-sandbox skill path varies, by direct override.
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const skill = await createSkill(path.join(localCwd, "skills"), "review");
|
|
const baseConfig = {
|
|
agent: "claude",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
paperclipRuntimeSkills: [skill],
|
|
paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
};
|
|
const seamWithOverriddenSkillsDir = (
|
|
overridePath: string,
|
|
): AcpxEngineExecutorOptions["prepareRemoteManagedHome"] =>
|
|
async (input) => {
|
|
const stagedRuntime = await input.stage(
|
|
input.skillsBundleDir
|
|
? [{ key: "skills", localDir: input.skillsBundleDir, followSymlinks: false }]
|
|
: [],
|
|
);
|
|
stagedRuntime.assetDirs.skills = overridePath;
|
|
return { stagedRuntime };
|
|
};
|
|
|
|
const runA = await runExecutor(baseConfig, {
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
prepareRemoteManagedHome: seamWithOverriddenSkillsDir("/sandbox/path-a/skills"),
|
|
});
|
|
const runB = await runExecutor(baseConfig, {
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
prepareRemoteManagedHome: seamWithOverriddenSkillsDir("/sandbox/path-b/skills"),
|
|
});
|
|
|
|
expect(String(runA.meta[0]?.prompt ?? "")).toContain("Skill root: /sandbox/path-a/skills");
|
|
expect(String(runB.meta[0]?.prompt ?? "")).toContain("Skill root: /sandbox/path-b/skills");
|
|
// The session fingerprint, which only ever saw the host-independent skill
|
|
// identity, stays the same across the two different in-sandbox paths.
|
|
expect(runA.result.sessionParams?.configFingerprint).toBe(runB.result.sessionParams?.configFingerprint);
|
|
});
|
|
|
|
it("stages no skills asset and leaves the prompt untouched when no skill is selected", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
|
|
const { meta } = await runExecutor(
|
|
{
|
|
agent: "claude",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
paperclipRuntimeSkills: [],
|
|
paperclipSkillSync: { desiredSkills: [] },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget, prepareRemoteManagedHome: stagingClaudeSeam() },
|
|
);
|
|
|
|
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
|
|
expect((stageArgs.assets ?? []).some((asset) => asset.key === "skills")).toBe(false);
|
|
expect(String(meta[0]?.prompt ?? "")).not.toContain("Skill root:");
|
|
});
|
|
|
|
it("drops a skill that fails to materialize from the prompt, the identity, and the staged bundle", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const skillsRoot = path.join(localCwd, "skills");
|
|
const review = await createSkill(skillsRoot, "review");
|
|
|
|
// A skill whose source is a symlink. `materializePaperclipSkillCopy`
|
|
// refuses a symlinked skill root, so this skill's copy fails while its
|
|
// source still exists (it does not hit the separate missing-source
|
|
// filter).
|
|
const linkedTarget = path.join(skillsRoot, "broken-target");
|
|
await fs.mkdir(linkedTarget, { recursive: true });
|
|
await fs.writeFile(path.join(linkedTarget, "SKILL.md"), "# broken\n", "utf8");
|
|
const brokenSource = path.join(skillsRoot, "broken");
|
|
await fs.symlink(linkedTarget, brokenSource, "dir");
|
|
const broken = {
|
|
key: "paperclipai/test/broken",
|
|
runtimeName: "broken",
|
|
source: brokenSource,
|
|
required: false,
|
|
};
|
|
|
|
const { meta, result } = await runExecutor(
|
|
{
|
|
agent: "claude",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
paperclipRuntimeSkills: [review, broken],
|
|
paperclipSkillSync: { desiredSkills: [review.key, broken.key] },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget, prepareRemoteManagedHome: stagingClaudeSeam() },
|
|
);
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toContain("Selected skills: review");
|
|
expect(prompt).not.toContain("broken");
|
|
|
|
const skillsIdentity = result.sessionParams?.skills as { selectedSkills?: string[] } | undefined;
|
|
expect(skillsIdentity?.selectedSkills).toEqual(["review"]);
|
|
|
|
const hostBundleDir = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
|
|
const hostSkillsHome = path.join(hostBundleDir, ".claude", "skills");
|
|
await expect(pathExists(path.join(hostSkillsHome, "broken"))).resolves.toBe(false);
|
|
});
|
|
|
|
it("stages no skills asset when every selected skill fails to materialize", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const skillsRoot = path.join(localCwd, "skills");
|
|
const linkedTarget = path.join(skillsRoot, "broken-target");
|
|
await fs.mkdir(linkedTarget, { recursive: true });
|
|
await fs.writeFile(path.join(linkedTarget, "SKILL.md"), "# broken\n", "utf8");
|
|
const brokenSource = path.join(skillsRoot, "broken");
|
|
await fs.symlink(linkedTarget, brokenSource, "dir");
|
|
const broken = {
|
|
key: "paperclipai/test/broken",
|
|
runtimeName: "broken",
|
|
source: brokenSource,
|
|
required: false,
|
|
};
|
|
|
|
const { meta } = await runExecutor(
|
|
{
|
|
agent: "claude",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
paperclipRuntimeSkills: [broken],
|
|
paperclipSkillSync: { desiredSkills: [broken.key] },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget, prepareRemoteManagedHome: stagingClaudeSeam() },
|
|
);
|
|
|
|
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
|
|
expect((stageArgs.assets ?? []).some((asset) => asset.key === "skills")).toBe(false);
|
|
expect(String(meta[0]?.prompt ?? "")).not.toContain("Skill root:");
|
|
});
|
|
|
|
it("drops a skill whose staged copy has no usable SKILL.md from the prompt, the identity, and the staged bundle", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const skillsRoot = path.join(localCwd, "skills");
|
|
const review = await createSkill(skillsRoot, "review");
|
|
|
|
// A skill root that is a real directory (so the copy itself does not
|
|
// throw), but whose `SKILL.md` is a symlink. `materializePaperclipSkillCopy`
|
|
// skips a symlinked file entry instead of copying it, so the staged
|
|
// directory ends up with no `SKILL.md`.
|
|
const linkedSkillMdTarget = path.join(skillsRoot, "linked-skill-md-target.md");
|
|
await fs.writeFile(linkedSkillMdTarget, "# linked\n", "utf8");
|
|
const symlinkedSkillMdSource = path.join(skillsRoot, "symlinked-skill-md");
|
|
await fs.mkdir(symlinkedSkillMdSource, { recursive: true });
|
|
await fs.symlink(linkedSkillMdTarget, path.join(symlinkedSkillMdSource, "SKILL.md"), "file");
|
|
const symlinkedSkillMd = {
|
|
key: "paperclipai/test/symlinked-skill-md",
|
|
runtimeName: "symlinked-skill-md",
|
|
source: symlinkedSkillMdSource,
|
|
required: false,
|
|
};
|
|
|
|
// A skill root that is a real directory with no `SKILL.md` at all.
|
|
const noSkillMdSource = path.join(skillsRoot, "no-skill-md");
|
|
await fs.mkdir(noSkillMdSource, { recursive: true });
|
|
await fs.writeFile(path.join(noSkillMdSource, "notes.md"), "# notes\n", "utf8");
|
|
const noSkillMd = {
|
|
key: "paperclipai/test/no-skill-md",
|
|
runtimeName: "no-skill-md",
|
|
source: noSkillMdSource,
|
|
required: false,
|
|
};
|
|
|
|
const { meta, result } = await runExecutor(
|
|
{
|
|
agent: "claude",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
paperclipRuntimeSkills: [review, symlinkedSkillMd, noSkillMd],
|
|
paperclipSkillSync: { desiredSkills: [review.key, symlinkedSkillMd.key, noSkillMd.key] },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget, prepareRemoteManagedHome: stagingClaudeSeam() },
|
|
);
|
|
|
|
const prompt = String(meta[0]?.prompt ?? "");
|
|
expect(prompt).toContain("Selected skills: review");
|
|
expect(prompt).not.toContain("symlinked-skill-md");
|
|
expect(prompt).not.toContain("no-skill-md");
|
|
|
|
const skillsIdentity = result.sessionParams?.skills as { selectedSkills?: string[] } | undefined;
|
|
expect(skillsIdentity?.selectedSkills).toEqual(["review"]);
|
|
|
|
const hostBundleDir = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
|
|
const hostSkillsHome = path.join(hostBundleDir, ".claude", "skills");
|
|
await expect(pathExists(path.join(hostSkillsHome, "symlinked-skill-md"))).resolves.toBe(false);
|
|
await expect(pathExists(path.join(hostSkillsHome, "no-skill-md"))).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine remote session-lifecycle re-staging (PR 3: stage once / reuse on compatible resume)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function setupRemoteSandbox() {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
|
}
|
|
|
|
// A runtime double that records ensureSession inputs and can be told to make
|
|
// the turn fail (to exercise the teardown/eviction path).
|
|
function recordingRuntime(input: {
|
|
ensureInputs: Array<Record<string, unknown>>;
|
|
terminalStatus?: "completed" | "failed";
|
|
}) {
|
|
return {
|
|
ensureSession: async (session: Record<string, unknown>) => {
|
|
input.ensureInputs.push(session);
|
|
return {
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
};
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result:
|
|
input.terminalStatus === "failed"
|
|
? Promise.resolve({ status: "failed", error: new Error("boom") })
|
|
: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
}
|
|
|
|
function baseExecuteArgs(input: {
|
|
stateDir: string;
|
|
localCwd: string;
|
|
executionTarget: Record<string, unknown>;
|
|
env?: Record<string, string>;
|
|
}) {
|
|
return {
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: input.stateDir,
|
|
cwd: input.localCwd,
|
|
mode: "persistent",
|
|
warmHandleIdleMs: 60_000,
|
|
...(input.env ? { env: input.env } : {}),
|
|
},
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget: input.executionTarget,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
};
|
|
}
|
|
|
|
it("cancels a stalled remote startup without waiting for the command or launching the provider", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const controller = new AbortController();
|
|
let entered!: () => void;
|
|
const started = new Promise<void>(resolve => { entered = resolve; });
|
|
let release!: () => void;
|
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
|
const stopRemoteStartup = vi.fn(async () => {});
|
|
const createRuntime = vi.fn(() => recordingRuntime({ ensureInputs: [] }) as never);
|
|
const originalExecute = executionTarget.runner.execute;
|
|
executionTarget.runner.execute = async input => {
|
|
if (input.command === "stalled-auth-setup") {
|
|
entered();
|
|
await blocked;
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() };
|
|
}
|
|
return originalExecute(input);
|
|
};
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime,
|
|
prepareRemoteManagedHome: async input => {
|
|
const target = input.executionTarget;
|
|
if (target?.kind !== "remote" || target.transport !== "sandbox" || !target.runner) {
|
|
throw new Error("Expected sandbox target");
|
|
}
|
|
await target.runner.execute({ command: "stalled-auth-setup" });
|
|
return { stagedRuntime: await input.stage([]) };
|
|
},
|
|
});
|
|
let settled = false;
|
|
const run = execute({
|
|
runId: "cancel-startup", runtime: {},
|
|
...baseExecuteArgs({ stateDir, localCwd, executionTarget }),
|
|
signal: controller.signal, stopRemoteStartup,
|
|
} as never).catch(error => error).finally(() => { settled = true; });
|
|
await started;
|
|
controller.abort(new Error("Stopped by user"));
|
|
try {
|
|
await vi.waitFor(() => expect(stopRemoteStartup).toHaveBeenCalledTimes(1), { timeout: 500 });
|
|
await vi.waitFor(() => expect(settled).toBe(true), { timeout: 500 });
|
|
expect(createRuntime).not.toHaveBeenCalled();
|
|
} finally {
|
|
release();
|
|
await run;
|
|
}
|
|
// A provider RPC completing late must not resume the cancelled startup.
|
|
expect(createRuntime).not.toHaveBeenCalled();
|
|
// A subsequent run can acquire the same local staging/auth preparation
|
|
// resources; cancellation must not leave the staging lease held.
|
|
const retry = await execute({
|
|
runId: "retry-after-cancel", runtime: {},
|
|
...baseExecuteArgs({ stateDir, localCwd, executionTarget }),
|
|
} as never);
|
|
expect(retry.exitCode).toBe(0);
|
|
expect(createRuntime).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("test_acp_resume_compatible_session_does_not_restage", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
const second = await execute({
|
|
runId: "run-b",
|
|
runtime: { sessionParams: first.sessionParams },
|
|
...base,
|
|
} as never);
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
// Staging (workspace ship + home seed) ran exactly ONCE across both runs:
|
|
// the compatible resume reused the already-staged in-sandbox runtime.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
|
|
// Both runs bind session/new (and resume) to the in-sandbox workspace cwd...
|
|
expect(ensureInputs[0]?.cwd).toBe(remoteCwd);
|
|
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
|
|
// ...and the second run RESUMES the first session rather than starting fresh.
|
|
expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId);
|
|
});
|
|
|
|
it("test_acp_resume_incompatible_fingerprint_stages_fresh", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
});
|
|
|
|
const first = await execute({
|
|
runId: "run-a",
|
|
runtime: {},
|
|
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { FOO: "a" } }),
|
|
} as never);
|
|
// A changed adapter env value shifts the session fingerprint → a different
|
|
// sessionKey → the cache slot does not match, so staging runs fresh.
|
|
const second = await execute({
|
|
runId: "run-b",
|
|
runtime: { sessionParams: first.sessionParams },
|
|
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { FOO: "b" } }),
|
|
} as never);
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
// Incompatible fingerprint → staged fresh, no stale reuse.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
|
|
expect(ensureInputs[0]?.cwd).toBe(remoteCwd);
|
|
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
|
|
// The second run does NOT resume the first session (fingerprint differs).
|
|
expect(ensureInputs[1]?.resumeSessionId).toBeUndefined();
|
|
});
|
|
|
|
it("test_warm_handle_scoped_per_fingerprint_no_cross_session_credential_reuse", async () => {
|
|
const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
// Two managed homes, one per session, each carrying a distinct credential
|
|
// marker. The seam seeds whichever home belongs to the current run.
|
|
const homeA = path.join(root, "home-a");
|
|
const homeB = path.join(root, "home-b");
|
|
await fs.mkdir(homeA, { recursive: true });
|
|
await fs.mkdir(homeB, { recursive: true });
|
|
await fs.writeFile(path.join(homeA, "auth.json"), JSON.stringify({ token: "SECRET-A" }), "utf8");
|
|
await fs.writeFile(path.join(homeB, "auth.json"), JSON.stringify({ token: "SECRET-B" }), "utf8");
|
|
|
|
const seededHomeEnv: string[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const localHome = input.env.SESSION_MARKER === "b" ? homeB : homeA;
|
|
const stagedRuntime = await input.stage([
|
|
{ key: "home", localDir: localHome, followSymlinks: true },
|
|
]);
|
|
input.env.MANAGED_HOME = stagedRuntime.assetDirs.home ?? "";
|
|
seededHomeEnv.push(input.env.MANAGED_HOME);
|
|
return { stagedRuntime };
|
|
},
|
|
});
|
|
|
|
const first = await execute({
|
|
runId: "run-a",
|
|
runtime: {},
|
|
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { SESSION_MARKER: "a" } }),
|
|
} as never);
|
|
// Different fingerprint (SESSION_MARKER changed) → different sessionKey. If the
|
|
// cache were NOT fingerprint-scoped, this run could silently inherit session A's
|
|
// staged auth.json without re-seeding. It must instead seed its own home.
|
|
const second = await execute({
|
|
runId: "run-b",
|
|
runtime: { sessionParams: first.sessionParams },
|
|
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { SESSION_MARKER: "b" } }),
|
|
} as never);
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
// Each session staged its OWN managed home — no cross-session reuse.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
|
|
expect(seededHomeEnv).toHaveLength(2);
|
|
// Session B's staged home holds session B's credential, never session A's.
|
|
const bHome = seededHomeEnv[1]!;
|
|
await expect(fs.readFile(path.join(bHome, "auth.json"), "utf8")).resolves.toContain("SECRET-B");
|
|
});
|
|
|
|
it("test_acp_failed_turn_evicts_staged_runtime_so_resume_restages", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
// The first turn fails; the second (compatible) run then completes.
|
|
createRuntime: (() => {
|
|
let call = 0;
|
|
return () => {
|
|
call += 1;
|
|
return recordingRuntime({
|
|
ensureInputs,
|
|
terminalStatus: call === 1 ? "failed" : "completed",
|
|
}) as never;
|
|
};
|
|
})(),
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
const second = await execute({
|
|
runId: "run-b",
|
|
runtime: { sessionParams: first.sessionParams },
|
|
...base,
|
|
} as never);
|
|
|
|
expect(first.exitCode).toBe(1);
|
|
expect(second.exitCode).toBe(0);
|
|
// A failed turn discards the staged runtime, so the next run stages fresh
|
|
// instead of reusing a torn-down session's staged credentials.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
// Greptile P1 "Cache Reuse Bypasses Session Compatibility": a fresh invocation
|
|
// that shares company/agent/task/fingerprint (hence sessionKey) with a prior
|
|
// run but carries NO sessionParams starts a new ACP session — it must NOT
|
|
// inherit the prior session's staged workspace + managed home.
|
|
it("test_acp_reuse_requires_compatible_resume_not_just_session_key", async () => {
|
|
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
let seamCalls = 0;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
seamCalls += 1;
|
|
return { stagedRuntime: await input.stage([]) };
|
|
},
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
// Same config (identical sessionKey) but sessionParams cleared → this is a
|
|
// NEW session, not a resume of A. The old code reused A's staged runtime on a
|
|
// bare sessionKey hit; the compatibility gate now forces a fresh stage.
|
|
const second = await execute({ runId: "run-b", runtime: {}, ...base } as never);
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
// Staged (and re-seeded the managed home) fresh for the new session — no
|
|
// silent inheritance of the prior session's staged credentials.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
|
|
expect(seamCalls).toBe(2);
|
|
// B binds a fresh session/new (no resumeSessionId), it does not resume A.
|
|
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
|
|
expect(ensureInputs[1]?.resumeSessionId).toBeUndefined();
|
|
});
|
|
|
|
// Greptile P1 "Teardown Invalidates Cached Runtime": the per-run copy-back must
|
|
// fire on every run (incl. a reused resume) while the one-time host staged-temp
|
|
// cleanup must NOT fire between clean runs — otherwise the reused staged runtime
|
|
// would be invalidated before the next resume.
|
|
it("test_reused_resume_copies_back_per_run_without_disposing_staged_temp", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
let teardownCalls = 0;
|
|
let disposeCalls = 0;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const stagedRuntime = await input.stage([]);
|
|
return {
|
|
stagedRuntime,
|
|
teardown: async () => {
|
|
teardownCalls += 1;
|
|
return { ok: true };
|
|
},
|
|
disposeStaged: async () => {
|
|
disposeCalls += 1;
|
|
},
|
|
};
|
|
},
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
const second = await execute({
|
|
runId: "run-b",
|
|
runtime: { sessionParams: first.sessionParams },
|
|
...base,
|
|
} as never);
|
|
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
// Staged once, reused on the compatible resume.
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
|
|
// Per-run copy-back fired on BOTH runs — cadence unchanged.
|
|
expect(teardownCalls).toBe(2);
|
|
// The staged temp was never disposed while the entry stayed warm for reuse,
|
|
// so the resume found its staged home intact.
|
|
expect(disposeCalls).toBe(0);
|
|
expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId);
|
|
});
|
|
|
|
// The one-time dispose DOES fire when the staged runtime is actually dropped
|
|
// (here: a failed turn), releasing the host staged-temp — the copy-back also
|
|
// still fires on the failure path.
|
|
it("test_dropped_staged_runtime_disposes_host_temp", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
let teardownCalls = 0;
|
|
let disposeCalls = 0;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs, terminalStatus: "failed" }) as never,
|
|
prepareRemoteManagedHome: async (input) => ({
|
|
stagedRuntime: await input.stage([]),
|
|
teardown: async () => {
|
|
teardownCalls += 1;
|
|
return { ok: true };
|
|
},
|
|
disposeStaged: async () => {
|
|
disposeCalls += 1;
|
|
},
|
|
}),
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
const result = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
// Failed turn → staged runtime dropped → host staged-temp disposed once, and
|
|
// the per-run copy-back still fired.
|
|
expect(teardownCalls).toBe(1);
|
|
expect(disposeCalls).toBe(1);
|
|
});
|
|
|
|
it("test_idle_staged_runtime_cleanup_waits_for_active_turn_release", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const events: string[] = [];
|
|
let currentNow = 0;
|
|
let releaseTurn!: () => void;
|
|
let signalTurnStarted!: () => void;
|
|
const turnStarted = new Promise<void>((resolve) => {
|
|
signalTurnStarted = resolve;
|
|
});
|
|
const turnCompleted = new Promise<void>((resolve) => {
|
|
releaseTurn = resolve;
|
|
});
|
|
const execute = createAcpxEngineExecutor({
|
|
now: () => currentNow,
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
stagingLocks: new Map(),
|
|
createRuntime: (() => {
|
|
let call = 0;
|
|
return () => {
|
|
call += 1;
|
|
return {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => {
|
|
if (call === 2) signalTurnStarted();
|
|
return {
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result:
|
|
call === 2
|
|
? turnCompleted.then(() => ({ status: "completed", stopReason: "end_turn" }))
|
|
: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
};
|
|
},
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
} as never;
|
|
};
|
|
})(),
|
|
prepareRemoteManagedHome: async (input) => {
|
|
events.push(`stage:${input.runId}`);
|
|
return {
|
|
stagedRuntime: await input.stage([]),
|
|
disposeStaged: async () => {
|
|
events.push(`dispose:${input.runId}`);
|
|
},
|
|
};
|
|
},
|
|
});
|
|
const base = baseExecuteArgs({
|
|
stateDir,
|
|
localCwd,
|
|
executionTarget,
|
|
env: { SESSION_MARKER: "idle-eviction" },
|
|
});
|
|
|
|
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
expect(first.exitCode).toBe(0);
|
|
|
|
const second = execute({
|
|
runId: "run-b",
|
|
runtime: { sessionParams: first.sessionParams },
|
|
...base,
|
|
} as never);
|
|
await turnStarted;
|
|
currentNow = 10_000;
|
|
const third = execute({ runId: "run-c", runtime: {}, ...base } as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
expect(events).toEqual(["stage:run-a"]);
|
|
|
|
releaseTurn();
|
|
const [resultB, resultC] = await Promise.all([second, third]);
|
|
|
|
expect(resultB.exitCode).toBe(0);
|
|
expect(resultC.exitCode).toBe(0);
|
|
expect(events).toEqual(["stage:run-a", "dispose:run-a", "stage:run-c"]);
|
|
});
|
|
|
|
// Superseding an incompatible session that collides on sessionKey re-stages
|
|
// fresh AND releases the superseded entry's host staged-temp (no leak, no
|
|
// reuse of the old session's staged credentials).
|
|
it("test_incompatible_restage_disposes_superseded_staged_temp", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
const disposedRunIds: string[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
prepareRemoteManagedHome: async (input) => ({
|
|
stagedRuntime: await input.stage([]),
|
|
disposeStaged: async () => {
|
|
disposedRunIds.push(input.runId);
|
|
},
|
|
}),
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
// Run A completes cleanly and caches its staged runtime.
|
|
await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
// Run B: same sessionKey, no sessionParams → not a compatible resume. It must
|
|
// drop + dispose A's superseded staged entry, then stage fresh.
|
|
await execute({ runId: "run-b", runtime: {}, ...base } as never);
|
|
|
|
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
|
|
// A's staged temp was disposed when B superseded it.
|
|
expect(disposedRunIds).toContain("run-a");
|
|
});
|
|
|
|
// F5 leak window 1: a seam that throws AFTER a successful stage() must not leave
|
|
// the fresh in-sandbox runtime behind. The engine records the staged result in
|
|
// the stage wrapper and disposes it when the seam throws.
|
|
it("test_seam_throw_after_stage_disposes_fresh_staged_runtime", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const stagedRuntimes = new Map();
|
|
let capturedRuntimeRoot: string | null = null;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes,
|
|
createRuntime: () => recordingRuntime({ ensureInputs: [] }) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const staged = await input.stage([]);
|
|
capturedRuntimeRoot = staged.runtimeRootDir;
|
|
// The seam fails AFTER a successful stage (e.g. an in-sandbox config
|
|
// materialize step throws). The fresh in-sandbox runtime is already shipped.
|
|
throw new Error("seam failed after stage");
|
|
},
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
await expect(
|
|
execute({ runId: "run-a", runtime: {}, ...base } as never),
|
|
).rejects.toThrow("seam failed after stage");
|
|
|
|
// The stage shipped a real in-sandbox runtime root (local runner = host FS)...
|
|
expect(typeof capturedRuntimeRoot).toBe("string");
|
|
// ...and the seam throw disposed it, so the abandoned managed home is gone.
|
|
expect(await pathExists(capturedRuntimeRoot!)).toBe(false);
|
|
// The failed fresh stage was never cached for reuse.
|
|
expect(stagedRuntimes.size).toBe(0);
|
|
});
|
|
|
|
// F5 leak window 2: when a bridge fails during bring-up on a resume that BORROWED
|
|
// the cached staged runtime, the rollback must remove the borrowed cache entry
|
|
// through the same identity guard `discardStagedRuntime` uses — else a later
|
|
// resume reuses an entry whose host staged-temp was already disposed.
|
|
it("test_partial_bridge_rollback_removes_borrowed_staged_entry", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
const stagedRuntimes = new Map();
|
|
let disposeCalls = 0;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes,
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
prepareRemoteManagedHome: async (input) => ({
|
|
stagedRuntime: await input.stage([]),
|
|
disposeStaged: async () => {
|
|
disposeCalls += 1;
|
|
},
|
|
}),
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
expect(first.exitCode).toBe(0);
|
|
// The clean turn cached the staged runtime for reuse; nothing disposed yet.
|
|
expect(stagedRuntimes.size).toBe(1);
|
|
expect(disposeCalls).toBe(0);
|
|
|
|
// Run B resumes the same session and borrows the cached staged runtime, but a
|
|
// bridge fails during bring-up.
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
|
|
throw new Error("paperclip bridge boom");
|
|
});
|
|
await expect(
|
|
execute({ runId: "run-b", runtime: { sessionParams: first.sessionParams }, ...base } as never),
|
|
).rejects.toThrow("paperclip bridge boom");
|
|
|
|
// The rollback removed the borrowed cache entry through the identity guard, so a
|
|
// later resume can never reuse an entry whose host staged-temp was disposed...
|
|
expect(stagedRuntimes.size).toBe(0);
|
|
// ...and it fired that entry's shared idempotent dispose exactly once.
|
|
expect(disposeCalls).toBe(1);
|
|
});
|
|
|
|
// Greptile P1 "Concurrent Runs Corrupt Cache Ownership": two overlapping runs
|
|
// of the same session key must not ship into the same remote workspace at once.
|
|
// The per-key staging lock serializes the stage-or-reuse section, so their
|
|
// staging windows never overlap.
|
|
it("test_concurrent_same_session_staging_is_serialized", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const ensureInputs: Array<Record<string, unknown>> = [];
|
|
const events: string[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
stagingLocks: new Map(),
|
|
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
events.push(`enter:${input.runId}`);
|
|
// Yield to the event loop so an unserialized second run would interleave
|
|
// its own enter here before we finish staging.
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
const stagedRuntime = await input.stage([]);
|
|
events.push(`exit:${input.runId}`);
|
|
return { stagedRuntime };
|
|
},
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
// Both runs share the sessionKey (identical config) and start concurrently.
|
|
const [a, b] = await Promise.all([
|
|
execute({ runId: "run-a", runtime: {}, ...base } as never),
|
|
execute({ runId: "run-b", runtime: {}, ...base } as never),
|
|
]);
|
|
|
|
expect(a.exitCode).toBe(0);
|
|
expect(b.exitCode).toBe(0);
|
|
// Each staging window is a matched enter/exit pair with no interleaving — the
|
|
// lock serialized them (never enter,enter,...,exit,exit).
|
|
expect(events).toHaveLength(4);
|
|
expect(events[0]).toMatch(/^enter:/);
|
|
expect(events[1]).toBe(`exit:${events[0]!.slice("enter:".length)}`);
|
|
expect(events[2]).toMatch(/^enter:/);
|
|
expect(events[3]).toBe(`exit:${events[2]!.slice("enter:".length)}`);
|
|
});
|
|
|
|
// Greptile P1 "Lock Ends Before Use": a same-session re-stage must wait for
|
|
// the prior run's active turn and cleanup to finish before it can touch the
|
|
// staged remote workspace again.
|
|
it("test_concurrent_same_session_staging_waits_for_active_turn_cleanup", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const events: string[] = [];
|
|
let releaseTurn!: () => void;
|
|
let signalTurnStarted!: () => void;
|
|
const turnStarted = new Promise<void>((resolve) => {
|
|
signalTurnStarted = resolve;
|
|
});
|
|
const turnCompleted = new Promise<void>((resolve) => {
|
|
releaseTurn = resolve;
|
|
});
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
stagingLocks: new Map(),
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => {
|
|
signalTurnStarted();
|
|
return {
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: turnCompleted.then(() => ({ status: "completed", stopReason: "end_turn" })),
|
|
cancel: async () => {},
|
|
};
|
|
},
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
}) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
events.push(`enter:${input.runId}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
const stagedRuntime = await input.stage([]);
|
|
events.push(`exit:${input.runId}`);
|
|
return { stagedRuntime };
|
|
},
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
const runA = execute({ runId: "run-a", runtime: {}, ...base } as never);
|
|
await turnStarted;
|
|
const runB = execute({ runId: "run-b", runtime: {}, ...base } as never);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(events).not.toContain("enter:run-b");
|
|
|
|
releaseTurn();
|
|
await runA;
|
|
events.push("run-a-finished");
|
|
await runB;
|
|
|
|
expect(events).toContain("enter:run-b");
|
|
expect(events.indexOf("enter:run-b")).toBeGreaterThan(events.indexOf("run-a-finished"));
|
|
});
|
|
|
|
// The per-session lease must be released when a run is abandoned before it
|
|
// reaches the executor's cleanup (e.g. staging or a bridge fails to start),
|
|
// otherwise the next run of the same session waits on the lease forever. Here
|
|
// the first run's staging throws; the second run of the same session must
|
|
// still acquire the lease and stage instead of deadlocking.
|
|
it("test_failed_staging_releases_lease_so_next_same_session_run_proceeds", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const events: string[] = [];
|
|
let failNextStaging = true;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
stagingLocks: new Map(),
|
|
createRuntime: () => ({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
}) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
events.push(`enter:${input.runId}`);
|
|
if (failNextStaging) {
|
|
failNextStaging = false;
|
|
throw new Error("staging boom");
|
|
}
|
|
const stagedRuntime = await input.stage([]);
|
|
events.push(`exit:${input.runId}`);
|
|
return { stagedRuntime };
|
|
},
|
|
});
|
|
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
|
|
|
|
await expect(execute({ runId: "run-a", runtime: {}, ...base } as never)).rejects.toThrow(
|
|
"staging boom",
|
|
);
|
|
// If the failed run had stranded its lease, this second same-session run
|
|
// would hang on it and the test would time out.
|
|
const resultB = await execute({ runId: "run-b", runtime: {}, ...base } as never);
|
|
|
|
expect(resultB.exitCode).toBe(0);
|
|
expect(events).toContain("enter:run-b");
|
|
expect(events).toContain("exit:run-b");
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function remoteSandboxTarget(root: string) {
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
return {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
// A plugin-backed key (not a built-in family). It must never ride a span
|
|
// as a raw attribute.
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
}
|
|
|
|
it("emits one root with startup and nested workspace boundaries", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
await runExecutor(
|
|
{
|
|
agent: "codex",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
env: { CODEX_HOME: codexHome },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext },
|
|
);
|
|
|
|
// Exactly one trace root, and it is the run root span.
|
|
const roots = spans.filter((span) => span.parent === null);
|
|
expect(roots).toHaveLength(1);
|
|
const runRootSpan = roots[0]!;
|
|
expect(runRootSpan.name).toBe("task.run");
|
|
expect(runRootSpan.ended).toBe(true);
|
|
expect(spans.filter((span) => span.name === "task.run")).toHaveLength(1);
|
|
expect(runRootSpan.parentContextArg).toBeUndefined();
|
|
expect(runRootSpan.endCalls).toBe(1);
|
|
expect(runRootSpan.status).toBeNull();
|
|
expect(runRootSpan.attributes["paperclip.task.run.run_id"]).toMatch(/^[0-9a-f]{12}$/);
|
|
expect(String(runRootSpan.attributes["paperclip.task.run.run_id"])).not.toContain("run-");
|
|
|
|
// The sandbox bring-up span parents to the run root span.
|
|
const startupSpan = spans.find((span) => span.name === "sandbox.startup");
|
|
expect(startupSpan).toBeTruthy();
|
|
expect(startupSpan!.parentContextArg).toEqual({ span: runRootSpan });
|
|
expect(startupSpan!.parent).toBe(runRootSpan);
|
|
expect(startupSpan!.ended).toBe(true);
|
|
|
|
// The agent turn span is a sibling of the bring-up span: it parents to the
|
|
// run root span, not to the bring-up span.
|
|
const turnSpan = spans.find((span) => span.name === "agent.turn");
|
|
expect(turnSpan).toBeTruthy();
|
|
expect(spans.filter((span) => span.name === "agent.turn")).toHaveLength(1);
|
|
expect(turnSpan!.parentContextArg).toEqual({ span: runRootSpan });
|
|
expect(turnSpan!.parent).toBe(runRootSpan);
|
|
expect(turnSpan!.ended).toBe(true);
|
|
expect(turnSpan!.endCalls).toBe(1);
|
|
expect(turnSpan!.status).toBeNull();
|
|
|
|
// The settlement sync-back opens the `sandbox.syncBack` span. It runs at
|
|
// teardown, so it parents to the run root span, not to the bring-up span.
|
|
const syncBackSpan = spans.find((span) => span.name === "sandbox.syncBack");
|
|
expect(syncBackSpan).toBeTruthy();
|
|
expect(syncBackSpan!.parent).toBe(runRootSpan);
|
|
expect(syncBackSpan!.ended).toBe(true);
|
|
|
|
const childNames = spans
|
|
.filter(
|
|
(span) =>
|
|
span !== runRootSpan &&
|
|
span !== startupSpan &&
|
|
span !== turnSpan &&
|
|
span !== syncBackSpan,
|
|
)
|
|
.map((span) => span.name)
|
|
.sort();
|
|
expect(childNames).toEqual(
|
|
[
|
|
"acp.handshake",
|
|
"bridge.paperclip",
|
|
"bridge.process-session",
|
|
"codex-home.seed",
|
|
"pack",
|
|
"skills.reconcile",
|
|
"snapshot.baseline",
|
|
"snapshot.git",
|
|
"stage.sync",
|
|
"stage.workspace",
|
|
"workspace.ignore_rules",
|
|
"workspace.inbound_barrier",
|
|
"workspace.overlay_archive",
|
|
"workspace.progress",
|
|
"workspace.progress",
|
|
"workspace.repositories",
|
|
"workspace.resolve",
|
|
"workspace.source_root",
|
|
"workspace.transfer",
|
|
],
|
|
);
|
|
|
|
const stageSyncSpan = spans.find((span) => span.name === "stage.sync");
|
|
const stageWorkspaceSpan = spans.find((span) => span.name === "stage.workspace");
|
|
const inboundSpan = spans.find((span) => span.name === "workspace.inbound_barrier");
|
|
const packSpan = spans.find((span) => span.name === "pack");
|
|
const snapshotGitSpan = spans.find((span) => span.name === "snapshot.git");
|
|
const snapshotBaselineSpan = spans.find((span) => span.name === "snapshot.baseline");
|
|
expect(stageSyncSpan).toBeTruthy();
|
|
expect(stageWorkspaceSpan).toBeTruthy();
|
|
expect(packSpan).toBeTruthy();
|
|
expect(snapshotGitSpan).toBeTruthy();
|
|
expect(snapshotBaselineSpan).toBeTruthy();
|
|
expect(stageWorkspaceSpan!.parent).toBe(inboundSpan);
|
|
expect(packSpan!.parent).toBe(stageWorkspaceSpan);
|
|
expect(snapshotGitSpan!.parent).toBe(stageSyncSpan);
|
|
expect(snapshotBaselineSpan!.parent).toBe(stageSyncSpan);
|
|
expect(packSpan!.ended).toBe(true);
|
|
|
|
const nonStartupChildren = new Set([
|
|
packSpan,
|
|
stageWorkspaceSpan,
|
|
snapshotGitSpan,
|
|
snapshotBaselineSpan,
|
|
syncBackSpan,
|
|
]);
|
|
for (const [name, parent] of [
|
|
["workspace.source_root", stageSyncSpan], ["workspace.repositories", stageSyncSpan],
|
|
["workspace.ignore_rules", stageSyncSpan], ["workspace.inbound_barrier", stageSyncSpan],
|
|
["workspace.overlay_archive", packSpan], ["workspace.transfer", stageWorkspaceSpan],
|
|
] as const) {
|
|
const span = spans.find(span => span.name === name);
|
|
expect(span?.parent, name).toBe(parent);
|
|
expect(span?.ended, name).toBe(true);
|
|
expect(span?.endCalls, name).toBe(1);
|
|
nonStartupChildren.add(span);
|
|
}
|
|
const progress = spans.filter(span => span.name === "workspace.progress");
|
|
expect(progress.map(span => span.parent)).toEqual([packSpan, stageWorkspaceSpan]);
|
|
for (const span of progress) {
|
|
expect(span.endCalls).toBe(1);
|
|
nonStartupChildren.add(span);
|
|
}
|
|
for (const span of spans) {
|
|
if (span === runRootSpan || span === startupSpan || span === turnSpan) continue;
|
|
if (nonStartupChildren.has(span)) continue;
|
|
expect(span.parent, `span "${span.name}" must parent to the startup span`).toBe(startupSpan);
|
|
expect(span.ended, `span "${span.name}" must end`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("test_run_parent_getter_tracks_task_run_then_turn", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
// Sample `getRuntimeParentContext` at three points of the run: at runtime
|
|
// construction (startup), inside `startTurn` (turn), and after the executor
|
|
// resolves (post-turn). The run publishes the current-run parent token
|
|
// through `runtimeOptions.getRuntimeParentContext`, so a fake runtime reads
|
|
// it to prove the holder tracks `task.run`, then `agent.turn`, then
|
|
// `task.run` again.
|
|
let startupToken: unknown;
|
|
let turnToken: unknown;
|
|
let capturedGetter: (() => unknown) | undefined;
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: (options) => {
|
|
const opts = options as unknown as { getRuntimeParentContext?: () => unknown };
|
|
// Keep the getter for the post-turn sample after the run resolves.
|
|
capturedGetter = opts.getRuntimeParentContext;
|
|
// Startup phase: the holder is the `task.run` token here.
|
|
startupToken = opts.getRuntimeParentContext?.();
|
|
const runtime = buildRuntime();
|
|
return {
|
|
...runtime,
|
|
startTurn: (input: Record<string, unknown>) => {
|
|
// Turn phase: the holder is the `agent.turn` token here.
|
|
turnToken = opts.getRuntimeParentContext?.();
|
|
return (runtime.startTurn as (input: unknown) => unknown)(input);
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-getter",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "codex",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
env: { CODEX_HOME: codexHome },
|
|
},
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
startupTraceContext: traceContext,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
// Post-turn phase: the holder is the `task.run` token again.
|
|
const postToken = capturedGetter?.();
|
|
|
|
const taskRunSpan = spans.find((span) => span.name === "task.run");
|
|
expect(taskRunSpan).toBeTruthy();
|
|
const agentTurnSpan = spans.find((span) => span.name === "agent.turn");
|
|
expect(agentTurnSpan).toBeTruthy();
|
|
|
|
// The recorder builds a parent-context token as `{ span }`. So the getter
|
|
// returns the `task.run` token during startup, the `agent.turn` token during
|
|
// the turn, and the `task.run` token after the turn.
|
|
expect(startupToken).toEqual({ span: taskRunSpan });
|
|
expect(turnToken).toEqual({ span: agentTurnSpan });
|
|
expect(postToken).toEqual({ span: taskRunSpan });
|
|
});
|
|
|
|
it("records root wall / work / diff times and the bounded context on the root span", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
// A plugin-backed target with a lease id. The provider clamps to `plugin`
|
|
// and the lease id rides only as a hash.
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
leaseId: "lease-super-secret-internal-id",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext },
|
|
);
|
|
|
|
const rootSpan = spans.find((span) => span.name === "sandbox.startup");
|
|
expect(rootSpan).toBeTruthy();
|
|
// The three timing numbers are present, finite, and non-negative.
|
|
for (const key of [A.rootWallMs, A.rootWorkMs, A.rootDiffMs]) {
|
|
expect(typeof rootSpan!.attributes[key], `attribute ${key}`).toBe("number");
|
|
expect(Number.isFinite(rootSpan!.attributes[key] as number)).toBe(true);
|
|
}
|
|
expect(rootSpan!.attributes[A.rootWorkMs] as number).toBeGreaterThanOrEqual(0);
|
|
// Missing lease/process evidence must not manufacture a cold label.
|
|
expect(rootSpan!.attributes).not.toHaveProperty(A.coldStart);
|
|
expect(rootSpan!.attributes[A.warmth]).toBe("unknown");
|
|
expect(rootSpan!.attributes[A.provider]).toBe("plugin");
|
|
// The lease id rides only as a non-reversible hash, never the raw value.
|
|
expect(rootSpan!.attributes[A.leaseId]).toMatch(/^[0-9a-f]{12}$/);
|
|
expect(String(rootSpan!.attributes[A.leaseId])).not.toContain("secret");
|
|
// The absent region, image id, and sandbox id set no attribute (fail open).
|
|
expect(rootSpan!.attributes).not.toHaveProperty(A.region);
|
|
expect(rootSpan!.attributes).not.toHaveProperty(A.imageId);
|
|
expect(rootSpan!.attributes).not.toHaveProperty(A.sandboxId);
|
|
});
|
|
|
|
it("excludes the nested skills.reconcile wall time from the root work sum (no double count)", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
// A codex bring-up runs the codex-home.seed step, which nests skills.reconcile.
|
|
const { events, sessionInputs } = await runExecutor(
|
|
{
|
|
agent: "codex",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
env: { CODEX_HOME: codexHome, OPENAI_API_KEY: "sk-acp-test-key" },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext },
|
|
);
|
|
|
|
const stepEvents = events.filter((event) => event.eventType === "run.startup.step");
|
|
// The nested skills.reconcile step still emits its own boundary event.
|
|
const reconcile = stepEvents.find((event) => event.payload?.step === "skills.reconcile");
|
|
expect(reconcile, "skills.reconcile must still emit its own step event").toBeTruthy();
|
|
|
|
// The root work sum is the sum of the top-level step walls only. The nested
|
|
// skills.reconcile wall sits inside the codex-home.seed wall, so it must not
|
|
// ride the sum a second time. The sum of every step wall except
|
|
// skills.reconcile equals the recorded root work sum exactly; each step
|
|
// reports the same duration to the event and to the root accumulator.
|
|
const sumExceptReconcile = stepEvents
|
|
.filter((event) => event.payload?.step !== "skills.reconcile")
|
|
.reduce((total, event) => total + (event.payload?.durationMs as number), 0);
|
|
|
|
const rootSpan = spans.find((span) => span.name === "sandbox.startup");
|
|
expect(rootSpan).toBeTruthy();
|
|
expect(rootSpan!.attributes[A.rootWorkMs]).toBe(sumExceptReconcile);
|
|
});
|
|
|
|
it("keeps every span attribute inside the closed allowlist (no command/path/id keys)", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext },
|
|
);
|
|
|
|
expect(spans.length).toBeGreaterThan(0);
|
|
for (const span of spans) {
|
|
// The run root span and the agent turn span each use their own closed
|
|
// allowlist; every other span uses the sandbox startup allowlist.
|
|
const allowed =
|
|
span.name === "task.run"
|
|
? ALLOWED_RUN_SPAN_ATTRIBUTE_KEYS
|
|
: span.name === "agent.turn"
|
|
? ALLOWED_TURN_SPAN_ATTRIBUTE_KEYS
|
|
: ALLOWED_STARTUP_SPAN_ATTRIBUTE_KEYS;
|
|
for (const [key, value] of Object.entries(span.attributes)) {
|
|
expect(
|
|
allowed.has(key),
|
|
`span "${span.name}" set a non-allowlisted attribute "${key}"`,
|
|
).toBe(true);
|
|
// No non-finite numeric attribute (no NaN, no Infinity).
|
|
if (typeof value === "number") {
|
|
expect(Number.isFinite(value), `attribute "${key}" must be finite`).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
const rootSpan = spans.find((span) => span.name === "sandbox.startup");
|
|
expect(rootSpan).toBeTruthy();
|
|
const paperclip = spans.find((span) => span.name === "bridge.paperclip");
|
|
const processSession = spans.find((span) => span.name === "bridge.process-session");
|
|
expect(paperclip?.parent).toBe(rootSpan);
|
|
expect(processSession?.parent).toBe(rootSpan);
|
|
// Both bridge spans carry the same batch tag, so the trace marks them as one
|
|
// parallel batch.
|
|
expect(paperclip?.attributes[A.batch]).toBe("bridge");
|
|
expect(processSession?.attributes[A.batch]).toBe("bridge");
|
|
expect(paperclip?.attributes[A.batch]).toBe(processSession?.attributes[A.batch]);
|
|
|
|
const handshake = spans.find((span) => span.name === "acp.handshake");
|
|
expect(handshake).toBeTruthy();
|
|
// A cold start records both sub-times as finite float ms on the span.
|
|
expect(typeof handshake!.attributes[A.handshakeCreateRuntimeWallMs]).toBe("number");
|
|
expect(handshake!.attributes[A.handshakeCreateRuntimeWallMs] as number).toBeGreaterThanOrEqual(0);
|
|
expect(typeof handshake!.attributes[A.handshakeEnsureSessionWallMs]).toBe("number");
|
|
expect(handshake!.attributes[A.handshakeEnsureSessionWallMs] as number).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("closes the root span with error status when the bring-up handshake fails", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => {
|
|
throw new Error("handshake boom");
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "failed" }),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-handshake-fail",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
startupTraceContext: traceContext,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
const rootSpan = spans.find((span) => span.name === "sandbox.startup");
|
|
expect(rootSpan).toBeTruthy();
|
|
expect(rootSpan!.ended).toBe(true);
|
|
// `2` is `SpanStatusCode.ERROR`.
|
|
expect(rootSpan!.status?.code).toBe(2);
|
|
});
|
|
|
|
it("opens no span and does not throw when no trace context is injected", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
|
|
// runExecutor asserts exitCode 0 internally; the run must complete with no
|
|
// injected trace context (the default no-op path).
|
|
const { result } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
});
|
|
|
|
it("opens no span for a local run, even when a trace context is injected", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
// A local run has no execution target. The `sandbox.startup` span names a
|
|
// sandbox bring-up, so a local run must stay out of sandbox telemetry.
|
|
const { result } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", startupTraceContext: traceContext },
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(spans).toHaveLength(0);
|
|
});
|
|
|
|
it("opens no span for an SSH run, even when a trace context is injected", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
// An SSH run is remote but is not a sandbox, so it also stays out of sandbox
|
|
// telemetry.
|
|
const { result } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{
|
|
authToken: "real-run-jwt",
|
|
executionTarget: { kind: "remote", transport: "ssh", remoteCwd },
|
|
startupTraceContext: traceContext,
|
|
},
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(spans).toHaveLength(0);
|
|
});
|
|
|
|
it("test_full_trace_tree_parents_correctly", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
// Drive one remote-sandbox run and issue an exec at four points of the run.
|
|
// Each exec issues through the same host-to-sandbox seam that the run uses,
|
|
// so the recorder captures the exact parent of each exec span. Each point
|
|
// fires exactly once, so the run records exactly four `sandbox.exec` spans.
|
|
let rootRegionExecFired = false;
|
|
let syncExecFired = false;
|
|
let turnExecFired = false;
|
|
|
|
// A run-scoped getter holder. The engine publishes the current-run parent
|
|
// token through `runtimeOptions.getRuntimeParentContext`. A detached site
|
|
// reads it per unit of work and wraps the work in `runWithRuntimeParent`.
|
|
let getRuntimeParentContext: (() => unknown) | undefined;
|
|
|
|
const execute = createAcpxEngineExecutor({
|
|
prepareRemoteManagedHome: async (input) => {
|
|
// Point 2: an exec inside the measured `stage.sync` step body. The step
|
|
// overrides the runtime-parent store, so this exec parents to the sync
|
|
// step span, not to `sandbox.startup`.
|
|
if (!syncExecFired) {
|
|
syncExecFired = true;
|
|
issueSandboxExecFromStore(traceContext);
|
|
}
|
|
const stagedRuntime = await input.stage([]);
|
|
return { stagedRuntime };
|
|
},
|
|
createRuntime: (options) => {
|
|
const opts = options as unknown as {
|
|
getRuntimeParentContext?: () => unknown;
|
|
};
|
|
getRuntimeParentContext = opts.getRuntimeParentContext;
|
|
const runtime = buildRuntime();
|
|
return {
|
|
...runtime,
|
|
startTurn: (input: Record<string, unknown>) => {
|
|
// Point 3: a detached exec during the turn. The detached site reads
|
|
// the getter (the `agent.turn` token here) and wraps the work in
|
|
// `runWithRuntimeParent`, so the exec parents to `agent.turn`.
|
|
if (!turnExecFired) {
|
|
turnExecFired = true;
|
|
runWithRuntimeParent(opts.getRuntimeParentContext?.(), () =>
|
|
issueSandboxExecFromStore(traceContext),
|
|
);
|
|
}
|
|
return (runtime.startTurn as (input: unknown) => unknown)(input);
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
|
|
const runtimeMcp = {
|
|
getServers: () => {
|
|
// Point 1: a root-region exec. `getServers` runs inside the bring-up,
|
|
// after `sandbox.startup` opens and outside every measured step, so the
|
|
// store holds the `sandbox.startup` context and this exec parents to it.
|
|
if (!rootRegionExecFired) {
|
|
rootRegionExecFired = true;
|
|
issueSandboxExecFromStore(traceContext);
|
|
}
|
|
return [];
|
|
},
|
|
};
|
|
|
|
const result = await execute({
|
|
runId: "run-e2e-tree",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "codex",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
env: { CODEX_HOME: codexHome },
|
|
},
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
runtimeMcp: runtimeMcp as never,
|
|
startupTraceContext: traceContext,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
// Point 4: a detached exec after the turn. The getter returns the `task.run`
|
|
// token now, so an off-turn detached exec parents to `task.run`.
|
|
runWithRuntimeParent(getRuntimeParentContext?.(), () =>
|
|
issueSandboxExecFromStore(traceContext),
|
|
);
|
|
|
|
// The four framing spans of the run.
|
|
const runSpan = spans.find((span) => span.name === "task.run");
|
|
expect(runSpan).toBeTruthy();
|
|
const startupSpan = spans.find((span) => span.name === "sandbox.startup");
|
|
expect(startupSpan).toBeTruthy();
|
|
const turnSpan = spans.find((span) => span.name === "agent.turn");
|
|
expect(turnSpan).toBeTruthy();
|
|
const syncStepSpan = spans.find((span) => span.name === "stage.sync");
|
|
expect(syncStepSpan).toBeTruthy();
|
|
|
|
// `task.run` is the trace root. `sandbox.startup` and `agent.turn` are its
|
|
// direct children.
|
|
expect(runSpan!.parent).toBeNull();
|
|
expect(startupSpan!.parent).toBe(runSpan);
|
|
expect(turnSpan!.parent).toBe(runSpan);
|
|
|
|
// The run records exactly the four injected exec spans.
|
|
const execSpans = spans.filter((span) => span.name === "sandbox.exec");
|
|
expect(execSpans).toHaveLength(4);
|
|
|
|
// A root-region exec parents to `sandbox.startup`.
|
|
const rootRegionExec = execSpans.find((span) => span.parent === startupSpan);
|
|
expect(rootRegionExec, "a root-region exec must parent to sandbox.startup").toBeTruthy();
|
|
expect(rootRegionExec!.parentContextArg).toEqual({ span: startupSpan });
|
|
|
|
// A `stage.sync` body exec parents to the sync step, not to `sandbox.startup`.
|
|
const syncExec = execSpans.find((span) => span.parent === syncStepSpan);
|
|
expect(syncExec, "a stage.sync exec must parent to the sync step").toBeTruthy();
|
|
expect(syncExec!.parentContextArg).toEqual({ span: syncStepSpan });
|
|
expect(syncExec!.parent).not.toBe(startupSpan);
|
|
|
|
// A turn detached exec parents to `agent.turn`.
|
|
const turnExec = execSpans.find((span) => span.parent === turnSpan);
|
|
expect(turnExec, "a turn detached exec must parent to agent.turn").toBeTruthy();
|
|
|
|
// An off-turn detached exec parents to `task.run`.
|
|
const offTurnExec = execSpans.find((span) => span.parent === runSpan);
|
|
expect(offTurnExec, "an off-turn detached exec must parent to task.run").toBeTruthy();
|
|
|
|
// The four execs are distinct spans with four distinct parents.
|
|
const execParents = new Set([rootRegionExec, syncExec, turnExec, offTurnExec]);
|
|
expect(execParents.size).toBe(4);
|
|
});
|
|
|
|
it("test_sync_back_runs_inside_sandbox_syncback_span", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
// The settlement `syncBack` step runs the managed-home teardown. The teardown
|
|
// issues one exec through the same host-to-sandbox seam the run uses. The
|
|
// recorder captures the exec parent, so the test proves the teardown runs
|
|
// inside the run-parented `sandbox.syncBack` span.
|
|
let teardownExecFired = false;
|
|
const execute = createAcpxEngineExecutor({
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const stagedRuntime = await input.stage([]);
|
|
return {
|
|
stagedRuntime,
|
|
teardown: async () => {
|
|
if (!teardownExecFired) {
|
|
teardownExecFired = true;
|
|
issueSandboxExecFromStore(traceContext);
|
|
}
|
|
return { ok: true };
|
|
},
|
|
};
|
|
},
|
|
createRuntime: () => buildRuntime() as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-sync-back-span",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "codex",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
env: { CODEX_HOME: codexHome },
|
|
},
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
startupTraceContext: traceContext,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(teardownExecFired).toBe(true);
|
|
|
|
// One `sandbox.syncBack` span opens, ends, and parents to `task.run`. The
|
|
// settlement runs the teardown after the turn, so the run parent is
|
|
// `task.run` at that time.
|
|
const runSpan = spans.find((span) => span.name === "task.run");
|
|
expect(runSpan).toBeTruthy();
|
|
const syncBackSpans = spans.filter((span) => span.name === "sandbox.syncBack");
|
|
expect(syncBackSpans).toHaveLength(1);
|
|
const syncBackSpan = syncBackSpans[0]!;
|
|
expect(syncBackSpan.ended).toBe(true);
|
|
expect(syncBackSpan.parent).toBe(runSpan);
|
|
|
|
// The teardown exec parents to `sandbox.syncBack`, so the sync-back copy-back
|
|
// runs inside the span, not unparented at teardown.
|
|
const syncBackExec = spans.find(
|
|
(span) => span.name === "sandbox.exec" && span.parent === syncBackSpan,
|
|
);
|
|
expect(syncBackExec, "the teardown exec must parent to sandbox.syncBack").toBeTruthy();
|
|
});
|
|
|
|
it("test_no_sandbox_exec_parents_to_http_root", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
const executionTarget = await remoteSandboxTarget(root);
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
|
|
// Open a top-level HTTP request span before the run. In production the run
|
|
// opens under the HTTP request span, and the buggy exec parented to it. The
|
|
// recorder resolves an explicit parent token to its `span`, and it resolves
|
|
// an `undefined` token to `null`. A `sandbox.exec` issued with an `undefined`
|
|
// token is exactly the exec that the real tracer would attach to the ambient
|
|
// HTTP request span. So the two negatives are: no exec names this request
|
|
// span, and no exec opens with an `undefined` token (the ambient root).
|
|
const httpRequestSpan = traceContext.tracer.startSpan("http.request", undefined, undefined);
|
|
|
|
let rootRegionExecFired = false;
|
|
let syncExecFired = false;
|
|
let turnExecFired = false;
|
|
let getRuntimeParentContext: (() => unknown) | undefined;
|
|
|
|
const execute = createAcpxEngineExecutor({
|
|
prepareRemoteManagedHome: async (input) => {
|
|
if (!syncExecFired) {
|
|
syncExecFired = true;
|
|
issueSandboxExecFromStore(traceContext);
|
|
}
|
|
const stagedRuntime = await input.stage([]);
|
|
return { stagedRuntime };
|
|
},
|
|
createRuntime: (options) => {
|
|
const opts = options as unknown as {
|
|
getRuntimeParentContext?: () => unknown;
|
|
};
|
|
getRuntimeParentContext = opts.getRuntimeParentContext;
|
|
const runtime = buildRuntime();
|
|
return {
|
|
...runtime,
|
|
startTurn: (input: Record<string, unknown>) => {
|
|
// The detached site reads the getter and wraps the work. The getter
|
|
// never returns `undefined` inside a live run, so this exec never
|
|
// detaches to the HTTP request span.
|
|
if (!turnExecFired) {
|
|
turnExecFired = true;
|
|
runWithRuntimeParent(opts.getRuntimeParentContext?.(), () =>
|
|
issueSandboxExecFromStore(traceContext),
|
|
);
|
|
}
|
|
return (runtime.startTurn as (input: unknown) => unknown)(input);
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
|
|
const runtimeMcp = {
|
|
getServers: () => {
|
|
if (!rootRegionExecFired) {
|
|
rootRegionExecFired = true;
|
|
issueSandboxExecFromStore(traceContext);
|
|
}
|
|
return [];
|
|
},
|
|
};
|
|
|
|
const result = await execute({
|
|
runId: "run-e2e-http",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "codex",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
env: { CODEX_HOME: codexHome },
|
|
},
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
runtimeMcp: runtimeMcp as never,
|
|
startupTraceContext: traceContext,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
// The off-turn detached exec also reads the getter, so it also stays under a
|
|
// run span.
|
|
runWithRuntimeParent(getRuntimeParentContext?.(), () =>
|
|
issueSandboxExecFromStore(traceContext),
|
|
);
|
|
|
|
const execSpans = spans.filter((span) => span.name === "sandbox.exec");
|
|
// The run issues four exec spans, so there is something to scan.
|
|
expect(execSpans.length).toBeGreaterThan(0);
|
|
|
|
for (const span of execSpans) {
|
|
// Negative 1: no exec parents to the top-level HTTP request span.
|
|
expect(span.parent, "a sandbox.exec must not parent to the HTTP request span").not.toBe(
|
|
httpRequestSpan,
|
|
);
|
|
// Negative 2: no exec opens unparented inside the run. An `undefined`
|
|
// parent token is the ambient root that would attach to the HTTP request
|
|
// span in production, and a `null` parent is a detached span.
|
|
expect(span.parentContextArg, "a sandbox.exec must open with an explicit parent token")
|
|
.not.toBeUndefined();
|
|
expect(span.parent, "a sandbox.exec must not open unparented inside the run").not.toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine per-step startup timing (run.startup.step events)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function stepEvents(events: Array<{ eventType: string; payload?: Record<string, unknown> }>) {
|
|
return events.filter((event) => event.eventType === "run.startup.step");
|
|
}
|
|
|
|
it("emits a run.startup.step event for each of the 7 bring-up boundaries with numeric durationMs", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
// A configured CODEX_HOME keeps the codex-home seed deterministic (skips the
|
|
// managed-home copy from the host ~/.codex) so steps 2 and 3 run cleanly.
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.mkdir(codexHome, { recursive: true });
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const { events, sessionInputs } = await runExecutor(
|
|
{
|
|
agent: "codex",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
cwd: localCwd,
|
|
env: { CODEX_HOME: codexHome, OPENAI_API_KEY: "sk-acp-test-key" },
|
|
},
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
|
|
const steps = stepEvents(events);
|
|
const seen = new Map(steps.map((event) => [String(event.payload?.step), event]));
|
|
// A codex bring-up over the remote sandbox lane crosses all 7 boundaries.
|
|
for (const step of [
|
|
"workspace.resolve",
|
|
"codex-home.seed",
|
|
"skills.reconcile",
|
|
"stage.sync",
|
|
"bridge.paperclip",
|
|
"bridge.process-session",
|
|
"acp.handshake",
|
|
]) {
|
|
const event = seen.get(step);
|
|
expect(event, `expected a run.startup.step event for "${step}"`).toBeTruthy();
|
|
expect(typeof event!.payload?.durationMs).toBe("number");
|
|
expect(event!.payload?.durationMs as number).toBeGreaterThanOrEqual(0);
|
|
}
|
|
const sessionEnv = (sessionInputs[0]?.sessionOptions as { env: Record<string, string> }).env;
|
|
expect(sessionEnv.OPENAI_API_KEY).toBe("sk-acp-test-key");
|
|
expect(sessionEnv.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" }));
|
|
});
|
|
|
|
it("emits the 5 non-codex boundaries for a custom-agent sandbox bring-up (no codex steps)", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const { events } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
|
|
const emitted = new Set(stepEvents(events).map((event) => String(event.payload?.step)));
|
|
// The custom-agent lane skips the codex-only skill prep entirely...
|
|
expect(emitted.has("codex-home.seed")).toBe(false);
|
|
expect(emitted.has("skills.reconcile")).toBe(false);
|
|
// ...but still times the shared workspace/stage/bridge/handshake boundaries.
|
|
for (const step of [
|
|
"workspace.resolve",
|
|
"stage.sync",
|
|
"bridge.paperclip",
|
|
"bridge.process-session",
|
|
"acp.handshake",
|
|
]) {
|
|
expect(emitted.has(step), `expected a run.startup.step event for "${step}"`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("emits only the high-level fields on every startup-step payload, never the detailed timing or counts", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
|
|
const { events } = await runExecutor(
|
|
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
{ authToken: "real-run-jwt", executionTarget },
|
|
);
|
|
|
|
const steps = stepEvents(events);
|
|
expect(steps.length).toBeGreaterThan(0);
|
|
|
|
// Every timed boundary still records the high-level duration and outcome.
|
|
// The detailed per-step round-trip and provider-duration numbers ride the
|
|
// OTel spans now, so no payload carries them.
|
|
for (const event of steps) {
|
|
const payload = event.payload ?? {};
|
|
expect(typeof payload.durationMs).toBe("number");
|
|
expect(payload).not.toHaveProperty("roundTrips");
|
|
expect(payload).not.toHaveProperty("providerExecMs");
|
|
expect(payload).not.toHaveProperty("providerGetMs");
|
|
expect(payload).not.toHaveProperty("createRuntimeMs");
|
|
expect(payload).not.toHaveProperty("ensureSessionMs");
|
|
}
|
|
});
|
|
|
|
it.each([true, false])("derives warmth from physical reuse when the provider session stays continuous: cached=%s", async cached => {
|
|
const root = await makeTempRoot();
|
|
const warmHandles = new Map();
|
|
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
|
let runtimeOptions: AcpRuntimeOptions;
|
|
const runtime = buildRuntime(undefined, () => {
|
|
// Exercise the real engine spawn observer, including a second launch in
|
|
// the same ensure call. A session ID cannot hide either startup cost.
|
|
void runtimeOptions.onAgentSpawn?.({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
void runtimeOptions.onAgentSpawn?.({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
});
|
|
const execute = createAcpxEngineExecutor({ warmHandles, createRuntime: options => {
|
|
runtimeOptions = options;
|
|
return runtime as never;
|
|
} });
|
|
const base = { agent: { id: "agent-1", companyId: "company-1" },
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), mode: "persistent" },
|
|
context: {}, onLog: async () => {}, onMeta: async () => {} };
|
|
const first = await execute({ ...base, runId: "reuse-first", runtime: {} } as never);
|
|
const params = first.sessionParams!;
|
|
if (cached) warmHandles.set(params.sessionKey, { runtime,
|
|
handle: { backendSessionId: first.sessionId, agentSessionId: "agent-session", runtimeSessionName: "runtime-session" },
|
|
fingerprint: params.configFingerprint, lastUsedAt: Date.now(),
|
|
processIdentitySink: { current: undefined, latest: null },
|
|
childStderrState: { logPath: null, pendingLiveLine: "" } });
|
|
const second = await execute({ ...base, runId: "reuse-second", runtime: { sessionParams: params },
|
|
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => { events.push(event); },
|
|
} as never);
|
|
expect(first.exitCode).toBe(0);
|
|
expect(second.exitCode).toBe(0);
|
|
expect(second.sessionId).toBe(first.sessionId);
|
|
expect(summarizeStartupReuse(events)).toMatchObject({
|
|
warmth: cached ? "warm" : "partial_reuse", providerLaunchCount: cached ? 0 : 2,
|
|
layers: { provider_session: { state: "reused" }, provider_process: { state: cached ? "reused" : "fresh" } },
|
|
});
|
|
});
|
|
|
|
it("runs a fresh acp.handshake on a compatible second run (host lane re-creates, no warm hit)", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const warmHandles = new Map();
|
|
let created = 0;
|
|
const secondEvents: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles,
|
|
createRuntime: () => { created += 1; return buildRuntime() as never; },
|
|
});
|
|
const config = {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
mode: "persistent",
|
|
warmHandleIdleMs: 60_000,
|
|
};
|
|
const first = await execute({
|
|
runId: "run-warm-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
// Amendment B: the first run closed and relaunched (no warm save), so the
|
|
// compatible second run re-creates the runtime and runs a fresh handshake — one
|
|
// acp.handshake event whose outcome is not `skipped`.
|
|
await execute({
|
|
runId: "run-warm-2",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
|
secondEvents.push(event);
|
|
},
|
|
} as never);
|
|
|
|
const handshakeEvents = secondEvents.filter(
|
|
(event) => event.eventType === "run.startup.step" && event.payload?.step === "acp.handshake",
|
|
);
|
|
expect(created).toBe(2);
|
|
expect(handshakeEvents).toHaveLength(1);
|
|
expect(handshakeEvents[0]!.payload?.outcome).not.toBe("skipped");
|
|
});
|
|
|
|
it("does not emit startup-step events on a local (non-sandbox) run except workspace.resolve", async () => {
|
|
const root = await makeTempRoot();
|
|
const localCwd = path.join(root, "worktree");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
|
|
const { events } = await runExecutor({
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
cwd: localCwd,
|
|
});
|
|
|
|
const emitted = new Set(stepEvents(events).map((event) => String(event.payload?.step)));
|
|
// A local run never crosses the staging seam or starts a bridge, so only the
|
|
// always-run workspace resolution and the ACP handshake are timed.
|
|
expect(emitted.has("workspace.resolve")).toBe(true);
|
|
expect(emitted.has("acp.handshake")).toBe(true);
|
|
expect(emitted.has("stage.sync")).toBe(false);
|
|
expect(emitted.has("bridge.paperclip")).toBe(false);
|
|
expect(emitted.has("bridge.process-session")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine run lifecycle corrections (F1: settle every failure after buildRuntime)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// A remote sandbox setup that stages the host worktree through the real local
|
|
// runner, so a run reaches the post-build window with live bridges and a held
|
|
// staging lease.
|
|
async function setupRemoteSandbox() {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
|
}
|
|
|
|
it("test_runtime_create_failure_returns_error_result_with_phase_create_runtime", async () => {
|
|
const root = await makeTempRoot();
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => {
|
|
throw new Error("createRuntime boom");
|
|
},
|
|
});
|
|
const result = await execute({
|
|
runId: "run-create-fail",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
// The post-build runtime-creation window failure returns a settled error
|
|
// result instead of throwing.
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.resultJson?.phase).toBe("create_runtime");
|
|
expect(result.summary).toContain("createRuntime boom");
|
|
});
|
|
|
|
it.each(["create_failure", "relay_respawn", "stalled_receipt"])(
|
|
"keeps remote provider evidence unknown and releases the staging lease on %s", async (scenario) => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const paperclipStop = vi.fn(async () => {});
|
|
const processStop = vi.fn(async () => {});
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
|
|
async () => ({ env: {}, stop: paperclipStop }) as never,
|
|
);
|
|
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
|
async () => ({ agentCommand: null, stop: processStop }) as never,
|
|
);
|
|
const stagingLocks = new Map<string, Promise<unknown>>();
|
|
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
|
let releaseReceipt!: () => void;
|
|
const receiptGate = new Promise<void>((resolve) => { releaseReceipt = resolve; });
|
|
let receiptStarted!: () => void;
|
|
const receiptReady = new Promise<void>((resolve) => { receiptStarted = resolve; });
|
|
const execute = createAcpxEngineExecutor({
|
|
stagingLocks,
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: (options) => {
|
|
// The bridge models an already-started sandbox provider. This observer
|
|
// sees only the host relay, including relay respawns.
|
|
expect(startAdapterExecutionTargetProcessSessionBridge).toHaveBeenCalledTimes(1);
|
|
expect(stagingLocks.size).toBe(1);
|
|
if (scenario === "create_failure") throw new Error("createRuntime boom");
|
|
return buildRuntime(undefined, () => {
|
|
void options.onAgentSpawn?.({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
void options.onAgentSpawn?.({ pid: process.pid, startedAt: new Date().toISOString() });
|
|
}) as never;
|
|
},
|
|
});
|
|
|
|
const execution = execute({
|
|
runId: "run-create-fail-remote",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
|
events.push(event);
|
|
if (event.eventType === "run.startup.reuse" && event.payload?.layer === "provider_process"
|
|
&& (scenario !== "stalled_receipt" || processStop.mock.calls.length > 0)) {
|
|
receiptStarted();
|
|
if (scenario === "stalled_receipt") await receiptGate;
|
|
}
|
|
},
|
|
} as never);
|
|
|
|
let completed = false;
|
|
void execution.then(() => { completed = true; }, () => {});
|
|
try {
|
|
await Promise.race([receiptReady, execution]);
|
|
// Keep the persistence await open while checking real cleanup and return.
|
|
await expect.poll(() => completed, { timeout: 500 }).toBe(true);
|
|
expect(stagingLocks.size).toBe(0);
|
|
} finally {
|
|
releaseReceipt();
|
|
await execution;
|
|
}
|
|
const result = await execution;
|
|
expect(result.exitCode).toBe(scenario === "create_failure" ? 1 : 0);
|
|
if (scenario === "create_failure") expect(result.resultJson?.phase).toBe("create_runtime");
|
|
expect(summarizeStartupReuse(events)).toMatchObject({
|
|
providerLaunchCount: null,
|
|
layers: { provider_process: { state: "unknown", reason: "evidence_missing" } },
|
|
});
|
|
// Both live bridges stop exactly once.
|
|
expect(paperclipStop).toHaveBeenCalledTimes(1);
|
|
expect(processStop).toHaveBeenCalledTimes(1);
|
|
// The per-session staging lease released, so the lock map does not strand the
|
|
// next same-session run.
|
|
expect(stagingLocks.size).toBe(0);
|
|
},
|
|
);
|
|
|
|
it.each(["prompt", "metadata", "dispatch"])("records whether provider work could have started on %s failure", async (failure) => {
|
|
const root = await makeTempRoot();
|
|
const close = vi.fn(async () => {});
|
|
const startTurn = vi.fn(() => { throw new Error("dispatch failed"); });
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn,
|
|
close,
|
|
}) as never,
|
|
});
|
|
|
|
// A throwing accessor on a field only `buildPrompt` reads makes the prompt
|
|
// build fail after the session handshake succeeds.
|
|
const context: Record<string, unknown> = {};
|
|
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
|
|
enumerable: false,
|
|
get() {
|
|
if (failure === "prompt") throw new Error("prompt build boom");
|
|
return undefined;
|
|
},
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-prepare-fail",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context,
|
|
onLog: async () => {},
|
|
onMeta: async () => { if (failure === "metadata") throw new Error("metadata failed"); },
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
if (failure === "dispatch") {
|
|
expect(startTurn).toHaveBeenCalledTimes(1);
|
|
expect(result.executionRecovery).toBeUndefined();
|
|
expect(result.usageComplete).toBe(false);
|
|
} else {
|
|
expect(result.resultJson?.phase).toBe("prepare_turn");
|
|
expect(startTurn).not.toHaveBeenCalled();
|
|
expect(result.executionRecovery).toEqual({ kind: "bootstrap", providerWorkStarted: false });
|
|
}
|
|
// The established runtime is closed on the pre-turn failure, so no session
|
|
// leaks.
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("test_sandbox_startup_span_ends_exactly_once_on_every_exit_path", async () => {
|
|
// Each scenario runs a fresh remote bring-up with a recording trace and asserts
|
|
// the one sandbox.startup span ends exactly once, whatever the exit path.
|
|
const failingEnsure = () =>
|
|
({
|
|
ensureSession: async () => {
|
|
throw new Error("ensureSession boom");
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never;
|
|
const scenarios: Array<{ name: string; createRuntime: AcpxEngineExecutorOptions["createRuntime"] }> = [
|
|
{ name: "success", createRuntime: () => buildRuntime() as never },
|
|
{
|
|
name: "create_runtime failure",
|
|
createRuntime: () => {
|
|
throw new Error("createRuntime boom");
|
|
},
|
|
},
|
|
{ name: "ensure_session failure", createRuntime: () => failingEnsure() },
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { traceContext, spans } = createRecordingStartupTrace();
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
stagingLocks: new Map(),
|
|
createRuntime: scenario.createRuntime,
|
|
});
|
|
|
|
await execute({
|
|
runId: `run-span-${scenario.name.replace(/\s+/g, "-")}`,
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
startupTraceContext: traceContext,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
|
|
const startupSpans = spans.filter((span) => span.name === "sandbox.startup");
|
|
expect(startupSpans, `scenario "${scenario.name}" must open one sandbox.startup span`).toHaveLength(1);
|
|
expect(
|
|
startupSpans[0]!.endCalls,
|
|
`scenario "${scenario.name}" must end the sandbox.startup span exactly once`,
|
|
).toBe(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine run lifecycle corrections (F2: close the runtime for every pre-turn failure)", () => {
|
|
const startedAt = "2026-01-01T00:00:00.000Z";
|
|
const handle = {
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
};
|
|
|
|
it("test_persistent_host_failure_closes_recreated_runtime_no_warm_reuse", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const closeSpy = vi.fn(async () => {});
|
|
let created = 0;
|
|
const warmHandles = new Map();
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles,
|
|
createRuntime: (options) => {
|
|
created += 1;
|
|
const opts = options as AcpRuntimeOptions & {
|
|
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
|
};
|
|
return {
|
|
ensureSession: async () => {
|
|
await opts.onAgentSpawn?.({ pid: 4242, startedAt });
|
|
return handle;
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: closeSpy,
|
|
} as never;
|
|
},
|
|
});
|
|
const config = {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
mode: "persistent",
|
|
warmHandleIdleMs: 60_000,
|
|
};
|
|
|
|
const first = await execute({
|
|
runId: "warm-handshake-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onSpawn: async () => {},
|
|
} as never);
|
|
expect(first.exitCode).toBe(0);
|
|
// Amendment B: the first clean persistent turn closes and relaunches instead of
|
|
// warm-saving, so no warm handle survives.
|
|
expect(warmHandles.size).toBe(0);
|
|
expect(created).toBe(1);
|
|
|
|
// The second run finds no warm handle, so it re-creates the runtime and fails
|
|
// while persisting process identity.
|
|
const second = await execute({
|
|
runId: "warm-handshake-2",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: { sessionParams: first.sessionParams },
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onSpawn: async () => {
|
|
throw new Error("onSpawn boom");
|
|
},
|
|
} as never);
|
|
|
|
expect(created).toBe(2);
|
|
expect(second.exitCode).toBe(1);
|
|
expect(second.resultJson?.phase).toBe("ensure_session");
|
|
// Each run closed its own runtime (one per run); no warm entry survived.
|
|
expect(closeSpy).toHaveBeenCalledTimes(2);
|
|
expect(warmHandles.size).toBe(0);
|
|
});
|
|
|
|
it("test_missing_session_handle_closes_runtime", async () => {
|
|
const root = await makeTempRoot();
|
|
const closeSpy = vi.fn(async () => {});
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
// A runtime that returns no session handle drives the missing-handle path.
|
|
ensureSession: async () => undefined,
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: closeSpy,
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "missing-handle",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.resultJson?.phase).toBe("ensure_session");
|
|
expect(result.errorCode).toBe("acpx_runtime_error");
|
|
// The constructed runtime is closed so its child process cannot leak.
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("test_host_lane_closes_and_relaunches_every_run_and_leaves_no_warm_entry", async () => {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
let created = 0;
|
|
const closes: number[] = [];
|
|
const warmHandles = new Map();
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles,
|
|
createRuntime: (options) => {
|
|
const id = (created += 1);
|
|
const opts = options as AcpRuntimeOptions & {
|
|
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
|
};
|
|
return {
|
|
ensureSession: async () => {
|
|
await opts.onAgentSpawn?.({ pid: 1000 + id, startedAt });
|
|
return handle;
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {
|
|
closes.push(id);
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
const config = {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir,
|
|
mode: "persistent",
|
|
warmHandleIdleMs: 60_000,
|
|
};
|
|
|
|
const first = await execute({
|
|
runId: "warm-timer-1",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onSpawn: async () => {},
|
|
} as never);
|
|
expect(first.exitCode).toBe(0);
|
|
// Amendment B: the first clean persistent turn closes runtime #1 and leaves no
|
|
// warm entry, so the host lane never keeps a live runtime warm.
|
|
expect(warmHandles.size).toBe(0);
|
|
expect(closes).toEqual([1]);
|
|
|
|
// The second run finds no warm entry, so it re-creates runtime #2 and fails
|
|
// while persisting process identity; runtime #2 closes.
|
|
const second = await execute({
|
|
runId: "warm-timer-2",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: { sessionParams: first.sessionParams },
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onSpawn: async () => {
|
|
throw new Error("onSpawn boom");
|
|
},
|
|
} as never);
|
|
expect(created).toBe(2);
|
|
expect(second.exitCode).toBe(1);
|
|
expect(closes).toEqual([1, 2]);
|
|
expect(warmHandles.size).toBe(0);
|
|
|
|
// A later heartbeat also finds no warm entry and constructs a fresh runtime.
|
|
const third = await execute({
|
|
runId: "warm-timer-3",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: { sessionParams: first.sessionParams },
|
|
config,
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onSpawn: async () => {},
|
|
} as never);
|
|
expect(third.exitCode).toBe(0);
|
|
expect(created).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine run lifecycle corrections (F3: one teardown error policy)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const okHandle = {
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
};
|
|
|
|
async function setupRemoteSandbox() {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
};
|
|
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
|
}
|
|
|
|
// Stub both sandbox bridges with stop spies collected per start, so a test can
|
|
// assert the bridges stopped without running the real bridge transport.
|
|
function stubBridges() {
|
|
const paperclipStops: Array<ReturnType<typeof vi.fn>> = [];
|
|
const processStops: Array<ReturnType<typeof vi.fn>> = [];
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
|
|
const stop = vi.fn(async () => {});
|
|
paperclipStops.push(stop);
|
|
return { env: {}, stop } as never;
|
|
});
|
|
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
|
|
const stop = vi.fn(async () => {});
|
|
processStops.push(stop);
|
|
return { agentCommand: null, stop } as never;
|
|
});
|
|
const anyStopped = (stops: Array<ReturnType<typeof vi.fn>>) =>
|
|
stops.some((stop) => stop.mock.calls.length > 0);
|
|
const stoppedCount = (stops: Array<ReturnType<typeof vi.fn>>) =>
|
|
stops.filter((stop) => stop.mock.calls.length > 0).length;
|
|
return { paperclipStops, processStops, anyStopped, stoppedCount };
|
|
}
|
|
|
|
function throwingHandoffContext(): Record<string, unknown> {
|
|
const context: Record<string, unknown> = {};
|
|
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
|
|
enumerable: false,
|
|
get() {
|
|
throw new Error("prompt build boom");
|
|
},
|
|
});
|
|
return context;
|
|
}
|
|
|
|
function completedTurn() {
|
|
return {
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
};
|
|
}
|
|
|
|
function throwingTurn() {
|
|
return {
|
|
events: (async function* () {
|
|
throw new Error("turn upstream boom");
|
|
})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
};
|
|
}
|
|
|
|
function remoteArgs(
|
|
stateDir: string,
|
|
localCwd: string,
|
|
executionTarget: unknown,
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it.each(["close_session", "stop_transport", "instruction_collection", "workspace_restore", "phase_reporting", "close_error_reporting"])(
|
|
"keeps %s visible while its real settlement await is stalled",
|
|
async (blockedPhase) => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
stubBridges();
|
|
let resume!: () => void;
|
|
let entered!: () => void;
|
|
const gate = new Promise<void>((resolve) => { resume = resolve; });
|
|
const reached = new Promise<void>((resolve) => { entered = resolve; });
|
|
const block = () => { entered(); return gate; };
|
|
const scopes = new Map<symbol, string>();
|
|
if (blockedPhase === "stop_transport") {
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => ({ env: {}, stop: block }) as never);
|
|
}
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => blockedPhase === "close_error_reporting" ? throwingTurn() : completedTurn(),
|
|
close: blockedPhase === "close_session" ? block : async () => {
|
|
if (blockedPhase === "close_error_reporting") throw new Error("close fixture failed");
|
|
},
|
|
}) as never,
|
|
prepareRemoteManagedHome: async (input) => ({
|
|
stagedRuntime: await input.stage([]),
|
|
teardown: async () => {
|
|
if (blockedPhase === "workspace_restore") await block();
|
|
return { ok: true };
|
|
},
|
|
}),
|
|
});
|
|
const execution = execute({
|
|
runId: "pending-settlement-fixture",
|
|
...remoteArgs(stateDir, localCwd, executionTarget),
|
|
onProviderStopped: blockedPhase === "instruction_collection" ? block : async () => {},
|
|
onExecutionPhase: (phase: string) => {
|
|
const token = Symbol();
|
|
scopes.set(token, phase);
|
|
return () => { scopes.delete(token); };
|
|
},
|
|
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
|
|
if (blockedPhase === "phase_reporting" && event.eventType === "run.phase.timing" && event.payload?.phase === "end_session") await block();
|
|
},
|
|
onLog: async (_stream: string, text: string) => {
|
|
if (blockedPhase === "close_error_reporting" && text.includes("close fixture failed")) await block();
|
|
},
|
|
} as never);
|
|
try {
|
|
await reached;
|
|
expect([...scopes.values()].at(-1)).toBe(blockedPhase === "close_error_reporting" ? "phase_reporting" : blockedPhase);
|
|
} finally {
|
|
resume();
|
|
expect((await execution).exitCode).toBe(blockedPhase === "close_error_reporting" ? 1 : 0);
|
|
}
|
|
expect(scopes.size).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("keeps the primary Stop cancellation visible until its await settles", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
stubBridges();
|
|
const controller = new AbortController();
|
|
let started!: () => void;
|
|
let cancelled!: () => void;
|
|
let resume!: () => void;
|
|
const turnStarted = new Promise<void>((resolve) => { started = resolve; });
|
|
const cancellationStarted = new Promise<void>((resolve) => { cancelled = resolve; });
|
|
const gate = new Promise<void>((resolve) => { resume = resolve; });
|
|
const scopes = new Map<symbol, string>();
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => ({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => {
|
|
started();
|
|
return {
|
|
events: (async function* () { await gate; })(),
|
|
result: gate.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
|
|
cancel: async () => { cancelled(); await gate; },
|
|
};
|
|
},
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
const execution = execute({
|
|
runId: "pending-cancel-fixture",
|
|
...remoteArgs(stateDir, localCwd, executionTarget),
|
|
signal: controller.signal,
|
|
onExecutionPhase: (phase: string) => {
|
|
const token = Symbol();
|
|
scopes.set(token, phase);
|
|
return () => { scopes.delete(token); };
|
|
},
|
|
} as never);
|
|
try {
|
|
await turnStarted;
|
|
controller.abort();
|
|
await cancellationStarted;
|
|
expect([...scopes.values()].at(-1)).toBe("cancel_turn");
|
|
} finally {
|
|
resume();
|
|
expect((await execution).exitCode).toBe(1);
|
|
}
|
|
expect(scopes.size).toBe(0);
|
|
});
|
|
|
|
it("test_clean_completed_remote_teardown_runs_bridge_stop_then_sync_back_then_lease_release", async () => {
|
|
// cleanupRemoteBridges (execute.ts:2306-2326) fixes the sub-order for a clean
|
|
// exit: stop both bridges (allSettled) → run the managed-home sync-back
|
|
// (remoteManagedHomeTeardown) → release the per-session staging lease LAST in
|
|
// a finally. Record the order through spies threaded into the stubbed bridges
|
|
// and the seam teardown, and read the still-held lease during the sync-back.
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const order: string[] = [];
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => ({
|
|
env: {},
|
|
stop: vi.fn(async () => {
|
|
order.push("bridge-stop");
|
|
}),
|
|
}) as never);
|
|
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => ({
|
|
agentCommand: null,
|
|
stop: vi.fn(async () => {
|
|
order.push("bridge-stop");
|
|
}),
|
|
}) as never);
|
|
|
|
const stagingLocks = new Map<string, Promise<unknown>>();
|
|
let leaseSizeDuringSyncBack = -1;
|
|
const execute = createAcpxEngineExecutor({
|
|
stagingLocks,
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => completedTurn(),
|
|
close: vi.fn(async () => {}),
|
|
}) as never,
|
|
prepareRemoteManagedHome: async (input) => {
|
|
const stagedRuntime = await input.stage([]);
|
|
return {
|
|
stagedRuntime,
|
|
teardown: async () => {
|
|
order.push("sync-back");
|
|
// The lease is still held while the sync-back runs; it releases only
|
|
// afterward, in the cleanupRemoteBridges finally.
|
|
leaseSizeDuringSyncBack = stagingLocks.size;
|
|
return { ok: true };
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "clean-order",
|
|
...remoteArgs(stateDir, localCwd, executionTarget),
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
// Both bridge stops precede the sync-back; the sync-back is the last recorded
|
|
// step before the lease release.
|
|
expect(order).toEqual(["bridge-stop", "bridge-stop", "sync-back"]);
|
|
// The lease was still held during the sync-back...
|
|
expect(leaseSizeDuringSyncBack).toBe(1);
|
|
// ...and released last, so the lock map never strands the next run.
|
|
expect(stagingLocks.size).toBe(0);
|
|
});
|
|
|
|
|
|
it("test_terminal_close_reason_and_discard_persistent_state_per_outcome", async () => {
|
|
const cases: Array<{
|
|
status: "completed" | "failed" | "cancelled";
|
|
reason: string;
|
|
discard: boolean;
|
|
exitCode: number;
|
|
}> = [
|
|
{ status: "completed", reason: "paperclip completed turn cleanup", discard: false, exitCode: 0 },
|
|
{ status: "failed", reason: "paperclip turn failed", discard: false, exitCode: 1 },
|
|
{ status: "cancelled", reason: "paperclip turn cancelled", discard: true, exitCode: 1 },
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
const root = await makeTempRoot();
|
|
const closeSpy = vi.fn(async () => {});
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result:
|
|
testCase.status === "failed"
|
|
? Promise.resolve({ status: "failed", error: new Error("turn boom") })
|
|
: testCase.status === "cancelled"
|
|
? Promise.resolve({ status: "cancelled", stopReason: "cancelled" })
|
|
: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: closeSpy,
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: `terminal-${testCase.status}`,
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.exitCode, `${testCase.status} exit`).toBe(testCase.exitCode);
|
|
expect(closeSpy, `${testCase.status} closes once`).toHaveBeenCalledTimes(1);
|
|
expect((closeSpy.mock.calls[0]! as unknown[])[0], `${testCase.status} close args`).toMatchObject({
|
|
reason: testCase.reason,
|
|
discardPersistentState: testCase.discard,
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
it("collects instructions after confirmed close on a thrown provider turn, before workspace restore", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { paperclipStops, anyStopped } = stubBridges();
|
|
const order: string[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
stagingLocks: new Map(), warmHandles: new Map(), stagedRuntimes: new Map(),
|
|
createRuntime: () => ({ ensureSession: async () => okHandle, startTurn: () => throwingTurn(),
|
|
close: async () => { order.push("close"); } }) as never,
|
|
});
|
|
const result = await execute({ runId: "instruction-stop-failure",
|
|
...remoteArgs(stateDir, localCwd, executionTarget, { onProviderStopped: async () => {
|
|
expect(anyStopped(paperclipStops)).toBe(true);
|
|
order.push("collect");
|
|
} }),
|
|
} as never);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(order).toEqual(["close", "collect"]);
|
|
});
|
|
|
|
it("test_teardown_continues_after_one_teardown_step_fails", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { paperclipStops, processStops, anyStopped } = stubBridges();
|
|
const stagingLocks = new Map<string, Promise<unknown>>();
|
|
const collectInstructions = vi.fn(async () => {});
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
stagingLocks,
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => throwingTurn(),
|
|
// A failing teardown step must not stop the later teardown steps.
|
|
close: async () => {
|
|
throw new Error("close boom");
|
|
},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "td-continue",
|
|
...remoteArgs(stateDir, localCwd, executionTarget, {
|
|
onProviderStopped: collectInstructions,
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
}),
|
|
} as never);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(collectInstructions).not.toHaveBeenCalled();
|
|
// The close failure did not stop the bridge stops or the lease release.
|
|
expect(anyStopped(paperclipStops)).toBe(true);
|
|
expect(anyStopped(processStops)).toBe(true);
|
|
expect(stagingLocks.size).toBe(0);
|
|
// The close failure was recorded, not silently dropped.
|
|
expect(
|
|
logs.some((entry) => entry.stream === "stderr" && entry.text.includes('teardown step "runtime-close" failed')),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("restores managed home and releases the lease when instruction collection rejects", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { paperclipStops, processStops, anyStopped } = stubBridges();
|
|
const stagingLocks = new Map<string, Promise<unknown>>();
|
|
const order: string[] = [];
|
|
const logs: string[] = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
stagingLocks, warmHandles: new Map(), stagedRuntimes: new Map(),
|
|
prepareRemoteManagedHome: async (input) => ({
|
|
stagedRuntime: await input.stage([]),
|
|
teardown: async () => { order.push("restore"); return { ok: true }; },
|
|
}),
|
|
createRuntime: () => ({ ensureSession: async () => okHandle, startTurn: () => throwingTurn(),
|
|
close: async () => { order.push("close"); } }) as never,
|
|
});
|
|
const result = await execute({ runId: "instruction-collection-reject",
|
|
...remoteArgs(stateDir, localCwd, executionTarget, {
|
|
onProviderStopped: async () => { order.push("collect"); throw new Error("collector failed reading /private/test-instructions/AGENTS.md"); },
|
|
onLog: async (_stream: string, text: string) => { logs.push(text); },
|
|
}),
|
|
} as never);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(order).toEqual(["close", "collect", "restore"]);
|
|
expect(anyStopped(paperclipStops)).toBe(true);
|
|
expect(anyStopped(processStops)).toBe(true);
|
|
expect(stagingLocks.size).toBe(0);
|
|
expect(logs.some(text => text.includes('teardown step "instruction-collection" failed: Instruction collection failed'))).toBe(true);
|
|
expect(logs.some(text => text.includes("/private/test-instructions"))).toBe(false);
|
|
});
|
|
|
|
it("test_staging_lease_releases_in_finally_on_every_exit_path", async () => {
|
|
const scenarios: Array<{
|
|
name: string;
|
|
createRuntime: AcpxEngineExecutorOptions["createRuntime"];
|
|
context: Record<string, unknown>;
|
|
}> = [
|
|
{
|
|
name: "create_runtime",
|
|
createRuntime: () => {
|
|
throw new Error("createRuntime boom");
|
|
},
|
|
context: {},
|
|
},
|
|
{
|
|
name: "ensure_session",
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => {
|
|
throw new Error("ensure boom");
|
|
},
|
|
startTurn: () => completedTurn(),
|
|
close: async () => {},
|
|
}) as never,
|
|
context: {},
|
|
},
|
|
{
|
|
name: "prepare_turn",
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => completedTurn(),
|
|
close: async () => {},
|
|
}) as never,
|
|
context: throwingHandoffContext(),
|
|
},
|
|
{
|
|
name: "turn",
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => throwingTurn(),
|
|
close: async () => {},
|
|
}) as never,
|
|
context: {},
|
|
},
|
|
{
|
|
name: "success",
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => completedTurn(),
|
|
close: async () => {},
|
|
}) as never,
|
|
context: {},
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
stubBridges();
|
|
const stagingLocks = new Map<string, Promise<unknown>>();
|
|
const execute = createAcpxEngineExecutor({
|
|
stagingLocks,
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: scenario.createRuntime,
|
|
});
|
|
|
|
await execute({
|
|
runId: `lease-${scenario.name}`,
|
|
...remoteArgs(stateDir, localCwd, executionTarget, { context: scenario.context }),
|
|
} as never).catch(() => {});
|
|
|
|
// The staging lease is released in a finally on every exit path, so the lock
|
|
// map never strands the next same-session run.
|
|
expect(stagingLocks.size, `lease must release on the ${scenario.name} path`).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("test_result_emission_failure_does_not_skip_teardown", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { paperclipStops, processStops, anyStopped } = stubBridges();
|
|
const stagingLocks = new Map<string, Promise<unknown>>();
|
|
const execute = createAcpxEngineExecutor({
|
|
stagingLocks,
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => throwingTurn(),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
await execute({
|
|
runId: "emit-fail",
|
|
...remoteArgs(stateDir, localCwd, executionTarget, {
|
|
// Fail the acpx.error emission only, so teardown must still run.
|
|
onLog: async (_stream: "stdout" | "stderr", text: string) => {
|
|
if (text.includes('"type":"acpx.error"')) throw new Error("onLog boom");
|
|
},
|
|
}),
|
|
} as never).catch(() => {});
|
|
|
|
expect(anyStopped(paperclipStops)).toBe(true);
|
|
expect(anyStopped(processStops)).toBe(true);
|
|
expect(stagingLocks.size).toBe(0);
|
|
});
|
|
|
|
it("test_result_mapping_throw_after_close_does_not_rerun_teardown", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
const { paperclipStops, processStops, stoppedCount } = stubBridges();
|
|
let closeCount = 0;
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
stagingLocks: new Map(),
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
// A completed turn whose result mapping throws after the close is read.
|
|
result: Promise.resolve({
|
|
status: "completed",
|
|
get stopReason(): string {
|
|
throw new Error("mapping boom");
|
|
},
|
|
}),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {
|
|
closeCount += 1;
|
|
},
|
|
}) as never,
|
|
});
|
|
|
|
await execute({
|
|
runId: "map-throw",
|
|
...remoteArgs(stateDir, localCwd, executionTarget),
|
|
} as never).catch(() => {});
|
|
|
|
// The completed turn closed the runtime once; the mapping throw did not re-run
|
|
// the teardown through the turn catch.
|
|
expect(closeCount).toBe(1);
|
|
expect(stoppedCount(paperclipStops)).toBe(1);
|
|
expect(stoppedCount(processStops)).toBe(1);
|
|
});
|
|
|
|
it("test_teardown_failure_does_not_change_external_result", async () => {
|
|
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
|
|
stubBridges();
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles: new Map(),
|
|
stagedRuntimes: new Map(),
|
|
stagingLocks: new Map(),
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => okHandle,
|
|
startTurn: () => throwingTurn(),
|
|
close: async () => {
|
|
throw new Error("close boom");
|
|
},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "td-result",
|
|
...remoteArgs(stateDir, localCwd, executionTarget),
|
|
} as never);
|
|
|
|
// The exit cause (the turn failure) determines the external result; the failing
|
|
// teardown step never leaks into it.
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.resultJson?.phase).toBe("turn");
|
|
expect(result.errorCode).toBe("acpx_turn_failed");
|
|
expect(result.errorMessage).toContain("turn upstream boom");
|
|
expect(result.errorMessage).not.toContain("close boom");
|
|
});
|
|
|
|
it("test_flush_child_stderr_runs_on_every_exit_path", async () => {
|
|
const writes: string[] = [];
|
|
const spy = vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => {
|
|
writes.push(String(chunk));
|
|
return true;
|
|
}) as never);
|
|
try {
|
|
const scenarios: Array<{ name: string; mode: string; context: Record<string, unknown> }> = [
|
|
{ name: "ensure_session", mode: "ensure_fail", context: {} },
|
|
{ name: "prepare_turn", mode: "success", context: throwingHandoffContext() },
|
|
{ name: "turn", mode: "turn_fail", context: {} },
|
|
{ name: "success", mode: "success", context: {} },
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const root = await makeTempRoot();
|
|
const marker = `paperclip-flush-probe-${scenario.name}`;
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: (options) => {
|
|
const opts = options as {
|
|
onAgentStderr?: (chunk: string) => void;
|
|
};
|
|
// Emit a partial (newline-free) stderr chunk so it stays pending until a
|
|
// teardown flush writes it to process.stderr.
|
|
const emit = () => opts.onAgentStderr?.(marker);
|
|
return {
|
|
ensureSession: async () => {
|
|
emit();
|
|
if (scenario.mode === "ensure_fail") throw new Error("ensure boom");
|
|
return okHandle;
|
|
},
|
|
startTurn: () =>
|
|
scenario.mode === "turn_fail" ? throwingTurn() : completedTurn(),
|
|
close: async () => {},
|
|
} as never;
|
|
},
|
|
});
|
|
|
|
await execute({
|
|
runId: `flush-${scenario.name}`,
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: scenario.context,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never).catch(() => {});
|
|
|
|
expect(
|
|
writes.some((write) => write.includes(marker)),
|
|
`flushChildStderr must run on the ${scenario.name} path`,
|
|
).toBe(true);
|
|
}
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
/**
|
|
* A minimal run-disposition latch. It reproduces the same ordering rule the
|
|
* bridge transport applies: the first ordered loss or orderly completion
|
|
* latches the terminal disposition, and a later call never overrides it.
|
|
* The test drives the latch directly, with no channel and no live process.
|
|
*/
|
|
function createFakeBridgeHandle() {
|
|
let lossOrdered = false;
|
|
let lossReason: string | null = null;
|
|
let completionOrdered = false;
|
|
let lossListener: ((reason: string) => void) | null = null;
|
|
const readDisposition = () => ({ failed: lossOrdered, lossReason });
|
|
const markOrderlyCompletion = vi.fn(() => {
|
|
if (completionOrdered || lossOrdered) return;
|
|
completionOrdered = true;
|
|
});
|
|
const settleRunDisposition = vi.fn(() => {
|
|
markOrderlyCompletion();
|
|
return readDisposition();
|
|
});
|
|
const onLoss = vi.fn((listener: (reason: string) => void) => {
|
|
lossListener = listener;
|
|
return () => {
|
|
if (lossListener === listener) lossListener = null;
|
|
};
|
|
});
|
|
const stop = vi.fn(async () => {});
|
|
const handle = {
|
|
env: {
|
|
PAPERCLIP_API_URL: "http://127.0.0.1:1",
|
|
PAPERCLIP_API_KEY: "bridge-token",
|
|
PAPERCLIP_API_BRIDGE_MODE: "http2_v1",
|
|
},
|
|
readRunDisposition: () => readDisposition(),
|
|
settleRunDisposition,
|
|
markOrderlyCompletion,
|
|
onLoss,
|
|
stop,
|
|
};
|
|
return {
|
|
handle,
|
|
markOrderlyCompletion,
|
|
settleRunDisposition,
|
|
onLoss,
|
|
readDisposition,
|
|
// Record the first ordered loss. A loss ordered after a completion, or a
|
|
// second loss, is a no-op — the same rule the real transport applies.
|
|
// A loss that latches here (the first ordered call) also pushes the
|
|
// reason to the one registered listener, the same way the real HTTP/2
|
|
// transport's disposition latch does.
|
|
emitLoss: (reason: string) => {
|
|
if (lossOrdered || completionOrdered) return;
|
|
lossOrdered = true;
|
|
lossReason = reason;
|
|
lossListener?.(reason);
|
|
},
|
|
};
|
|
}
|
|
|
|
// A runtime whose one turn completes cleanly. The `beforeResult` hook runs at
|
|
// the exact point the ACP terminal resolves, so the test orders a duplex loss
|
|
// before the completion when it needs to.
|
|
function runtimeWithControlledResult(beforeResult?: () => void) {
|
|
return {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: (async () => {
|
|
beforeResult?.();
|
|
return { status: "completed" as const, stopReason: "end_turn" };
|
|
})(),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
}
|
|
|
|
// A runtime whose one turn fails. The `beforeResult` hook runs at the exact
|
|
// point the ACP terminal resolves, so the test orders channel activity before
|
|
// the failed finalization when it needs to.
|
|
function runtimeWithFailedResult(beforeResult?: () => void) {
|
|
return {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: (async () => {
|
|
beforeResult?.();
|
|
return { status: "failed" as const, error: new Error("agent failed") };
|
|
})(),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
}
|
|
|
|
// A runtime whose one turn never resolves on its own — it hangs exactly
|
|
// like a turn whose sandbox duplex channel died mid-turn produces no
|
|
// terminal result. The turn only ends when something calls `cancel()`, the
|
|
// same mechanism the push seam calls. `onCancel` observes each call.
|
|
function hangingTurnRuntime(onCancel: (reason: string | undefined) => void) {
|
|
let release: (() => void) | null = null;
|
|
const released = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
return {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
await released;
|
|
})(),
|
|
result: (async () => {
|
|
await released;
|
|
return { status: "cancelled" as const, stopReason: "cancelled" };
|
|
})(),
|
|
cancel: async (input?: { reason?: string }) => {
|
|
onCancel(input?.reason);
|
|
release?.();
|
|
},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
}
|
|
|
|
// A runtime whose one turn hangs exactly like the real `acpx` shape: its
|
|
// `cancel()` only sends the cancel request and returns. It does NOT settle
|
|
// the turn — neither `events` nor `result` ever resolves on its own.
|
|
// `closeStream()` ends the event drain locally, with no agent cooperation,
|
|
// the same way the real runtime's does; it still leaves `result` pending.
|
|
// This is the sensitivity control for the fail-fast deadline: only the
|
|
// deadline, not the cancel request, can end this turn.
|
|
function unresponsiveCancelTurnRuntime(input: {
|
|
onCancel: (reason: string | undefined) => void;
|
|
onCloseStream: (reason: string | undefined) => void;
|
|
}) {
|
|
let endEvents: (() => void) | null = null;
|
|
const eventsEnded = new Promise<void>((resolve) => {
|
|
endEvents = resolve;
|
|
});
|
|
return {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
await eventsEnded;
|
|
})(),
|
|
// Never settles on its own. The real acpx result settles only when
|
|
// the provider process returns or rejects, bounded by the adapter
|
|
// execution timeout — not by a `session/cancel` request.
|
|
result: new Promise<never>(() => {}),
|
|
cancel: async (reasonInput?: { reason?: string }) => {
|
|
input.onCancel(reasonInput?.reason);
|
|
},
|
|
closeStream: async (reasonInput?: { reason?: string }) => {
|
|
input.onCloseStream(reasonInput?.reason);
|
|
endEvents?.();
|
|
},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
}
|
|
|
|
async function setupRemoteSandbox() {
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
const runner = createLocalSandboxRunner();
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner,
|
|
};
|
|
return { root, stateDir, localCwd, remoteCwd, executionTarget };
|
|
}
|
|
|
|
async function runRemote(
|
|
handle: unknown,
|
|
runtime: unknown,
|
|
sandbox: Awaited<ReturnType<typeof setupRemoteSandbox>>,
|
|
deps: Partial<AcpxEngineExecutorOptions> = {},
|
|
) {
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
|
|
async () => handle as never,
|
|
);
|
|
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
|
async () => ({ agentCommand: null, stop: async () => {} }) as never,
|
|
);
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () => runtime as never,
|
|
...deps,
|
|
});
|
|
return await execute({
|
|
runId: "run-duplex-seam",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: sandbox.stateDir,
|
|
cwd: sandbox.localCwd,
|
|
},
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget: sandbox.executionTarget,
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
}
|
|
|
|
it("fails a completed run when the bridge channel was lost before the completion", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
// Latch the loss before the ACP terminal resolves.
|
|
const runtime = runtimeWithControlledResult(() => fake.emitLoss("provider_exit"));
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
|
|
// The lost channel overrides the nominally completed terminal to a failure.
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.errorCode).toBe("duplex_channel_lost");
|
|
expect(result.usageComplete).toBe(false);
|
|
// The message carries only the typed loss reason, not raw provider text.
|
|
expect(result.errorMessage).toContain("provider_exit");
|
|
expect(result.resultJson).toMatchObject({ status: "failed" });
|
|
// The seam read the disposition through the atomic settle step, and the
|
|
// latched loss kept the failure, so no orderly completion ordered.
|
|
expect(fake.settleRunDisposition).toHaveBeenCalledTimes(1);
|
|
expect(fake.readDisposition().failed).toBe(true);
|
|
});
|
|
|
|
it("keeps a completed run a success when the channel stays live, and a later teardown loss is benign", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
// No loss before the completion.
|
|
const runtime = runtimeWithControlledResult();
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.errorCode ?? null).toBeNull();
|
|
expect(result.usageComplete).toBe(true);
|
|
// The atomic settle step marked the orderly completion for the
|
|
// success-eligible terminal.
|
|
expect(fake.settleRunDisposition).toHaveBeenCalledTimes(1);
|
|
// A teardown loss ordered after the orderly completion is a normal teardown,
|
|
// so the run disposition stays a success.
|
|
fake.emitLoss("provider_exit");
|
|
expect(fake.readDisposition().failed).toBe(false);
|
|
});
|
|
|
|
it("does not let a later completion or activity clear the loss latch", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
// Latch the loss before the ACP terminal resolves.
|
|
const runtime = runtimeWithControlledResult(() => fake.emitLoss("provider_exit"));
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.errorCode).toBe("duplex_channel_lost");
|
|
// A later orderly-completion mark and further channel activity cannot clear
|
|
// the latched loss.
|
|
fake.markOrderlyCompletion();
|
|
fake.emitLoss("provider_exit");
|
|
expect(fake.readDisposition().failed).toBe(true);
|
|
expect(fake.readDisposition().lossReason).toBe("provider_exit");
|
|
});
|
|
|
|
it("marks an orderly completion on a failed terminal so the teardown loss emits no false loss", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
// The turn fails, and no channel loss ordered before the finalization.
|
|
const runtime = runtimeWithFailedResult();
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
|
|
// The failed terminal stays a failure, but not a bridge-channel loss.
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.errorCode).not.toBe("duplex_channel_lost");
|
|
// The non-success-eligible terminal marked the orderly completion, so the
|
|
// teardown loss orders after the mark and does not latch a loss.
|
|
expect(fake.markOrderlyCompletion).toHaveBeenCalledTimes(1);
|
|
fake.emitLoss("provider_exit");
|
|
expect(fake.readDisposition().failed).toBe(false);
|
|
});
|
|
|
|
it("keeps duplex_channel_lost precedence when the loss latches before a failed terminal", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
// Latch the loss before the ACP terminal resolves, and the terminal
|
|
// itself also reports a provider failure.
|
|
const runtime = runtimeWithFailedResult(() => fake.emitLoss("provider_exit"));
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
|
|
expect(result.exitCode).not.toBe(0);
|
|
// The duplex loss reason wins over the provider's own failed terminal.
|
|
expect(result.errorCode).toBe("duplex_channel_lost");
|
|
// The message carries only the typed loss reason, not the raw provider
|
|
// failure text.
|
|
expect(result.errorMessage).toContain("provider_exit");
|
|
expect(result.errorMessage).not.toContain("agent failed");
|
|
expect(result.resultJson).toMatchObject({ status: "failed" });
|
|
});
|
|
|
|
it("releases the runtime locally and places no remote close call once the duplex channel is lost", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
let closeCalls = 0;
|
|
const runtime = runtimeWithControlledResult(() => fake.emitLoss("provider_exit"));
|
|
// A close call with no deadline of its own would hang forever on a dead
|
|
// channel. The test times out if the teardown still places the call.
|
|
runtime.close = () => {
|
|
closeCalls += 1;
|
|
return new Promise(() => {});
|
|
};
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
|
|
expect(result.errorCode).toBe("duplex_channel_lost");
|
|
expect(closeCalls).toBe(0);
|
|
});
|
|
|
|
it("skips the remote close when the channel loses during the awaited turn-error finalization, after the settlement snapshot", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
let closeCalls = 0;
|
|
// The event stream errors out (a `turnFinalize` "error" input, not a
|
|
// "terminal" one), so the settlement snapshot never inspects the bridge
|
|
// disposition and always sets `skipRemoteClose: false`. Order the channel
|
|
// loss inside the event stream, right before it throws, so the loss
|
|
// latches strictly after that snapshot and before the end-session step
|
|
// reaches its remote-close boundary.
|
|
const runtime = {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
fake.emitLoss("provider_exit");
|
|
throw new Error("turn upstream boom");
|
|
})(),
|
|
result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
// A close call with no deadline of its own would hang forever on a dead
|
|
// channel. The test fails on a nonzero call count instead of timing out.
|
|
close: () => {
|
|
closeCalls += 1;
|
|
return new Promise(() => {});
|
|
},
|
|
};
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
|
|
expect(result.errorCode).toBe("acpx_turn_failed");
|
|
// The end-session step re-read the disposition at the remote-close
|
|
// boundary and saw the loss the settlement snapshot missed, so it
|
|
// released the runtime locally and placed no remote close call.
|
|
expect(closeCalls).toBe(0);
|
|
});
|
|
|
|
it("ends a pending handshake with a closed transport-lost code when the duplex channel is lost before ensureSession settles", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
// `ensureSession` never settles on its own; only the guard can end it.
|
|
const runtime = {
|
|
ensureSession: () => new Promise(() => {}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
|
|
// Real timers: the sandbox lane spawns a real staging subprocess, whose
|
|
// completion callbacks run on the real event loop, not through fake
|
|
// timers. The poll interval is short (a fixed host constant well under a
|
|
// second), so this converges quickly without needing the deadline to
|
|
// fire — the loss is ordered up front, before `ensureSession` ever runs.
|
|
const resultPromise = runRemote(fake.handle, runtime, sandbox);
|
|
fake.emitLoss("provider_exit");
|
|
const result = await resultPromise;
|
|
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.errorCode).toBe("acpx_handshake_transport_lost");
|
|
expect(result.resultJson).toMatchObject({ phase: "ensure_session" });
|
|
// Distinct from a session-identity failure and from the deadline code.
|
|
expect(result.errorCode).not.toBe("acpx_session_init_failed");
|
|
expect(result.errorCode).not.toBe("acpx_handshake_timeout");
|
|
}, 10000);
|
|
|
|
it("aborts an in-flight turn and fails the run when the duplex channel latches a loss mid-turn", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
const cancelReasons: (string | undefined)[] = [];
|
|
// This turn never returns a terminal result on its own: without a push
|
|
// seam it would wait for the wall-clock adapter execution timeout. It
|
|
// ends only once something calls `cancel()`.
|
|
const runtime = hangingTurnRuntime((reason) => cancelReasons.push(reason));
|
|
|
|
const resultPromise = runRemote(fake.handle, runtime, sandbox);
|
|
// Wait until the turn registers its loss listener, then latch the loss —
|
|
// the same order a real mid-turn channel death follows: the turn starts,
|
|
// then later the channel is lost.
|
|
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
|
|
fake.emitLoss("provider_exit");
|
|
|
|
const result = await resultPromise;
|
|
|
|
// The push seam cancelled the hanging turn instead of waiting for the
|
|
// turn to return a terminal result on its own, so the run ends promptly
|
|
// instead of waiting for the adapter execution timeout.
|
|
expect(cancelReasons).toHaveLength(1);
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.errorCode).toBe("duplex_channel_lost");
|
|
// The failure message carries only the closed loss-reason enum, never
|
|
// raw provider text.
|
|
expect(result.errorMessage).toContain("provider_exit");
|
|
expect(result.resultJson).toMatchObject({ status: "failed" });
|
|
}, 5000);
|
|
|
|
it("bounds the wait with a deadline when a latched loss cancel gets no agent cooperation", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
const cancelReasons: (string | undefined)[] = [];
|
|
const closeStreamReasons: (string | undefined)[] = [];
|
|
// The real acpx shape: `cancel()` only requests cancellation and returns.
|
|
// It settles neither `events` nor `result`. Only the fail-fast deadline
|
|
// can end this turn.
|
|
const runtime = unresponsiveCancelTurnRuntime({
|
|
onCancel: (reason) => cancelReasons.push(reason),
|
|
onCloseStream: (reason) => closeStreamReasons.push(reason),
|
|
});
|
|
|
|
const resultPromise = runRemote(fake.handle, runtime, sandbox, {
|
|
// Small and fake-time-free: real-timer test, so the deadline must stay
|
|
// short enough to run fast without waiting 60 real seconds.
|
|
duplexLossCancelDeadlineMs: 25,
|
|
});
|
|
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
|
|
fake.emitLoss("provider_exit");
|
|
|
|
const result = await resultPromise;
|
|
|
|
// The seam still tried the cooperative cancel first.
|
|
expect(cancelReasons).toHaveLength(1);
|
|
// The agent never answered the cancel, so the deadline ended the event
|
|
// drain locally instead of waiting for it.
|
|
expect(closeStreamReasons).toHaveLength(1);
|
|
// The run reached a failure terminal within the deadline, even though
|
|
// neither `events` nor `result` ever settled on their own.
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.errorCode).toBe("duplex_channel_lost");
|
|
// The failure message carries only the closed loss-reason enum, never
|
|
// raw provider text.
|
|
expect(result.errorMessage).toContain("provider_exit");
|
|
expect(result.resultJson).toMatchObject({ status: "failed" });
|
|
}, 5000);
|
|
|
|
it("awaits stream closure and the event drain before finalizing a duplex loss deadline", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
let endEvents: (() => void) | null = null;
|
|
const eventsEnded = new Promise<void>((resolve) => {
|
|
endEvents = resolve;
|
|
});
|
|
let releaseCloseStream!: () => void;
|
|
const closeStreamGate = new Promise<void>((resolve) => {
|
|
releaseCloseStream = resolve;
|
|
});
|
|
let closeStreamCalls = 0;
|
|
// `closeStream()` stays pending on a gate the test controls, and only
|
|
// ends the event drain once the test releases that gate. If the run
|
|
// finalizes before the gate opens, the seam did not wait for the close
|
|
// call, so a late event on this drain could still land after the result.
|
|
const runtime = {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
await eventsEnded;
|
|
})(),
|
|
result: new Promise<never>(() => {}),
|
|
cancel: async () => {},
|
|
closeStream: async () => {
|
|
closeStreamCalls += 1;
|
|
await closeStreamGate;
|
|
endEvents?.();
|
|
},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
|
|
const resultPromise = runRemote(fake.handle, runtime, sandbox, {
|
|
duplexLossCancelDeadlineMs: 25,
|
|
});
|
|
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
|
|
fake.emitLoss("provider_exit");
|
|
|
|
await vi.waitFor(() => expect(closeStreamCalls).toBe(1));
|
|
// The close call has not resolved yet, so the run must still be pending.
|
|
let settled = false;
|
|
void resultPromise.then(() => {
|
|
settled = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(settled).toBe(false);
|
|
|
|
releaseCloseStream();
|
|
const result = await resultPromise;
|
|
|
|
expect(result.errorCode).toBe("duplex_channel_lost");
|
|
}, 5000);
|
|
|
|
it("does not abort or fail an already-completed run when the duplex channel loses after an orderly completion", async () => {
|
|
const sandbox = await setupRemoteSandbox();
|
|
const fake = createFakeBridgeHandle();
|
|
const cancelReasons: (string | undefined)[] = [];
|
|
const runtime = {
|
|
ensureSession: async () => ({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
}),
|
|
startTurn: () => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }),
|
|
cancel: async (input?: { reason?: string }) => {
|
|
cancelReasons.push(input?.reason);
|
|
},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
};
|
|
|
|
const result = await runRemote(fake.handle, runtime, sandbox);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.errorCode ?? null).toBeNull();
|
|
|
|
// The channel dies only after the turn already completed cleanly. The
|
|
// loss listener the turn registered is still live at this point, but the
|
|
// latch already marked the orderly completion, so the loss cannot relatch
|
|
// and must never reach a cancel call on the (already-finished) turn.
|
|
fake.emitLoss("provider_exit");
|
|
|
|
expect(cancelReasons).toHaveLength(0);
|
|
expect(fake.readDisposition().failed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("ACPX startup handshake guard and late-completion fence", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("ends a handshake that stays pending past the startup deadline with a closed timeout code", async () => {
|
|
const root = await makeTempRoot();
|
|
const ensureSession = trackEnsureSessionCall(() => new Promise<never>(() => {}));
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
// Never settles on its own; only the guard's deadline can end it.
|
|
ensureSession: ensureSession.call,
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
try {
|
|
vi.useFakeTimers();
|
|
const resultPromise = execute({
|
|
runId: "run-handshake-timeout",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
|
const result = await resultPromise;
|
|
|
|
// The run terminalizes promptly on its own; no server restart needed.
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
|
expect(result.resultJson).toMatchObject({ phase: "ensure_session" });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("never promotes a late ensureSession resolution into the live handle after a guard rejection", async () => {
|
|
const root = await makeTempRoot();
|
|
let resolveEnsure!: (handle: unknown) => void;
|
|
const ensureSessionPromise = new Promise((resolve) => {
|
|
resolveEnsure = resolve;
|
|
});
|
|
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
|
const startTurn = vi.fn(() => ({
|
|
events: (async function* () {
|
|
yield { type: "done", stopReason: "end_turn" };
|
|
})(),
|
|
result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}));
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: ensureSession.call,
|
|
startTurn,
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
try {
|
|
vi.useFakeTimers();
|
|
const resultPromise = execute({
|
|
runId: "run-no-promotion",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
|
const result = await resultPromise;
|
|
|
|
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
|
// The run settled as a pre-turn handshake failure; the turn never ran,
|
|
// which it could only do with a promoted, live session handle.
|
|
expect(result.resultJson).toMatchObject({ phase: "ensure_session" });
|
|
expect(startTurn).not.toHaveBeenCalled();
|
|
|
|
// The abandoned promise now resolves. It must still never start a turn.
|
|
resolveEnsure({
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(startTurn).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}, 10_000);
|
|
|
|
it("closes a late-resolving real handle exactly once, whether it arrives before or after settlement seals", async () => {
|
|
const lateHandle = {
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
};
|
|
|
|
async function runAbandonedHandshake(): Promise<{
|
|
closeSpy: ReturnType<typeof vi.fn>;
|
|
resolveEnsure: (handle: unknown) => void;
|
|
resultPromise: Promise<unknown>;
|
|
}> {
|
|
const root = await makeTempRoot();
|
|
let resolveEnsure!: (handle: unknown) => void;
|
|
const ensureSessionPromise = new Promise((resolve) => {
|
|
resolveEnsure = resolve;
|
|
});
|
|
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
|
const closeSpy = vi.fn(async () => {});
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: ensureSession.call,
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: closeSpy,
|
|
}) as never,
|
|
});
|
|
const resultPromise = execute({
|
|
runId: "run-fence-close",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
|
return { closeSpy, resolveEnsure, resultPromise };
|
|
}
|
|
|
|
try {
|
|
vi.useFakeTimers();
|
|
|
|
// Order A: the late resolution lands as soon as possible after the
|
|
// guard rejects, ahead of the settlement steps that follow it.
|
|
{
|
|
const { closeSpy, resolveEnsure, resultPromise } = await runAbandonedHandshake();
|
|
resolveEnsure(lateHandle);
|
|
await resultPromise;
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
expect(closeSpy).toHaveBeenCalledWith(expect.objectContaining({ handle: lateHandle }));
|
|
}
|
|
|
|
// Order B: the run fully settles first (so settlement has already
|
|
// sealed the fence), and only then does the late resolution land.
|
|
{
|
|
const { closeSpy, resolveEnsure, resultPromise } = await runAbandonedHandshake();
|
|
await resultPromise;
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
resolveEnsure(lateHandle);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
expect(closeSpy).toHaveBeenCalledWith(expect.objectContaining({ handle: lateHandle }));
|
|
}
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}, 10_000);
|
|
|
|
it("discards the reuse decision and leaves no warm entry after a guard rejection", async () => {
|
|
const root = await makeTempRoot();
|
|
const warmHandles = new Map();
|
|
const ensureSession = trackEnsureSessionCall(() => new Promise<never>(() => {}));
|
|
const execute = createAcpxEngineExecutor({
|
|
warmHandles,
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: ensureSession.call,
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
try {
|
|
vi.useFakeTimers();
|
|
const resultPromise = execute({
|
|
runId: "run-discard-reuse",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: {
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
mode: "persistent",
|
|
warmHandleIdleMs: 60_000,
|
|
},
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
|
const result = await resultPromise;
|
|
|
|
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
|
expect(warmHandles.size).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps the session-identity mismatch code unchanged and distinct from both new handshake-guard codes", async () => {
|
|
class FakeAcpRuntimeError extends Error {
|
|
readonly code = "ACP_SESSION_INIT_FAILED";
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "AcpRuntimeError";
|
|
}
|
|
}
|
|
const root = await makeTempRoot();
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: async () => {
|
|
throw new FakeAcpRuntimeError("session/new failed: backend rejected initialize");
|
|
},
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
const result = await execute({
|
|
runId: "run-session-mismatch",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async () => {},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
|
|
expect(result.errorCode).toBe("acpx_session_init_failed");
|
|
expect(result.errorCode).not.toBe("acpx_handshake_timeout");
|
|
expect(result.errorCode).not.toBe("acpx_handshake_transport_lost");
|
|
|
|
// A normal cold start and session establishment stay unaffected.
|
|
const { result: coldStartResult } = await runExecutor({
|
|
agent: "custom",
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state-cold"),
|
|
});
|
|
expect(coldStartResult.exitCode).toBe(0);
|
|
expect(coldStartResult.errorCode ?? null).toBeNull();
|
|
});
|
|
|
|
it("never leaks a sandbox-provided value from a late ensureSession rejection into logs, the result, or a classification", async () => {
|
|
const secretMarker = "sandbox-secret-marker-9f3c1a";
|
|
const root = await makeTempRoot();
|
|
let rejectEnsure!: (err: unknown) => void;
|
|
const ensureSessionPromise = new Promise((_resolve, reject) => {
|
|
rejectEnsure = reject;
|
|
});
|
|
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const unhandledRejections: unknown[] = [];
|
|
const onUnhandledRejection = (err: unknown) => unhandledRejections.push(err);
|
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
|
|
try {
|
|
vi.useFakeTimers();
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: ensureSession.call,
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
const resultPromise = execute({
|
|
runId: "run-late-rejection",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
|
const result = await resultPromise;
|
|
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
|
|
|
// The abandoned promise now rejects with a value carrying a secret-like
|
|
// marker, as if a compromised sandbox child placed it there.
|
|
rejectEnsure(new Error(`leaked credential: ${secretMarker}`));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
for (const entry of logs) {
|
|
expect(entry.text).not.toContain(secretMarker);
|
|
}
|
|
expect(JSON.stringify(result)).not.toContain(secretMarker);
|
|
const lateRejectionLog = logs.find((entry) => entry.text.includes("acpx_handshake_late_rejection"));
|
|
expect(lateRejectionLog).toBeTruthy();
|
|
expect(unhandledRejections).toHaveLength(0);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandledRejection);
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("never leaks the child stderr tail into logs or the acpx.error payload after a startup-timeout guard trip", async () => {
|
|
const secretMarker = "sandbox-secret-marker-timeout-7d2e";
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const runStderrDir = path.join(stateDir, "run-stderr");
|
|
await fs.mkdir(runStderrDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(runStderrDir, "run-guard-timeout.log"),
|
|
`leaked credential: ${secretMarker}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const ensureSession = trackEnsureSessionCall(() => new Promise<never>(() => {}));
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
// Never settles on its own; only the guard's deadline can end it.
|
|
ensureSession: ensureSession.call,
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
try {
|
|
vi.useFakeTimers();
|
|
const resultPromise = execute({
|
|
runId: "run-guard-timeout",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 50);
|
|
const result = await resultPromise;
|
|
|
|
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
|
for (const entry of logs) {
|
|
expect(entry.text).not.toContain(secretMarker);
|
|
}
|
|
expect(JSON.stringify(result)).not.toContain(secretMarker);
|
|
const errorLogLine = logs.find((entry) => entry.text.includes("\"type\":\"acpx.error\""));
|
|
expect(errorLogLine).toBeTruthy();
|
|
const errorPayload = JSON.parse(errorLogLine!.text.trim());
|
|
expect(errorPayload.childStderrTail).toBeUndefined();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("never leaks the child stderr tail into logs or the acpx.error payload after a transport-lost guard trip", async () => {
|
|
const secretMarker = "sandbox-secret-marker-transport-4b91";
|
|
const root = await makeTempRoot();
|
|
const stateDir = path.join(root, "state");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
const runStderrDir = path.join(stateDir, "run-stderr");
|
|
await fs.mkdir(runStderrDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(runStderrDir, "run-guard-transport-lost.log"),
|
|
`leaked credential: ${secretMarker}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const runner = createLocalSandboxRunner();
|
|
const executionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner,
|
|
};
|
|
|
|
// A minimal run-disposition latch, matching the real bridge transport: once
|
|
// the loss is ordered, every later read still reports it.
|
|
let lossOrdered = false;
|
|
const readDisposition = () => ({ failed: lossOrdered, lossReason: lossOrdered ? "provider_exit" : null });
|
|
const bridgeHandle = {
|
|
env: {
|
|
PAPERCLIP_API_URL: "http://127.0.0.1:1",
|
|
PAPERCLIP_API_KEY: "bridge-token",
|
|
PAPERCLIP_API_BRIDGE_MODE: "http2_v1",
|
|
},
|
|
readRunDisposition: () => readDisposition(),
|
|
settleRunDisposition: () => readDisposition(),
|
|
markOrderlyCompletion: () => {},
|
|
stop: async () => {},
|
|
};
|
|
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
|
|
async () => bridgeHandle as never,
|
|
);
|
|
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
|
|
async () => ({ agentCommand: null, stop: async () => {} }) as never,
|
|
);
|
|
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
// Never settles on its own; only the guard's transport-loss poll can
|
|
// end it.
|
|
ensureSession: () => new Promise(() => {}),
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
setConfigOption: async () => {},
|
|
close: async () => {},
|
|
}) as never,
|
|
});
|
|
|
|
// Real timers: the sandbox lane spawns a real staging subprocess, whose
|
|
// completion callbacks run on the real event loop, not through fake
|
|
// timers. The loss is ordered up front, before `ensureSession` ever runs.
|
|
const resultPromise = execute({
|
|
runId: "run-guard-transport-lost",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
|
context: {},
|
|
authToken: "real-run-jwt",
|
|
executionTarget,
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
onEvent: async () => {},
|
|
} as never);
|
|
lossOrdered = true;
|
|
const result = await resultPromise;
|
|
|
|
expect(result.errorCode).toBe("acpx_handshake_transport_lost");
|
|
for (const entry of logs) {
|
|
expect(entry.text).not.toContain(secretMarker);
|
|
}
|
|
expect(JSON.stringify(result)).not.toContain(secretMarker);
|
|
const errorLogLine = logs.find((entry) => entry.text.includes("\"type\":\"acpx.error\""));
|
|
expect(errorLogLine).toBeTruthy();
|
|
const errorPayload = JSON.parse(errorLogLine!.text.trim());
|
|
expect(errorPayload.childStderrTail).toBeUndefined();
|
|
}, 10000);
|
|
|
|
it("never leaks a sandbox-provided value from a late close rejection into logs or the result", async () => {
|
|
const secretMarker = "sandbox-secret-marker-late-close-2a7f";
|
|
const root = await makeTempRoot();
|
|
const lateHandle = {
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
runtimeSessionName: "runtime-session",
|
|
};
|
|
let resolveEnsure!: (handle: unknown) => void;
|
|
const ensureSessionPromise = new Promise((resolve) => {
|
|
resolveEnsure = resolve;
|
|
});
|
|
const ensureSession = trackEnsureSessionCall(() => ensureSessionPromise);
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const unhandledRejections: unknown[] = [];
|
|
const onUnhandledRejection = (err: unknown) => unhandledRejections.push(err);
|
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
|
|
try {
|
|
vi.useFakeTimers();
|
|
const execute = createAcpxEngineExecutor({
|
|
createRuntime: () =>
|
|
({
|
|
ensureSession: ensureSession.call,
|
|
startTurn: () => ({
|
|
events: (async function* () {})(),
|
|
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
|
cancel: async () => {},
|
|
}),
|
|
close: async () => {
|
|
throw new Error(`leaked credential: ${secretMarker}`);
|
|
},
|
|
}) as never,
|
|
});
|
|
const resultPromise = execute({
|
|
runId: "run-late-close-rejection",
|
|
agent: { id: "agent-1", companyId: "company-1" },
|
|
runtime: {},
|
|
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
|
|
context: {},
|
|
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
onMeta: async () => {},
|
|
} as never);
|
|
await advanceHandshakeGuardAfterStart(ensureSession.started, ACPX_HANDSHAKE_TIMEOUT_MS + 1);
|
|
const result = await resultPromise;
|
|
expect(result.errorCode).toBe("acpx_handshake_timeout");
|
|
|
|
// The run has already settled (the fence is sealed), and only now does the
|
|
// abandoned promise resolve into a real handle, as if a compromised
|
|
// sandbox child forged a late handle. The engine's own close attempt on
|
|
// that handle then rejects with a value carrying a secret-like marker.
|
|
resolveEnsure(lateHandle);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
for (const entry of logs) {
|
|
expect(entry.text).not.toContain(secretMarker);
|
|
}
|
|
expect(JSON.stringify(result)).not.toContain(secretMarker);
|
|
const lateCloseLog = logs.find((entry) => entry.text.includes("acpx_handshake_late_close_failed"));
|
|
expect(lateCloseLog).toBeTruthy();
|
|
expect(unhandledRejections).toHaveLength(0);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandledRejection);
|
|
vi.useRealTimers();
|
|
}
|
|
}, 10000);
|
|
});
|