Files
paperclip/scripts/acpx-patch-packaging.test.mjs
Nicky LeachandClaude Sonnet 5 c1b55537ba fix(paperclip-runner): bump claude-agent-acp pin to 0.73.0 (#13162)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Claude local adapter can run agent turns through an ACP (Agent
Client Protocol) server, `claude-agent-acp`, instead of the plain CLI
> - Two separate packages each pin their own copy of that dependency:
`packages/adapters/claude-local` (the server-side adapter) and
`packages/paperclip-runner` (which builds the provider pack baked into
every managed sandbox image)
> - `claude-local` moved to `^0.73.0` in #12730, but `paperclip-runner`
was never bumped past `0.70.0` — nothing keeps the two in sync when only
one changes
> - That split means a sandbox image built from `paperclip-runner`'s
provider pack ships a `claude-agent-acp` the server-side adapter was
never actually compatible with
> - This pull request bumps `paperclip-runner`'s pin to `0.73.0`, the
only version that satisfies both packages' declared ranges at once, and
fixes the matching hardcoded version assertion in
`docker/daytona-runner/Dockerfile`
> - The benefit is one consistent, compatible `claude-agent-acp` version
across both the server host and every sandbox image built from this
source, instead of a silent split that only surfaces as a runtime
failure

## Linked Issues or Issue Description

No public issue exists for this specific split; opening directly per
CONTRIBUTING.md path B, following the bug report template fields.

