fix(runner): repair paid provider startup paths

This commit is contained in:
Dotta 2026-09-02 16:27:48 -05:00
parent 0f94521017
commit e9a9abf743
16 changed files with 526 additions and 12 deletions

View File

@ -58,7 +58,7 @@
"sideEffects": false,
"scripts": {
"build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && pnpm run check:semantic-contracts && pnpm run check:runner-workflow-traceability && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check",
"build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json",
"build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs",
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
"build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs",
"build:provider-pack": "pnpm run build:typescript && node scripts/build-provider-pack.mjs",
@ -73,7 +73,7 @@
"typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace",
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust",
"test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs && vitest run",
"test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs && vitest run",
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
"test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::",

View File

@ -119,6 +119,7 @@ impl AcpxSidecarTransport {
"RUST_BACKTRACE",
"PAPERCLIP_NATIVE_MCP_NAME",
"PAPERCLIP_NATIVE_MCP_URL",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
];
keys.extend_from_slice(credential_keys);
Self::start_with_environment_keys(config, &keys)

View File

@ -492,7 +492,30 @@ fn process_command<E: CommandExecutor>(
// in the effect window, recovery returns an indeterminate result and never
// executes the same logical command twice.
store.save(state)?;
let execution = executor.execute(command)?;
let execution = match executor.execute(command) {
Ok(execution) => execution,
Err(error) => {
// An executor-returned error is a terminal observation, not crash
// ambiguity. Commit it before replying so recovery can replay the
// original provider/bootstrap failure without executing the
// command twice. A process death inside execute still leaves the
// pre-effect marker pending and remains indeterminate on recovery.
let message = error.to_string();
state.record_diagnostic(format!(
"{} command failed: {message}",
command.command_type
));
let result = state.fail_command(
command,
json!({
"code": "command_execution_failed",
"message": message,
}),
)?;
store.save(state)?;
return Ok((result, CommandLifecycle::Continue));
}
};
for (event_type, priority, payload) in execution.events {
state.enqueue_event(config, event_type, priority, payload)?;
}
@ -566,6 +589,10 @@ mod tests {
calls: usize,
}
struct FailingExecutor {
calls: usize,
}
struct RetainingEventExecutor {
events: VecDeque<PolledEvent>,
fail_acknowledgement: bool,
@ -578,6 +605,15 @@ mod tests {
}
}
impl CommandExecutor for FailingExecutor {
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
self.calls += 1;
Err(DurableRunnerError::invalid(
"provider bootstrap rejected authorization=Bearer test-secret",
))
}
}
impl CommandExecutor for RetainingEventExecutor {
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
Ok(CommandExecution::result(json!({"status": "completed"})))
@ -758,6 +794,78 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn executor_failure_is_durable_and_does_not_become_indeterminate() {
let directory = std::env::temp_dir().join(format!(
"paperclip-runner-command-failure-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&directory);
let config = config(directory.clone());
let store = DurableStateStore::new(&directory).unwrap();
let (mut state, _) = store.load_or_create(&config).unwrap();
let mut executor = FailingExecutor { calls: 0 };
let command = command("session.open");
let failed = process_command(&mut state, &store, &config, &mut executor, &command)
.unwrap()
.0;
let (mut recovered, existed) = store.load_or_create(&config).unwrap();
let replay = process_command(&mut recovered, &store, &config, &mut executor, &command)
.unwrap()
.0;
assert!(existed);
assert_eq!(executor.calls, 1);
assert_eq!(failed, replay);
assert_eq!(failed.status, "failed");
assert_eq!(failed.result["code"], "command_execution_failed");
assert_eq!(
failed.result["message"],
"provider bootstrap rejected authorization=Bearer [REDACTED]"
);
assert!(recovered.diagnostics.iter().any(|diagnostic| {
diagnostic
== "session.open command failed: provider bootstrap rejected authorization=Bearer [REDACTED]"
}));
assert!(recovered
.diagnostics
.iter()
.all(|diagnostic| !diagnostic.contains("test-secret")));
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn process_death_after_journaling_remains_indeterminate_without_reexecution() {
let directory = std::env::temp_dir().join(format!(
"paperclip-runner-command-indeterminate-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&directory);
let config = config(directory.clone());
let store = DurableStateStore::new(&directory).unwrap();
let (mut state, _) = store.load_or_create(&config).unwrap();
let command = command("session.open");
assert_eq!(
state.begin_command(&command).unwrap(),
CommandDisposition::Execute
);
store.save(&state).unwrap();
let (mut recovered, existed) = store.load_or_create(&config).unwrap();
let mut executor = CountingExecutor { calls: 0 };
let replay = process_command(&mut recovered, &store, &config, &mut executor, &command)
.unwrap()
.0;
assert!(existed);
assert_eq!(executor.calls, 0);
assert_eq!(replay.status, "indeterminate");
assert_eq!(replay.result["code"], "execution_indeterminate");
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn completed_shutdown_replay_still_stops_after_delivery() {
let directory = std::env::temp_dir().join(format!(

View File

@ -537,6 +537,28 @@ impl DurableState {
command: &Command,
result: Value,
) -> Result<StoredCommandResult, DurableRunnerError> {
self.finish_command(command, "completed", result)
}
pub fn fail_command(
&mut self,
command: &Command,
result: Value,
) -> Result<StoredCommandResult, DurableRunnerError> {
self.finish_command(command, "failed", result)
}
fn finish_command(
&mut self,
command: &Command,
status: &str,
result: Value,
) -> Result<StoredCommandResult, DurableRunnerError> {
if status != "completed" && status != "failed" {
return Err(DurableRunnerError::invalid(
"durable command terminal status is unsupported",
));
}
{
let stored = self
.processed_commands
@ -568,7 +590,7 @@ impl DurableState {
.processed_commands
.get_mut(&command.command_id)
.expect("pending command was checked above");
stored.status = "completed".to_owned();
stored.status = status.to_owned();
stored.result = sanitized_result;
Ok(stored.clone())
}
@ -903,7 +925,7 @@ fn validate_binding(
|| command.controller_seq > state.last_controller_command_seq
|| !matches!(
command.status.as_str(),
"pending" | "completed" | "indeterminate"
"pending" | "completed" | "failed" | "indeterminate"
)
{
return Err(DurableRunnerError::invalid(

View File

@ -0,0 +1,76 @@
import { builtinModules } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const verifiedProviderEntrypoints = Object.freeze([
Object.freeze({
name: "acpx-runtime-sidecar",
source: resolve(packageRoot, "src/cli/acpx-runtime-sidecar.ts"),
output: resolve(packageRoot, "dist/cli/acpx-runtime-sidecar.js"),
}),
Object.freeze({
name: "opencode-app-server-proxy",
source: resolve(packageRoot, "src/cli/opencode-app-server-proxy.ts"),
output: resolve(packageRoot, "dist/cli/opencode-app-server-proxy.js"),
}),
]);
const nodeBuiltins = new Set([
...builtinModules,
...builtinModules.map((name) => `node:${name}`),
]);
function assertSelfContainedBundle(entrypoint, result) {
const outputs = Object.entries(result.metafile.outputs).filter(
([, output]) => output.entryPoint !== undefined,
);
if (outputs.length !== 1) {
throw new Error(
`${entrypoint.name} bundle emitted ${outputs.length} entrypoint outputs instead of one`,
);
}
const imports = outputs[0][1].imports;
for (const dependency of imports) {
if (!dependency.external || !nodeBuiltins.has(dependency.path)) {
throw new Error(
`${entrypoint.name} bundle retained a non-builtin import: ${dependency.path}`,
);
}
}
}
export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) {
const results = [];
for (const entrypoint of verifiedProviderEntrypoints) {
const result = await build({
entryPoints: [entrypoint.source],
outfile: entrypoint.output,
bundle: true,
platform: "node",
format: "esm",
target: "node24",
packages: "bundle",
splitting: false,
sourcemap: false,
legalComments: "none",
metafile: true,
treeShaking: true,
write,
logLevel: "silent",
});
assertSelfContainedBundle(entrypoint, result);
results.push({ entrypoint, result });
}
return results;
}
const invokedPath = process.argv[1]
? pathToFileURL(resolve(process.argv[1])).href
: null;
if (invokedPath === import.meta.url) {
await bundleVerifiedProviderEntrypoints();
}

View File

@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
bundleVerifiedProviderEntrypoints,
verifiedProviderEntrypoints,
} from "./build-verified-provider-entrypoints.mjs";
test("verified JS provider entrypoints bundle into one descriptor-safe file", async () => {
const bundles = await bundleVerifiedProviderEntrypoints({ write: false });
assert.equal(bundles.length, verifiedProviderEntrypoints.length);
for (const { entrypoint, result } of bundles) {
assert.equal(result.outputFiles?.length, 1, entrypoint.name);
const source = result.outputFiles[0].text;
assert.match(source, /^#!\/usr\/bin\/env node\n/);
}
});

View File

@ -192,6 +192,48 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary"
expect(launches[0]!.environment.PAPERCLIP_OPENCODE_COMMAND).toBeUndefined();
});
it("preserves the controller-selected ACPX provider package root", () => {
const launches: RunnerProcessLaunchSpec[] = [];
spawnRunner({
connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" },
stateDirectory: "/tmp/paperclip-runner-test",
identity,
ticket: "bootstrap-ticket",
maxOutboxBytes: 256 * 1024,
p0ReserveBytes: 64 * 1024,
runnerVersion: expectedRunnerVersion,
runnerDigest: expectedRunnerDigest,
environment: {
PATH: "/bin",
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack",
NODE_PATH: "/untrusted/modules",
},
processLauncher: (spec) => {
launches.push(spec);
return {
child: {
pid: 42,
exitCode: null,
signalCode: null,
kill: () => true,
},
completion: Promise.resolve({
code: 0,
signal: null,
stdout: "",
stderr: "",
}),
};
},
});
expect(launches).toHaveLength(1);
expect(
launches[0]!.environment.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT,
).toBe("/verified/provider-pack");
expect(launches[0]!.environment.NODE_PATH).toBeUndefined();
});
it("preserves file-backed AWS workload identity at the runner spawn boundary", () => {
const launches: RunnerProcessLaunchSpec[] = [];
spawnRunner({

View File

@ -1892,6 +1892,7 @@ const runnerExplicitProviderEnvironmentKeys = [
"PAPERCLIP_NATIVE_MCP_URL",
"PAPERCLIP_NATIVE_MCP_TOKEN",
"PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
"PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY",
"PAPERCLIP_PROVIDER_TRACE_PATH",
"PAPERCLIP_PROVIDER_TRACE_MAX_BYTES",

View File

@ -24,6 +24,7 @@ import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
import {
awaitVerifiedAcpxProviderExit,
awaitVerifiedAcpxProviderOwnership,
createAcpxPackageJsonResolver,
guardSnapshotModuleLookup,
guardSnapshotModuleResolution,
reapCurrentProviderProcessGroup,
@ -48,6 +49,31 @@ afterEach(async () => {
});
describe("ACPX installation integrity", () => {
it("anchors dynamic provider package resolution at an explicit root", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-package-root-"));
temporaryDirectories.push(root);
const providerDirectory = join(root, "node_modules", "qualified-provider");
const providerPackageJson = join(providerDirectory, "package.json");
await mkdir(providerDirectory, { recursive: true });
await Promise.all([
writeFile(join(root, "package.json"), JSON.stringify({ private: true })),
writeFile(
providerPackageJson,
JSON.stringify({ name: "qualified-provider", version: "1.0.0" }),
),
]);
expect(createAcpxPackageJsonResolver(root)("qualified-provider")).toBe(
providerPackageJson,
);
expect(() =>
createAcpxPackageJsonResolver("relative/provider-pack"),
).toThrow("explicit normalized absolute path");
expect(() => createAcpxPackageJsonResolver(undefined)).toThrow(
"explicit normalized absolute path",
);
});
it("rejects an unregistered provider exit proof", async () => {
await expect(
awaitVerifiedAcpxProviderExit({} as ChildProcess),

View File

@ -198,6 +198,25 @@ const providerExitProof = new WeakMap<ChildProcess, Promise<void>>();
export type AcpxPackageJsonResolver = (packageName: string) => string;
export function createAcpxPackageJsonResolver(
providerPackageRoot: string | undefined,
): AcpxPackageJsonResolver {
const root = providerPackageRoot?.trim();
if (
!root ||
!isAbsolute(root) ||
root.includes("\0") ||
resolve(root) !== root
) {
throw new Error(
"ACPX provider package root must be an explicit normalized absolute path",
);
}
const providerRequire = createRequire(resolve(root, "package.json"));
return (packageName) =>
providerRequire.resolve(`${packageName}/package.json`);
}
export interface VerifiedAcpxInstallation {
readonly commandDigest: string;
readonly agentServerPackageJsonPath: string;
@ -469,6 +488,12 @@ export async function verifyQualifiedAcpxInstallation(
}
function defaultPackageJsonResolver(packageName: string): string {
const providerPackageRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT;
if (providerPackageRoot !== undefined) {
return createAcpxPackageJsonResolver(providerPackageRoot)(packageName);
}
// Source-mode and direct runtimes still have a stable module URL. The
// descriptor-backed runner sidecar always receives the explicit root above.
return createRequire(import.meta.url).resolve(`${packageName}/package.json`);
}

View File

@ -117,6 +117,19 @@ it.each(["acpx-runtime-sidecar.js", "opencode-app-server-proxy.js"] as const)(
},
);
it("derives the ACPX package root only from the verified dist/cli layout", () => {
expect(
runnerdLaunchProfileInternals.acpxProviderPackageRoot(
"/provider-pack/dist/cli/acpx-runtime-sidecar.js",
),
).toBe("/provider-pack");
expect(() =>
runnerdLaunchProfileInternals.acpxProviderPackageRoot(
"/unverified/acpx-runtime-sidecar.js",
),
).toThrow("ACPX sidecar must use the provider package dist/cli layout");
});
it("requires a provider-pack authority for remote ACPX artifact hashes", () => {
expect(() =>
runnerdLaunchProfileInternals.acpxRunnerLaunchProfile(
@ -409,6 +422,7 @@ it.each([
environment: {
PATH: "/bin",
...credentialEnvironment,
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/attacker/package-root",
PAPERCLIP_API_KEY: "must-not-reach-provider",
DATABASE_URL: "must-not-reach-provider",
},
@ -424,6 +438,8 @@ it.each([
codexHome: "/isolated/codex-home",
runtimeContextPath: "/isolated/runtime-context.json",
hasRuntimeContext: true,
acpxSidecarPath:
"/verified/provider-pack/dist/cli/acpx-runtime-sidecar.js",
});
expect(environment).toMatchObject({
@ -432,6 +448,7 @@ it.each([
PAPERCLIP_RUN_ID: "run-1",
PAPERCLIP_NORMALIZED_SESSION_ID: "session-1",
PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json",
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack",
});
for (const key of allowed)
expect(environment[key]).toBe(credentialEnvironment[key]);

View File

@ -1111,6 +1111,20 @@ function resolveBuildOwnedCliArtifact(
);
}
function acpxProviderPackageRoot(sidecarScript: string): string {
const cliDirectory = dirname(sidecarScript);
if (
basename(sidecarScript) !== "acpx-runtime-sidecar.js" ||
basename(cliDirectory) !== "cli" ||
basename(dirname(cliDirectory)) !== "dist"
) {
throw new Error(
"runner_provider_package_root_incompatible: ACPX sidecar must use the provider package dist/cli layout",
);
}
return resolve(cliDirectory, "../..");
}
function acpxRunnerLaunchProfile(
options: CapabilityRunnerdCodexTransportOptions,
command: string,
@ -1279,6 +1293,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
codexHome: string;
runtimeContextPath: string;
hasRuntimeContext: boolean;
acpxSidecarPath?: string;
}): NodeJS.ProcessEnv {
const commonIdentity = {
PAPERCLIP_RUNNER_INSTANCE_ID: input.identity.runnerInstanceId,
@ -1300,12 +1315,21 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
};
}
if (input.provider === "acpx") {
const sidecarPath =
input.acpxSidecarPath ??
input.options.acpxSidecarPath ??
resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.js");
return {
...createSanitizedAcpxSpawnInput(
input.options.environment,
input.options.acpxAgent ?? "codex",
).env,
...commonIdentity,
// The verified sidecar bundle cannot use import.meta.url while Node
// executes it through /proc/self/fd. Anchor its closed provider package
// lookups at the package that owns the already-authenticated bundle.
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT:
acpxProviderPackageRoot(sidecarPath),
...(input.options.providerRecoveryPolicy ===
"allow_replacement_after_governed_wait"
? {
@ -2354,6 +2378,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
codexHome,
runtimeContextPath,
hasRuntimeContext: runtimeContext !== null,
acpxSidecarPath,
}),
this.options.environment,
),
@ -2657,6 +2682,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
codexHome,
runtimeContextPath,
hasRuntimeContext: runtimeContext !== null,
acpxSidecarPath,
}),
this.options.environment,
),
@ -3361,6 +3387,7 @@ export const createRunnerdCodexTransport =
createCapabilityRunnerdCodexTransport;
export const runnerdLaunchProfileInternals = Object.freeze({
acpxProviderPackageRoot,
acpxRunnerLaunchProfile,
resolveBuildOwnedCliArtifact,
});

View File

@ -93,6 +93,12 @@ describe("runner E2E catalog", () => {
expectedRunCount: 2,
});
expect(localQuestion?.buildPrompt("nonce")).toContain("ask_user_questions");
expect(localQuestion?.buildPrompt("nonce")).toContain(
"do not spell, quote, repeat, announce, or include PAPERCLIP_E2E_QUESTION_DONE_nonce",
);
expect(localQuestion?.buildPrompt("nonce")).toContain(
"refer to it only as “the terminal marker.”",
);
expect(restartQuestion).toMatchObject({
flow: "question_resume_completion",
expectedRunCount: 2,

View File

@ -572,6 +572,7 @@ const structuredQuestionResumeTask = {
[
"Ask the user one structured question before completing this task.",
"The question must be required, single-select, and offer Cobalt and Amber. Do not publish a final answer or mark the task Done while it is pending.",
`Before the answer arrives, do not spell, quote, repeat, announce, or include PAPERCLIP_E2E_QUESTION_DONE_${nonce} in any visible response, comment, or tool payload; refer to it only as “the terminal marker.”`,
`In a native runner, call request_human_input exactly once with idempotencyKey \`question-${nonce}\`, interactionKind \`questions\`, title \`Verification word\`, prompt \`Choose the verification word\`, continuationPolicy \`wake_assignee\`, and payload {version:1,questions:[{id:\`verification-word\`,prompt:\`Choose the verification word.\`,selectionMode:\`single\`,required:true,options:[{id:\`cobalt\`,label:\`Cobalt\`},{id:\`amber\`,label:\`Amber\`}]}]}.`,
"In a legacy runner, POST /api/issues/$PAPERCLIP_TASK_ID/interactions with {kind:`ask_user_questions`,continuationPolicy:`wake_assignee`,payload:{version:1,questions:[{id:`verification-word`,prompt:`Choose the verification word.`,selectionMode:`single`,required:true,options:[{id:`cobalt`,label:`Cobalt`},{id:`amber`,label:`Amber`}]}]}} using Authorization and X-Paperclip-Run-Id, then move the issue to `in_review`.",
`After the answer arrives, if it is Cobalt, publish exactly PAPERCLIP_E2E_QUESTION_DONE_${nonce} once as the complete visible response and mark the task Done.`,

View File

@ -178,6 +178,85 @@ describe("nativeRunEventsToTranscript", () => {
]);
});
it("coalesces sparse Codex tool lifecycle events at the named write boundary", () => {
const transcript = nativeRunEventsToTranscript([
event(1, "tool.execution.started", {
schema: "paperclip.tool.execution.v1",
executionId: "exec-write-plan",
transport: "dynamic",
operation: "unknown",
name: null,
status: "running",
output: null,
}),
itemEvent(2, "item.started", "exec-write-plan", {
kind: "dynamicToolCall",
item: { id: "exec-write-plan" },
}),
itemEvent(3, "item.started", "exec-write-plan", {
kind: "dynamicToolCall",
item: {
type: "tool_use",
id: "exec-write-plan",
name: "write_document",
input: {
key: "plan",
title: "Plan",
body: "Ship the durable runner.",
},
},
}),
itemEvent(4, "item.completed", "exec-write-plan", {
kind: "dynamicToolCall",
item: {
type: "tool_result",
id: "exec-write-plan",
tool_use_id: "exec-write-plan",
result: {
commandKind: "write_document",
revisionId: "revision-1",
},
},
}),
event(5, "tool.execution.completed", {
schema: "paperclip.tool.execution.v1",
executionId: "exec-write-plan",
transport: "dynamic",
operation: "unknown",
name: null,
status: "completed",
output: null,
}),
itemEvent(6, "item.completed", "exec-write-plan", {
kind: "dynamicToolCall",
item: { id: "exec-write-plan", status: "completed" },
}),
]);
expect(transcript).toEqual([
expect.objectContaining({
kind: "tool_call",
name: "write_document",
toolUseId: "exec-write-plan",
input: {
key: "plan",
title: "Plan",
body: "Ship the durable runner.",
},
}),
expect.objectContaining({
kind: "tool_result",
toolUseId: "exec-write-plan",
toolName: "write_document",
content: JSON.stringify({
commandKind: "write_document",
revisionId: "revision-1",
}),
isError: false,
}),
]);
});
it("streams deltas until a loss-resistant completed item is available", () => {
expect(nativeRunEventsToTranscript([
event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Still " }),

View File

@ -503,10 +503,44 @@ function timestamp(event: HeartbeatRunEvent, envelope: Record<string, unknown>):
return Number.isNaN(Date.parse(createdAt)) ? new Date(0).toISOString() : createdAt;
}
function toolPresentation(payload: Record<string, unknown>): { name: string; input: unknown } {
interface NativeToolItemDetails {
name: string | null;
input?: unknown;
result?: unknown;
isError: boolean;
}
function nativeToolItemDetails(
payload: Record<string, unknown>,
): { id: string | null; details: NativeToolItemDetails } | null {
const item = normalizedItem(payload);
const kind = (text(item.type) ?? "").replaceAll("_", "").toLowerCase();
if (kind !== "tooluse" && kind !== "toolresult") return null;
const id = kind === "toolresult"
? text(item.tool_use_id) ?? text(item.id)
: text(item.id);
return {
id,
details: {
name: text(item.name),
...(Object.prototype.hasOwnProperty.call(item, "input")
? { input: item.input }
: {}),
...(Object.prototype.hasOwnProperty.call(item, "result")
? { result: item.result }
: {}),
isError: item.isError === true || item.is_error === true,
},
};
}
function toolPresentation(
payload: Record<string, unknown>,
item?: NativeToolItemDetails,
): { name: string; input: unknown } {
const transport = text(payload.transport);
const operation = text(payload.operation);
const reportedName = text(payload.name);
const reportedName = text(payload.name) ?? item?.name ?? null;
if (transport === "process") {
return {
name: "Bash",
@ -515,7 +549,7 @@ function toolPresentation(payload: Record<string, unknown>): { name: string; inp
}
return {
name: reportedName ?? operation ?? "Tool",
input: {
input: item?.input ?? {
...(operation ? { operation } : {}),
...(text(payload.namespace) ? { namespace: text(payload.namespace) } : {}),
...(text(payload.target) ? { target: text(payload.target) } : {}),
@ -531,6 +565,7 @@ function toolPresentation(payload: Record<string, unknown>): { name: string; inp
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
const startedToolIds = new Set<string>();
const completedToolIds = new Set<string>();
let hasFinalAssistantMessage = false;
let usageSummary: {
ts: string;
@ -569,6 +604,7 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
const completedAgentMessageIds = new Set<string>();
const completedReasoningIds = new Set<string>();
const completionItemIdentityById = new Map<string, ItemIdentity>();
const nativeToolItemsById = new Map<string, NativeToolItemDetails>();
for (const event of orderedEvents) {
if (!isItemIdentityEvent(event.eventType)) continue;
const envelope = record(event.payload?.prpEvent);
@ -583,6 +619,25 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
if (!payload) continue;
const itemId = normalizedItemId(envelope, payload);
if (!itemId) continue;
const toolItem = nativeToolItemDetails(payload);
if (toolItem) {
const toolId = toolItem.id ?? itemId;
const previous = nativeToolItemsById.get(toolId);
nativeToolItemsById.set(toolId, {
name: toolItem.details.name ?? previous?.name ?? null,
...(toolItem.details.input !== undefined
? { input: toolItem.details.input }
: previous?.input !== undefined
? { input: previous.input }
: {}),
...(toolItem.details.result !== undefined
? { result: toolItem.details.result }
: previous?.result !== undefined
? { result: previous.result }
: {}),
isError: toolItem.details.isError || previous?.isError === true,
});
}
const identity = resolveItemIdentity(
payload,
completionItemIdentityById.get(itemId),
@ -700,7 +755,8 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue;
const executionId = text(payload.executionId);
if (!executionId) continue;
const presentation = toolPresentation(payload);
const nativeToolItem = nativeToolItemsById.get(executionId);
const presentation = toolPresentation(payload, nativeToolItem);
if (!startedToolIds.has(executionId)) {
startedToolIds.add(executionId);
entries.push({
@ -711,14 +767,24 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
toolUseId: executionId,
});
}
if (event.eventType === "tool.execution.completed") {
if (event.eventType === "tool.execution.completed" && !completedToolIds.has(executionId)) {
completedToolIds.add(executionId);
const output = text(payload.output);
let content = output ?? "";
if (!output && nativeToolItem?.result !== undefined) {
try {
content = JSON.stringify(nativeToolItem.result) ?? "";
} catch {
content = "Tool result could not be serialized";
}
}
entries.push({
kind: "tool_result",
ts,
toolUseId: executionId,
toolName: presentation.name,
content: text(payload.output) ?? "",
isError: payload.status === "failed",
content,
isError: payload.status === "failed" || nativeToolItem?.isError === true,
});
}
continue;