**What happened?**
`packages/paperclip-runner/package.json` pins
`@agentclientprotocol/claude-agent-acp` at an exact `0.70.0`.
`packages/adapters/claude-local/package.json` requires `^0.73.0` (added
in #12730, 2026-09-02). Nobody re-synced `paperclip-runner`'s pin after
that change — the two packages' dependency graphs are independent, so a
bump in one doesn't propagate to the other. `paperclip-runner`'s copy is
what the fleet sandbox image's provider pack actually ships, so every
managed sandbox built from current source carries a `claude-agent-acp`
version the server-side adapter's own declared compatibility range
excludes.

**Expected behavior**
The two packages' `claude-agent-acp` pins should stay within a mutually
compatible range, so a sandbox image built from this source always ships
a version the server-side adapter actually supports.

**Steps to reproduce**
1. Check `packages/adapters/claude-local/package.json`'s
`@agentclientprotocol/claude-agent-acp` range (`^0.73.0`).
2. Check `packages/paperclip-runner/package.json`'s pin for the same
package (`0.70.0` before this PR).
3. Note that `^0.73.0` on a `0.x` version only admits patch releases
(`>=0.73.0 <0.74.0` per semver caret rules), so `0.70.0` falls outside
it.

**Paperclip version or commit**
`master` as of this PR (paperclip-runner still at `0.70.0` prior to this
change; claude-local's `^0.73.0` requirement landed in #12730).

**Deployment mode**
Any deployment that runs `claude_local` agents through the ACP engine
against a sandbox image built from `packages/paperclip-runner`'s
provider pack (managed cloud sandboxes in particular).

Related PRs for context (not duplicates — none of these touch
`paperclip-runner`'s pin):
- #12730 — introduced the `^0.73.0` requirement in `claude-local`
- #11873 — the last time `paperclip-runner`'s pin moved (`0.69.0` →
`0.70.0`)
- #13105 — separately made an unavailable ACP engine a hard failure
instead of a silent CLI fallback, which is what turned this version
split into a visible, run-blocking error rather than a quiet downgrade

## What Changed

- Bump `@agentclientprotocol/claude-agent-acp` from `0.70.0` to `0.73.0`
(exact pin, matching this package's existing pin style for its other
agent-CLI dependencies) in `packages/paperclip-runner/package.json`.
- Update the corresponding hardcoded version assertion (`test
"$(claude-agent-acp --version)" = "0.70.0"`) in
`docker/daytona-runner/Dockerfile` to `0.73.0`, so its own build-time
check stays accurate instead of failing on the next build for an
unrelated reason.
- `pnpm-lock.yaml` is intentionally **not** included —
`pr-trusted.yml`'s `Validate dependency resolution and regenerate stale
lockfile` step already regenerates it for the merge tree and hands it to
downstream `--frozen-lockfile` jobs as an artifact, so a manual lockfile
commit here would just be stale the moment CI runs.

## Verification

- `0.73.0` is a real published version on npm (confirmed via `npm view
@agentclientprotocol/claude-agent-acp versions`), and it's the *only*
version satisfying claude-local's `^0.73.0` range, so this isn't a guess
at compatibility — it's the unique intersection of both packages'
declared ranges.
- `grep -rn "0\.70\.0" docker/ packages/paperclip-runner/package.json`
after this change shows no remaining stale references to the old pin.
- I did not run a full local install/test pass against a hand-updated
lockfile, since regenerating one locally would conflict with leaving
`pnpm-lock.yaml` untouched per the note above; CI's own
lockfile-regeneration step is the intended verification path for a
manifest-only dependency bump like this one.
- Downstream/full verification (does a sandbox image actually built with
this pin work end-to-end) is tracked separately in `paperclip-cloud` —
an unrelated internal-only repo, so not linked here — where a sibling
fix restores the ACP servers to the runtime `PATH` in the fleet sandbox
image itself; both fixes are needed together for a working sandbox, but
this PR is scoped to the version pin alone.

## Risks

- Low risk: single-line dependency version bump plus a matching
test-assertion update, no code changes. `0.73.0` is a patch release
within claude-local's own already-declared-safe range, so there's no
reason to expect it changes behavior tenants depend on.
- The main risk is unknown breaking changes between `claude-agent-acp`
0.70.0 and 0.73.0 that aren't caught by the version-string assertion
alone (that check only confirms the binary reports the right version,
not that its behavior is unchanged). I have not audited that package's
own changelog between those versions.
- `docker/daytona-runner/Dockerfile` is a parallel/reference image (per
its own header comment, meant to stay aligned with the private
`paperclip-cloud/fleet-sandbox-image/Dockerfile`, which is out of scope
here) — this PR does not touch that other Dockerfile.

## Model Used

Claude Sonnet 5 (`claude-sonnet-5`), via Claude Code, with tool use
(file edits, shell/git, `gh` CLI, `npm view` for version verification).
No extended-thinking mode. Standard Claude Code context window.

## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass — see Verification: a
manifest-only bump with the lockfile intentionally left to CI's own
regeneration step; no local test run applicable
- [x] I have added or updated tests where applicable — version-pin bump
only, no new behavior to test
- [x] I have updated relevant documentation to reflect my changes — none
applicable
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green — pending CI run on this PR
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
pending review
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:35:33 -07:00

447 lines
17 KiB
JavaScript

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createRequire } from "node:module";
import { runInNewContext } from "node:vm";
import cliEsbuildConfig from "../cli/esbuild.config.mjs";
import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs";
import {
createBundledInstallManifest,
materializePublishManifest,
selectBundledDependencyPatches,
} from "./prepare-bundled-package.mjs";
const rootPackage = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
const adapterUtilsPackage = JSON.parse(
await readFile(new URL("../packages/adapter-utils/package.json", import.meta.url), "utf8"),
);
const runnerPackage = JSON.parse(
await readFile(new URL("../packages/paperclip-runner/package.json", import.meta.url), "utf8"),
);
const serverPackage = JSON.parse(
await readFile(new URL("../server/package.json", import.meta.url), "utf8"),
);
const dbPackage = JSON.parse(
await readFile(new URL("../packages/db/package.json", import.meta.url), "utf8"),
);
const releaseScript = await readFile(new URL("./release.sh", import.meta.url), "utf8");
const releaseLib = await readFile(new URL("./release-lib.sh", import.meta.url), "utf8");
const buildNpmScript = await readFile(new URL("./build-npm.sh", import.meta.url), "utf8");
const acpxRuntimePatch = await readFile(
new URL("../patches/acpx@0.13.1.patch", import.meta.url),
"utf8",
);
const claudeAcpPatch = await readFile(
new URL("../patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch", import.meta.url),
"utf8",
);
for (const version of ["0.12.0", "0.13.1"]) {
test(`ACPX ${version} release patch uses portable generated unified hunks`, async () => {
const patch = await readFile(
new URL(`../patches/acpx@${version}.patch`, import.meta.url),
"utf8",
);
const lines = patch.split("\n");
let hunkCount = 0;
for (let index = 0; index < lines.length; index += 1) {
const header = lines[index].match(
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/,
);
if (!header) continue;
hunkCount += 1;
const body = [];
let oldLines = 0;
let newLines = 0;
while (index + 1 < lines.length && (oldLines < Number(header[2] ?? 1) || newLines < Number(header[4] ?? 1))) {
const line = lines[++index];
// Unified diff's EOF marker is metadata, not a source/destination
// line, and may occur between the removed and added final lines.
if (line === "\\ No newline at end of file") continue;
// Git accepts an empty context line with its optional space omitted.
assert.ok(line === "" || /^[ +\-]/.test(line), `invalid unified hunk line: ${line}`);
const normalized = line === "" ? " " : line;
body.push(normalized);
if (!normalized.startsWith("+")) oldLines += 1;
if (!normalized.startsWith("-")) newLines += 1;
}
assert.equal(
body.filter((line) => !line.startsWith("+")).length,
Number(header[2] ?? 1),
);
assert.equal(
body.filter((line) => !line.startsWith("-")).length,
Number(header[4] ?? 1),
);
const prefix = body.findIndex((line) => !line.startsWith(" "));
const suffix = body
.slice()
.reverse()
.findIndex((line) => !line.startsWith(" "));
// pnpm patch-commit emits three context lines. Hand-added asymmetric
// context can force GNU patch's locate_hunk() to require EOF even when
// BSD patch and git apply accept the same source and hunk.
assert.ok(
prefix >= 0 && prefix <= 3,
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (prefix ${prefix})`,
);
assert.ok(
suffix >= 0 && suffix <= 3,
`regenerate ${version} hunk at old line ${header[1]} with pnpm patch-commit (suffix ${suffix})`,
);
}
assert.ok(hunkCount > 0);
});
}
test("published packages preserve the patched ACPX runtime", () => {
assert.equal(
rootPackage.pnpm.patchedDependencies["acpx@0.12.0"],
"patches/acpx@0.12.0.patch",
);
assert.equal(
rootPackage.pnpm.patchedDependencies["acpx@0.13.1"],
"patches/acpx@0.13.1.patch",
);
assert.equal(adapterUtilsPackage.dependencies.acpx, "0.12.0");
assert.deepEqual(adapterUtilsPackage.bundleDependencies, ["acpx"]);
assert.equal(serverPackage.dependencies.acpx, "0.13.1");
assert.ok(serverPackage.bundleDependencies.includes("acpx"));
assert.equal(bundledCliNpmDependencies.has("acpx"), true);
assert.equal(cliEsbuildConfig.external.includes("acpx"), false);
});
test("Paperclip Runner pins the qualified ACPX host callbacks", () => {
assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch");
assert.equal(
rootPackage.pnpm.patchedDependencies["@agentclientprotocol/claude-agent-acp@0.73.0"],
"patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
);
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
assert.equal(runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], "0.73.0");
assert.equal(runnerPackage.dependencies["@agentclientprotocol/codex-acp"], "1.6.2");
for (const callback of [
"spawnEnvironment", "spawnCwd", "spawnAgent", "isPlainStringEnvironment",
"onAgentSpawn", "onAgentStderr", "onAgentExit",
"onSessionNotification", "onClientOperation",
]) assert.match(acpxRuntimePatch, new RegExp(callback));
assert.match(claudeAcpPatch, /usage: \{/);
assert.match(claudeAcpPatch, /cache_creation_input_tokens/);
});
test("published packages preserve the patched embedded-postgres runtime", () => {
assert.equal(
rootPackage.pnpm.patchedDependencies["embedded-postgres@18.1.0-beta.16"],
"patches/embedded-postgres@18.1.0-beta.16.patch",
);
assert.deepEqual(dbPackage.bundleDependencies, ["embedded-postgres"]);
assert.equal(bundledCliNpmDependencies.has("embedded-postgres"), true);
assert.equal(cliEsbuildConfig.external.includes("embedded-postgres"), false);
});
test("bundled package staging materializes publishConfig entrypoints", () => {
const staged = materializePublishManifest(adapterUtilsPackage);
assert.equal(staged.publishConfig, undefined);
assert.equal(staged.main, "./dist/index.js");
assert.equal(staged.types, "./dist/index.d.ts");
assert.deepEqual(staged.exports, adapterUtilsPackage.publishConfig.exports);
});
test("bundled package staging materializes workspace dependency versions", () => {
const staged = materializePublishManifest({
name: "@paperclipai/example",
version: "2026.723.0",
dependencies: { exact: "workspace:*", caret: "workspace:^", tilde: "workspace:~" },
});
assert.deepEqual(staged.dependencies, {
exact: "2026.723.0",
caret: "^2026.723.0",
tilde: "~2026.723.0",
});
});
test("bundled package staging installs only dependencies included in the tarball", () => {
const publishManifest = {
name: "@paperclipai/db",
version: "2026.723.0-canary.8",
dependencies: {
"@paperclipai/shared": "2026.723.0-canary.8",
"drizzle-orm": "^0.45.2",
"embedded-postgres": "^18.1.0-beta.16",
},
devDependencies: {
"@paperclipai/paperclip-runner": "2026.723.0-canary.8",
},
bundleDependencies: ["embedded-postgres"],
};
const installManifest = createBundledInstallManifest(publishManifest, ["embedded-postgres"]);
assert.deepEqual(installManifest.dependencies, {
"embedded-postgres": "^18.1.0-beta.16",
});
assert.equal(installManifest.devDependencies, undefined);
assert.deepEqual(publishManifest.devDependencies, {
"@paperclipai/paperclip-runner": "2026.723.0-canary.8",
});
assert.deepEqual(installManifest.bundleDependencies, ["embedded-postgres"]);
});
test("bundled package staging selects only the installed dependency version's patch", (t) => {
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-patch-selection-"));
const installedPackageDir = join(destinationDir, "node_modules", "acpx");
mkdirSync(installedPackageDir, { recursive: true });
writeFileSync(
join(installedPackageDir, "package.json"),
JSON.stringify({ name: "acpx", version: "0.12.0" }),
);
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
assert.deepEqual(
selectBundledDependencyPatches(destinationDir, ["acpx"], {
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
}),
[
{
packageName: "acpx",
specifier: "acpx@0.12.0",
patchPath: "patches/acpx@0.12.0.patch",
},
],
);
});
test("bundled package patch selection handles scoped package names", (t) => {
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-scoped-patch-selection-"));
const installedPackageDir = join(destinationDir, "node_modules", "@example", "runtime");
mkdirSync(installedPackageDir, { recursive: true });
writeFileSync(
join(installedPackageDir, "package.json"),
JSON.stringify({ name: "@example/runtime", version: "1.2.3" }),
);
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
assert.deepEqual(
selectBundledDependencyPatches(destinationDir, ["@example/runtime"], {
"@example/runtime@1.2.3": "patches/runtime@1.2.3.patch",
"@example/runtime@2.0.0": "patches/runtime@2.0.0.patch",
}),
[
{
packageName: "@example/runtime",
specifier: "@example/runtime@1.2.3",
patchPath: "patches/runtime@1.2.3.patch",
},
],
);
});
test("bundled package patch selection reports missing installed metadata", (t) => {
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-missing-patch-metadata-"));
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
assert.throws(
() =>
selectBundledDependencyPatches(destinationDir, ["acpx"], {
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
}),
/Cannot select a patch for bundled dependency acpx: failed to read/,
);
});
test("bundled package patch selection rejects an unpatched installed version", (t) => {
const destinationDir = mkdtempSync(join(tmpdir(), "paperclip-unmatched-patch-version-"));
const installedPackageDir = join(destinationDir, "node_modules", "acpx");
mkdirSync(installedPackageDir, { recursive: true });
writeFileSync(
join(installedPackageDir, "package.json"),
JSON.stringify({ name: "acpx", version: "0.14.0" }),
);
t.after(() => rmSync(destinationDir, { recursive: true, force: true }));
assert.throws(
() =>
selectBundledDependencyPatches(destinationDir, ["acpx"], {
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
}),
/installed acpx@0\.14\.0, but configured patches are acpx@0\.12\.0, acpx@0\.13\.1/,
);
});
test("server package staging applies every bundled runtime patch and preserves the vendored runner", (t) => {
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-"));
const sourceDir = join(fixtureDir, "source");
const destinationDir = join(fixtureDir, "destination");
const binDir = join(fixtureDir, "bin");
const callLog = join(fixtureDir, "calls.log");
mkdirSync(sourceDir);
mkdirSync(join(sourceDir, "dist"));
writeFileSync(join(sourceDir, "dist", "index.js"), "export {};\n");
mkdirSync(destinationDir);
mkdirSync(binDir);
writeFileSync(
join(sourceDir, "package.json"),
JSON.stringify({ ...serverPackage, files: ["dist"] }),
);
writeFileSync(callLog, "");
t.after(() => rmSync(fixtureDir, { recursive: true, force: true }));
const writeExecutable = (name, body) => {
writeFileSync(join(binDir, name), body, { mode: 0o755 });
};
writeExecutable(
"pnpm",
`#!/usr/bin/env bash
set -euo pipefail
printf 'pnpm %s\\n' "$*" >> "$FAKE_CALL_LOG"
destination="\${!#}"
cp "$FAKE_SOURCE_PACKAGE" "$destination/package.json"
mkdir -p "$destination/node_modules/.pnpm"
`,
);
writeExecutable(
"npm",
`#!/usr/bin/env bash
set -euo pipefail
printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG"
[ "$*" = "install --omit=dev --ignore-scripts --no-audit --no-fund" ]
node -e 'const fs = require("node:fs"); const pkg = require("./package.json"); if ("devDependencies" in pkg) process.exit(1); for (const [name, version] of Object.entries(pkg.dependencies)) { const dir = "node_modules/" + name; fs.mkdirSync(dir + "/dist", { recursive: true }); fs.writeFileSync(dir + "/package.json", JSON.stringify({ name, version })); }'
mkdir -p node_modules/acpx/dist
printf 'unpatched runtime\\n' > node_modules/acpx/dist/runtime.js
printf '{"name":"acpx","version":"0.13.1"}\\n' > node_modules/acpx/package.json
`,
);
writeExecutable(
"patch",
`#!/usr/bin/env bash
set -euo pipefail
printf 'patch %s\\n' "$*" >> "$FAKE_CALL_LOG"
target=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-d" ]; then
target="$2"
shift 2
else
shift
fi
done
patch_input="$(cat)"
printf '%s\\n' "$patch_input" > "$target/applied.patch"
if [[ "$target" != */acpx ]]; then
exit 0
fi
grep -q spawnEnvironment <<< "$patch_input"
grep -q spawnAgent <<< "$patch_input"
grep -q onAgentStderr <<< "$patch_input"
printf 'patched spawnEnvironment runtime\\n' > "$target/dist/runtime.js"
`,
);
execFileSync(
process.execPath,
[
new URL("./prepare-bundled-package.mjs", import.meta.url).pathname,
sourceDir,
destinationDir,
],
{
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
FAKE_CALL_LOG: callLog,
FAKE_SOURCE_PACKAGE: join(sourceDir, "package.json"),
},
stdio: "pipe",
},
);
const stagedAcpxDir = join(destinationDir, "node_modules/acpx");
assert.equal(lstatSync(stagedAcpxDir).isDirectory(), true);
assert.equal(lstatSync(stagedAcpxDir).isSymbolicLink(), false);
assert.equal(existsSync(join(destinationDir, "node_modules/.pnpm")), false);
assert.match(
readFileSync(join(stagedAcpxDir, "dist/runtime.js"), "utf8"),
/spawnEnvironment/,
);
assert.match(
readFileSync(callLog, "utf8"),
/patch -p1 --forward -d .*node_modules\/acpx/,
);
assert.equal(
readFileSync(callLog, "utf8")
.split("\n")
.filter((line) => line.startsWith("patch ")).length,
serverPackage.bundleDependencies.length,
);
for (const name of serverPackage.bundleDependencies) {
const specifier = `${name}@${serverPackage.dependencies[name]}`;
const patchPath = rootPackage.pnpm.patchedDependencies[specifier];
assert.equal(
readFileSync(
join(destinationDir, "node_modules", name, "applied.patch"),
"utf8",
),
`${readFileSync(new URL(`../${patchPath}`, import.meta.url), "utf8").trimEnd()}\n`,
`${specifier} receives its own full configured patch`,
);
}
});
test("bundled package dry runs preview without querying published versions", () => {
assert.match(releaseScript, /run_bundled_npm_pack pack --pack-destination "\$publish_dir"/);
assert.match(releaseLib, /BUNDLED_NPM_PACK_VERSION="10\.9\.7"/);
assert.match(releaseLib, /BUNDLED_NPM_PUBLISH_VERSION="11\.18\.0"/);
assert.match(
releaseLib,
/npx --yes "npm@\$BUNDLED_NPM_PACK_VERSION" "\$@" --ignore-scripts/,
);
assert.match(
releaseLib,
/npx --yes "npm@\$BUNDLED_NPM_PUBLISH_VERSION" "\$@" --ignore-scripts/,
);
assert.match(releaseLib, /"\$@" --ignore-scripts --loglevel verbose/);
assert.match(releaseLib, /run_bundled_npm_publish publish --tag "\$dist_tag"/);
assert.doesNotMatch(releaseLib, /run_bundled_npm_publish publish "\.\/\$tarball"/);
});
test("npm builds use corepack instead of requiring a global pnpm", () => {
assert.match(buildNpmScript, /corepack pnpm -r typecheck/);
assert.doesNotMatch(buildNpmScript, /^\s*pnpm -r typecheck/m);
});
test("installed ACPX runtime persists and restores optional goal capabilities", () => {
const requireRunner = createRequire(new URL("../packages/paperclip-runner/package.json", import.meta.url));
const runtimeSource = readFileSync(requireRunner.resolve("acpx/runtime"), "utf8");
const start = runtimeSource.indexOf("function persistedGoalCapability(");
const end = runtimeSource.indexOf("function planUpdateEvent(", start);
assert.ok(start >= 0 && end > start, "the installed patch must define both goal helpers");
const helpers = runInNewContext(runtimeSource.slice(start, end) + ";({ persistedGoalCapability, restoredGoalCapability })", {
isRecord: (value) => value !== null && typeof value === "object" && !Array.isArray(value),
});
assert.equal(helpers.persistedGoalCapability(undefined), undefined);
assert.equal(helpers.restoredGoalCapability(undefined), undefined);
const goal = { version: 1, controlMethod: "_session/goal", actions: ["set", "pause", "clear"] };
const saved = JSON.parse(JSON.stringify(helpers.persistedGoalCapability(goal)));
assert.equal(saved.control_method, "_session/goal");
assert.deepEqual(JSON.parse(JSON.stringify(helpers.restoredGoalCapability(saved))), goal);
assert.equal(helpers.persistedGoalCapability({ ...goal, version: 2 }), undefined);
assert.equal(helpers.persistedGoalCapability({ ...goal, actions: ["set"] }), undefined);
});