Merge current master and decision admission fixes from work-folders base
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/work-folders-base-refresh: (25 commits) fix: retain saved messages while decisions are pending fix(server): stop reporting expected managed-cloud transients to Sentry (#13323) fix: promote review tasks when continuations start (#13318) fix: require explicit native completion reviews (#13314) fix(onboarding): make chief-of-staff hiring reliable (#13317) fix: fence native startup against cancellation (#13316) chore(skills): allow verified PR merges (#13313) feat: add experimental persistent agent chat (#13284) chore(lockfile): refresh pnpm-lock.yaml (#13279) fix(ui): improve mobile task spacing (#13304) fix(connections): repair and simplify Google Workspace setup (#13289) fix(ui): stabilize steered chat activity presentation (#13246) fix(ui): make feed cards fully clickable (#13294) ci: activate shared PR dependency cache restores (#13302) test: remove cold executable reads from runner integrity deadlines (#13301) ci: reuse dependency caches without per-PR uploads (#13300) fix(ui): hide profile feedback flag on Cloud (#13292) feat(ui): refine dashboard cards, charts, and recent lists (#13269) fix: protect starting runs during overlapping deployments (#13285) fix: reliably interrupt and resume legacy message queues (#13275) ... # Conflicts: # packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs
This commit is contained in:
commit
bb80a847c4
|
|
@ -79,7 +79,6 @@ each one).
|
|||
|
||||
## Hard rules
|
||||
|
||||
* **YOU DO NOT MERGE THE PR YOURSELF. NEVER MERGE THE PR YOURSELF.**
|
||||
* Never lose work: no orphaned stashes, no dropped files, no force-pushes
|
||||
that discard commits.
|
||||
* Always post the URLs to every pull request you created.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const workflow = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8");
|
||||
const jobs = [...workflow.matchAll(/^ ([a-z_][a-z_0-9]*):\n([\s\S]*?)(?=^ [a-z_][a-z_0-9]*:\n|$(?![\s\S]))/gm)];
|
||||
const installers = jobs.filter(([, , body]) => body.includes("run: pnpm install --frozen-lockfile"));
|
||||
|
||||
test("PR workflows restore dependency stores without creating branch copies", () => {
|
||||
assert.equal(installers.length, 7);
|
||||
assert.doesNotMatch(workflow, /^ +cache: pnpm$/m);
|
||||
assert.doesNotMatch(workflow, /uses: actions\/cache(?:@|\/save@)/);
|
||||
for (const [, job, body] of jobs) {
|
||||
for (const step of body.split(" - name:").filter((step) => step.includes("uses: actions/setup-node@"))) {
|
||||
assert.match(step, /package-manager-cache: false/, job);
|
||||
}
|
||||
}
|
||||
const policy = jobs.find(([, name]) => name === "policy")[2];
|
||||
assert.doesNotMatch(policy, /uses: actions\/cache|cache: pnpm/);
|
||||
});
|
||||
|
||||
for (const [, job, body] of installers) {
|
||||
test(`${job}: reuse master keys before restoring the resolved PR lockfile`, () => {
|
||||
const locate = body.indexOf(" - name: Locate pnpm store");
|
||||
const restore = body.indexOf(" - name: Restore pnpm store (read only)");
|
||||
const artifact = body.indexOf(" - name: Restore regenerated PR lockfile");
|
||||
const install = body.indexOf("run: pnpm install --frozen-lockfile");
|
||||
assert.ok(locate >= 0 && locate < restore && restore < artifact && artifact < install);
|
||||
const cache = body.slice(restore, artifact);
|
||||
assert.match(body.slice(locate, restore), /pnpm store path --silent/);
|
||||
assert.match(body.slice(locate, restore), /node -p 'process.arch'/);
|
||||
assert.match(cache, /uses: actions\/cache\/restore@[a-f0-9]{40}/);
|
||||
assert.ok(cache.includes("key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}"));
|
||||
assert.ok(cache.includes("restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-"));
|
||||
assert.match(body.slice(artifact, install), /if: needs.policy.outputs.lockfile_regenerated == '1'/);
|
||||
assert.match(body.slice(artifact, install), /name: pr-lockfile/);
|
||||
assert.doesNotMatch(body.slice(artifact, install), /continue-on-error/);
|
||||
});
|
||||
}
|
||||
|
|
@ -284,6 +284,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -295,12 +296,6 @@ jobs:
|
|||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate migration ordering against target branch
|
||||
run: >-
|
||||
node .github/scripts/check-pr-migration-order.mjs
|
||||
|
|
@ -389,6 +384,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -399,6 +395,21 @@ jobs:
|
|||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -406,12 +417,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -485,6 +490,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -495,6 +501,21 @@ jobs:
|
|||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -502,12 +523,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -607,6 +622,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -617,6 +633,21 @@ jobs:
|
|||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -624,12 +655,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -653,6 +678,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -663,6 +689,21 @@ jobs:
|
|||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -670,12 +711,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -727,6 +762,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -737,6 +773,21 @@ jobs:
|
|||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -744,12 +795,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -773,6 +818,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -783,6 +829,21 @@ jobs:
|
|||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -790,12 +851,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -860,6 +915,7 @@ jobs:
|
|||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
|
||||
|
|
@ -870,6 +926,21 @@ jobs:
|
|||
with:
|
||||
version: 9.15.4
|
||||
|
||||
# Share the checked-in lockfile key with master. PR merge refs must not
|
||||
# save full copies of the store or evict the post-merge build caches.
|
||||
- name: Locate pnpm store
|
||||
id: pnpm_store
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "arch=$(node -p 'process.arch')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store (read only)
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
with:
|
||||
path: ${{ steps.pnpm_store.outputs.path }}
|
||||
key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-
|
||||
|
||||
- name: Restore regenerated PR lockfile (if policy uploaded one)
|
||||
if: needs.policy.outputs.lockfile_regenerated == '1'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
|
|
@ -877,12 +948,6 @@ jobs:
|
|||
name: pr-lockfile
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@ permissions:
|
|||
|
||||
jobs:
|
||||
ci:
|
||||
# Pin: #12858 merge — docker context integrity gate + traceability context fix.
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@03609aa6ecc9a047ed53d6b6469d8be554fbc46d
|
||||
# Pin: #13300 merge — restore-only dependency caches and parallel native verification.
|
||||
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@44dde2dec42a22746a2f36b595acacc9ccfa1df6
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Run deterministic Runner workflow scorer tests
|
||||
run: pnpm test:runner-workflow-evals
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build eval and Runner contracts
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -968,6 +968,40 @@ jobs:
|
|||
sleep "$((attempt * 10))"
|
||||
done
|
||||
|
||||
# This definition executes only from the authorized default-branch workflow.
|
||||
# Provision host policy before credentials reach target-controlled tests.
|
||||
- name: Provision Codex sandbox on the disposable trusted runner
|
||||
if: matrix.environmentId == 'local' && matrix.profileId == 'runner-codex'
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
if (process.platform !== "linux") process.exit(0);
|
||||
let restricted = "0";
|
||||
try { restricted = readFileSync("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8").trim(); } catch {}
|
||||
if (restricted !== "1") process.exit(0);
|
||||
const root = realpathSync(process.env.GITHUB_WORKSPACE);
|
||||
const runnerRequire = createRequire(path.join(root, "packages/paperclip-runner/package.json"));
|
||||
const acpRequire = createRequire(runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"));
|
||||
const codexRequire = createRequire(acpRequire.resolve("@openai/codex/package.json"));
|
||||
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
|
||||
if (!arch) throw new Error("Unsupported Codex CI architecture");
|
||||
const platformPackage = codexRequire.resolve(`@openai/codex-linux-${arch}/package.json`);
|
||||
const triple = arch === "x64" ? "x86_64-unknown-linux-musl" : "aarch64-unknown-linux-musl";
|
||||
const suffix = `/vendor/${triple}/bin/codex`;
|
||||
const binary = realpathSync(path.join(path.dirname(platformPackage), suffix));
|
||||
if (!binary.startsWith(root + "/node_modules/.pnpm/") || !binary.endsWith(suffix) || !/^[/A-Za-z0-9_.@+\-]+$/.test(binary)) {
|
||||
throw new Error("Codex executable is outside the resolved dependency tree");
|
||||
}
|
||||
const name = `paperclip-e2e-codex-${createHash("sha256").update(binary).digest("hex").slice(0,16)}`;
|
||||
const profilePath = path.join(process.env.RUNNER_TEMP, "paperclip-codex-userns.apparmor");
|
||||
writeFileSync(profilePath, `abi <abi/4.0>,\ninclude <tunables/global>\nprofile ${name} "${binary}" flags=(unconfined) {\n userns,\n}\n`, {mode:0o600, flag:"wx"});
|
||||
execFileSync("sudo", ["-n", "apparmor_parser", "-r", profilePath], {timeout:15000, stdio:"pipe"});
|
||||
NODE
|
||||
|
||||
- name: Run paid cell
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ matrix.credentialName == 'OPENAI_API_KEY' && secrets.OPENAI_API_KEY || '' }}
|
||||
|
|
|
|||
|
|
@ -165,15 +165,12 @@ async function seedValidWorktreeSource(
|
|||
principalId: userId,
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Representative seed issue",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "SEED-1",
|
||||
});
|
||||
// This helper also seeds an intentionally older schema. Current Drizzle
|
||||
// insert builders include defaults for newly added columns absent there.
|
||||
await db.$client`
|
||||
insert into issues (id, company_id, title, status, priority, issue_number, identifier)
|
||||
values (${issueId}, ${companyId}, 'Representative seed issue', 'backlog', 'medium', 1, 'SEED-1')
|
||||
`;
|
||||
await db.$client.end({ timeout: 5 });
|
||||
return { companyId, issueId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -386,3 +386,18 @@ pnpm secrets:migrate-inline-env --apply
|
|||
```
|
||||
|
||||
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
||||
|
||||
### Persistent agent conversations
|
||||
|
||||
Migration `0274_agent_chat.sql` adds conversation identity/state and session generation/boundary columns to `issues`, plus idempotent client request IDs and processed session-boundary generations to `issue_comments`. The company/agent/user unique index resolves concurrent first writes to one issue. A check constraint preserves the assigned-agent identity and prevents terminal conversation status. Comment request IDs are unique per issue and user. There is no separate chat/message store. Provider sessions continue to use `agent_task_sessions`; `/new` removes only the matching conversation session, and session writers fence stale generations against the issue row.
|
||||
|
||||
## Legacy controller ownership
|
||||
|
||||
Legacy run claims atomically record `controller_boot_id`, a database-clock
|
||||
`controller_lease_expires_at`, and `execution_stage` before workspace provisioning.
|
||||
The lease renews independently of output. A different container must not infer
|
||||
controller death from its own process map or numeric PIDs. Expiration grants
|
||||
cleanup authority; it does not prove that remote inference has stopped. Recovery
|
||||
revokes the previous boot identity with a conditional update. Its own claim also
|
||||
expires so another sweep can finish cleanup after a restart. Historical rows keep
|
||||
null ownership fields and follow the previous recovery path.
|
||||
|
|
|
|||
|
|
@ -417,6 +417,19 @@ configs with `database.mode: postgres`, suppresses the invocation directory's
|
|||
guard. The selected instance's own environment file still loads. The command
|
||||
selects the first available loopback port at or above `3100`.
|
||||
|
||||
Source-checkout startup builds the shared and plugin SDK packages when needed.
|
||||
It prints build progress and any wait for another build. Interrupted builds
|
||||
release their lock after the compiler stops; later startups recover locks whose
|
||||
owner and compiler have exited. Empty locks from older versions are recovered
|
||||
once they are at least two minutes old. The command remains in the foreground
|
||||
after printing its ready URL to serve the instance; use Ctrl-C to stop it.
|
||||
Each package gets a completion marker only after a successful build. A hard
|
||||
kill leaves that marker absent, so the next startup rebuilds partial output.
|
||||
The marker records source and output content fingerprints, so recovery does
|
||||
not depend on filesystem timestamp precision. Direct `tsc` builds that produce
|
||||
identical output reuse the marker. Changed or partial output is rebuilt once
|
||||
before later startups reuse the completed build.
|
||||
|
||||
Claude uses `ANTHROPIC_API_KEY`; Codex uses `OPENAI_API_KEY`; OpenCode uses
|
||||
`OPENROUTER_API_KEY` and requires an `openrouter/...` model. `--api-key-env`
|
||||
can name a different source variable while the agent still receives the
|
||||
|
|
|
|||
|
|
@ -160,3 +160,17 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
|||
|
||||
9. **Thin core, rich edges**
|
||||
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
|
||||
|
||||
### Experimental persistent agent conversations
|
||||
|
||||
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
|
|
|||
|
|
@ -1267,6 +1267,11 @@ Scheduler must skip invocation when:
|
|||
- an existing run is active
|
||||
- hard budget limit has been hit
|
||||
|
||||
Legacy execution records a renewable controller lease when claiming a queued run,
|
||||
before provisioning. A live lease protects the run during overlapping service
|
||||
deployments. An expired controller loses dispatch authority; a recovery worker
|
||||
must establish that the previous execution stopped before starting a successor.
|
||||
|
||||
## 11.7 Durable agent session goals
|
||||
|
||||
Runner Protocol v2 negotiates a required `sessionGoals` capability and typed
|
||||
|
|
@ -1574,10 +1579,32 @@ Export/import behavior in V1:
|
|||
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
|
||||
- GitHub imports warn on unpinned refs instead of blocking
|
||||
|
||||
### User messages after native execution recovery stops
|
||||
### Experimental task-backed agent chat (2026-09-10)
|
||||
|
||||
An authenticated user message can start a fresh native conversation turn once
|
||||
the prior execution is confirmed stopped. Retain the source history and uncertain
|
||||
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
|
||||
|
||||
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue on first send/upload. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
|
||||
|
||||
The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history.
|
||||
|
||||
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
|
||||
|
||||
Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. Feature disablement prevents new turns and resets while retaining data and lifecycle protection; already-running turns may settle normally.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### User continuation after execution recovery stops
|
||||
|
||||
An authenticated user message or an exact failed-run Retry can start a fresh
|
||||
native or legacy conversation turn once the prior execution is confirmed stopped. Retain the source history and uncertain
|
||||
action outcomes; do not replay tool calls or reset the failed incident's automatic
|
||||
retry budget. Existing pause, approval, budget, ownership, and dependency gates
|
||||
remain in effect. See `doc/execution-semantics.md` for admission and stop-proof
|
||||
|
|
@ -1595,3 +1622,14 @@ normal task conversation; rich email cards show the correspondence and delivery
|
|||
outcomes without a separate email composer. See
|
||||
[AgentMail connections](connections/AGENTMAIL.md) for setup, transports, recovery,
|
||||
authorization, and the API/CLI contract.
|
||||
|
||||
### Native task completion
|
||||
|
||||
For ordinary low-risk tasks, accept the current agent's structured `done` claim
|
||||
subject to explicit workflow constraints. Missing independent evidence or a
|
||||
`needs_review` label alone must not create a human approval. Require a concrete
|
||||
reviewer decision for a new review request. Keep unfinished work with the agent,
|
||||
with bounded continuation and visible recovery. Preserve explicit approvals,
|
||||
current task ownership, cancellation, dependencies, and newer task state. See
|
||||
`doc/architecture/native-status-arbitration.md` for finish feedback and the
|
||||
provenance-checked cleanup of historical automatic completion reviews.
|
||||
|
|
|
|||
12
doc/SPEC.md
12
doc/SPEC.md
|
|
@ -277,6 +277,8 @@ All agent communication flows through the **task system**.
|
|||
|
||||
There is no separate messaging or chat system. Tasks are the communication channel. This keeps all context attached to the work it relates to and creates a natural audit trail.
|
||||
|
||||
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
|
||||
|
||||
### Implications
|
||||
|
||||
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
|
||||
|
|
@ -549,6 +551,16 @@ Things Paperclip explicitly does **not** do:
|
|||
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
|
||||
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### Paused task messages
|
||||
|
||||
A paused task takes over the composer with an amber notice and a Resume action.
|
||||
|
|
|
|||
|
|
@ -136,7 +136,8 @@ conditions before model disposition:
|
|||
| Run failed | Preserve | Schedule recovery |
|
||||
| Approval, interaction, or execution stage is pending | `in_review` | Materialize/bind the governance gate and notify its owner |
|
||||
| Completion satisfies its authority policy | `done` | Release checkout |
|
||||
| Runner reports `needs_review` | `in_review` | Bind a reviewer and notify the owner |
|
||||
| Runner reports a concrete attention request with a reviewer and decision | `in_review` | Bind the requested reviewer |
|
||||
| Runner reports `needs_review` without a decision, or an incomplete completion claim | Keep work with the agent | No automatic human approval; at most one corrective continuation, then a visible recovery action |
|
||||
| Runner reports a task-wide blocker | `blocked` | Persist blocker owner and unblock action |
|
||||
| Runner reports a current-track blocker | `in_progress` | Enqueue another productive track |
|
||||
| Runner reports `yielded` with a valid continuation | `in_progress` | Enqueue the declared continuation |
|
||||
|
|
@ -280,3 +281,33 @@ Common patterns:
|
|||
See also
|
||||
[`durable-continuation-scheduler.md`](./durable-continuation-scheduler.md) for
|
||||
the scheduler and recovery behavior that follows an `in_progress` decision.
|
||||
|
||||
## Explicit completion reviews
|
||||
|
||||
Ordinary task completion uses the agent's structured `done` claim under the
|
||||
contract's low-risk claim policy. Unknown evidence references remain diagnostic
|
||||
information; they do not create human approval requirements. Cancellation,
|
||||
newer task state, unresolved dependencies, and explicit governance still win.
|
||||
|
||||
Paperclip no longer creates a generic "Native completion review" because a
|
||||
report is incomplete, verification failed, or the agent says `needs_review`.
|
||||
A new review interaction requires an explicit attention request naming the
|
||||
reviewer's responsibility and the decision. The card displays that request.
|
||||
Waiting for CI remains agent work, not a human completion approval.
|
||||
|
||||
The native runner returns current approval/dependency constraints to the agent
|
||||
when it calls `paperclip_finish`. An empty `needs_review` report without an
|
||||
existing gate is rejected with instructions to correct it. The final reply must
|
||||
explain any required user action and link to the relevant task or approval.
|
||||
The tool acknowledges receipt, not a premature status commit: final status is
|
||||
committed only after the provider turn and workspace finalization settle.
|
||||
|
||||
On upgrade, bounded cleanup withdraws only unanswered, system-created fallback
|
||||
cards proven by their decision/effect ledger, original prompt/target, empty
|
||||
attention request list, and low-risk claim policy. Explicit or answered reviews
|
||||
and stronger completion policies are preserved. Withdrawal has audit history
|
||||
and retires chat actions. Reconciliation reassesses only the current successful
|
||||
run's result, with the same task status/version and completion contract and no
|
||||
newer execution owner. It applies normal governance and dependency checks and
|
||||
appends a decision; it never marks every affected task done blindly. A persisted
|
||||
withdrawal marker makes restart between cleanup and reassessment retryable.
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ job still runs one test worker. The partition covers every suite exactly once;
|
|||
normal PR and local test groups keep their existing shape. More jobs increase
|
||||
concurrent runner demand, so compare queue time as well as test duration.
|
||||
|
||||
All release verification installs, including the Runner scorer and chaos evals,
|
||||
allow pnpm to refresh an outdated lockfile. Contributor PRs leave lockfile updates
|
||||
to the separate refresh bot, so a dependency-changing master commit can arrive
|
||||
before that bot's PR merges. Verification must install and test that commit
|
||||
without waiting for another merge. The generated lockfile stays in the job's
|
||||
workspace; these checks do not commit it back to the repository.
|
||||
|
||||
The artifact wait runs for up to 30 minutes and reports what is missing. Only
|
||||
an HTTP 404 means publication is pending; authorization errors, upstream outages,
|
||||
and identity mismatches fail the job. A failed, cancelled, or skipped prerequisite
|
||||
|
|
@ -191,7 +198,37 @@ all typechecks still execute. A missing or invalidated cache triggers compilatio
|
|||
|
||||
The Refresh Lockfile workflow does not cache the pnpm store. Its resolution-only
|
||||
command does not download packages and can save an empty default-branch cache
|
||||
before full install jobs finish. Jobs that install dependencies retain caching.
|
||||
before full install jobs finish. The PR policy job also leaves store caching off.
|
||||
|
||||
PR install jobs restore the pnpm store without saving it. They hash the checked-in
|
||||
lockfile before downloading the policy job's regenerated lockfile, matching the
|
||||
key format used by master install jobs. A same-OS, same-architecture pnpm fallback
|
||||
can reuse older package downloads when the exact key is absent. Each job still
|
||||
installs with `--frozen-lockfile` against the policy artifact when one exists;
|
||||
cache contents do not select dependency versions. A cache miss downloads packages
|
||||
normally. New PR-only dependencies may be downloaded again on each PR run until
|
||||
master populates a cache that contains them.
|
||||
|
||||
This avoids storing a full dependency archive under every PR merge ref. Those
|
||||
copies competed with the Rust caches for the repository's storage limit. Keep
|
||||
master cache writes enabled so trusted post-merge installs refresh shared stores.
|
||||
After activating the new trusted workflow pin, verify cache restores and package
|
||||
reuse in an allowlisted PR, and verify that no new `node-cache-` entries appear
|
||||
under its `refs/pull/<number>/merge` ref. Existing copies can expire normally.
|
||||
|
||||
The repository cache storage ceiling is managed in GitHub Settings, separately
|
||||
from this workflow. Check it with:
|
||||
|
||||
```sh
|
||||
gh api repos/paperclipai/paperclip/actions/cache/storage-limit
|
||||
```
|
||||
|
||||
Increasing the repository limit above 10 GB can require an organization owner to
|
||||
raise the maximum in organization Settings → Actions → General first. Repository
|
||||
administration access alone cannot override that maximum. Paid cache storage also
|
||||
requires a payment method and sufficient Actions Cache Storage budget; see the
|
||||
[GitHub cache storage documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#increasing-cache-size).
|
||||
Preserve populated master pnpm and Rust caches when inspecting pressure.
|
||||
|
||||
After deploying this correction, remove any existing empty default-branch entry
|
||||
for the current lockfile key. List cache IDs, branches, and archive sizes first:
|
||||
|
|
|
|||
|
|
@ -60,8 +60,10 @@ Google makes Workspace MCP generally available.
|
|||
| Google People | `https://people.googleapis.com/mcp/v1` | Read contacts |
|
||||
| Google Workspace Search | `https://workspacemcp.googleapis.com/mcp/v1` | Search Workspace |
|
||||
|
||||
The setup flow asks for the capability first. It then offers the authentication
|
||||
methods available for that capability:
|
||||
The setup flow asks for the capability first. When the managed method is
|
||||
available, it uses Paperclip by default. A small **Use your own Google OAuth app**
|
||||
link reveals the custom client fields; **Use Paperclip instead** returns to the
|
||||
managed method. The available authentication methods are:
|
||||
|
||||
- **Connect with Paperclip** uses the Paperclip Cloud broker when that exact
|
||||
profile is returned for this enrolled instance by the signed
|
||||
|
|
@ -80,6 +82,14 @@ default organization grant, while still recording which signed-in Google
|
|||
principal completed consent so refresh and reconnect stay bound to that
|
||||
principal.
|
||||
|
||||
Catalog discovery and connection creation use the same signed, instance-specific
|
||||
profile availability. Local enrollment files and Cloud-delivered environment
|
||||
identities follow this same path; neither enables managed methods globally in
|
||||
the static app definitions. Saved connections remain recognizable for OAuth
|
||||
callback, refresh, and revoke, while the broker enforces current profile access.
|
||||
Switching capability or authentication methods preserves the selected credential
|
||||
owner when the new method supports that owner.
|
||||
|
||||
## Broker profiles
|
||||
|
||||
The Paperclip-managed method signs every broker request with one explicit
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ New comments received during an execution hold retain their individual deferred
|
|||
|
||||
The conversation groups repeated empty pre-start reconciliation cancellations into a neutral waiting notice. Started runs, actual startup failures, and run history remain inspectable. No historical run records are deleted.
|
||||
|
||||
The legacy remote ACP process-session relay runs on the control-plane host. Its
|
||||
launch command uses the host's absolute Node executable even when the adapter's
|
||||
launch environment is sanitized for a remote sandbox; the sandbox PATH remains
|
||||
owned by the sandbox image.
|
||||
|
||||
### Pre-dispatch configuration validation
|
||||
|
||||
Pre-dispatch configuration validation is a distinct gate that runs after ownership and checkout are resolved but before the control plane actually dispatches a run.
|
||||
|
|
@ -352,6 +357,8 @@ A board comment can be an interrupt, an ownership change, both, or neither. Pape
|
|||
|
||||
An interrupt stops the current live execution path for the issue. It does not, by itself, select the next owner. If an active run is interrupted by the board, the run may still terminate with the underlying `cancelled` status, but the issue activity and wake context should make the operator intent visible as an interruption rather than an unexplained runtime failure.
|
||||
|
||||
For legacy runners, **Interrupt** on a queued message stops the active run and explicitly continues the pending queue after execution cleanup. It validates the queue revision and target run, then dispatches the requested queue’s current message bodies in their saved order. Other actors’ queues cannot consume that interrupt. The persisted interrupt intent is retried by the scheduler after a promotion error or server restart until that queue is dispatched or discarded. Edits and discards remain authoritative until dispatch; deleting the final message must not create an empty continuation. Pending messages remain visible after a run stops. Cancelling only the run preserves the queue for a later explicit wake; pausing the task retains its separate queue-cancellation behavior. Native same-turn steering keeps its separate acknowledgement protocol. Legacy Codex uses Ctrl-C to stop its tool sessions and cannot retry a missing-session fallback after the provider has confirmed that the session started.
|
||||
|
||||
An ownership change selects who owns the issue after the comment is committed:
|
||||
|
||||
- setting `assigneeAgentId` makes the named agent the owner
|
||||
|
|
@ -832,6 +839,14 @@ Every continuation carries the triggering request, ordered user direction, inter
|
|||
|
||||
### Interrupted conversation continuation
|
||||
|
||||
Before provider dispatch, chat-control admission retries transient database lock
|
||||
contention with up to 50 waits of 100 ms. Each attempt starts a new transaction
|
||||
and rechecks the current run and committed conversation-close evidence. No lock
|
||||
is held between attempts, and no provider call is retried. Queue claims remain
|
||||
nonblocking. Persistent contention retains the bounded admission failure, with
|
||||
an explicit database-lock error; missing or invalid source evidence still stops
|
||||
the run without retrying the admission check.
|
||||
|
||||
An interrupted conversation does not permanently block its task. For local conversational adapters, Paperclip starts a new bounded turn with the existing session when compatible, or the full task conversation when the session is unavailable. The prompt says: “Your previous run was interrupted. Continue from where you left off.” The agent decides what remains from the history and latest user request. Paperclip never automatically replays recorded tool calls. Unknown past action outcomes are not a task-wide execution gate, and no action-reconciliation questionnaire is required.
|
||||
|
||||
Shutdown, process loss, and provider failure use the existing durable failure retry counter and delay. Ordinary failure recovery permits at most two automatic retries in a failure chain. Accepted-interaction infrastructure recovery retains its existing bounded policy. Repeated scheduler visits reuse the same successor; restarting the server does not reset the counter. After exhaustion, automatic attempts stop. A new explicit user message can start a fresh run and failure budget. Productive max-turn continuation and confirmed workspace waits keep their separate existing semantics.
|
||||
|
|
@ -846,7 +861,7 @@ Local recovery records a server-authored stop receipt before it clears a verifie
|
|||
|
||||
If cleanup or another execution gate is still pending, the message stays in its existing queue receipt. Startup and periodic scheduling reconsider up to 50 due receipts per pass, at most once per 30 seconds per receipt, without calling a model or resetting recovery attempts. Cleanup callbacks use the same admission path. The issue lock prevents concurrent workers from delivering an adopted or discarded receipt again. The queued-message area shows the current wait reason. Pauses, approvals, budgets, ownership, and external chat authorization remain enforced. A message sent before the run finished does not grant new post-stop authority.
|
||||
|
||||
Historical legacy interruption holds for conversational adapters no longer block new messages or Resume. Classification uses the run’s saved adapter invocation or continuation policy, never the agent’s current adapter settings. Missing historical adapter evidence retains the hold. A terminal row with a live predecessor process or unreleased environment lease still blocks actual admission and Resume. Retry scheduling can happen before cleanup, but grants no execution authority. Recovery folds their obsolete no-replay bookkeeping without changing task ownership, status, or automatically waking old work. The audit trail remains readable. Native integrity and ownership holds, and non-conversational adapter holds, remain enforced.
|
||||
Historical legacy interruption holds for conversational adapters no longer block new messages or Resume. Automatic classification uses the server-owned adapter identity saved atomically at run claim, the saved adapter invocation, or the continuation policy, never the agent’s current adapter settings. Missing historical adapter evidence retains the automatic hold; an explicit user continuation can retire it after proving the predecessor stopped. A terminal row with a live predecessor process, an unreleased environment lease, or failed/pending cleanup still blocks actual admission and Resume; a release timestamp alone does not prove cleanup succeeded. Retry scheduling can happen before cleanup, but grants no execution authority. Recovery folds their obsolete no-replay bookkeeping without changing task ownership, status, or automatically waking old work. The audit trail remains readable. Native integrity and ownership holds, and non-conversational adapter holds, remain enforced.
|
||||
|
||||
The server projection remains available for diagnostics. Normal working, finishing, and interaction waits add no badges or cards to task lists or feeds. Active transcript headers keep saying Working during automatic retry and execution confirmation; attempts, causes, and recovery decisions belong in the run log. Recovery uses the existing transcript and run log rather than adding a reconciliation form. A cancelled run that never started says “Couldn't start” instead of implying that the agent answered.
|
||||
|
||||
|
|
@ -870,16 +885,23 @@ new run. Preserve the baseline across recovery of the same run and start a new
|
|||
delta when attaching a new run. Other stale-event and authority checks remain.
|
||||
|
||||
|
||||
### Explicit user continuation after a native failure
|
||||
### Explicit user continuation after execution failure
|
||||
|
||||
An execution recovery hold blocks automatic replay. A new authenticated user
|
||||
comment can authorize a fresh native conversation turn after the predecessor's
|
||||
comment or exact failed-run Retry can authorize a fresh native or legacy
|
||||
conversation turn after the predecessor's
|
||||
execution is confirmed stopped. This is a new request, not another automatic
|
||||
attempt in the failed incident. The old attempt count and unknown action outcomes
|
||||
remain unchanged.
|
||||
remain unchanged. Known non-conversation adapter evidence still requires its
|
||||
original reconciliation flow even if the agent's current settings change.
|
||||
Pre-upgrade runs with no adapter evidence may receive a new explicit user turn
|
||||
only after termination is proven; their old adapter and action outcomes remain
|
||||
unknown, and they do not gain automatic replay eligibility.
|
||||
|
||||
Admission validates the persisted comment's author, task, and time against every
|
||||
held predecessor. An agent-authored comment, an old queued request, or a generic
|
||||
held predecessor. Retry validates the selected failed run's company, task, and
|
||||
agent and preserves that run's identity through admission and history loading.
|
||||
Duplicate Retry requests adopt the same successor. An agent-authored comment, an old queued request, or a generic
|
||||
system wake cannot release a hold. The source task keeps its assignee. Process
|
||||
ownership, active controllers, cleanup leases, pause, approval, budget, and normal
|
||||
execution gates still apply. Dependency-blocked interaction mode remains limited
|
||||
|
|
@ -891,7 +913,7 @@ request, task history, completed work, and the interruption notice. It receives
|
|||
no instruction to repeat old tool calls. Later messages cannot reset the old
|
||||
incident's retry budget or create another automatic replacement for it.
|
||||
|
||||
Native admission verifies local process identities for local runs. Remote runs
|
||||
Explicit continuation verifies local process identities for local runs. Remote runs
|
||||
instead require a provider termination receipt for every lease, with successful
|
||||
cleanup and no active ownership. This applies to both per-turn and warm native
|
||||
runners. A stop receipt retires only the settled cleanup owner for that exact company, run, provider, and sandbox resource, without changing its checkpoint or recorded action outcomes. Independent remote sandboxes have separate cleanup gates, including when one run owns multiple sandboxes. Successful pending-cleanup retries persist the same receipt and reconsider deferred user messages; a delivery failure never reverts successful provider cleanup. A failed checkpoint does not prevent destruction of a terminal run's isolated sandbox; busy ownership still prevents it.
|
||||
|
|
@ -900,6 +922,15 @@ no receipt remain supported but cannot authorize remote continuation. A terminal
|
|||
database status or a PID check on the wrong host is insufficient.
|
||||
No historical task is automatically awakened by this change.
|
||||
|
||||
Startup waits for provider plugin initialization before remote recovery and
|
||||
lease cleanup. The task's blocked notice offers Retry, and a refused retry
|
||||
shows the actual recovery hold. Each explicit user Retry can make one scoped
|
||||
cleanup attempt for its failed run even after automatic cleanup is exhausted.
|
||||
If that attempt fails, a later user Retry may try again after the provider
|
||||
recovers. The failed cleanup keeps the execution hold in place. Retry does not reset
|
||||
the automatic limit or clean up another task's leases. Provider shutdown must
|
||||
still be confirmed before a new conversation is admitted.
|
||||
|
||||
### Explicit Recovery Action
|
||||
|
||||
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.
|
||||
|
|
@ -956,3 +987,25 @@ For a board operator, the intended meaning is:
|
|||
- blockers explain waiting
|
||||
|
||||
That is the execution contract Paperclip should present to operators.
|
||||
|
||||
### Cancellation during native startup
|
||||
|
||||
Cancellation records a preparation fence while holding the run row lock. Native
|
||||
runtime selection checks that fence, the running status, and the current startup
|
||||
controller lease in the same transaction that creates the native coordinator.
|
||||
The native executor rechecks cancellation and terminal status when claiming the
|
||||
coordinator, before starting or attaching a provider.
|
||||
|
||||
A cancelled startup can continue from a newer authenticated user message after
|
||||
cleanup. The server requires either its explicit before-selection fence or an
|
||||
unclaimed native coordinator (zero attempts and controller generations, no
|
||||
controller, lease, or result). It also checks for contradictory launch/process
|
||||
evidence and verifies local cleanup or exact remote termination receipts. The
|
||||
preparer must have finished or its startup lease must have expired. A missing
|
||||
PID alone does not establish this proof.
|
||||
|
||||
The existing bounded saved-message worker rechecks this proof after restart.
|
||||
Admission atomically settles an unclaimed coordinator and admits one fresh turn,
|
||||
preserving history, unknown action outcomes, and attempt counts. Pauses, approvals,
|
||||
budgets, task ownership, and terminal task status still gate admission. No
|
||||
automatic provider replay is authorized by a cancelled startup.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
# Persistent agent chat, backed by tasks
|
||||
|
||||
Date: 2026-09-10
|
||||
Status: Implemented behind `enableAgentChat`; verification recorded in the implementation handoff.
|
||||
|
||||
## Contract
|
||||
|
||||
Each person has one persistent conversation with each agent in a company. A conversation is an ordinary issue with fixed `conversationAgentId` and `conversationUserId`, a matching agent assignee, and a unique company/agent/user identity. The authenticated board actor supplies the user identity (`local-board` in local trusted mode). Company task authorization still applies: these are separate conversations, not private messages.
|
||||
|
||||
Opening an unused conversation performs a read. First send or upload resolves its backing issue atomically through `POST /api/companies/:companyId/chats/:agentRef`. `GET` on the same path returns the existing issue or null. Comments, documents, files, interactions, runs, and subscriptions use the existing task APIs. User chat comments require a stable `clientRequestId`; retries return the same comment. Comment rows also provide a durable delivery outbox, serialized across servers before admission to the normal execution queue.
|
||||
|
||||
Idle conversations are `in_review` with server-owned `conversationState: waiting`. A new message makes the conversation active. A successful run parks it only after a durable agent response, with no later pending message. Failed or unanswered turns retain ordinary error handling. Finalizers, assignment recovery, liveness classification, timer eligibility, and work counts distinguish conversations from execution tasks. Completion or reassignment cannot terminate or transfer the container. Child completion does not wake the conversation.
|
||||
|
||||
## Session boundaries
|
||||
|
||||
Standalone `/new` is an ordinary queued user comment. The shared composer offers it as a slash command. It executes at a turn boundary without invoking the provider, increments `conversationSessionGeneration`, and records `conversationBoundaryCommentId` plus the generation on the command comment. Processing a retried command is idempotent. Only the matching agent/task provider session is deleted; agent-wide state and other task sessions are untouched.
|
||||
|
||||
A board-authored `/new` also releases pause holds rooted at the chat without waking the stopped turn. Dispatch admits the verified reset even when the previous turn has a no-replay recovery disposition; after the boundary, that old disposition remains auditable but does not block a fresh session. Pending clarification questions from the old session expire, including questions configured to survive ordinary comments. The reset run adds no empty-response notice.
|
||||
|
||||
Provider-session writes and run-authored replies check the generation. Cancelled conversation runs cannot issue mutating API calls, post late replies, or restore provider sessions. Fresh prompt replay is bounded to nondeleted comments after the boundary and before the current wake comment, with source-trust sanitization. Automatic task continuation summaries are omitted for conversations. History, artifacts, plans, and linked tasks retain their IDs and remain available for explicit inspection. The shared transcript renders processed command comments as session dividers.
|
||||
|
||||
## Agent policy
|
||||
|
||||
`server/src/services/agent-conversations.ts` owns the chat directive. The task prompt includes it on initial turns, retries, resumed turns, and fresh sessions. It asks the agent to clarify material gaps, then create and assign ordinary project tasks with outcomes, context, copied plans, and acceptance criteria before claiming they exist. It explicitly overrides ordinary completion and accepted-plan execution instructions for the container. Ask mode stays non-mutating; plan mode supports clarification and planning. Normal tools, approvals, budgets, assignment, and execution policies continue to apply.
|
||||
|
||||
## Shared production composition
|
||||
|
||||
`TaskDetailSurface` in `IssueDetail.tsx` is the shared controller and surface. `AgentChat.tsx` resolves the canonical task and provides an ephemeral view model before the first write. It does not implement a second transcript, composer, file panel, or run controller. The chat presentation hides task metadata and the seeded description bubble, uses task breadcrumb typography with agent avatar/name and a configuration-page gear, and defaults the existing task side panel to artifacts/plans rather than Properties.
|
||||
|
||||
Company-prefixed `chats/:agentRef` routes open the current person's conversation. Direct task URLs remain supported. The existing Agents roster provides a Chat action. The shared sidebar lists starred agents alphabetically, then four recent unstarred conversations, without a divider. Stars appear on hover or keyboard focus. Existing resource memberships store stars; company/user-scoped recent-navigation storage records conversation visits only. The gear goes to agent runtime configuration; See all agents goes to `/agents/all`.
|
||||
|
||||
## Rollout
|
||||
|
||||
`enableAgentChat` defaults to false in the shared feature catalog, validator, server settings, and Experimental settings UI. Navigation, resolution, new messages, and reset commands are gated. Turning the flag off preserves data, allows already-running turns to settle, and prevents new chat execution. Lifecycle protection is independent of flag state; task links remain readable under normal authorization.
|
||||
|
||||
## Verification
|
||||
|
||||
Database and route tests cover concurrent canonical creation, independent users with ordinary company visibility, client retry identity, cross-company denial, local identity, ordered concurrent delivery, separate queued resets, generation fences, replay boundaries, idle recovery classification, disabled admission, and child wake suppression. Shared composer/sidebar/settings tests and Storybook fixtures cover the production composition. Storybook scenarios include first conversation, returning, working, paused, failed send, long history, session boundary, disabled feature, light theme, and ordinary task comparison.
|
||||
|
||||
Required handoff checks: targeted tests; token gates; Storybook build; repository typecheck, tests, and build; browser checks of first send, session divider, stars, switching, drafts, configuration, roster, and disabled states. Fixture navigation is not a claim of a live provider evaluation: task creation quality remains prompt-guided and should be observed during the experimental rollout.
|
||||
|
||||
### Implementation verification — 2026-09-10
|
||||
|
||||
- Repository typecheck (`pnpm -r typecheck`), production build (`pnpm build`), token gates, and Storybook build passed.
|
||||
- Final UI suite: 563 files, 5,622 tests passed. The shared controller/live-update regression pass covers first send and upload, preservation of agent routes, read-only unused conversations, personal live-update resolution, and durable session-divider refresh.
|
||||
- General server lane: 8,345 passed and 38 skipped initially; the four failures (a stale module loaded during editing and three socket disconnects) passed in a fresh 68-test rerun. The conversation suite also executes a real process adapter: two ordinary turns invoke it twice, `/new` invokes it zero times, and each answered turn returns to idle.
|
||||
- All 144 serialized route suites were exercised. The skill-route socket failure and queued-comment fixture cleanup failures passed in a 70-test rerun. Queue test cleanup now clears its full company-scoped foreign-key closure rather than ignoring failed deletes.
|
||||
- Shared, database, CLI, adapter, skills-catalog, and plugin project suites passed. The CLI migration test exposed and verified the cloned-database constraint upgrade fix. Adapter suites that exceeded the default five-second timeout passed with capped workers and a 30-second test timeout. Tests ran against isolated temporary homes and databases; repository-standard unsupported integration cases remained skipped.
|
||||
- Browser checks used the production composition with fixture APIs: first send, retry and draft retention, agent switching, stars, configuration/roster navigation, linked subtasks, paused-agent controls, disabled navigation, long history, and `/new` preserving earlier messages and plans. Live-update unit tests cover the socket/cache behavior independently of Storybook fixtures. No live-model task-handoff quality evaluation was performed.
|
||||
|
||||
The broad `pnpm test:run` attempt was followed by isolated group/file reruns for the failures above; this is not a claim that the initial monolithic command exited successfully. The experiment remains off by default.
|
||||
|
||||
## September 11 reset regression verification
|
||||
|
||||
The initial live demo checked an idle reset but missed Stop followed by `/new`. In the reported Claude run, dispatch cancelled both the reset and follow-up before reset processing; the provider generation stayed at zero, and the cancelled old turn posted a late reply. Regression coverage now includes pause plus a prior no-replay recovery disposition, reset and immediate follow-up queue order, cancelled-run write rejection, expiring persistent clarification questions, and suppressing empty reset-run transcript notices.
|
||||
|
||||
Live Codex and Claude checks confirmed fresh context after pause → `/new` → follow-up. An additional Claude check stopped an actively streaming turn containing a unique code word, reset, and asked for that word without history inspection. Claude reported it was absent; the chat returned to waiting.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
|
||||
### Project handoff verification (September 11)
|
||||
|
||||
The real-server tool tests cover concurrent project retries, task/plan atomic creation, ordinary child delegation, import/reparenting rejection under conversations, mode restrictions, cancellation, repository URL normalization, and committed project cards. The ordinary task review-path guard now exempts conversations; the server owns their waiting state after a successful reply. The chat directive explicitly tells agents to reply and end their turn without inventing a reviewer or changing status.
|
||||
|
||||
Five focused Codex live evals passed: existing-project reuse, new project/task handoff, multiple repository URLs, plan-only drafting, and authorized repository discovery. Four provider-free contract evals passed for retries, missing access, Ask-mode denial, and persisted handoff plans. The companion harness has 30 passing tests. The qualified Claude eval profile could not start on macOS without its explicit eval credential (it additionally requires Linux x64); it was not bypassed.
|
||||
|
||||
A separate local Claude agent drafted and revised a chat plan, created Garden Club Demo through the dedicated project tool, and created normal assigned task AGE-7 with its initial plan. The plan was persisted at 16:47:51.426 UTC before execution started at 16:47:51.492 UTC; the task completed with an output document and the original chat plan remained. Local Codex created Repository URL Demo with two URLs absent from its catalog; both appeared on the inline card and project configuration. The card persisted across reload and `/new`. Light and dark production-composition stories were inspected in the browser.
|
||||
|
||||
The broad general-server run reported 8,362 passing tests and two failures from pre-fix modules cached before the review guard and explicit-workspace card changes. The fresh current-source API run passed all 21 tests across four files, including both regressions. Remaining repository groups are verified separately so the initial monolithic exit is not represented as a clean pass.
|
||||
|
||||
The full UI lane passed 5,626 tests and the CLI passed 484. The shared and skills-catalog projects passed. The remaining database/adapter/plugin group passed 2,365 tests; a migration startup failure passed alone (1 test), after reducing workers to avoid embedded-Postgres contention. Existing unsupported integration tests remained skipped.
|
||||
|
||||
Both serialized server shards are now verified: all 144 suites passed across their final runs/resumed segments. An outdated project-route mock and the new MCP transport's missing OpenAPI inventory entry were corrected; embedded-Postgres startup failures passed in isolated retries. The API catalog now includes the task-run-only MCP transport and points project discovery/creation to their dedicated tools; its focused suite passed 824 tests. The catalog census has 792 operations (555 authored REST contract cases).
|
||||
|
||||
Repository-wide typecheck and build, Storybook build, and token gates passed. The final API metadata change also passed server typecheck/build. Two follow-up Codex live cases passed with the final directive, and all 11 retained deterministic/live artifacts passed the stronger persisted-state scoring, including detection of unintended tasks created through API fallback. These results do not turn the earlier failed monolithic test command into a clean run.
|
||||
|
||||
|
||||
### 2026-09-11: E2E regression coverage
|
||||
|
||||
Persistent conversations now have dedicated `tests/e2e/agent-chat.spec.ts`
|
||||
coverage using a deterministic process adapter against a disposable real server.
|
||||
The authenticated suite additionally checks separate canonical chats, personal
|
||||
stars/recency, shared company visibility, and cross-company denial for two people.
|
||||
The runner catalog registers `agent-chat`: six scenarios on four local
|
||||
Codex/Claude profiles (24 paid cells). See `tests/runner-e2e/README.md` for launch
|
||||
commands, credential preflight, evidence, and reset/child-run accounting.
|
||||
|
||||
Browser testing identified a company-cache shape mismatch in chat live updates
|
||||
and a dropped reset marker in compact run summaries. Preserve the shared cache
|
||||
contract and `conversationReset` summary field so messages refresh live and reset
|
||||
boundaries do not render empty model-completion notices.
|
||||
|
||||
Local acceptance on 2026-09-11: all 20 deterministic chat scenarios and two
|
||||
existing repository browser scenarios passed against a fresh test instance.
|
||||
The new authenticated two-person scenario passed independently. Runner fixture
|
||||
checks passed (121 tests), and the live-update/run-summary regression checks
|
||||
passed (46 tests). Repository typecheck, build, Storybook build, and token gates
|
||||
passed. Paid Codex and Claude smoke attempts failed credential preflight because
|
||||
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY` were unavailable; the 24-cell matrix is
|
||||
registered but has no claimed paid passing coverage from this run.
|
||||
|
||||
|
||||
### 2026-09-11: Paid runner regression fixes
|
||||
|
||||
The first GitHub campaign exercised all 24 cells and exposed provider-session,
|
||||
queue/lifecycle, plan-review, and shared-feed issues. Follow-up work uses focused
|
||||
provider-free regressions first, then individual paid cells on disposable
|
||||
instances; the running demo remains untouched.
|
||||
|
||||
Claude session serialization now retains its MCP server identity. Conversation
|
||||
containers ignore dependency and child-completion wakes, while pending questions
|
||||
and plan reviews count as durable replies and settle the conversation to waiting.
|
||||
Rejected plan feedback is included in both full and resumed prompt assembly;
|
||||
acceptance resolves the implicit current-task target and hands off the selected
|
||||
plan revision before execution begins.
|
||||
|
||||
Native provider handling preserves FIFO events and terminal schema, projects
|
||||
committed normal replies into chat, and verifies ownership when a restored ACPX
|
||||
session lazily launches its provider during model selection. Linux Codex preflight
|
||||
uses an exact executable AppArmor profile and a provider-free sandbox probe. The
|
||||
focused GitHub campaign `34638268637` passed native Codex continuity/restart and
|
||||
fresh-session reset on both selected cells.
|
||||
|
||||
The shared project card hydrates repository links from the authorized project
|
||||
record while retaining its original durable creation receipt. Regression coverage
|
||||
checks a second repository arriving after creation, reload, and `/new`. Handoff
|
||||
fixtures check committed repository workspaces and actual output documents rather
|
||||
than assuming URL registration adds an entry to the external connection catalog
|
||||
or requiring an unspecified output document key. Failure classification avoids
|
||||
paid retries for explicit non-retryable provider-session failures.
|
||||
|
||||
All 20 deterministic chat browser scenarios and Storybook build passed after
|
||||
these fixes. Focused live checks additionally passed legacy Codex project reuse
|
||||
and repository handoff, legacy Claude plan revision/acceptance/handoff, and native
|
||||
Claude planning, Stop/reset/resume, fresh sessions, and multiple repositories.
|
||||
Final campaign results and broad verification are recorded below when complete.
|
||||
|
||||
The next full campaign (`34640536416`) reached 18/24 passing cells and identified
|
||||
three additional issues. Execution prompts now include the task's persisted plan
|
||||
and selected revision on both fresh and resumed runs; a plan handed off without a
|
||||
description therefore still reaches its executor. Native durable redaction keeps
|
||||
explicit literal/exact acceptance identifiers while continuing to redact actual
|
||||
credential-shaped values. Recovery for an older conversation generation or an
|
||||
already answered turn cannot block a reset or healthy idle chat. Regression tests
|
||||
also preserve recovery for current unanswered turns and unprepared failures.
|
||||
|
||||
Fixture assertions now accept concrete clarification requests without requiring a
|
||||
question mark. They check the approved revision and final execution output rather
|
||||
than rejecting an old draft quoted in plan revision history. Restart verification
|
||||
opens the canonical chat route after reconnecting, preserving the continuity and
|
||||
no-unsolicited-run checks. Stable inconsistent idle states fail promptly instead
|
||||
of waiting through a long timeout and hiding a product race behind a paid retry.
|
||||
Focused native Claude project reuse and multiple-repository handoffs, and legacy
|
||||
Claude multiple-repository handoff, passed on their first attempts with these fixes.
|
||||
|
||||
The focused legacy Claude Stop/reset/resume regression also passed on its first
|
||||
attempt. Latest repository-wide typecheck, build, and token gates passed. Final
|
||||
runner fixture checks passed 151 tests; fresh chat/prompt/recovery checks passed
|
||||
209 tests, and the native session executor file passed 207 tests. Broad local
|
||||
verification is recorded as resumed groups rather than a clean monolithic run:
|
||||
the original command encountered source edits during execution, generated-evidence
|
||||
scanner input, and cold-import/process-startup timeouts under concurrent load.
|
||||
The guidance scanner now excludes only generated runner evidence and has a
|
||||
regression proving authored runner guidance remains scanned. Focused UI, database,
|
||||
publication, and canonical-path CLI reruns passed without product changes.
|
||||
|
||||
Broader adapter verification exposed OpenCode test fixtures reading the developer's
|
||||
real configuration directory. Those fixtures now allocate and restore isolated
|
||||
XDG configuration directories; all 44 source tests and package typecheck pass.
|
||||
The remaining workspace projects were run even after earlier groups stopped at a
|
||||
failure, and the original failure logs remain available alongside focused reruns.
|
||||
|
||||
Campaign `34642700703` passed 19/24 cells. Its remaining failures were traced to
|
||||
one clarification-oracle phrasing, revision-write guidance, runner teardown after
|
||||
a successful restart, and native mutation content passing through diagnostic
|
||||
redaction. The clarification fixture now also recognizes substantive requests
|
||||
for a brief or details. Revision instructions and HTTP conflict errors explicitly
|
||||
map the GET `latestRevisionId` to PUT `baseRevisionId`; a live Codex
|
||||
plan/revise/accept/handoff run passed on its first attempt with that fix.
|
||||
|
||||
Playwright now gives the restart supervisor a bounded SIGTERM shutdown so it can
|
||||
reap children and close log streams. A real zero-provider Playwright regression
|
||||
verifies restart, child process exit, and port closure; cleanup failure still
|
||||
fails the campaign. Native schema-declared task/project/document prose retains
|
||||
its complete contents while credentials and diagnostic data remain scrubbed.
|
||||
Regression coverage includes long plans beyond the diagnostic preview limit.
|
||||
The macOS fake Anthropic service now clears inherited nonblocking socket mode
|
||||
before its bounded request read; all 271 Rust library tests passed afterward.
|
||||
|
||||
All 144 serialized server suites have passing coverage across the resumed shards
|
||||
and focused reruns. Three route fixtures moved cold module imports into bounded
|
||||
setup hooks, preserving their HTTP assertion timeouts; the final affected files
|
||||
passed 119 tests. The completed workspace groups likewise have passing focused
|
||||
reruns for every observed failure. These results are recorded alongside, rather
|
||||
than replacing, the earlier failed monolithic invocation.
|
||||
|
||||
The ACPX sidecar decoder was an additional execution boundary: it applied generic
|
||||
diagnostic redaction before the native semantic-input stage. It now uses the same
|
||||
schema-declared prose policy at decode. The regression feeds a real
|
||||
`runtime.tool_called` event through decoding, pending-call state, and semantic
|
||||
projection, checking complete long-plan contents, protected credentials, unknown
|
||||
operation handling, and matching content digests. The decoder/state checks passed
|
||||
22 tests and durable-state checks passed 30 tests before the next paid campaign.
|
||||
|
||||
The final local native Claude repository handoff preserved the exact previously
|
||||
corrupted task description, plan, and execution output. Its product assertions
|
||||
passed on the first attempt; post-run secret scanning then exposed PostgreSQL
|
||||
removing `instances/<id>/db/postmaster.pid` after directory enumeration. Only
|
||||
ENOENT for that exact transient path is now tolerated. Existing PID contents,
|
||||
other scan errors, mandatory evidence, and process/lease cleanup remain enforced.
|
||||
All 157 runner fixture tests and final repository typecheck/build passed.
|
||||
Campaign `34645293835` tests the complete set of fixes.
|
||||
|
||||
Campaign `34645293835` passed 20/24 cells. Two failures exposed narrow lifecycle
|
||||
races: a successful native chat turn could be mistaken for productive unfinished
|
||||
work before response publication, and an agent comment deferred behind an active
|
||||
execution could wake its assignee after that execution completed. Recovery now
|
||||
leaves the first case to the conversation finalizer; queue promotion cancels the
|
||||
stale terminal-task continuation while retaining human reopening and notifications
|
||||
to other agents. The recovery regression fails with the guard removed and passes
|
||||
with it restored; all 20 comment-wake batching tests and server typecheck pass.
|
||||
|
||||
The other failures distinguish requested approval from ordinary draft planning,
|
||||
and a persisted Paperclip document from a workspace file. The chat directive now
|
||||
explains how to create a revision-bound approval card when explicitly requested,
|
||||
including after a revision. Paid fixtures name the requested Paperclip document
|
||||
explicitly while retaining strict checks of approvals, transferred plans, and
|
||||
persisted execution output.
|
||||
|
||||
Native reconciliation also preserves assessment lineage within its owning run
|
||||
when the task's previous status decision belongs to a different run. Decision
|
||||
lineage still spans runs; the database ownership constraint remains unchanged.
|
||||
The regression reproduces the original foreign-key failure without the fix and
|
||||
passes for absent, same-run, and different-run predecessors with it. The next
|
||||
24-cell campaign is `34646672139`, pinned to `3556fa25f`.
|
||||
|
||||
Campaign `34646672139` passed 23/24 cells: all native cases and all legacy Claude
|
||||
cases passed. The remaining legacy Codex plan-revision failure exposed an adapter
|
||||
prompt omission. Its resume delta discarded the server's task-context Markdown,
|
||||
including both the chat directive and document-concurrency guidance. Codex now
|
||||
selects the same full/compact task-context Markdown as Claude on initial and
|
||||
resumed sessions. Both adapters suppress generic task-completion and child-task
|
||||
planning directives in chat, leaving the central chat policy authoritative.
|
||||
The approval and output assertions remain unchanged.
|
||||
|
||||
Fresh chat prompts use a small conversation-safe default template that retains
|
||||
connection guidance, permissions, budgets, cancellation, and mutation honesty.
|
||||
Explicit custom agent templates remain intact. Native execution and continuation
|
||||
prompts also carry the conversation flag so shared wake rendering cannot reinsert
|
||||
ordinary completion/subtask instructions. The integration regression inspects
|
||||
both fake-CLI stdin and the recorded adapter invocation with the production chat
|
||||
directive; removing the task-context section reproduces the failure. Shared prompt
|
||||
checks (102), actual Codex prompt cases (3), native resume checks (11), affected
|
||||
package typechecks, and server typecheck pass. Campaign `34648511170` tests all
|
||||
24 cells on `abacbdfd2`.
|
||||
|
||||
The complete Codex/Claude execution regression files passed 46 tests. Final
|
||||
repository-wide typecheck and build also passed on `abacbdfd2`, after all prompt
|
||||
changes.
|
||||
|
||||
Final paid verification: campaign `34648511170` passed **24/24** chat cases on
|
||||
`abacbdfd2`: legacy Codex 6/6, legacy Claude 6/6, native Codex 6/6, and native
|
||||
ACPX Claude 6/6. All cells completed by 21:34 UTC on September 11, within the
|
||||
requested three-hour repair window. No acceptance assertions were disabled.
|
||||
|
||||
- [Exact campaign results](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/summary.md)
|
||||
- [GitHub run and retained evidence](https://github.com/paperclipai/paperclip/actions/runs/34648511170)
|
||||
|
||||
The [HTML dashboard](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat)
|
||||
was repaired from retained evidence after its older trusted catalog omitted the
|
||||
branch-only suite. It now includes the chat suite and 32 screenshots, including
|
||||
eight draft/revised plan captures recovered from their original Playwright
|
||||
attachments. No paid cells were rerun; result records, tested SHA, timestamps,
|
||||
usage, billing, attempts, and cleanup outcomes remain unchanged.
|
||||
|
||||
Reporting now discovers validated display-only entries for unknown selected
|
||||
execution IDs, and publication rejects missing declared screenshots. The exact
|
||||
chat plan filenames are included in packaged evidence. All 165 runner unit tests
|
||||
and runner TypeScript checks passed. Browser verification covered suite
|
||||
filtering, restored plan images, and gallery navigation. This explicitly
|
||||
authorized repair replaces only this campaign's report objects; normal
|
||||
immutable-publication protections remain unchanged.
|
||||
|
||||
The published summary and normalized results were verified after publication:
|
||||
exactly 24 unique expected cells, all passed on attempt 1, all cleanup checks
|
||||
passed, all evidence valid with no evidence errors, and every result bound to
|
||||
`abacbdfd2f660709ec37312cdb758284c8399d04`. The public report returned HTTP 200.
|
||||
|
|
@ -161,6 +161,7 @@ async function runExecutor(
|
|||
config: Record<string, unknown>,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
runtime?: Record<string, unknown>;
|
||||
executionTransport?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
executionTarget?: Record<string, unknown>;
|
||||
|
|
@ -194,7 +195,7 @@ async function runExecutor(
|
|||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
},
|
||||
runtime: {},
|
||||
runtime: options.runtime ?? {},
|
||||
config,
|
||||
context: options.context ?? {},
|
||||
executionTransport: options.executionTransport,
|
||||
|
|
@ -592,6 +593,52 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
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,
|
||||
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 { 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." : "Continue your Paperclip conversation");
|
||||
expect(String(reset.meta[0]?.prompt)).toContain(custom ? "Custom agent instructions." : "Continue your Paperclip conversation");
|
||||
const ordinary = await runExecutor({ ...config, promptTemplate: "" }, { context: { ...context, conversationMode: false } });
|
||||
expect(String(ordinary.meta[0]?.prompt)).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" },
|
||||
|
|
@ -2092,6 +2139,9 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
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({
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
} from "../workspace-restore-merge.js";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
asNumber,
|
||||
asString,
|
||||
|
|
@ -2484,7 +2485,12 @@ async function buildRuntime(input: {
|
|||
await emitRunPhaseTiming(input.ctx, "start_transport", nowMs() - startTransportStart, "failed");
|
||||
throw err;
|
||||
}
|
||||
const overrideCommand = processSessionBridge?.agentCommand ?? agentCommand;
|
||||
// The relay runs on the host with the sanitized remote launch environment.
|
||||
// Its /usr/bin/env node shebang cannot rely on that environment's PATH.
|
||||
const overrideCommand = processSessionBridge?.agentCommand
|
||||
? [process.execPath, processSessionBridge.agentCommand]
|
||||
.map((part) => JSON.stringify(part.replaceAll("\\", "/"))).join(" ")
|
||||
: agentCommand;
|
||||
const overrides = overrideCommand ? { [acpxAgent]: overrideCommand } : undefined;
|
||||
const agentRegistry = createAgentRegistry({ overrides });
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
|
|
@ -2923,7 +2929,9 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
|
|||
const hasCustomPromptTemplate = configuredPromptTemplate.trim().length > 0;
|
||||
const promptTemplate = hasCustomPromptTemplate
|
||||
? configuredPromptTemplate
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
||||
: context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
|
|
@ -2967,6 +2975,7 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
|
|||
const externalChatTurn = isPaperclipExternalChatTurn(context.paperclipWake);
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession,
|
||||
conversationMode: context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
|
|
|
|||
|
|
@ -1910,6 +1910,38 @@ describe("createHttp2BridgeServer + createSandboxHttp2BridgeGateway", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("serves only exact GET schema discovery through HTTP/2", async () => {
|
||||
const schema = { openapi: "3.1.0", paths: {} };
|
||||
const forwarded: string[] = [];
|
||||
const { gateway, handle } = createTestPair({
|
||||
forwardRequest: async (request) => {
|
||||
forwarded.push(`${request.method} ${request.pathname}`);
|
||||
return { status: 200, body: Buffer.from(JSON.stringify(schema)) };
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (const [method, path, status] of [
|
||||
["GET", "/api/openapi.json", 200],
|
||||
["POST", "/api/openapi.json", 403],
|
||||
["PATCH", "/api/openapi.json", 403],
|
||||
["DELETE", "/api/openapi.json", 403],
|
||||
["GET", "/api/openapi.json/extra", 403],
|
||||
["GET", "/api/openapiXjson", 403],
|
||||
["GET", "/api/secrets", 403],
|
||||
] as const) {
|
||||
const response = await gateway.forwardRequest({
|
||||
method, path, query: "", headers: {}, body: Buffer.alloc(0), receivedToken: BRIDGE_TOKEN,
|
||||
});
|
||||
expect(response.status).toBe(status);
|
||||
if (status === 200) expect(JSON.parse(response.body!.toString())).toEqual(schema);
|
||||
}
|
||||
expect(forwarded).toEqual(["GET /api/openapi.json"]);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a route the allowlist does not carry, before the forwarder runs", async () => {
|
||||
const forwarderTracker = createForwarderCallTracker();
|
||||
const { gateway, handle } = createTestPair({
|
||||
|
|
|
|||
|
|
@ -286,6 +286,44 @@ describe("sandbox callback bridge", () => {
|
|||
|
||||
});
|
||||
|
||||
it("serves schema discovery over the queue and denies schema mutations and lookalikes", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-schema-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const queueDir = path.join(rootDir, "queue");
|
||||
const directories = sandboxCallbackBridgeDirectories(queueDir);
|
||||
const schema = { openapi: "3.1.0", paths: {} };
|
||||
const forwarded: string[] = [];
|
||||
const worker = await startSandboxCallbackBridgeWorker({
|
||||
client: createFileSystemSandboxCallbackBridgeQueueClient(), queueDir,
|
||||
handleRequest: async (request) => {
|
||||
forwarded.push(`${request.method} ${request.path}`);
|
||||
return { status: 200, body: JSON.stringify(schema) };
|
||||
},
|
||||
});
|
||||
cleanupFns.push(() => worker.stop());
|
||||
const requests = [
|
||||
{ method: "GET", path: "/api/openapi.json" },
|
||||
{ method: "POST", path: "/api/openapi.json" },
|
||||
{ method: "PATCH", path: "/api/openapi.json" },
|
||||
{ method: "DELETE", path: "/api/openapi.json" },
|
||||
{ method: "GET", path: "/api/openapi.json/extra" },
|
||||
{ method: "GET", path: "/api/openapiXjson" },
|
||||
{ method: "GET", path: "/api/secrets" },
|
||||
];
|
||||
for (const [index, request] of requests.entries()) {
|
||||
await writeFile(path.join(directories.requestsDir, `schema-${index}.json`), JSON.stringify({
|
||||
id: `schema-${index}`, ...request, query: "", headers: {}, body: "", createdAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
await worker.stop({ drainTimeoutMs: 5_000 });
|
||||
for (const [index] of requests.entries()) {
|
||||
const response = JSON.parse(await readFile(path.join(directories.responsesDir, `schema-${index}.json`), "utf8"));
|
||||
expect(response.status).toBe(index === 0 ? 200 : 403);
|
||||
if (index === 0) expect(JSON.parse(response.body)).toEqual(schema);
|
||||
}
|
||||
expect(forwarded).toEqual(["GET /api/openapi.json"]);
|
||||
});
|
||||
|
||||
it("denies non-allowlisted requests by default", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-default-policy-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
|
|||
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
|
||||
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
|
||||
|
||||
// Read-only schema discovery for validated control-plane requests.
|
||||
{ method: "GET", path: /^\/api\/openapi\.json$/ },
|
||||
|
||||
// Company-level reads used to discover work and context
|
||||
{ method: "GET", path: /^\/api\/companies\/[^/]+$/ },
|
||||
{ method: "GET", path: /^\/api\/companies\/[^/]+\/dashboard$/ },
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
isPaperclipExternalChatContractTurn,
|
||||
isPaperclipExternalChatQuestionResponseTurn,
|
||||
isPaperclipExternalChatTurn,
|
||||
|
|
@ -86,6 +87,9 @@ describe("runtime connection tool delivery", () => {
|
|||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).toContain(CONNECTION_INTENT_AGENT_GUIDANCE);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("Execution contract:");
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("child issues");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -908,6 +912,30 @@ describe("runChildProcess", () => {
|
|||
});
|
||||
|
||||
describe("renderPaperclipWakePrompt", () => {
|
||||
it("leaves conversation disposition and accepted-plan handoff to the injected chat policy", () => {
|
||||
const payload = {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "chat", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
comments: [],
|
||||
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
||||
fallbackFetchNeeded: false,
|
||||
};
|
||||
const ordinary = renderPaperclipWakePrompt(payload, { resumedSession: true });
|
||||
expect(ordinary).toContain("Execution contract:");
|
||||
expect(ordinary).toContain("Create child issues from the approved plan");
|
||||
for (const resumedSession of [false, true]) {
|
||||
const chat = renderPaperclipWakePrompt(payload, {
|
||||
resumedSession, conversationMode: true, includeExecutionContract: true,
|
||||
});
|
||||
expect(chat).not.toContain("Execution contract:");
|
||||
expect(chat).not.toContain("clear final disposition");
|
||||
expect(chat).not.toContain("Create child issues");
|
||||
expect(chat).not.toContain("you may create child implementation issues");
|
||||
}
|
||||
});
|
||||
|
||||
const ordinaryExternalChatWake = {
|
||||
reason: "External chat message received",
|
||||
externalChatProvider: " GitHub ",
|
||||
|
|
|
|||
|
|
@ -230,6 +230,18 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
|||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
// Chat behavior is supplied centrally by the server's task-context markdown.
|
||||
// Keep the ordinary task's completion/delegation contract out of this template.
|
||||
export const DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE = [
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip conversation using the supplied chat mode directive.",
|
||||
"Use available tools and assigned skills as needed; respect budget, pause/cancel, approval gates, and company boundaries.",
|
||||
"Prefer the smallest verification that proves the action. Use PAPERCLIP_SCRATCH_DIR / PAPERCLIP_RUN_SCRATCH_DIR for temporary scratch files.",
|
||||
"After 2 consecutive failures of the same control-plane write, stop retrying that write for the rest of the turn. Report the failure honestly; never claim an unconfirmed mutation succeeded.",
|
||||
"Never create probe or throwaway issue-thread interactions. Every interaction must carry a real, answerable prompt; withdraw one you no longer need.",
|
||||
"",
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
export const WATCHDOG_DEFAULT_MANDATE = [
|
||||
"You are running as a task watchdog, not as the original deliverable worker.",
|
||||
"Your mission is to keep the watched issue tree moving by verifying stopped work, not by trusting agent claims.",
|
||||
|
|
@ -2181,6 +2193,9 @@ function renderPaperclipWakePromptBody(
|
|||
options: {
|
||||
resumedSession?: boolean;
|
||||
includeExecutionContract?: boolean;
|
||||
// Conversation policy arrives in the server-owned task markdown. Generic
|
||||
// task disposition and child-delegation instructions conflict with it.
|
||||
conversationMode?: boolean;
|
||||
nativeWakeReaderAvailable?: boolean;
|
||||
// Set by adapters whose prompt already carries the task-context markdown
|
||||
// (the authoritative, uncapped brief) so the description is not delivered
|
||||
|
|
@ -2204,8 +2219,8 @@ function renderPaperclipWakePromptBody(
|
|||
// The heartbeat prompt template already carries the execution contract on
|
||||
// fresh sessions; only resume deltas (which replace the template) and
|
||||
// template-less adapters need the wake-payload copy.
|
||||
const includeExecutionContract =
|
||||
resumedSession || options.includeExecutionContract === true;
|
||||
const includeExecutionContract = options.conversationMode !== true &&
|
||||
(resumedSession || options.includeExecutionContract === true);
|
||||
const hasWakeCommentBatch =
|
||||
normalized.comments.length > 0 ||
|
||||
normalized.includedCount > 0 ||
|
||||
|
|
@ -2500,7 +2515,7 @@ function renderPaperclipWakePromptBody(
|
|||
lines.push(`- checkbox selection ids: ${selectedOptionIds}`);
|
||||
lines.push(`- checkbox selection options: ${selectedOptions}`);
|
||||
}
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) {
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog && options.conversationMode !== true) {
|
||||
const hasWakeComments = normalized.comments.length > 0;
|
||||
const acceptedPlanContinuation =
|
||||
!hasWakeComments &&
|
||||
|
|
@ -2647,7 +2662,7 @@ function renderPaperclipWakePromptBody(
|
|||
"",
|
||||
"Open plan comments to incorporate:",
|
||||
"These open plan annotations are user feedback. Resolved annotations were intentionally omitted.",
|
||||
"Read this before revising the plan or creating child issues from an accepted plan.",
|
||||
"Read this before revising the plan or acting on an accepted plan.",
|
||||
);
|
||||
if (context.latestRevisionNumber || context.latestRevisionId) {
|
||||
lines.push(
|
||||
|
|
@ -2655,9 +2670,10 @@ function renderPaperclipWakePromptBody(
|
|||
);
|
||||
}
|
||||
if (context.interaction) {
|
||||
lines.push(
|
||||
`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`,
|
||||
);
|
||||
lines.push(`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`);
|
||||
if (context.interaction.status === "rejected") {
|
||||
lines.push("The user requested changes to this plan. Revise it using the feedback below; this is not approval to implement or hand off execution tasks. In Ask mode, discuss the requested changes without mutating documents or tasks.");
|
||||
}
|
||||
if (context.interaction.result) {
|
||||
const result = context.interaction.result;
|
||||
lines.push(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { buildSkillLibraryManifestMarkdown } from "@paperclipai/adapter-utils/skill-library-manifest";
|
||||
import {
|
||||
|
|
@ -430,7 +431,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
|
|
@ -847,6 +850,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
const promptBundleKey =
|
||||
readNonEmptyString(record.promptBundleKey) ??
|
||||
readNonEmptyString(record.prompt_bundle_key);
|
||||
const mcpServerIdentity = readNonEmptyString(record.mcpServerIdentity);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
|
|
@ -89,6 +90,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
|
|
@ -105,6 +107,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
const promptBundleKey =
|
||||
readNonEmptyString(params.promptBundleKey) ??
|
||||
readNonEmptyString(params.prompt_bundle_key);
|
||||
const mcpServerIdentity = readNonEmptyString(params.mcpServerIdentity);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
|
|
@ -112,6 +115,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ import {
|
|||
readPaperclipIssueWorkModeFromContext,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
|
|
@ -587,7 +589,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
|
|
@ -1119,7 +1123,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
||||
instructionsChars = promptInstructionsPrefix.length;
|
||||
|
|
@ -1202,6 +1211,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
wakePrompt,
|
||||
codexFallbackHandoffNote,
|
||||
sessionHandoffNote,
|
||||
taskContextNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
|
|
@ -1210,6 +1220,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
|
|
@ -1546,6 +1557,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
!initial.proc.signal &&
|
||||
// A started session can emit stale-rollout warnings for other threads.
|
||||
// After Ctrl-C those warnings must not restart the cancelled turn.
|
||||
!initial.parsed.sessionId &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function createMockSdkAgent(options: MockAgentOptions = {}) {
|
|||
const sendRun = options.sendRun ?? createMockRun();
|
||||
return {
|
||||
agentId: options.agentId ?? sendRun.agentId,
|
||||
send: vi.fn(async () => sendRun),
|
||||
send: vi.fn(async (_prompt: string, _options?: Record<string, unknown>) => sendRun),
|
||||
[Symbol.asyncDispose]: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -142,6 +142,32 @@ describe("cursor_cloud execute", () => {
|
|||
getRunMock.mockReset();
|
||||
});
|
||||
|
||||
it.each([false, true])("sends the central chat directive to Cursor Cloud (custom=%s)", async (custom) => {
|
||||
const sdkAgent = createMockSdkAgent();
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext();
|
||||
if (!custom) delete ctx.config.promptTemplate;
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
ctx.context = {
|
||||
...ctx.context,
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "issue-1", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
};
|
||||
const result = await execute(ctx);
|
||||
expect(result.exitCode).toBe(0);
|
||||
const prompt = String(sdkAgent.send.mock.calls[0]?.[0]);
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).toContain(custom ? "Do the work for" : "Continue your Paperclip conversation");
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
});
|
||||
|
||||
it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => {
|
||||
const run = createMockRun({
|
||||
agentId: "agent-fresh",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
asBoolean,
|
||||
asString,
|
||||
buildPaperclipEnv,
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
renderTemplate,
|
||||
stringifyPaperclipWakePayload,
|
||||
|
|
@ -400,7 +402,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
: null);
|
||||
const canReuseSession = sessionMatches(session, envType, envName, repos);
|
||||
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
||||
const promptTemplate = asString(config.promptTemplate, context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
|
|
@ -412,7 +416,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
context,
|
||||
};
|
||||
const instructions = await buildInstructionsPrefix(config, onLog);
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canReuseSession });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: canReuseSession })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: canReuseSession,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const renderedBootstrapPrompt =
|
||||
!canReuseSession && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
|
|
@ -426,6 +437,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructions.prefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
|
|
@ -465,6 +477,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructions.chars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
},
|
||||
context: {
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ import {
|
|||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
|
@ -206,7 +208,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
let command = asString(config.command, "agent");
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
|
|
@ -566,7 +570,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -577,6 +588,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
|
|
@ -586,6 +598,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -47,9 +47,11 @@ import {
|
|||
parseObject,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
|
@ -230,7 +232,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "gemini");
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
|
|
@ -555,7 +559,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -567,6 +578,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
|
|
@ -577,6 +589,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -34,11 +34,13 @@ import {
|
|||
readPaperclipRuntimeSkillEntries,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
stringifyPaperclipWakePayload,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||
import { copyBackGrokAuth } from "./grok-auth-copyback.js";
|
||||
|
|
@ -202,7 +204,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "grok");
|
||||
const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
|
||||
|
|
@ -474,7 +478,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -484,6 +495,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
|
|
@ -492,6 +504,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -174,6 +174,40 @@ describe("execute", () => {
|
|||
expect(body.session_id).toBe("paperclip:company:company-1:agent:agent-1:issue:issue-1");
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves chat handoff policy on gateway turns (resumed=%s)", async (resumed) => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => new Response(JSON.stringify(
|
||||
String(input).endsWith("/v1/runs")
|
||||
? { run_id: "run-hermes-1", status: "started" }
|
||||
: { status: "completed", output: "done" },
|
||||
), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const ctx = makeCtx({ apiBaseUrl: "http://127.0.0.1:8642", apiKey: "secret-key", timeoutSec: 5 });
|
||||
ctx.config.payloadTemplate = { input: "Custom gateway instruction." };
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
ctx.context = {
|
||||
conversationMode: true,
|
||||
issueId: "issue-1",
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipTaskMarkdownCompact: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "issue-1", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
};
|
||||
if (resumed) ctx.runtime.sessionId = "prior-session";
|
||||
await execute(ctx);
|
||||
const calls = fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>;
|
||||
const call = calls.find(([input]) => String(input).endsWith("/v1/runs"));
|
||||
const prompt = JSON.parse(String(call?.[1]?.body)).input as string;
|
||||
expect(prompt).toContain("Custom gateway instruction.");
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("clear final disposition");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
});
|
||||
|
||||
it("sends the task brief once on fresh runs and compacts it on stable-session resumes", async () => {
|
||||
const description = "Update launch-card.svg and change the CTA to Try Team free.";
|
||||
const fullTaskMarkdown = [
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null
|
|||
Boolean(nonEmpty(ctx.runtime?.sessionId));
|
||||
const taskMarkdown = nonEmpty(selectPaperclipTaskMarkdown(ctx.context, { resumedSession }));
|
||||
const wakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
|
||||
conversationMode: ctx.context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: Boolean(taskMarkdown),
|
||||
|
|
@ -293,7 +294,7 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null
|
|||
...(paperclipApiUrl ? [`- Paperclip API URL: ${paperclipApiUrl}`] : []),
|
||||
...(issueWorkMode ? [`- Issue work mode: ${issueWorkMode}`] : []),
|
||||
"",
|
||||
...(isPaperclipRecoveryWakePayload(ctx.context.paperclipWake)
|
||||
...(ctx.context.conversationMode === true || isPaperclipRecoveryWakePayload(ctx.context.paperclipWake)
|
||||
? []
|
||||
: [
|
||||
"Execution contract:",
|
||||
|
|
@ -322,7 +323,10 @@ function buildInput(ctx: AdapterExecutionContext, paperclipApiUrl: string | null
|
|||
function buildRunBody(ctx: AdapterExecutionContext, sessionKey: string | null): Record<string, unknown> {
|
||||
const paperclipApiUrl = nonEmpty(ctx.config.paperclipApiUrl);
|
||||
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
|
||||
const input = nonEmpty(payloadTemplate.input) ?? buildInput(ctx, paperclipApiUrl);
|
||||
const configuredInput = nonEmpty(payloadTemplate.input);
|
||||
const input = configuredInput && ctx.context.conversationMode === true
|
||||
? `${configuredInput}\n\n${buildInput(ctx, paperclipApiUrl)}`
|
||||
: configuredInput ?? buildInput(ctx, paperclipApiUrl);
|
||||
const instructions =
|
||||
nonEmpty(ctx.config.instructions) ??
|
||||
nonEmpty(payloadTemplate.instructions) ??
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
renderTemplate,
|
||||
ensureAbsoluteDirectory,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
|
|
@ -140,9 +141,10 @@ export function buildPrompt(
|
|||
config: Record<string, unknown>,
|
||||
options: { resumedSession?: boolean } = {},
|
||||
): string {
|
||||
const template = cfgString(config.promptTemplate) || HERMES_DEFAULT_PROMPT_TEMPLATE;
|
||||
|
||||
const context = (ctx as any).context || {};
|
||||
const template = cfgString(config.promptTemplate) || (context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: HERMES_DEFAULT_PROMPT_TEMPLATE);
|
||||
const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId);
|
||||
const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || "";
|
||||
const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || "";
|
||||
|
|
@ -166,6 +168,7 @@ export function buildPrompt(
|
|||
resumedSession: options.resumedSession === true,
|
||||
});
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: options.resumedSession === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
|
|
|
|||
|
|
@ -246,3 +246,23 @@ test("preserves custom prompt templates while exposing runtime and wake variable
|
|||
expect(prompt).toContain("Issue description:\n```text\nUse the wake payload as runtime authority.\n```");
|
||||
expect(prompt).not.toContain("Paperclip runtime identity:");
|
||||
});
|
||||
|
||||
|
||||
test.each([false, true])("conversation prompts preserve the handoff policy (resumed=%s)", (resumedSession) => {
|
||||
const directive = "Chat directive: clarify goals and hand the plan off to project tasks.";
|
||||
const ctx = baseContext({
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipTaskMarkdownCompact: directive,
|
||||
});
|
||||
ctx.context.paperclipWake.interactionKind = "request_confirmation";
|
||||
ctx.context.paperclipWake.interactionStatus = "accepted";
|
||||
for (const config of [{}, { promptTemplate: "Custom agent instruction." }]) {
|
||||
const prompt = buildPrompt(ctx, config, { resumedSession });
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("clear final disposition");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
expect(prompt).not.toContain("--arg status done");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,9 +39,11 @@ import {
|
|||
parseObject,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
SANDBOX_INSTALL_COMMAND,
|
||||
|
|
@ -210,7 +212,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "kimi");
|
||||
const model = asString(config.model, "").trim();
|
||||
|
|
@ -512,7 +516,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -524,6 +535,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
|
|
@ -534,6 +546,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const websocketState = vi.hoisted(() => ({
|
|||
failConnectAttempts: 0,
|
||||
failAgentRequests: 0,
|
||||
events: [] as string[],
|
||||
messages: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("ws", async () => {
|
||||
|
|
@ -35,7 +36,8 @@ vi.mock("ws", async () => {
|
|||
}
|
||||
|
||||
send(payload: string) {
|
||||
const request = JSON.parse(payload) as { id: string; method: string };
|
||||
const request = JSON.parse(payload) as { id: string; method: string; params?: { message?: string } };
|
||||
if (request.method === "agent") websocketState.messages.push(request.params?.message ?? "");
|
||||
websocketState.events.push(`send:${request.method}`);
|
||||
if (request.method === "agent" && websocketState.failAgentRequests > 0) {
|
||||
websocketState.failAgentRequests--;
|
||||
|
|
@ -105,12 +107,41 @@ describe("openclaw_gateway execute dispatch boundary", () => {
|
|||
websocketState.failConnectAttempts = 0;
|
||||
websocketState.failAgentRequests = 0;
|
||||
websocketState.events = [];
|
||||
websocketState.messages = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([false, true])("sends conversation policy without the issue-completion workflow (resumed=%s)", async (resumed) => {
|
||||
const ctx = createContext();
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
ctx.context = {
|
||||
...ctx.context,
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipTaskMarkdownCompact: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "issue-1", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
};
|
||||
if (resumed) ctx.runtime.sessionId = "prior-session";
|
||||
const result = await execute(ctx);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(websocketState.messages).toHaveLength(1);
|
||||
const prompt = websocketState.messages[0]!;
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).toContain("X-Paperclip-Run-Id");
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
expect(prompt).not.toContain('"status":"done"');
|
||||
expect(prompt).not.toContain("GET /api/issues/{issueId}/comments");
|
||||
});
|
||||
|
||||
it("reports dispatch after transport setup and before the remote agent request", async () => {
|
||||
const onDispatch = vi.fn(() => {
|
||||
websocketState.events.push("dispatch");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import crypto, { randomUUID } from "node:crypto";
|
||||
|
|
@ -372,6 +373,7 @@ function buildWakeText(
|
|||
paperclipEnv: Record<string, string>,
|
||||
structuredWakePrompt: string,
|
||||
claimedApiKeyPath: string,
|
||||
conversationTaskMarkdown?: string,
|
||||
): string {
|
||||
const orderedKeys = [
|
||||
"PAPERCLIP_RUN_ID",
|
||||
|
|
@ -396,6 +398,19 @@ function buildWakeText(
|
|||
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
|
||||
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
|
||||
|
||||
if (conversationTaskMarkdown !== undefined) {
|
||||
return [
|
||||
"Paperclip conversation turn for a cloud adapter.",
|
||||
"Set these values in your run context:",
|
||||
...envLines,
|
||||
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token saved after claim-api-key).`,
|
||||
"Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call and X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutation.",
|
||||
"Follow the supplied chat mode directive. Keep this conversation available for the next message.",
|
||||
structuredWakePrompt,
|
||||
conversationTaskMarkdown,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Paperclip wake event for a cloud adapter.",
|
||||
"",
|
||||
|
|
@ -1091,6 +1106,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
// must carry the execution contract itself.
|
||||
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake, {
|
||||
includeExecutionContract: true,
|
||||
conversationMode: ctx.context.conversationMode === true,
|
||||
});
|
||||
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
|
||||
const wakeText = buildWakeText(
|
||||
|
|
@ -1100,6 +1116,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
? joinWakePayloadSections(structuredWakePrompt, structuredWakeJson)
|
||||
: structuredWakePrompt,
|
||||
resolveClaimedApiKeyPath(ctx.config.claimedApiKeyPath),
|
||||
ctx.context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) })
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
||||
|
|
|
|||
|
|
@ -104,12 +104,16 @@ describe("opencode remote execution", () => {
|
|||
const cleanupDirs: string[] = [];
|
||||
const originalOpenCodeAllowAllModels = process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
cleanupDirs.push(configHome);
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
if (originalOpenCodeAllowAllModels === undefined) {
|
||||
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,53 @@ function probeResult(overrides: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
describe("OpenCode local skill injection", () => {
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
configHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps chat policy with a legacy OpenCode prompt (custom=%s)", async (custom) => {
|
||||
const commandPath = path.join(configHome, "fake-opencode");
|
||||
await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
runProcessMock.mockReset();
|
||||
runProcessMock.mockResolvedValue(probeResult({ stdout: JSON.stringify({
|
||||
type: "text", sessionID: "chat-session", part: { text: "Reply" },
|
||||
}) }));
|
||||
const directive = "Chat directive: clarify goals and hand plans off to project tasks.";
|
||||
let prompt = "";
|
||||
const result = await execute({
|
||||
runId: "chat-run",
|
||||
agent: { id: "agent-1", companyId: "company-1", name: "OpenCode", adapterType: "opencode_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath, cwd: configHome, model: "openai/gpt-5", env: { OPENCODE_ALLOW_ALL_MODELS: "1" },
|
||||
...(custom ? { promptTemplate: "Custom agent instruction." } : {}),
|
||||
},
|
||||
context: {
|
||||
conversationMode: true,
|
||||
paperclipTaskMarkdown: directive,
|
||||
paperclipWake: {
|
||||
reason: "issue_commented", issue: { id: "chat-1", status: "in_progress", workMode: "planning" },
|
||||
interactionKind: "request_confirmation", interactionStatus: "accepted",
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => { prompt = String(meta.prompt ?? ""); },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(prompt).toContain(directive);
|
||||
expect(prompt).toContain(custom ? "Custom agent instruction." : "Continue your Paperclip conversation");
|
||||
expect(prompt).not.toContain("Execution contract:");
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
});
|
||||
|
||||
it("injects runtime skills into the configured child HOME", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-"));
|
||||
const processHome = path.join(root, "process-home");
|
||||
|
|
|
|||
|
|
@ -40,9 +40,11 @@ import {
|
|||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
|
|
@ -229,7 +231,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "opencode");
|
||||
const model = asString(config.model, "").trim();
|
||||
|
|
@ -562,7 +566,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: Boolean(sessionId),
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -572,6 +583,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
|
|
@ -580,6 +592,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
const {
|
||||
|
|
@ -71,8 +74,17 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|||
import { testEnvironment } from "./test.js";
|
||||
|
||||
describe("opencode remote environment diagnostics", () => {
|
||||
afterEach(() => {
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stages remote runtime config assets for sandbox hello probes", async () => {
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ import {
|
|||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
||||
|
|
@ -228,7 +230,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "pi");
|
||||
const model = asString(config.model, "").trim();
|
||||
|
|
@ -585,7 +589,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
||||
(context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
||||
} catch (err) {
|
||||
instructionsReadFailed = true;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -615,7 +621,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession });
|
||||
const taskContextNote = context.conversationMode === true
|
||||
? selectPaperclipTaskMarkdown(context, { resumedSession: canResumeSession })
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
conversationMode: context.conversationMode === true,
|
||||
resumedSession: canResumeSession,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0;
|
||||
const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
|
||||
? ""
|
||||
|
|
@ -624,6 +637,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const userPrompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
taskContextNote,
|
||||
sessionHandoffNote,
|
||||
renderedHeartbeatPrompt,
|
||||
]);
|
||||
|
|
@ -632,6 +646,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
promptChars: userPrompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedHeartbeatPrompt.length,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { createDb } from "../src/client.js";
|
||||
|
|
@ -31,13 +31,28 @@ async function main() {
|
|||
database?: {
|
||||
mode?: string;
|
||||
embeddedPostgresPort?: number;
|
||||
embeddedPostgresDataDir?: string;
|
||||
connectionString?: string;
|
||||
};
|
||||
};
|
||||
// The server can select another port when the configured one is occupied.
|
||||
// Bind bootstrap to this data directory's running process, never another instance.
|
||||
let embeddedPort: number | undefined;
|
||||
if (config.database?.mode !== "postgres") {
|
||||
const dataDir = config.database?.embeddedPostgresDataDir;
|
||||
if (!dataDir) throw new Error("Embedded bootstrap requires its configured data directory");
|
||||
const pidLines = readFileSync(path.join(dataDir, "postmaster.pid"), "utf8").split(/\r?\n/);
|
||||
if (realpathSync(pidLines[1] ?? "") !== realpathSync(dataDir)) throw new Error("Embedded bootstrap data directory does not match the running postmaster");
|
||||
const postmasterPid = Number(pidLines[0]);
|
||||
if (!Number.isInteger(postmasterPid) || postmasterPid <= 1) throw new Error("Invalid embedded postmaster PID");
|
||||
process.kill(postmasterPid, 0);
|
||||
embeddedPort = Number(pidLines[3]);
|
||||
if (!Number.isInteger(embeddedPort) || embeddedPort < 1 || embeddedPort > 65535) throw new Error("Invalid running embedded database port");
|
||||
}
|
||||
const dbUrl =
|
||||
config.database?.mode === "postgres"
|
||||
? config.database.connectionString
|
||||
: `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`;
|
||||
: `postgres://paperclip:paperclip@127.0.0.1:${embeddedPort}/paperclip`;
|
||||
if (!dbUrl) {
|
||||
throw new Error(`Could not resolve database connection from ${configPath}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const migrationFile = "0274_agent_chat.sql";
|
||||
const migrationSql = await readFile(new URL(`./migrations/${migrationFile}`, import.meta.url), "utf8");
|
||||
const migrationHash = createHash("sha256").update(migrationSql).digest("hex");
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length) await cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function seed(sql: postgres.Sql) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
const userId = `chat-user-${randomUUID()}`;
|
||||
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Chat migration', 'CHM')`;
|
||||
await sql`INSERT INTO agents (id, company_id, name, role, adapter_type) VALUES (${agentId}, ${companyId}, 'Chat agent', 'engineer', 'process')`;
|
||||
await sql`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES (${userId}, 'Chat user', ${`${userId}@example.test`}, true, now(), now())`;
|
||||
await sql`INSERT INTO issues (id, company_id, title, assignee_agent_id, status,
|
||||
conversation_agent_id, conversation_user_id, conversation_state, conversation_session_generation, conversation_boundary_comment_id)
|
||||
VALUES (${issueId}, ${companyId}, 'Preserved chat', ${agentId}, 'in_review',
|
||||
${agentId}, ${userId}, 'waiting', 7, ${commentId})`;
|
||||
await sql`INSERT INTO issue_comments (id, company_id, issue_id, author_user_id, body, client_request_id, conversation_session_generation)
|
||||
VALUES (${commentId}, ${companyId}, ${issueId}, ${userId}, 'Preserved conversation history', 'first-message', 7)`;
|
||||
return { companyId, agentId, issueId, commentId, userId };
|
||||
}
|
||||
|
||||
async function assertConstraints(sql: postgres.Sql, row: Awaited<ReturnType<typeof seed>>) {
|
||||
for (const update of [
|
||||
{ conversation_state: null },
|
||||
{ status: "done" },
|
||||
{ status: "cancelled" },
|
||||
{ assignee_agent_id: null },
|
||||
{ conversation_user_id: null },
|
||||
]) {
|
||||
await expect(sql`UPDATE issues SET ${sql(update)} WHERE id = ${row.issueId}`)
|
||||
.rejects.toMatchObject({ code: "23514", constraint_name: "issues_conversation_identity_check" });
|
||||
}
|
||||
await expect(sql`INSERT INTO issues (company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${row.companyId}, 'Duplicate conversation', ${row.agentId}, 'in_review', ${row.agentId}, ${row.userId}, 'waiting')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issues_conversation_identity_idx" });
|
||||
await expect(sql`INSERT INTO issue_comments (company_id, issue_id, author_user_id, body, client_request_id)
|
||||
VALUES (${row.companyId}, ${row.issueId}, ${row.userId}, 'Duplicate message', 'first-message')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issue_comments_client_request_uq" });
|
||||
await sql`INSERT INTO issues (company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${row.companyId}, 'Other person conversation', ${row.agentId}, 'in_review', ${row.agentId}, 'other-person', 'waiting')`;
|
||||
await sql`INSERT INTO issues (company_id, title, status) VALUES (${row.companyId}, 'Ordinary completed task', 'done')`;
|
||||
}
|
||||
|
||||
describePostgres("persistent agent chat migration", () => {
|
||||
it("applies to a fresh database and enforces conversation identity and message retry uniqueness", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-chat-migration-fresh-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await assertConstraints(sql, await seed(sql));
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("replays over pre-release columns and constraints without losing history or weakening the state guard", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-chat-migration-replay-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const row = await seed(sql);
|
||||
const beforeIssue = await sql`SELECT * FROM issues WHERE id = ${row.issueId}`;
|
||||
const beforeComment = await sql`SELECT * FROM issue_comments WHERE id = ${row.commentId}`;
|
||||
// The original pre-release guard omitted the explicit state null check.
|
||||
// Keep every column, index and FK to model an already-upgraded development DB.
|
||||
await sql`ALTER TABLE issues DROP CONSTRAINT issues_conversation_identity_check`;
|
||||
const legacyGuard = migrationSql.slice(migrationSql.lastIndexOf('ALTER TABLE "issues" ADD CONSTRAINT'))
|
||||
.replace(' and "issues"."conversation_state" is not null', "");
|
||||
await sql.unsafe(legacyGuard);
|
||||
const legacyNullIds = [randomUUID(), randomUUID()];
|
||||
for (const [index, status] of ["in_review", "in_progress"].entries()) {
|
||||
await sql`INSERT INTO issues (id, company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${legacyNullIds[index]!}, ${row.companyId}, 'Legacy null state', ${row.agentId}, ${status}, ${row.agentId}, ${`legacy-null-${index}`}, NULL)`;
|
||||
}
|
||||
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
|
||||
expect(await inspectMigrations(database.connectionString)).toMatchObject({
|
||||
status: "needsMigrations", pendingMigrations: [migrationFile],
|
||||
});
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
// Exercise the SQL itself a second time, even with every new object present.
|
||||
await sql.begin(async (tx) => {
|
||||
for (const statement of migrationSql.split("--> statement-breakpoint")) {
|
||||
if (statement.trim()) await tx.unsafe(statement);
|
||||
}
|
||||
});
|
||||
expect(await sql`SELECT * FROM issues WHERE id = ${row.issueId}`).toEqual(beforeIssue);
|
||||
expect(await sql`SELECT * FROM issue_comments WHERE id = ${row.commentId}`).toEqual(beforeComment);
|
||||
const repaired = await sql`SELECT id, conversation_state FROM issues WHERE id IN ${sql(legacyNullIds)}`;
|
||||
expect(repaired.find((item) => item.id === legacyNullIds[0])?.conversation_state).toBe("waiting");
|
||||
expect(repaired.find((item) => item.id === legacyNullIds[1])?.conversation_state).toBe("active");
|
||||
await assertConstraints(sql, row);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "controller_boot_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "controller_lease_expires_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "execution_stage" text;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
-- Idempotent for development instances that applied the pre-release chat migrations.
|
||||
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "client_request_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "conversation_session_generation" integer;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_agent_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_state" text;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_session_generation" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_boundary_comment_id" uuid;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_conversation_agent_id_agents_id_fk' AND conrelid = 'issues'::regclass) THEN
|
||||
ALTER TABLE "issues" ADD CONSTRAINT "issues_conversation_agent_id_agents_id_fk" FOREIGN KEY ("conversation_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issues_conversation_identity_idx" ON "issues" USING btree ("company_id","conversation_agent_id","conversation_user_id");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_comments_client_request_uq' AND conrelid = 'issue_comments'::regclass) THEN
|
||||
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_client_request_uq" UNIQUE("issue_id","author_user_id","client_request_id");
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
-- The first development guard allowed NULL through SQL three-valued logic.
|
||||
-- Recover the server-owned idle/active state before enforcing the stronger guard.
|
||||
UPDATE "issues" SET "conversation_state" = CASE WHEN "status" = 'in_review' THEN 'waiting' ELSE 'active' END
|
||||
WHERE "conversation_agent_id" IS NOT NULL AND "conversation_state" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" DROP CONSTRAINT IF EXISTS "issues_conversation_identity_check";--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD CONSTRAINT "issues_conversation_identity_check" CHECK ((
|
||||
"issues"."conversation_agent_id" is null and "issues"."conversation_user_id" is null and "issues"."conversation_state" is null
|
||||
) or (
|
||||
"issues"."conversation_agent_id" is not null and "issues"."conversation_user_id" is not null
|
||||
and "issues"."assignee_agent_id" = "issues"."conversation_agent_id" and "issues"."assignee_agent_id" is not null
|
||||
and "issues"."assignee_user_id" is null and "issues"."conversation_state" is not null
|
||||
and "issues"."conversation_state" in ('active', 'waiting')
|
||||
and "issues"."status" not in ('done', 'cancelled')
|
||||
));
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"id": "b091657b-9ea0-4576-b34f-221d097467d5",
|
||||
"id": "d01dd077-2ab3-494b-962c-02b219026b64",
|
||||
"prevId": "cad9198b-f814-4ed9-b364-e8677eab5c23",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
|
|
@ -21977,6 +21977,24 @@
|
|||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"controller_boot_id": {
|
||||
"name": "controller_boot_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"controller_lease_expires_at": {
|
||||
"name": "controller_lease_expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"execution_stage": {
|
||||
"name": "execution_stage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"process_pid": {
|
||||
"name": "process_pid",
|
||||
"type": "integer",
|
||||
|
|
@ -47319,655 +47337,6 @@
|
|||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.task_repository_bindings": {
|
||||
"name": "task_repository_bindings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"workspace_id": {
|
||||
"name": "workspace_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"repo_url": {
|
||||
"name": "repo_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repo_ref": {
|
||||
"name": "repo_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"setup_complete": {
|
||||
"name": "setup_complete",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"retired_at": {
|
||||
"name": "retired_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_key": {
|
||||
"name": "checkpoint_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_sha256": {
|
||||
"name": "checkpoint_sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"checkpoint_at": {
|
||||
"name": "checkpoint_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_repository_bindings_company_id_companies_id_fk": {
|
||||
"name": "task_repository_bindings_company_id_companies_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_repository_bindings_task_id_issues_id_fk": {
|
||||
"name": "task_repository_bindings_task_id_issues_id_fk",
|
||||
"tableFrom": "task_repository_bindings",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"task_repository_bindings_workspace_uq": {
|
||||
"name": "task_repository_bindings_workspace_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"workspace_id"
|
||||
]
|
||||
},
|
||||
"task_repository_bindings_name_uq": {
|
||||
"name": "task_repository_bindings_name_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"task_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_file_operations": {
|
||||
"name": "work_file_operations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"operation_id": {
|
||||
"name": "operation_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fingerprint": {
|
||||
"name": "fingerprint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_file_operations_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_file_operations_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_file_operations",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_file_operations_receipt_uq": {
|
||||
"name": "work_file_operations_receipt_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"folder_id",
|
||||
"operation_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_files": {
|
||||
"name": "work_files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'file'"
|
||||
},
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"byte_size": {
|
||||
"name": "byte_size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"sha256": {
|
||||
"name": "sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'application/octet-stream'"
|
||||
},
|
||||
"executable": {
|
||||
"name": "executable",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_files_folder_path_uq": {
|
||||
"name": "work_files_folder_path_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "path",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"work_files\".\"deleted_at\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"work_files_company_folder_idx": {
|
||||
"name": "work_files_company_folder_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "folder_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_files_company_id_folder_id_work_folders_company_id_id_fk": {
|
||||
"name": "work_files_company_id_folder_id_work_folders_company_id_id_fk",
|
||||
"tableFrom": "work_files",
|
||||
"tableTo": "work_folders",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"folder_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folder_objects": {
|
||||
"name": "work_folder_objects",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"object_key": {
|
||||
"name": "object_key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"folder_id": {
|
||||
"name": "folder_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repository_binding_id": {
|
||||
"name": "repository_binding_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"delete_after": {
|
||||
"name": "delete_after",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_folder_objects_cleanup_idx": {
|
||||
"name": "work_folder_objects_cleanup_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "provider",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "delete_after",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folder_runs": {
|
||||
"name": "work_folder_runs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"manifest": {
|
||||
"name": "manifest",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"baselines": {
|
||||
"name": "baselines",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"pending_operations": {
|
||||
"name": "pending_operations",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'starting'"
|
||||
},
|
||||
"last_saved_at": {
|
||||
"name": "last_saved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_requested": {
|
||||
"name": "refresh_requested",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"work_folder_runs_company_idx": {
|
||||
"name": "work_folder_runs_company_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"work_folder_runs_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "work_folder_runs_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"work_folder_runs_company_id_companies_id_fk": {
|
||||
"name": "work_folder_runs_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folder_runs",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.work_folders": {
|
||||
"name": "work_folders",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"imported_at": {
|
||||
"name": "imported_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"work_folders_company_id_companies_id_fk": {
|
||||
"name": "work_folders_company_id_companies_id_fk",
|
||||
"tableFrom": "work_folders",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"work_folders_owner_uq": {
|
||||
"name": "work_folders_owner_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"scope",
|
||||
"owner_id"
|
||||
]
|
||||
},
|
||||
"work_folders_company_id_uq": {
|
||||
"name": "work_folders_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1902,8 +1902,22 @@
|
|||
{
|
||||
"idx": 273,
|
||||
"version": "7",
|
||||
"when": 1789164867037,
|
||||
"tag": "0273_sandbox_work_folders",
|
||||
"when": 1789164595203,
|
||||
"tag": "0273_aromatic_moondragon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 274,
|
||||
"version": "7",
|
||||
"when": 1789219070888,
|
||||
"tag": "0274_agent_chat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 275,
|
||||
"version": "7",
|
||||
"when": 1789237657148,
|
||||
"tag": "0275_sandbox_work_folders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ export const heartbeatRuns = pgTable(
|
|||
stderrExcerpt: text("stderr_excerpt"),
|
||||
errorCode: text("error_code"),
|
||||
externalRunId: text("external_run_id"),
|
||||
// Legacy controller lease. A PID alone is not an identity across containers.
|
||||
controllerBootId: uuid("controller_boot_id"),
|
||||
controllerLeaseExpiresAt: timestamp("controller_lease_expires_at", { withTimezone: true }),
|
||||
executionStage: text("execution_stage"),
|
||||
processPid: integer("process_pid"),
|
||||
processGroupId: integer("process_group_id"),
|
||||
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
IssueCommentPresentation,
|
||||
SourceTrustMetadata,
|
||||
} from "@paperclipai/shared";
|
||||
import { pgTable, uuid, text, timestamp, index, jsonb, unique } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, timestamp, index, jsonb, unique, integer } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
|
@ -30,6 +30,8 @@ export const issueComments = pgTable(
|
|||
derivedAuthorAgentId: uuid("derived_author_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
derivedCreatedByRunId: uuid("derived_created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
derivedAuthorSource: text("derived_author_source").$type<IssueCommentDerivedAuthorSource>(),
|
||||
clientRequestId: text("client_request_id"),
|
||||
conversationSessionGeneration: integer("conversation_session_generation"),
|
||||
body: text("body").notNull(),
|
||||
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
|
||||
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
|
||||
|
|
@ -43,6 +45,7 @@ export const issueComments = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
clientRequestUq: unique("issue_comments_client_request_uq").on(table.issueId, table.authorUserId, table.clientRequestId),
|
||||
companyIdUq: unique("issue_comments_company_id_uq").on(table.companyId, table.id),
|
||||
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
|
||||
companyIdx: index("issue_comments_company_idx").on(table.companyId),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
uniqueIndex,
|
||||
unique,
|
||||
bigint,
|
||||
check,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
|
@ -26,6 +27,12 @@ export const issues = pgTable(
|
|||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
// Conversation identity and session boundaries are owned by the server.
|
||||
conversationAgentId: uuid("conversation_agent_id").references(() => agents.id),
|
||||
conversationUserId: text("conversation_user_id"),
|
||||
conversationState: text("conversation_state").$type<"active" | "waiting">(),
|
||||
conversationSessionGeneration: integer("conversation_session_generation").notNull().default(0),
|
||||
conversationBoundaryCommentId: uuid("conversation_boundary_comment_id"),
|
||||
projectId: uuid("project_id").references(() => projects.id),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
goalId: uuid("goal_id").references(() => goals.id),
|
||||
|
|
@ -83,6 +90,16 @@ export const issues = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
conversationIdentityIdx: uniqueIndex("issues_conversation_identity_idx").on(table.companyId, table.conversationAgentId, table.conversationUserId),
|
||||
conversationIdentityCheck: check("issues_conversation_identity_check", sql`(
|
||||
${table.conversationAgentId} is null and ${table.conversationUserId} is null and ${table.conversationState} is null
|
||||
) or (
|
||||
${table.conversationAgentId} is not null and ${table.conversationUserId} is not null
|
||||
and ${table.assigneeAgentId} = ${table.conversationAgentId} and ${table.assigneeAgentId} is not null
|
||||
and ${table.assigneeUserId} is null and ${table.conversationState} is not null
|
||||
and ${table.conversationState} in ('active', 'waiting')
|
||||
and ${table.status} not in ('done', 'cancelled')
|
||||
)`),
|
||||
companyIdUq: unique("issues_company_id_uq").on(table.companyId, table.id),
|
||||
companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status),
|
||||
companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
|
|||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "./test-embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const migration = readFileSync(new URL("./migrations/0273_sandbox_work_folders.sql", import.meta.url), "utf8");
|
||||
const migration = readFileSync(new URL("./migrations/0275_sandbox_work_folders.sql", import.meta.url), "utf8");
|
||||
|
||||
(support.supported ? describe : describe.skip)("work folder preview migration", () => {
|
||||
it("preserves cached content, trash, and unpushed repository checkpoints on replay", async () => {
|
||||
|
|
@ -47,7 +47,7 @@ const migration = readFileSync(new URL("./migrations/0273_sandbox_work_folders.s
|
|||
await database.cleanup();
|
||||
}
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
it("applies an earlier mainline migration after a renamed preview without losing files", async () => {
|
||||
it("applies missing mainline migrations after a renamed preview without losing files", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("work-folder-renumber-");
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
|
|
@ -59,14 +59,17 @@ const migration = readFileSync(new URL("./migrations/0273_sandbox_work_folders.s
|
|||
VALUES (${company}, ${folder}, 'saved.sh', 'preview/saved', true)`;
|
||||
await sql`INSERT INTO task_repository_bindings (company_id, task_id, workspace_id, name, checkpoint_key)
|
||||
VALUES (${company}, ${task}, ${randomUUID()}, 'repo', 'preview/unpushed')`;
|
||||
const mainline = readFileSync(new URL("./migrations/0272_light_kate_bishop.sql", import.meta.url), "utf8");
|
||||
const mainlineHash = createHash("sha256").update(mainline).digest("hex");
|
||||
const mainlineHashes = ["0272_light_kate_bishop", "0273_aromatic_moondragon", "0274_agent_chat"].map((name) =>
|
||||
createHash("sha256").update(readFileSync(new URL(`./migrations/${name}.sql`, import.meta.url), "utf8")).digest("hex"),
|
||||
);
|
||||
const previewHash = createHash("sha256").update(migration).digest("hex");
|
||||
// Model a preview that already recorded its work-folder migration with a
|
||||
// timestamp newer than the subsequently merged mainline migration.
|
||||
await sql`DROP TABLE email_sends, email_messages, email_endpoints`;
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${mainlineHash}`;
|
||||
await sql`UPDATE drizzle.__drizzle_migrations SET created_at = 1789153813732 WHERE hash = ${previewHash}`;
|
||||
await sql`ALTER TABLE heartbeat_runs DROP COLUMN controller_boot_id, DROP COLUMN controller_lease_expires_at, DROP COLUMN execution_stage`;
|
||||
await sql`ALTER TABLE issue_comments DROP COLUMN client_request_id`;
|
||||
for (const hash of mainlineHashes) await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
|
||||
await sql`UPDATE drizzle.__drizzle_migrations SET created_at = 1799999999999 WHERE hash = ${previewHash}`;
|
||||
const before = await inspectMigrations(database.connectionString);
|
||||
expect(before.status).toBe("needsMigrations");
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
|
|
@ -78,7 +81,13 @@ const migration = readFileSync(new URL("./migrations/0273_sandbox_work_folders.s
|
|||
.toMatchObject([{ path: "saved.sh", object_key: "preview/saved", executable: true }]);
|
||||
expect(await sql`SELECT checkpoint_key FROM task_repository_bindings WHERE task_id = ${task}`)
|
||||
.toMatchObject([{ checkpoint_key: "preview/unpushed" }]);
|
||||
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${mainlineHash}`).toHaveLength(1);
|
||||
expect(await sql`SELECT column_name FROM information_schema.columns WHERE table_name = 'heartbeat_runs'
|
||||
AND column_name IN ('controller_boot_id', 'controller_lease_expires_at', 'execution_stage')`).toHaveLength(3);
|
||||
expect(await sql`SELECT column_name FROM information_schema.columns WHERE table_name = 'issue_comments'
|
||||
AND column_name = 'client_request_id'`).toHaveLength(1);
|
||||
for (const hash of mainlineHashes) {
|
||||
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`).toHaveLength(1);
|
||||
}
|
||||
expect(await sql`SELECT hash FROM drizzle.__drizzle_migrations WHERE hash = ${previewHash}`).toHaveLength(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ The skill/reference inventory and eval cases are the only normative behavior sou
|
|||
|
||||
## Baseline Counts
|
||||
|
||||
- Skill/reference headings: 153
|
||||
- Skill/reference headings: 155
|
||||
- Eval cases: 106 across 16 groups
|
||||
- Total normative rows: 259
|
||||
- Total normative rows: 261
|
||||
- Legacy MCP aliases folded into normative rows: 42
|
||||
|
||||
| Eval group | Cases |
|
||||
|
|
@ -44,32 +44,33 @@ The skill/reference inventory and eval cases are the only normative behavior sou
|
|||
| skill:skills/paperclip/SKILL.md:paperclip-skill:10 | optional_agent_tool | skills/paperclip/SKILL.md:10 |
|
||||
| skill:skills/paperclip/SKILL.md:terminology:14 | optional_agent_tool | skills/paperclip/SKILL.md:14 |
|
||||
| skill:skills/paperclip/SKILL.md:authentication:18 | control_plane_owned | skills/paperclip/SKILL.md:18 |
|
||||
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:30 | control_plane_owned | skills/paperclip/SKILL.md:30 |
|
||||
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:70 | optional_agent_tool | skills/paperclip/SKILL.md:70 |
|
||||
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:142 | always_agent_tool | skills/paperclip/SKILL.md:142 |
|
||||
| skill:skills/paperclip/SKILL.md:status-quick-guide:190 | control_plane_owned | skills/paperclip/SKILL.md:190 |
|
||||
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:200 | optional_agent_tool | skills/paperclip/SKILL.md:200 |
|
||||
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:213 | always_agent_tool | skills/paperclip/SKILL.md:213 |
|
||||
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:224 | control_plane_owned | skills/paperclip/SKILL.md:224 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:232 | control_plane_owned | skills/paperclip/SKILL.md:232 |
|
||||
| skill:skills/paperclip/SKILL.md:requesting-board-approval:257 | optional_agent_tool | skills/paperclip/SKILL.md:257 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:278 | optional_agent_tool | skills/paperclip/SKILL.md:278 |
|
||||
| skill:skills/paperclip/SKILL.md:standalone-decisions:307 | optional_agent_tool | skills/paperclip/SKILL.md:307 |
|
||||
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:411 | optional_agent_tool | skills/paperclip/SKILL.md:411 |
|
||||
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:453 | optional_agent_tool | skills/paperclip/SKILL.md:453 |
|
||||
| skill:skills/paperclip/SKILL.md:cases:463 | optional_agent_tool | skills/paperclip/SKILL.md:463 |
|
||||
| skill:skills/paperclip/SKILL.md:company-skills-workflow:468 | optional_agent_tool | skills/paperclip/SKILL.md:468 |
|
||||
| skill:skills/paperclip/SKILL.md:routines:479 | optional_agent_tool | skills/paperclip/SKILL.md:479 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:490 | optional_agent_tool | skills/paperclip/SKILL.md:490 |
|
||||
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:497 | optional_agent_tool | skills/paperclip/SKILL.md:497 |
|
||||
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:504 | optional_agent_tool | skills/paperclip/SKILL.md:504 |
|
||||
| skill:skills/paperclip/SKILL.md:critical-rules:530 | optional_agent_tool | skills/paperclip/SKILL.md:530 |
|
||||
| skill:skills/paperclip/SKILL.md:comment-style-required:554 | always_agent_tool | skills/paperclip/SKILL.md:554 |
|
||||
| skill:skills/paperclip/SKILL.md:update:586 | optional_agent_tool | skills/paperclip/SKILL.md:586 |
|
||||
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:596 | optional_agent_tool | skills/paperclip/SKILL.md:596 |
|
||||
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:629 | optional_agent_tool | skills/paperclip/SKILL.md:629 |
|
||||
| skill:skills/paperclip/SKILL.md:searching-issues:658 | optional_agent_tool | skills/paperclip/SKILL.md:658 |
|
||||
| skill:skills/paperclip/SKILL.md:full-reference:668 | optional_agent_tool | skills/paperclip/SKILL.md:668 |
|
||||
| skill:skills/paperclip/SKILL.md:conversation-tasks:30 | optional_agent_tool | skills/paperclip/SKILL.md:30 |
|
||||
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:47 | control_plane_owned | skills/paperclip/SKILL.md:47 |
|
||||
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:87 | optional_agent_tool | skills/paperclip/SKILL.md:87 |
|
||||
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:159 | always_agent_tool | skills/paperclip/SKILL.md:159 |
|
||||
| skill:skills/paperclip/SKILL.md:status-quick-guide:207 | control_plane_owned | skills/paperclip/SKILL.md:207 |
|
||||
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:217 | optional_agent_tool | skills/paperclip/SKILL.md:217 |
|
||||
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:230 | always_agent_tool | skills/paperclip/SKILL.md:230 |
|
||||
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:241 | control_plane_owned | skills/paperclip/SKILL.md:241 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:249 | control_plane_owned | skills/paperclip/SKILL.md:249 |
|
||||
| skill:skills/paperclip/SKILL.md:requesting-board-approval:274 | optional_agent_tool | skills/paperclip/SKILL.md:274 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:295 | optional_agent_tool | skills/paperclip/SKILL.md:295 |
|
||||
| skill:skills/paperclip/SKILL.md:standalone-decisions:324 | optional_agent_tool | skills/paperclip/SKILL.md:324 |
|
||||
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:428 | optional_agent_tool | skills/paperclip/SKILL.md:428 |
|
||||
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:470 | optional_agent_tool | skills/paperclip/SKILL.md:470 |
|
||||
| skill:skills/paperclip/SKILL.md:cases:480 | optional_agent_tool | skills/paperclip/SKILL.md:480 |
|
||||
| skill:skills/paperclip/SKILL.md:company-skills-workflow:485 | optional_agent_tool | skills/paperclip/SKILL.md:485 |
|
||||
| skill:skills/paperclip/SKILL.md:routines:496 | optional_agent_tool | skills/paperclip/SKILL.md:496 |
|
||||
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:507 | optional_agent_tool | skills/paperclip/SKILL.md:507 |
|
||||
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:514 | optional_agent_tool | skills/paperclip/SKILL.md:514 |
|
||||
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:521 | optional_agent_tool | skills/paperclip/SKILL.md:521 |
|
||||
| skill:skills/paperclip/SKILL.md:critical-rules:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 |
|
||||
| skill:skills/paperclip/SKILL.md:comment-style-required:571 | always_agent_tool | skills/paperclip/SKILL.md:571 |
|
||||
| skill:skills/paperclip/SKILL.md:update:603 | optional_agent_tool | skills/paperclip/SKILL.md:603 |
|
||||
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:613 | optional_agent_tool | skills/paperclip/SKILL.md:613 |
|
||||
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:646 | optional_agent_tool | skills/paperclip/SKILL.md:646 |
|
||||
| skill:skills/paperclip/SKILL.md:searching-issues:675 | optional_agent_tool | skills/paperclip/SKILL.md:675 |
|
||||
| skill:skills/paperclip/SKILL.md:full-reference:685 | optional_agent_tool | skills/paperclip/SKILL.md:685 |
|
||||
| skill:skills/paperclip/references/artifacts.md:generated-artifacts-and-work-products:1 | always_agent_tool | skills/paperclip/references/artifacts.md:1 |
|
||||
| skill:skills/paperclip/references/artifacts.md:workspace-only-file-references:15 | optional_agent_tool | skills/paperclip/references/artifacts.md:15 |
|
||||
| skill:skills/paperclip/references/cases.md:cases:1 | optional_agent_tool | skills/paperclip/references/cases.md:1 |
|
||||
|
|
@ -127,73 +128,74 @@ The skill/reference inventory and eval cases are the only normative behavior sou
|
|||
| skill:skills/paperclip/references/workflows.md:company-import-export:79 | optional_agent_tool | skills/paperclip/references/workflows.md:79 |
|
||||
| skill:skills/paperclip/references/workflows.md:self-test-playbook-app-level:106 | optional_agent_tool | skills/paperclip/references/workflows.md:106 |
|
||||
| skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 |
|
||||
| skill:skills/paperclip/references/api-reference.md:response-schemas:7 | optional_agent_tool | skills/paperclip/references/api-reference.md:7 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
|
||||
| skill:skills/paperclip/references/api-reference.md:company-portability:42 | optional_agent_tool | skills/paperclip/references/api-reference.md:42 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-with-ancestors-get-api-issues-issueid:108 | optional_agent_tool | skills/paperclip/references/api-reference.md:108 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-update-response-patch-api-issues-issueid:194 | optional_agent_tool | skills/paperclip/references/api-reference.md:194 |
|
||||
| skill:skills/paperclip/references/api-reference.md:blocker-diagnostics-get-api-issues-issueid-diagnostics-blockers:236 | control_plane_owned | skills/paperclip/references/api-reference.md:236 |
|
||||
| skill:skills/paperclip/references/api-reference.md:wake-diagnostics-get-api-issues-issueid-diagnostics-wakes:275 | control_plane_owned | skills/paperclip/references/api-reference.md:275 |
|
||||
| skill:skills/paperclip/references/api-reference.md:subtree-diagnostics-get-api-issues-issueid-diagnostics-subtree:319 | optional_agent_tool | skills/paperclip/references/api-reference.md:319 |
|
||||
| skill:skills/paperclip/references/api-reference.md:execution-policy-fields-on-an-issue:367 | optional_agent_tool | skills/paperclip/references/api-reference.md:367 |
|
||||
| skill:skills/paperclip/references/api-reference.md:cross-agent-review-gates:419 | always_agent_tool | skills/paperclip/references/api-reference.md:419 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-ic-heartbeat:452 | optional_agent_tool | skills/paperclip/references/api-reference.md:452 |
|
||||
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:457 | control_plane_owned | skills/paperclip/references/api-reference.md:457 |
|
||||
| skill:skills/paperclip/references/api-reference.md:2-check-inbox:461 | control_plane_owned | skills/paperclip/references/api-reference.md:461 |
|
||||
| skill:skills/paperclip/references/api-reference.md:3-already-have-issue-101-inprogress-highest-priority-continue-it:468 | optional_agent_tool | skills/paperclip/references/api-reference.md:468 |
|
||||
| skill:skills/paperclip/references/api-reference.md:4-do-the-actual-work-write-code-run-tests:475 | optional_agent_tool | skills/paperclip/references/api-reference.md:475 |
|
||||
| skill:skills/paperclip/references/api-reference.md:5-work-is-done-update-status-and-comment-in-one-call:477 | always_agent_tool | skills/paperclip/references/api-reference.md:477 |
|
||||
| skill:skills/paperclip/references/api-reference.md:6-still-have-time-checkout-the-next-task:481 | control_plane_owned | skills/paperclip/references/api-reference.md:481 |
|
||||
| skill:skills/paperclip/references/api-reference.md:7-made-partial-progress-not-done-yet-comment-and-exit:488 | always_agent_tool | skills/paperclip/references/api-reference.md:488 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-report-a-board-user-s-mine-inbox:493 | control_plane_owned | skills/paperclip/references/api-reference.md:493 |
|
||||
| skill:skills/paperclip/references/api-reference.md:board-user-created-the-requesting-issue:498 | optional_agent_tool | skills/paperclip/references/api-reference.md:498 |
|
||||
| skill:skills/paperclip/references/api-reference.md:fetch-the-board-user-s-mine-inbox-issues:502 | control_plane_owned | skills/paperclip/references/api-reference.md:502 |
|
||||
| skill:skills/paperclip/references/api-reference.md:summarize-it-back-to-the-board-in-a-comment-or-document:516 | always_agent_tool | skills/paperclip/references/api-reference.md:516 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-archive-a-resolved-inbox-item:521 | control_plane_owned | skills/paperclip/references/api-reference.md:521 |
|
||||
| skill:skills/paperclip/references/api-reference.md:the-responsible-user-s-id-is-resolved-from-the-authenticated-agent-run:526 | optional_agent_tool | skills/paperclip/references/api-reference.md:526 |
|
||||
| skill:skills/paperclip/references/api-reference.md:reverse-the-archive-if-it-was-premature-or-no-longer-desired:535 | optional_agent_tool | skills/paperclip/references/api-reference.md:535 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-reviewer-approver-heartbeat:545 | always_agent_tool | skills/paperclip/references/api-reference.md:545 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-manager-heartbeat:584 | optional_agent_tool | skills/paperclip/references/api-reference.md:584 |
|
||||
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:587 | control_plane_owned | skills/paperclip/references/api-reference.md:587 |
|
||||
| skill:skills/paperclip/references/api-reference.md:2-check-team-status:591 | optional_agent_tool | skills/paperclip/references/api-reference.md:591 |
|
||||
| skill:skills/paperclip/references/api-reference.md:3-agent-42-is-blocked-read-comments:598 | control_plane_owned | skills/paperclip/references/api-reference.md:598 |
|
||||
| skill:skills/paperclip/references/api-reference.md:4-unblock-reassign-and-comment:602 | control_plane_owned | skills/paperclip/references/api-reference.md:602 |
|
||||
| skill:skills/paperclip/references/api-reference.md:5-check-own-assignments:606 | optional_agent_tool | skills/paperclip/references/api-reference.md:606 |
|
||||
| skill:skills/paperclip/references/api-reference.md:6-create-subtasks-and-delegate:613 | optional_agent_tool | skills/paperclip/references/api-reference.md:613 |
|
||||
| skill:skills/paperclip/references/api-reference.md:load-tests-depend-on-caching-layer-being-done-first-paperclip-will-auto-wake-agent-55-when-the-blocker-resolves:619 | control_plane_owned | skills/paperclip/references/api-reference.md:619 |
|
||||
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:624 | optional_agent_tool | skills/paperclip/references/api-reference.md:624 |
|
||||
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:630 | always_agent_tool | skills/paperclip/references/api-reference.md:630 |
|
||||
| skill:skills/paperclip/references/api-reference.md:update:637 | optional_agent_tool | skills/paperclip/references/api-reference.md:637 |
|
||||
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:675 | optional_agent_tool | skills/paperclip/references/api-reference.md:675 |
|
||||
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:679 | optional_agent_tool | skills/paperclip/references/api-reference.md:679 |
|
||||
| skill:skills/paperclip/references/api-reference.md:escalation:689 | optional_agent_tool | skills/paperclip/references/api-reference.md:689 |
|
||||
| skill:skills/paperclip/references/api-reference.md:company-context:699 | optional_agent_tool | skills/paperclip/references/api-reference.md:699 |
|
||||
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:711 | optional_agent_tool | skills/paperclip/references/api-reference.md:711 |
|
||||
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:731 | optional_agent_tool | skills/paperclip/references/api-reference.md:731 |
|
||||
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:750 | optional_agent_tool | skills/paperclip/references/api-reference.md:750 |
|
||||
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:783 | optional_agent_tool | skills/paperclip/references/api-reference.md:783 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:787 | optional_agent_tool | skills/paperclip/references/api-reference.md:787 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:806 | optional_agent_tool | skills/paperclip/references/api-reference.md:806 |
|
||||
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:835 | optional_agent_tool | skills/paperclip/references/api-reference.md:835 |
|
||||
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:839 | optional_agent_tool | skills/paperclip/references/api-reference.md:839 |
|
||||
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:859 | optional_agent_tool | skills/paperclip/references/api-reference.md:859 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:868 | always_agent_tool | skills/paperclip/references/api-reference.md:868 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:926 | always_agent_tool | skills/paperclip/references/api-reference.md:926 |
|
||||
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1041 | optional_agent_tool | skills/paperclip/references/api-reference.md:1041 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1151 | optional_agent_tool | skills/paperclip/references/api-reference.md:1151 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1157 | always_agent_tool | skills/paperclip/references/api-reference.md:1157 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1175 | always_agent_tool | skills/paperclip/references/api-reference.md:1175 |
|
||||
| skill:skills/paperclip/references/api-reference.md:error-handling:1205 | control_plane_owned | skills/paperclip/references/api-reference.md:1205 |
|
||||
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1219 | optional_agent_tool | skills/paperclip/references/api-reference.md:1219 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agents:1221 | optional_agent_tool | skills/paperclip/references/api-reference.md:1221 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1242 | optional_agent_tool | skills/paperclip/references/api-reference.md:1242 |
|
||||
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1282 | optional_agent_tool | skills/paperclip/references/api-reference.md:1282 |
|
||||
| skill:skills/paperclip/references/api-reference.md:routines:1306 | optional_agent_tool | skills/paperclip/references/api-reference.md:1306 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1322 | optional_agent_tool | skills/paperclip/references/api-reference.md:1322 |
|
||||
| skill:skills/paperclip/references/api-reference.md:secrets:1344 | optional_agent_tool | skills/paperclip/references/api-reference.md:1344 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1357 | optional_agent_tool | skills/paperclip/references/api-reference.md:1357 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1457 | optional_agent_tool | skills/paperclip/references/api-reference.md:1457 |
|
||||
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1497 | optional_agent_tool | skills/paperclip/references/api-reference.md:1497 |
|
||||
| skill:skills/paperclip/references/api-reference.md:response-schemas:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:11 | optional_agent_tool | skills/paperclip/references/api-reference.md:11 |
|
||||
| skill:skills/paperclip/references/api-reference.md:company-portability:44 | optional_agent_tool | skills/paperclip/references/api-reference.md:44 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-with-ancestors-get-api-issues-issueid:110 | optional_agent_tool | skills/paperclip/references/api-reference.md:110 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-update-response-patch-api-issues-issueid:196 | optional_agent_tool | skills/paperclip/references/api-reference.md:196 |
|
||||
| skill:skills/paperclip/references/api-reference.md:blocker-diagnostics-get-api-issues-issueid-diagnostics-blockers:238 | control_plane_owned | skills/paperclip/references/api-reference.md:238 |
|
||||
| skill:skills/paperclip/references/api-reference.md:wake-diagnostics-get-api-issues-issueid-diagnostics-wakes:277 | control_plane_owned | skills/paperclip/references/api-reference.md:277 |
|
||||
| skill:skills/paperclip/references/api-reference.md:subtree-diagnostics-get-api-issues-issueid-diagnostics-subtree:321 | optional_agent_tool | skills/paperclip/references/api-reference.md:321 |
|
||||
| skill:skills/paperclip/references/api-reference.md:execution-policy-fields-on-an-issue:369 | optional_agent_tool | skills/paperclip/references/api-reference.md:369 |
|
||||
| skill:skills/paperclip/references/api-reference.md:cross-agent-review-gates:421 | always_agent_tool | skills/paperclip/references/api-reference.md:421 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-ic-heartbeat:454 | optional_agent_tool | skills/paperclip/references/api-reference.md:454 |
|
||||
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:459 | control_plane_owned | skills/paperclip/references/api-reference.md:459 |
|
||||
| skill:skills/paperclip/references/api-reference.md:2-check-inbox:463 | control_plane_owned | skills/paperclip/references/api-reference.md:463 |
|
||||
| skill:skills/paperclip/references/api-reference.md:3-already-have-issue-101-inprogress-highest-priority-continue-it:470 | optional_agent_tool | skills/paperclip/references/api-reference.md:470 |
|
||||
| skill:skills/paperclip/references/api-reference.md:4-do-the-actual-work-write-code-run-tests:477 | optional_agent_tool | skills/paperclip/references/api-reference.md:477 |
|
||||
| skill:skills/paperclip/references/api-reference.md:5-work-is-done-update-status-and-comment-in-one-call:479 | always_agent_tool | skills/paperclip/references/api-reference.md:479 |
|
||||
| skill:skills/paperclip/references/api-reference.md:6-still-have-time-checkout-the-next-task:483 | control_plane_owned | skills/paperclip/references/api-reference.md:483 |
|
||||
| skill:skills/paperclip/references/api-reference.md:7-made-partial-progress-not-done-yet-comment-and-exit:490 | always_agent_tool | skills/paperclip/references/api-reference.md:490 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-report-a-board-user-s-mine-inbox:495 | control_plane_owned | skills/paperclip/references/api-reference.md:495 |
|
||||
| skill:skills/paperclip/references/api-reference.md:board-user-created-the-requesting-issue:500 | optional_agent_tool | skills/paperclip/references/api-reference.md:500 |
|
||||
| skill:skills/paperclip/references/api-reference.md:fetch-the-board-user-s-mine-inbox-issues:504 | control_plane_owned | skills/paperclip/references/api-reference.md:504 |
|
||||
| skill:skills/paperclip/references/api-reference.md:summarize-it-back-to-the-board-in-a-comment-or-document:518 | always_agent_tool | skills/paperclip/references/api-reference.md:518 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-archive-a-resolved-inbox-item:523 | control_plane_owned | skills/paperclip/references/api-reference.md:523 |
|
||||
| skill:skills/paperclip/references/api-reference.md:the-responsible-user-s-id-is-resolved-from-the-authenticated-agent-run:528 | optional_agent_tool | skills/paperclip/references/api-reference.md:528 |
|
||||
| skill:skills/paperclip/references/api-reference.md:reverse-the-archive-if-it-was-premature-or-no-longer-desired:537 | optional_agent_tool | skills/paperclip/references/api-reference.md:537 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-reviewer-approver-heartbeat:547 | always_agent_tool | skills/paperclip/references/api-reference.md:547 |
|
||||
| skill:skills/paperclip/references/api-reference.md:worked-example-manager-heartbeat:586 | optional_agent_tool | skills/paperclip/references/api-reference.md:586 |
|
||||
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:589 | control_plane_owned | skills/paperclip/references/api-reference.md:589 |
|
||||
| skill:skills/paperclip/references/api-reference.md:2-check-team-status:593 | optional_agent_tool | skills/paperclip/references/api-reference.md:593 |
|
||||
| skill:skills/paperclip/references/api-reference.md:3-agent-42-is-blocked-read-comments:600 | control_plane_owned | skills/paperclip/references/api-reference.md:600 |
|
||||
| skill:skills/paperclip/references/api-reference.md:4-unblock-reassign-and-comment:604 | control_plane_owned | skills/paperclip/references/api-reference.md:604 |
|
||||
| skill:skills/paperclip/references/api-reference.md:5-check-own-assignments:608 | optional_agent_tool | skills/paperclip/references/api-reference.md:608 |
|
||||
| skill:skills/paperclip/references/api-reference.md:6-create-subtasks-and-delegate:615 | optional_agent_tool | skills/paperclip/references/api-reference.md:615 |
|
||||
| skill:skills/paperclip/references/api-reference.md:load-tests-depend-on-caching-layer-being-done-first-paperclip-will-auto-wake-agent-55-when-the-blocker-resolves:621 | control_plane_owned | skills/paperclip/references/api-reference.md:621 |
|
||||
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:626 | optional_agent_tool | skills/paperclip/references/api-reference.md:626 |
|
||||
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:632 | always_agent_tool | skills/paperclip/references/api-reference.md:632 |
|
||||
| skill:skills/paperclip/references/api-reference.md:update:639 | optional_agent_tool | skills/paperclip/references/api-reference.md:639 |
|
||||
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:677 | optional_agent_tool | skills/paperclip/references/api-reference.md:677 |
|
||||
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:681 | optional_agent_tool | skills/paperclip/references/api-reference.md:681 |
|
||||
| skill:skills/paperclip/references/api-reference.md:escalation:691 | optional_agent_tool | skills/paperclip/references/api-reference.md:691 |
|
||||
| skill:skills/paperclip/references/api-reference.md:company-context:701 | optional_agent_tool | skills/paperclip/references/api-reference.md:701 |
|
||||
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:713 | optional_agent_tool | skills/paperclip/references/api-reference.md:713 |
|
||||
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:733 | optional_agent_tool | skills/paperclip/references/api-reference.md:733 |
|
||||
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:752 | optional_agent_tool | skills/paperclip/references/api-reference.md:752 |
|
||||
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:785 | optional_agent_tool | skills/paperclip/references/api-reference.md:785 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:809 | optional_agent_tool | skills/paperclip/references/api-reference.md:809 |
|
||||
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:828 | optional_agent_tool | skills/paperclip/references/api-reference.md:828 |
|
||||
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:857 | optional_agent_tool | skills/paperclip/references/api-reference.md:857 |
|
||||
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:861 | optional_agent_tool | skills/paperclip/references/api-reference.md:861 |
|
||||
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:893 | optional_agent_tool | skills/paperclip/references/api-reference.md:893 |
|
||||
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:902 | always_agent_tool | skills/paperclip/references/api-reference.md:902 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:984 | always_agent_tool | skills/paperclip/references/api-reference.md:984 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1042 | always_agent_tool | skills/paperclip/references/api-reference.md:1042 |
|
||||
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1157 | optional_agent_tool | skills/paperclip/references/api-reference.md:1157 |
|
||||
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1267 | optional_agent_tool | skills/paperclip/references/api-reference.md:1267 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1273 | always_agent_tool | skills/paperclip/references/api-reference.md:1273 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1291 | always_agent_tool | skills/paperclip/references/api-reference.md:1291 |
|
||||
| skill:skills/paperclip/references/api-reference.md:error-handling:1321 | control_plane_owned | skills/paperclip/references/api-reference.md:1321 |
|
||||
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1335 | optional_agent_tool | skills/paperclip/references/api-reference.md:1335 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agents:1337 | optional_agent_tool | skills/paperclip/references/api-reference.md:1337 |
|
||||
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1358 | optional_agent_tool | skills/paperclip/references/api-reference.md:1358 |
|
||||
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1398 | optional_agent_tool | skills/paperclip/references/api-reference.md:1398 |
|
||||
| skill:skills/paperclip/references/api-reference.md:routines:1422 | optional_agent_tool | skills/paperclip/references/api-reference.md:1422 |
|
||||
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1438 | optional_agent_tool | skills/paperclip/references/api-reference.md:1438 |
|
||||
| skill:skills/paperclip/references/api-reference.md:secrets:1460 | optional_agent_tool | skills/paperclip/references/api-reference.md:1460 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1473 | optional_agent_tool | skills/paperclip/references/api-reference.md:1473 |
|
||||
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1573 | optional_agent_tool | skills/paperclip/references/api-reference.md:1573 |
|
||||
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1613 | optional_agent_tool | skills/paperclip/references/api-reference.md:1613 |
|
||||
|
||||
## Legacy MCP Alias Index
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ the authoritative rows.
|
|||
Only two sources are normative:
|
||||
|
||||
1. The Paperclip skill and its seven references (`SKILL.md` plus
|
||||
`references/*.md`), contributing **152 headings**.
|
||||
`references/*.md`), contributing **153 headings**.
|
||||
2. The Paperclip Evals corpus, contributing **106 cases across 16 groups**.
|
||||
|
||||
Together these produce **258 normative rows**. The legacy Paperclip MCP tool
|
||||
Together these produce **259 normative rows**. The legacy Paperclip MCP tool
|
||||
surface (**41 tools**) is not a production capability surface; each MCP name is
|
||||
folded one-to-one into a normative eval row as a traceability alias and inherits
|
||||
that row's disposition. The contract prints the alias index only so the
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Generated by `scripts/generate-capability-contract.mjs`; do not edit generated files.
|
||||
|
||||
- Skill/reference headings: 154
|
||||
- Skill/reference headings: 156
|
||||
- Legacy MCP tools: 42
|
||||
- Eval cases: 106 across 16 groups
|
||||
- Deterministic content SHA-256: `7d89b580b41830403a625dc44644e5faf9b5eb83a27706bc2d624d9da464d331`
|
||||
- Deterministic content SHA-256: `8447cdf6ddc5fa7b36e9724b3df1ea695ac084cf3e104273186fbec3ed9bc5fd`
|
||||
|
||||
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1556,9 +1556,211 @@
|
|||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Create one child task under the active task.",
|
||||
"description": "Inspect available company projects before selecting a project for new work.",
|
||||
"effect": "read",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "list_projects",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [
|
||||
"discovery:projects:read"
|
||||
],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "List projects",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"ask",
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
|
||||
"effect": "read",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "list_project_repositories",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "List available repositories",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"archivedAt": {
|
||||
"description": "Archive timestamp.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"description": "Project color.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"description": "Project outcome and context.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"executionWorkspacePolicy": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"goalId": {
|
||||
"description": "Goal ID.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"goalIds": {
|
||||
"description": "Goal IDs.",
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"icon": {
|
||||
"description": "Project icon.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"description": "Caller-stable retry key.",
|
||||
"maxLength": 240,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"leadAgentId": {
|
||||
"description": "Lead agent ID.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "Project name.",
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"repositoryIds": {
|
||||
"description": "Authorized repository IDs from list_project_repositories; may contain multiple repositories.",
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 200,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"repositoryUrls": {
|
||||
"description": "Existing HTTPS GitHub repository URLs, including repos absent from the catalog.",
|
||||
"items": {
|
||||
"maxLength": 2000,
|
||||
"pattern": "^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$",
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 100,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"backlog",
|
||||
"planned",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"targetDate": {
|
||||
"description": "Target date.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey",
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"operationId": "create_project",
|
||||
"outputSchema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"placement": "optional",
|
||||
"requiredClaims": [],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Create project",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"skill_test"
|
||||
],
|
||||
"description": "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.",
|
||||
"effect": "write",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
|
|
@ -1595,6 +1797,14 @@
|
|||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"initialPlan": {
|
||||
"description": "Relevant markdown plan to persist on the new task before it starts.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"enum": [
|
||||
"critical",
|
||||
|
|
@ -1603,8 +1813,16 @@
|
|||
"low"
|
||||
]
|
||||
},
|
||||
"projectId": {
|
||||
"description": "Project identifier for the new task.",
|
||||
"maxLength": 20000,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"description": "Child task title.",
|
||||
"description": "Task title.",
|
||||
"maxLength": 500,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
|
|
@ -1671,7 +1889,7 @@
|
|||
"delegation:tasks:create"
|
||||
],
|
||||
"schema": "paperclip.semantic-action.v1",
|
||||
"title": "Create child task",
|
||||
"title": "Create task",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:842a1515a5b549fcc5df7675f3a96471b2f1ca33f4699cc5dd2ecf6c4235f2ec",
|
||||
"catalogSha256": "sha256:155849f666fffed8133d497c4323d42639eae7696699f9df049649f836e2edbc",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@
|
|||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "89641b73df452a5d03502bc151a81a68387ece129c8826e0572800c3b1c5265c",
|
||||
"sha256": "43bda8e713605d690a5e755f2d47eaea012d28fef81bd7dc787a5f9cacc507a7",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde_json::Value;
|
|||
|
||||
use crate::acpx_event_scope::AcpxEventScope;
|
||||
use crate::acpx_sidecar_transport::AcpxSidecarEvent;
|
||||
use crate::durable::{redact_text, sanitize_value};
|
||||
use crate::durable::{redact_text, sanitize_semantic_tool_input, sanitize_value};
|
||||
use crate::generated_acpx_sidecar_contract::{
|
||||
classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType,
|
||||
};
|
||||
|
|
@ -133,11 +133,19 @@ pub fn decode_acpx_event(
|
|||
"ACPX tool call input must be an object",
|
||||
));
|
||||
}
|
||||
let operation_id = required_id(&event.payload, "operationId", "tool operation")?;
|
||||
// This input is dispatched as a mutation, not merely displayed in
|
||||
// the event feed. Use the same declared-prose policy as native
|
||||
// semantic_tool.input before any generic diagnostic scrub can
|
||||
// irreversibly change the task's requirements.
|
||||
let safe_input = sanitize_semantic_tool_input(&operation_id, &input)
|
||||
.map_err(|error| LocalRunnerError::invalid(error.to_string()))?;
|
||||
Ok(AcpxEventPayload::ToolCalled {
|
||||
call_id: required_id(&event.payload, "callId", "tool call")?,
|
||||
operation_id: required_id(&event.payload, "operationId", "tool operation")?,
|
||||
operation_id,
|
||||
// Keep the original digest for the sidecar's result binding.
|
||||
input_digest: semantic_value_digest(&input),
|
||||
input: sanitize_value(&input),
|
||||
input: safe_input,
|
||||
})
|
||||
}
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal => {
|
||||
|
|
|
|||
|
|
@ -637,7 +637,9 @@ impl AcpxCommandExecutor {
|
|||
event_type: "run.terminal".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"status": "failed",
|
||||
"turnTerminalState": "failed",
|
||||
"runTerminalState": "failed",
|
||||
"reportedWorkDisposition": "unknown",
|
||||
"provider": "acpx",
|
||||
|
|
@ -1398,11 +1400,11 @@ impl AcpxCommandExecutor {
|
|||
{
|
||||
continue;
|
||||
}
|
||||
let status = match event_type.as_str() {
|
||||
"turn.completed" => "succeeded",
|
||||
"turn.cancelled" => "cancelled",
|
||||
"turn.interrupted" => "interrupted",
|
||||
_ => "failed",
|
||||
let (turn_terminal_state, status) = match event_type.as_str() {
|
||||
"turn.completed" => ("completed", "succeeded"),
|
||||
"turn.cancelled" => ("cancelled", "cancelled"),
|
||||
"turn.interrupted" => ("interrupted", "cancelled"),
|
||||
_ => ("failed", "failed"),
|
||||
};
|
||||
let disposition = goal_terminal_disposition(
|
||||
state
|
||||
|
|
@ -1420,7 +1422,9 @@ impl AcpxCommandExecutor {
|
|||
event_type: "run.terminal".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"status": status,
|
||||
"turnTerminalState": turn_terminal_state,
|
||||
"runTerminalState": status,
|
||||
"reportedWorkDisposition": disposition,
|
||||
"provider": "acpx",
|
||||
|
|
@ -1532,6 +1536,13 @@ impl CommandExecutor for AcpxCommandExecutor {
|
|||
return Ok(Vec::new());
|
||||
}
|
||||
self.poll_provider()?;
|
||||
self.retained_events()
|
||||
}
|
||||
|
||||
fn retained_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
|
||||
// Explicit drain runs while control traffic suppresses provider polling.
|
||||
// Expose the already-retained suffix so runnerd can commit and ACK it
|
||||
// before suspension, without restoring or advancing the provider.
|
||||
Ok(self
|
||||
.state
|
||||
.as_ref()
|
||||
|
|
@ -1819,6 +1830,57 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_events_exposes_terminal_suffix_without_restoring_provider() {
|
||||
let directory = temporary_directory("retained-terminal-suffix");
|
||||
let config = test_config(&directory, None);
|
||||
let mut executor = AcpxCommandExecutor::with_runner_config(&directory, &config);
|
||||
// Invalid on-disk state would fail restoration. Retained-only reads
|
||||
// must neither restore a provider nor inspect a different state owner.
|
||||
fs::write(executor.state_path(), b"not provider state").unwrap();
|
||||
assert!(executor.retained_events().unwrap().is_empty());
|
||||
|
||||
let operations = Vec::new();
|
||||
let tool_set = AuthorizedToolSet {
|
||||
schema: TOOL_SET_SCHEMA.to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let mut state = AcpxDurableState::new(
|
||||
serde_json::from_value(descriptor("claude")).unwrap(),
|
||||
tool_set,
|
||||
"retained-only-test".to_owned(),
|
||||
);
|
||||
state.lifecycle = "session_open".to_owned();
|
||||
for event_type in ["turn.completed", "run.usage", "run.completed"] {
|
||||
state
|
||||
.push(NormalizedProviderEvent {
|
||||
event_type: event_type.to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: json!({}),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
executor.state = Some(state);
|
||||
let suffix = executor.retained_events().unwrap();
|
||||
assert_eq!(
|
||||
suffix
|
||||
.iter()
|
||||
.map(|event| event.event_type.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["turn.completed", "run.usage", "run.completed"],
|
||||
);
|
||||
// Reading is not acknowledgement: a retry sees the exact same FIFO.
|
||||
assert_eq!(executor.retained_events().unwrap(), suffix);
|
||||
assert!(executor.session.is_none());
|
||||
assert_eq!(
|
||||
fs::read(executor.state_path()).unwrap(),
|
||||
b"not provider state"
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admits_only_exact_qualified_claude_codex_and_pi_descriptors() {
|
||||
for agent in ["claude", "codex", "pi"] {
|
||||
|
|
@ -2183,6 +2245,8 @@ mod tests {
|
|||
assert_eq!(events[0].event_type, "turn.failed");
|
||||
assert_eq!(events[0].payload["providerShutdownFailed"], true);
|
||||
assert_eq!(events[1].event_type, "run.terminal");
|
||||
assert_eq!(events[1].payload["schema"], "paperclip.prp.terminal.v1");
|
||||
assert_eq!(events[1].payload["turnTerminalState"], "failed");
|
||||
let cleanup_error = recovered
|
||||
.shutdown()
|
||||
.expect_err("cleanup must not succeed while the original lifetime remains active");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -83,6 +83,7 @@ pub struct AcpxSidecarTransport {
|
|||
last_event_sequence: u64,
|
||||
buffered_events: VecDeque<AcpxSidecarEvent>,
|
||||
stderr_tail: BoundedLogBuffer,
|
||||
stderr_categories: BTreeSet<&'static str>,
|
||||
poisoned: bool,
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +158,7 @@ impl AcpxSidecarTransport {
|
|||
last_event_sequence: 0,
|
||||
buffered_events: VecDeque::new(),
|
||||
stderr_tail: BoundedLogBuffer::new(32, 8 * 1024),
|
||||
stderr_categories: BTreeSet::new(),
|
||||
poisoned: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -326,7 +328,7 @@ impl AcpxSidecarTransport {
|
|||
match self.process.recv_timeout(remaining) {
|
||||
Ok(ProcessOutput::Stdout(line)) => return Ok(Some(line)),
|
||||
Ok(ProcessOutput::Stderr(line)) => {
|
||||
self.stderr_tail.push(redact_diagnostic(&line));
|
||||
self.record_stderr(&line);
|
||||
}
|
||||
Ok(ProcessOutput::StdoutError(message)) => {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
|
|
@ -415,7 +417,7 @@ impl AcpxSidecarTransport {
|
|||
};
|
||||
match output {
|
||||
Some(ProcessOutput::Stderr(line)) => {
|
||||
self.stderr_tail.push(redact_diagnostic(&line));
|
||||
self.record_stderr(&line);
|
||||
}
|
||||
Some(ProcessOutput::StderrClosed) | None => break,
|
||||
Some(ProcessOutput::Stdout(_))
|
||||
|
|
@ -427,13 +429,33 @@ impl AcpxSidecarTransport {
|
|||
|
||||
fn diagnostic_suffix(&self) -> String {
|
||||
let diagnostics = self.stderr_tail.snapshot().lines.join("\n");
|
||||
if diagnostics.is_empty() {
|
||||
let categories = if self.stderr_categories.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" stderrTail={diagnostics:?}")
|
||||
format!(
|
||||
" stderrCategories={}",
|
||||
self.stderr_categories
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
};
|
||||
if diagnostics.is_empty() {
|
||||
categories
|
||||
} else {
|
||||
format!("{categories} stderrTail={diagnostics:?}")
|
||||
}
|
||||
}
|
||||
|
||||
fn record_stderr(&mut self, line: &str) {
|
||||
// Only fixed categories cross this boundary. Raw errors, stack paths,
|
||||
// identifiers, and credential-bearing strings remain fully redacted.
|
||||
self.stderr_categories
|
||||
.extend(stderr_diagnostic_categories(line));
|
||||
self.stderr_tail.push(redact_diagnostic(line));
|
||||
}
|
||||
|
||||
fn poison(&mut self) {
|
||||
if self.poisoned {
|
||||
return;
|
||||
|
|
@ -601,6 +623,53 @@ fn redact_diagnostic(value: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> {
|
||||
const CATEGORIES: &[(&str, &str)] = &[
|
||||
("TypeError", "javascript_type_error"),
|
||||
("ReferenceError", "javascript_reference_error"),
|
||||
("SyntaxError", "javascript_syntax_error"),
|
||||
("RangeError", "javascript_range_error"),
|
||||
("AssertionError", "javascript_assertion_error"),
|
||||
("UnhandledPromiseRejection", "unhandled_rejection"),
|
||||
("ERR_UNHANDLED_REJECTION", "unhandled_rejection"),
|
||||
("ERR_UNHANDLED_ERROR", "unhandled_event_error"),
|
||||
("ERR_INVALID_ARG_TYPE", "invalid_argument_type"),
|
||||
("ERR_INVALID_ARG_VALUE", "invalid_argument_value"),
|
||||
("ERR_STREAM_WRITE_AFTER_END", "stream_write_after_end"),
|
||||
("ERR_STREAM_DESTROYED", "stream_destroyed"),
|
||||
("ERR_IPC_CHANNEL_CLOSED", "ipc_channel_closed"),
|
||||
("ERR_SOCKET_CLOSED", "socket_closed"),
|
||||
("ERR_MODULE_NOT_FOUND", "module_not_found"),
|
||||
("MODULE_NOT_FOUND", "module_not_found"),
|
||||
("EPIPE", "broken_pipe"),
|
||||
("ECONNRESET", "connection_reset"),
|
||||
("EADDRINUSE", "address_in_use"),
|
||||
("ENOENT", "file_not_found"),
|
||||
("EACCES", "permission_denied"),
|
||||
("EPERM", "permission_denied"),
|
||||
(
|
||||
"ACPX_PERSISTED_SESSION_IDENTITY_MISMATCH",
|
||||
"persisted_session_identity_mismatch",
|
||||
),
|
||||
("SESSION_RESUME_REQUIRED", "session_resume_required"),
|
||||
];
|
||||
let mut categories: BTreeSet<&'static str> = value
|
||||
.split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
|
||||
.filter_map(|token| {
|
||||
CATEGORIES
|
||||
.iter()
|
||||
.find_map(|(known, category)| (token == *known).then_some(*category))
|
||||
})
|
||||
.collect();
|
||||
if value.contains("triggerUncaughtException") && value.contains("fromPromise") {
|
||||
categories.insert("unhandled_rejection");
|
||||
}
|
||||
if value.contains("ACPX provider spawned after ownership admission was sealed") {
|
||||
categories.insert("provider_spawn_after_ownership_seal");
|
||||
}
|
||||
categories
|
||||
}
|
||||
|
||||
fn response_error_classification(error: &ResponseError) -> &'static str {
|
||||
match error.code.as_str() {
|
||||
"ACP_MODEL_UNSUPPORTED" => return "requested_model_unsupported",
|
||||
|
|
@ -645,6 +714,17 @@ fn response_error_classification(error: &ResponseError) -> &'static str {
|
|||
_ => {}
|
||||
}
|
||||
match error.message.as_str() {
|
||||
"ACPX provider spawned after ownership admission was sealed" => {
|
||||
"provider_spawn_after_ownership_seal"
|
||||
}
|
||||
"ACPX recovery identity conflicts with the immutable session configuration" => {
|
||||
"recovery_configuration_mismatch"
|
||||
}
|
||||
"ACPX recovery identity does not match the persisted runtime record" => {
|
||||
"recovery_identity_mismatch"
|
||||
}
|
||||
"ACPX provider lifetime lease is unavailable" => "provider_lifetime_unavailable",
|
||||
"Managed Codex credential home already has an active lease" => "provider_lifetime_owned",
|
||||
"ACPX session handshake exceeded its admission deadline" => "session_handshake_timeout",
|
||||
"ACPX provider lifetime guardian exited before ownership transfer" => {
|
||||
"provider_guardian_exit"
|
||||
|
|
@ -737,6 +817,39 @@ mod tests {
|
|||
)),
|
||||
"session_handshake_timeout"
|
||||
);
|
||||
for (message, classification) in [
|
||||
(
|
||||
"ACPX recovery identity conflicts with the immutable session configuration",
|
||||
"recovery_configuration_mismatch",
|
||||
),
|
||||
(
|
||||
"ACPX recovery identity does not match the persisted runtime record",
|
||||
"recovery_identity_mismatch",
|
||||
),
|
||||
(
|
||||
"ACPX provider lifetime lease is unavailable",
|
||||
"provider_lifetime_unavailable",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
response_error_classification(&error("acpx_sidecar_command_failed", message)),
|
||||
classification
|
||||
);
|
||||
assert_eq!(
|
||||
response_error_classification(&error(
|
||||
"acpx_sidecar_command_failed",
|
||||
&format!("{message}: private-provider-detail")
|
||||
)),
|
||||
"unclassified"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
response_error_classification(&error(
|
||||
"acpx_sidecar_command_failed",
|
||||
"Managed Codex credential home already has an active lease"
|
||||
)),
|
||||
"provider_lifetime_owned"
|
||||
);
|
||||
let admission_failures = [
|
||||
(
|
||||
"ACPX_RUNTIME_ADMISSION_VERIFICATION_TIMEOUT",
|
||||
|
|
|
|||
|
|
@ -2670,6 +2670,9 @@ mod tests {
|
|||
}
|
||||
|
||||
fn read_http_request(socket: &mut TcpStream) -> Result<CapturedRequest, std::io::Error> {
|
||||
// Darwin inherits the listener's nonblocking flag on accept. Wait for
|
||||
// request bytes within the timeout instead of dropping an early accept.
|
||||
socket.set_nonblocking(false)?;
|
||||
socket.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
|
|
@ -2722,6 +2725,34 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_service_waits_for_request_bytes_on_an_accepted_nonblocking_socket() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (mut accepted, _) = listener.accept().unwrap();
|
||||
// Reproduce Darwin's inherited listener flag on every test platform.
|
||||
accepted.set_nonblocking(true).unwrap();
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let reader = thread::spawn(move || {
|
||||
result_tx.send(read_http_request(&mut accepted)).unwrap();
|
||||
});
|
||||
assert!(matches!(
|
||||
result_rx.recv_timeout(Duration::from_millis(25)),
|
||||
Err(mpsc::RecvTimeoutError::Timeout)
|
||||
));
|
||||
client
|
||||
.write_all(b"POST /delayed HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}")
|
||||
.unwrap();
|
||||
let request = result_rx
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
reader.join().unwrap();
|
||||
assert_eq!(request.method, "POST");
|
||||
assert_eq!(request.path, "/delayed");
|
||||
assert_eq!(request.body, "{}");
|
||||
}
|
||||
|
||||
fn send_json_response(socket: &mut TcpStream, status: &str, value: &Value) {
|
||||
let body = serde_json::to_string(value).unwrap();
|
||||
let _ = write!(socket, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
|
|
|
|||
|
|
@ -1597,6 +1597,28 @@ pub(crate) fn sanitize_semantic_tool_input(
|
|||
input: &Value,
|
||||
) -> Result<Value, DurableRunnerError> {
|
||||
let mut sanitized = sanitize_value(input);
|
||||
// Mutation prose is the user's intended work, not a diagnostic. Preserve
|
||||
// ordinary references to a token in these declared text fields; credential
|
||||
// syntax and high-confidence secret values are still scrubbed. All other
|
||||
// fields and operations retain the strict diagnostic policy.
|
||||
let prose_fields: &[&str] = match operation_id {
|
||||
"create_task" => &["title", "description", "initialPlan"],
|
||||
"create_project" => &["name", "description"],
|
||||
"write_document" => &["title", "body", "changeSummary"],
|
||||
_ => &[],
|
||||
};
|
||||
if let Some(sanitized_input) = sanitized.as_object_mut() {
|
||||
for field in prose_fields {
|
||||
if let Some(text) = input.get(*field).and_then(Value::as_str) {
|
||||
sanitized_input.insert(
|
||||
(*field).to_owned(),
|
||||
// The tool/API schema bounds business content. A diagnostic
|
||||
// preview limit must never truncate a plan or document.
|
||||
Value::String(redact_sensitive_text_values_with_context(text, true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(operation_id, "paperclip_finish" | "paperclip_block") {
|
||||
return Ok(sanitized);
|
||||
}
|
||||
|
|
@ -1720,6 +1742,10 @@ pub(crate) fn redact_text(input: &str) -> String {
|
|||
}
|
||||
|
||||
fn redact_sensitive_text_values(input: &str) -> String {
|
||||
redact_sensitive_text_values_with_context(input, false)
|
||||
}
|
||||
|
||||
fn redact_sensitive_text_values_with_context(input: &str, semantic_prose: bool) -> String {
|
||||
let normalized = input.to_ascii_lowercase();
|
||||
let bytes = normalized.as_bytes();
|
||||
let mut ranges: Vec<(usize, usize)> = Vec::new();
|
||||
|
|
@ -1893,6 +1919,7 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
("ghu_", 20),
|
||||
("ghs_", 20),
|
||||
("ghr_", 20),
|
||||
("github_pat_", 20),
|
||||
] {
|
||||
for (start, _) in normalized.match_indices(prefix) {
|
||||
if start > 0 && is_name_byte(bytes[start - 1]) {
|
||||
|
|
@ -2088,6 +2115,35 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
.any(|delimiter| before.ends_with(delimiter))
|
||||
};
|
||||
let has_hyphenated_count_lead = token_phrase_has_lead("one-");
|
||||
// A bare token reference in declared mutation prose can be an output
|
||||
// requirement. Auth/access/session context, explicit assignment,
|
||||
// quoted credentials and CLI/compound names remain credential pairs.
|
||||
// Known key/JWT/Bearer values are independently scrubbed above.
|
||||
let is_semantic_token_reference = semantic_prose
|
||||
&& key == "token"
|
||||
&& !key_is_compound
|
||||
&& whitespace_start == start + key.len()
|
||||
&& separator > whitespace_start
|
||||
&& !has_assignment_separator
|
||||
&& bytes[whitespace_start..separator]
|
||||
.iter()
|
||||
.all(|value| matches!(value, b' ' | b'\t'))
|
||||
&& quoted_value_start(separator).1.is_none()
|
||||
&& ![
|
||||
"auth ",
|
||||
"authentication ",
|
||||
"authorization ",
|
||||
"access ",
|
||||
"refresh ",
|
||||
"session ",
|
||||
"api ",
|
||||
"security ",
|
||||
"secret ",
|
||||
"credential ",
|
||||
"bearer ",
|
||||
]
|
||||
.iter()
|
||||
.any(|lead| token_phrase_has_lead(lead));
|
||||
let is_benign_token_noun_phrase = key == "token"
|
||||
&& (!key_is_compound || has_hyphenated_count_lead)
|
||||
&& whitespace_start == start + key.len()
|
||||
|
|
@ -2150,7 +2206,8 @@ fn redact_sensitive_text_values(input: &str) -> String {
|
|||
|| (token_phrase_has_tail("can equal") && token_phrase_has_lead("one ")));
|
||||
let has_whitespace_separator = separator > whitespace_start
|
||||
&& (key != "authorization" || key_is_compound || has_authorization_scheme)
|
||||
&& !is_benign_token_noun_phrase;
|
||||
&& !is_benign_token_noun_phrase
|
||||
&& !is_semantic_token_reference;
|
||||
if !has_assignment_separator && !has_whitespace_separator {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -3159,6 +3216,148 @@ mod tests {
|
|||
assert_eq!(sanitized["accessToken"], json!("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_handoff_preserves_acceptance_identifiers_in_declared_prose() {
|
||||
let description =
|
||||
"The document body must contain the token CHAT250ed7e4dc071. No code changes needed.";
|
||||
let plan = format!(
|
||||
"## Plan\n{}\n- The token CHAT250ed7e4dc071 included somewhere in the body.\n- Save the output document.",
|
||||
"Relevant task context. ".repeat(300),
|
||||
);
|
||||
assert!(plan.len() > 4096);
|
||||
let input = json!({
|
||||
"title": "Write project description",
|
||||
"description": description,
|
||||
"initialPlan": plan,
|
||||
"idempotencyKey": "write-description-1",
|
||||
});
|
||||
assert_eq!(
|
||||
sanitize_semantic_tool_input("create_task", &input).unwrap(),
|
||||
input
|
||||
);
|
||||
for text in [
|
||||
description,
|
||||
plan.as_str(),
|
||||
"Must include the literal token `CHAT66e7813a4f9d1` somewhere in the text.",
|
||||
"Include the exact token ACCEPTANCE-42 in the final output.",
|
||||
] {
|
||||
assert_eq!(
|
||||
sanitize_semantic_tool_input("write_document", &json!({"body": text})).unwrap(),
|
||||
json!({"body": text})
|
||||
);
|
||||
assert_ne!(
|
||||
redact_text(text),
|
||||
text,
|
||||
"diagnostics keep their strict policy"
|
||||
);
|
||||
}
|
||||
let config = config(PathBuf::from("unused"));
|
||||
let mut state = DurableState::new(&config);
|
||||
state
|
||||
.enqueue_executor_event(
|
||||
&config,
|
||||
"provider-create-task".to_owned(),
|
||||
"semantic_tool.input".to_owned(),
|
||||
EventPriority::P0,
|
||||
json!({"semantic_tool": {
|
||||
"schema": "paperclip.prp.semantic_tool.v1",
|
||||
"schemaVersion": 1,
|
||||
"phase": "input",
|
||||
"operationId": "create_task",
|
||||
"content": {"digest": semantic_value_digest(&input)},
|
||||
"input": input,
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let transmitted = state.outbox[0]
|
||||
.envelope
|
||||
.pointer("/payload/payload/semantic_tool/input")
|
||||
.unwrap();
|
||||
assert_eq!(transmitted, &input);
|
||||
assert_eq!(
|
||||
state.outbox[0]
|
||||
.envelope
|
||||
.pointer("/payload/payload/semantic_tool/content/digest"),
|
||||
Some(&json!(semantic_value_digest(transmitted))),
|
||||
);
|
||||
let document = format!(
|
||||
"{}\nAuthorization: Bearer late-credential\nFINAL-ACCEPTANCE-42",
|
||||
"Document content. ".repeat(400)
|
||||
);
|
||||
let safe =
|
||||
sanitize_semantic_tool_input("write_document", &json!({"body": document})).unwrap();
|
||||
let body = safe["body"].as_str().unwrap();
|
||||
assert!(body.len() > 4096);
|
||||
assert!(body.ends_with("FINAL-ACCEPTANCE-42"));
|
||||
assert!(!body.contains("late-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_prose_does_not_exempt_credential_syntax_or_shapes() {
|
||||
for text in [
|
||||
"auth token opaque-credential",
|
||||
"access token opaque-credential",
|
||||
"session token opaque-credential",
|
||||
"refresh token opaque-credential",
|
||||
"authentication token opaque-credential",
|
||||
"literal token=opaque-credential",
|
||||
"literal token:opaque-credential",
|
||||
"literal --token opaque-credential",
|
||||
"literal access_token opaque-credential",
|
||||
"literal \"token\" opaque-credential",
|
||||
"literal token \"opaque-credential\"",
|
||||
] {
|
||||
assert!(!redact_text(text).contains("opaque-credential"), "{text}");
|
||||
let input = json!({"description": text, "initialPlan": text});
|
||||
assert!(
|
||||
!sanitize_semantic_tool_input("create_task", &input)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("opaque-credential"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for secret in [
|
||||
"sk-proj-secretvalue123456",
|
||||
"ghp_secretvalue12345678901234567890",
|
||||
"github_pat_secretvalue12345678901234567890",
|
||||
"eyJhbGciOiJIUzI1NiJ9.c2VjcmV0LWNsYWlt.signaturesecret",
|
||||
] {
|
||||
let text = format!("Include the literal token {secret} in the document.");
|
||||
assert!(!redact_text(&text).contains(secret), "{text}");
|
||||
assert!(
|
||||
!sanitize_semantic_tool_input("write_document", &json!({"body": text}))
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains(secret)
|
||||
);
|
||||
}
|
||||
let input = json!({
|
||||
"description": "Include the literal token ACCEPTANCE-42. Authorization: Bearer opaque-credential",
|
||||
"token": "opaque-credential",
|
||||
});
|
||||
let safe = sanitize_semantic_tool_input("create_task", &input).unwrap();
|
||||
assert!(safe["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("ACCEPTANCE-42"));
|
||||
assert!(!safe.to_string().contains("opaque-credential"));
|
||||
let diagnostic = json!({"description": "the token opaque-credential"});
|
||||
assert!(
|
||||
!sanitize_semantic_tool_input("get_task_context", &diagnostic)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("opaque-credential")
|
||||
);
|
||||
assert!(!sanitize_semantic_tool_input(
|
||||
"create_task",
|
||||
&json!({"diagnostic": "token opaque-credential"})
|
||||
)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("opaque-credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_redaction_preserves_benign_token_system_prose() {
|
||||
let prose = "Offer a simple token system so guests can exchange items even when their contributions differ in quantity.";
|
||||
|
|
|
|||
|
|
@ -495,3 +495,116 @@ fn terminal_events_clear_pending_requests_and_reject_late_turn_events() {
|
|||
))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_prose_survives_sidecar_decode_pending_state_and_semantic_projection() {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let plan = format!(
|
||||
"{}\nThe token CHAT8322bda781b81 must be included in the document.",
|
||||
"Relevant context. ".repeat(400)
|
||||
);
|
||||
let input = json!({
|
||||
"title": "Write project description",
|
||||
"description": "The document must contain the token CHAT8322bda781b81.",
|
||||
"initialPlan": plan,
|
||||
"idempotencyKey": "CHAT8322bda781b81-task",
|
||||
"apiToken": "actual-credential",
|
||||
});
|
||||
let mut expected = input.clone();
|
||||
expected["apiToken"] = json!("[REDACTED]");
|
||||
let emitted = state
|
||||
.accept_event(&event(
|
||||
1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
|
||||
Some("turn-1"),
|
||||
json!({"callId": "call-1", "operationId": "create_task", "input": input}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(state.pending_tool("call-1").unwrap().input, expected);
|
||||
let projected = project_acpx_state_event(
|
||||
&AcpxEventProjectionContext {
|
||||
run_id: "run-1".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
turn_id: "turn-1".to_owned(),
|
||||
provider_turn_id: None,
|
||||
item_id: "call-1".to_owned(),
|
||||
},
|
||||
&emitted[0],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projected[0].event_type, "semantic_tool.input");
|
||||
assert_eq!(projected[0].payload["semantic_tool"]["input"], expected);
|
||||
assert_eq!(
|
||||
projected[0].payload["semantic_tool"]["content"]["digest"],
|
||||
json!(paperclip_runner_core::provider_bridge::semantic_value_digest(&expected))
|
||||
);
|
||||
|
||||
for (operation, field, prose, preserved) in [
|
||||
(
|
||||
"write_document",
|
||||
"body",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"create_project",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"get_task_context",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"mcp__untrusted__create_task",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"create_task",
|
||||
"description",
|
||||
"Authorization: Bearer actual-credential",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"create_task",
|
||||
"initialPlan",
|
||||
"access token actual-credential",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
state
|
||||
.complete_tool(
|
||||
"call-1",
|
||||
state
|
||||
.pending_tool("call-1")
|
||||
.unwrap()
|
||||
.operation_id
|
||||
.clone()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
let emitted = state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
|
||||
Some("turn-1"),
|
||||
json!({"callId": "call-1", "operationId": operation, "input": {field: prose}}),
|
||||
))
|
||||
.unwrap();
|
||||
let AcpxProviderStateEvent::ToolCall { input, .. } = &emitted[0] else {
|
||||
panic!("expected tool call");
|
||||
};
|
||||
assert_eq!(
|
||||
input[field] == json!(prose),
|
||||
preserved,
|
||||
"{operation}: {prose}"
|
||||
);
|
||||
assert!(!input.to_string().contains("actual-credential"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,3 +180,37 @@ fn redacts_sidecar_stderr_when_the_process_exits() {
|
|||
assert!(message.contains("[REDACTED]"));
|
||||
assert!(!message.contains("amber-signal-7305"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn preserves_only_allowlisted_stderr_categories_when_the_process_exits() {
|
||||
let mut transport = AcpxSidecarTransport::start(&AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from("/bin/sh"),
|
||||
args: vec![
|
||||
"-c".to_owned(),
|
||||
"printf '%s\n' 'TypeError [ERR_INVALID_ARG_TYPE]: token=amber-signal-7305' ' at /private/secret-project/session-123.js:42' 'triggerUncaughtException(err, true /* fromPromise */);' 'Error: ACPX provider spawned after ownership admission was sealed' 'code: EPIPE' 'UnknownProviderError: private-value' 'prefixECONNRESETsuffix' >&2; exit 1".to_owned(),
|
||||
],
|
||||
verified_launch: None,
|
||||
request_timeout: Duration::from_secs(1),
|
||||
shutdown_grace: Duration::from_millis(50),
|
||||
})
|
||||
.expect("diagnostic fixture should start");
|
||||
let error = transport
|
||||
.poll_event(Duration::from_secs(1))
|
||||
.expect_err("exited sidecar must fail");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("stderrCategories=broken_pipe,invalid_argument_type,javascript_type_error,provider_spawn_after_ownership_seal,unhandled_rejection"));
|
||||
assert!(message.contains("stderrTail="));
|
||||
assert!(message.contains("[REDACTED]"));
|
||||
for sensitive in [
|
||||
"amber-signal-7305",
|
||||
"secret-project",
|
||||
"session-123",
|
||||
"private-value",
|
||||
"UnknownProviderError",
|
||||
"connection_reset",
|
||||
"TypeError",
|
||||
] {
|
||||
assert!(!message.contains(sensitive));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,30 @@ fn opencode_call_count(state_dir: &Path, method: &str) -> usize {
|
|||
.count()
|
||||
}
|
||||
|
||||
fn assert_valid_terminal(payload: &Value) {
|
||||
let schema: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/terminal.schema.json"
|
||||
))
|
||||
.unwrap();
|
||||
let stop_reason: Value = serde_json::from_str(include_str!(
|
||||
"../../../../protocol/schemas/stop-reason.schema.json"
|
||||
))
|
||||
.unwrap();
|
||||
let registry = jsonschema::Registry::new()
|
||||
.add(
|
||||
"https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json",
|
||||
stop_reason,
|
||||
)
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
let validator = jsonschema::options()
|
||||
.with_registry(®istry)
|
||||
.build(&schema)
|
||||
.unwrap();
|
||||
validator.validate(payload).unwrap();
|
||||
}
|
||||
|
||||
fn command(sequence: u64, command_type: &str, payload: Value) -> Command {
|
||||
Command {
|
||||
schema: "paperclip.prp.command.v1".to_owned(),
|
||||
|
|
@ -216,6 +240,7 @@ fn preserves_acpx_semantic_disposition_in_the_run_terminal() {
|
|||
.iter()
|
||||
.find(|event| event.event_type == "run.terminal")
|
||||
.expect("ACPX blocked result must become terminal");
|
||||
assert_valid_terminal(&terminal.payload);
|
||||
assert_eq!(terminal.payload["runTerminalState"], "succeeded");
|
||||
assert_eq!(terminal.payload["reportedWorkDisposition"], "blocked");
|
||||
|
||||
|
|
@ -360,7 +385,19 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() {
|
|||
assert!(events
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal"));
|
||||
assert_valid_terminal(
|
||||
&events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "run.terminal")
|
||||
.unwrap()
|
||||
.payload,
|
||||
);
|
||||
// runner.drain must see this exact terminal suffix without polling the
|
||||
// provider again. An empty default implementation strands the suffix and
|
||||
// makes shared native transport closure fail after a successful reply.
|
||||
assert_eq!(executor.retained_events().unwrap(), events);
|
||||
executor.acknowledge_events(events.len()).unwrap();
|
||||
assert!(executor.retained_events().unwrap().is_empty());
|
||||
executor
|
||||
.execute(&command(4, "session.close", json!({})))
|
||||
.unwrap();
|
||||
|
|
@ -368,6 +405,57 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() {
|
|||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_an_idle_acpx_session_in_a_cold_replacement_runner() {
|
||||
let directory = temporary_directory("acpx-cold-idle-recovery");
|
||||
let config = acpx_config(&directory, "turns-reserved-result-terminal");
|
||||
let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
|
||||
executor
|
||||
.execute(&command(
|
||||
1,
|
||||
"run.prepare",
|
||||
prepare_payload(&directory, "codex"),
|
||||
))
|
||||
.unwrap();
|
||||
let original = executor
|
||||
.execute(&command(2, "session.open", json!({})))
|
||||
.unwrap();
|
||||
executor
|
||||
.execute(&command(
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text":"Acknowledge.", "turnId":"provider-turn-first"}),
|
||||
))
|
||||
.unwrap();
|
||||
let events = executor.poll_events().unwrap();
|
||||
executor.acknowledge_events(events.len()).unwrap();
|
||||
executor
|
||||
.execute(&command(4, "runner.suspend", json!({})))
|
||||
.unwrap();
|
||||
executor.shutdown().unwrap();
|
||||
drop(executor);
|
||||
|
||||
let mut replacement_config = config.clone();
|
||||
replacement_config.run_id = "run-2".to_owned();
|
||||
replacement_config.turn_id = "turn-2".to_owned();
|
||||
let mut replacement =
|
||||
NativeProviderCommandExecutor::with_runner_config(&directory, &replacement_config);
|
||||
let mut payload = prepare_payload(&directory, "codex");
|
||||
payload["provider"]["runId"] = json!("run-2");
|
||||
let resumed = replacement
|
||||
.execute(&command(1, "run.attach", payload))
|
||||
.unwrap();
|
||||
assert_eq!(resumed.result["status"], "resumed");
|
||||
assert_eq!(
|
||||
resumed.result["providerSessionId"],
|
||||
original.result["providerSessionId"]
|
||||
);
|
||||
// Admission itself must preserve the provider identity before any new
|
||||
// model turn. The fixture's scripted terminal events belong to run-1.
|
||||
replacement.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_native_acpx_semantic_events_on_the_durable_controller_turn() {
|
||||
let directory = temporary_directory("acpx-durable-turn-correlation");
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function validInventories() {
|
|||
schemaVersion: 2,
|
||||
inventoryRole: "normative",
|
||||
generatedFrom: ["skills/paperclip/SKILL.md"],
|
||||
rows: Array.from({ length: 153 }, (_, index) => row(`capability-${index}`)),
|
||||
rows: Array.from({ length: 155 }, (_, index) => row(`capability-${index}`)),
|
||||
},
|
||||
evaluations: {
|
||||
schemaVersion: 2,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,18 @@ import { readFile, writeFile } from "node:fs/promises";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { serializeCapabilityGeneratedSemanticContracts } from "../dist/semantic-tools/provider-neutral.js";
|
||||
import { PAPERCLIP_RUNNER_BUILD_METADATA } from "../dist/evals/build-metadata.js";
|
||||
import { buildProtocolManifest } from "./generate-protocol-manifest.mjs";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const outputPath = resolve(packageRoot, "generated/capability/semantic-tool-contracts.json");
|
||||
const generated = serializeCapabilityGeneratedSemanticContracts();
|
||||
const manifestPath = resolve(packageRoot, "protocol/manifest.json");
|
||||
// This is an explicitly seeded schema fixture, not retained live evidence.
|
||||
// Keep its advertised catalog identity synchronized with the shipped contracts.
|
||||
const fixturePath = resolve(packageRoot, "protocol/fixtures/evals/native-execution-seeded.json");
|
||||
const fixture = JSON.parse(await readFile(fixturePath, "utf8"));
|
||||
const fixtureCurrent = fixture.runner.catalogSha256 === PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256;
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = await readFile(outputPath, "utf8").catch(() => "");
|
||||
|
|
@ -13,7 +21,22 @@ if (process.argv.includes("--check")) {
|
|||
process.stderr.write("semantic-tool-contracts.json is stale; run generate:semantic-contracts\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (!fixtureCurrent) {
|
||||
process.stderr.write("native-execution-seeded.json catalog is stale; run generate:semantic-contracts\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
const manifest = `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`;
|
||||
if (await readFile(manifestPath, "utf8").catch(() => "") !== manifest) {
|
||||
process.stderr.write("protocol/manifest.json is stale; run generate:semantic-contracts\n");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
await writeFile(outputPath, generated);
|
||||
process.stdout.write(`wrote ${outputPath}\n`);
|
||||
if (!fixtureCurrent) {
|
||||
fixture.runner.catalogSha256 = PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256;
|
||||
await writeFile(fixturePath, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
}
|
||||
// The manifest hashes fixture bytes, so refresh it after the seeded catalog.
|
||||
await writeFile(manifestPath, `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`);
|
||||
process.stdout.write(`wrote ${outputPath} and ${manifestPath}\n`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export async function buildMcpInventory(repoRoot) {
|
|||
|
||||
export function validateInventories(inventories) {
|
||||
const errors = [];
|
||||
const expectedCounts = { capabilities: 153, evaluations: 106, legacyMcpAliases: 42 };
|
||||
const expectedCounts = { capabilities: 155, evaluations: 106, legacyMcpAliases: 42 };
|
||||
const normativeNames = ["capabilities", "evaluations"];
|
||||
const normativeRows = new Map();
|
||||
const globalNormativeIds = new Set();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,10 +7,48 @@
|
|||
"src/scenarios/scenario-plan.ts"
|
||||
],
|
||||
"counts": {
|
||||
"actions": 43,
|
||||
"actions": 45,
|
||||
"legacyRequirements": 106
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "create_project",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:create_project",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::create_project has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:create_project"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::create_project has a schema-valid canonical example and every declared projection",
|
||||
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "list_project_repositories",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:list_project_repositories",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::list_project_repositories has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:list_project_repositories"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::list_project_repositories has a schema-valid canonical example and every declared projection",
|
||||
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "search_api",
|
||||
"ownership": "optional_agent_tool",
|
||||
|
|
@ -913,7 +951,8 @@
|
|||
"id": "list_projects",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"scenario"
|
||||
"scenario",
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:list_projects",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"covers": { "decisionRows": ["SD-03"], "terminalRows": [], "attentionRows": [], "livenessRows": [], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": [] },
|
||||
"tags": ["premature_done_claim", "incomplete_evidence", "partial_progress", "atomic_liveness"],
|
||||
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "nativeFinalization": "present", "completionState": "missing_required_test", "trigger": "runner_finalizer" },
|
||||
"expected": { "runStatus": "succeeded", "statusAction": "in_review", "reasonCode": "external_verification_required", "requiredEffects": ["bind_reviewer"], "forbiddenEffects": ["release_checkout_as_done", "enqueue_continuation"], "livePathKind": "review", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 },
|
||||
"expected": { "runStatus": "succeeded", "statusAction": "in_progress", "reasonCode": "completion_evidence_incomplete", "requiredEffects": ["enqueue_continuation"], "forbiddenEffects": ["release_checkout_as_done", "bind_reviewer"], "livePathKind": "continuation", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 1, "maxNotificationCount": 0 },
|
||||
"replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
|
||||
},
|
||||
{
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
"covers": { "decisionRows": [], "terminalRows": [], "attentionRows": [], "livenessRows": [], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": [] },
|
||||
"tags": ["incomplete_evidence", "atomic_liveness"],
|
||||
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "nativeFinalization": "present", "completionState": "missing_required_test", "trigger": "runner_finalizer", "fault": "continuation_insert_failure" },
|
||||
"expected": { "runStatus": "succeeded", "statusAction": "in_review", "reasonCode": "external_verification_required", "requiredEffects": ["bind_reviewer"], "forbiddenEffects": ["enqueue_continuation"], "livePathKind": "review", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 },
|
||||
"expected": { "runStatus": "succeeded", "statusAction": "preserve", "reasonCode": "side_effect_planning_failed", "requiredEffects": ["record_finalization_error"], "forbiddenEffects": ["enqueue_continuation"], "livePathKind": null, "preserveClaim": true, "nativeRecords": true, "decisionCount": 0, "maxWakeCount": 0, "maxNotificationCount": 0 },
|
||||
"replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
|
||||
},
|
||||
{
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
"covers": { "decisionRows": ["SD-04"], "terminalRows": [], "attentionRows": [], "livenessRows": ["LIVE-01"], "reconciliationRows": [], "compatibilityRows": [], "migrationRows": [] },
|
||||
"tags": ["required_review", "atomic_liveness"],
|
||||
"given": { "priorIssueStatus": "in_progress", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "needs_review", "nativeFinalization": "present", "completionState": "named_reviewer_required", "trigger": "runner_finalizer" },
|
||||
"expected": { "runStatus": "succeeded", "statusAction": "in_review", "reasonCode": "external_verification_required", "requiredEffects": ["bind_reviewer"], "forbiddenEffects": ["bind_blocker", "notify_owner"], "livePathKind": "review", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 },
|
||||
"expected": { "runStatus": "succeeded", "statusAction": "in_review", "reasonCode": "actionable_attention_pending", "requiredEffects": ["bind_reviewer"], "forbiddenEffects": ["bind_blocker", "notify_owner"], "livePathKind": "review", "preserveClaim": true, "nativeRecords": true, "decisionCount": 1, "maxWakeCount": 0, "maxNotificationCount": 0 },
|
||||
"replay": { "attempts": 2, "sameDecisionDigest": true, "maxSemanticDecisions": 1, "maxDomainEffectsPerKey": 1 }
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@
|
|||
"description": "Company-visible task, agent, project, and goal discovery.",
|
||||
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals"]
|
||||
},
|
||||
{
|
||||
"id": "projects",
|
||||
"description": "Project creation and authorized repository discovery through the live company/run authority.",
|
||||
"operationIds": ["create_project", "list_project_repositories"]
|
||||
},
|
||||
{
|
||||
"id": "delegation_dependencies",
|
||||
"description": "Create delegated work and maintain dependency edges.",
|
||||
|
|
@ -163,12 +168,12 @@
|
|||
"legacyGroup": 5,
|
||||
"name": "Search",
|
||||
"owner": "optional discovery tools",
|
||||
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals"],
|
||||
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals", "list_project_repositories"],
|
||||
"controlPlaneOperationIds": [],
|
||||
"realSurface": "company issue search and agent/project/goal list/get routes",
|
||||
"mockStateDomains": ["company", "task", "actor", "project", "goal"],
|
||||
"prpEvidence": "bounded redacted read projections through tool-result item events",
|
||||
"gap": "Project and goal operations are scenario-only; every real service binding is unbound."
|
||||
"gap": "Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher."
|
||||
},
|
||||
{
|
||||
"id": "su",
|
||||
|
|
@ -259,12 +264,12 @@
|
|||
"legacyGroup": 13,
|
||||
"name": "Reference files",
|
||||
"owner": "optional domain tools + test-only escape hatch",
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api"],
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project"],
|
||||
"controlPlaneOperationIds": ["append_audit_record"],
|
||||
"realSurface": "case, routine, company-skill, secret, portability, and administration services",
|
||||
"realSurface": "project, case, routine, company-skill, secret, portability, and administration services",
|
||||
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
|
||||
"prpEvidence": "bounded domain projections, redacted broker receipts, company diffs, and audit references",
|
||||
"gap": "These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
|
||||
"gap": "Project creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
|
||||
},
|
||||
{
|
||||
"id": "mh",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
|
|||
|
||||
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
|
||||
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **43 reconciled semantic operations** (14 always, 29 optional), and **106 scenarios in 16 behavior groups**.
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **45 reconciled semantic operations** (14 always, 31 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
|
|
@ -87,13 +87,14 @@ Placement has exactly three outcomes:
|
|||
|
||||
`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
|
||||
|
||||
### Optional operations (29) and grant groups (12)
|
||||
### Optional operations (31) and grant groups (13)
|
||||
|
||||
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
|
||||
|
||||
| Grant group | Operations | Required claims represented | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `discovery` | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | `discovery:agents:read`<br>`discovery:goals:read`<br>`discovery:projects:read`<br>`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
|
||||
| `projects` | `create_project`<br>`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. |
|
||||
| `delegation_dependencies` | `create_task`<br>`set_dependencies` | `delegation:tasks:create`<br>`dependencies:write` | Create delegated work and maintain dependency edges. |
|
||||
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
|
||||
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
|
||||
|
|
@ -118,7 +119,8 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
|||
| `call_api` | `optional_agent_tool` | `api:call` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `company_write` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
|
||||
| `comment_on_approval` | `optional_agent_tool` | `governance:approvals:comment` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `governance` | `required` | no | `semantic_command:comment_on_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
|
||||
| `control_workspace_service` | `optional_agent_tool` | `workspace:control` | `standard`<br>`skill_test` | `workspace_control` | `required` | no | `semantic_command:control_workspace_service` | `scenario` + `live`<br>`live_codex` | `unbound`<br>workspace service lifecycle event<br>catalog PRP status: `audit_pending` |
|
||||
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`<br>`live_codex` | `issues.createChild`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
|
||||
| `create_project` | `optional_agent_tool` | none | `standard`<br>`skill_test` | `company_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
|
||||
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`<br>`live_codex` | `issues.create / issues.createChild`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
|
||||
| `decide_approval` | `optional_agent_tool` | `governance:approvals:decide` | `standard`<br>`skill_test`<br>roles: `board`<br>`approver`<br>`security` | `governance` | `required` | no | `semantic_command:decide_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
|
||||
| `export_company` | `optional_agent_tool` | `portability:export` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:portability.export` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `finish_task` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
|
|
@ -137,7 +139,8 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
|||
| `list_document_revisions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_documents` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_goals` | `optional_agent_tool` | `discovery:goals:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.goals` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_project_repositories` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
|
||||
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
|
||||
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
|
|
@ -168,7 +171,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
|
|||
| [`co` — Checkout](#behavior-group-co-checkout) | control plane | none | `checkout_task` | POST /api/issues/:id/checkout and execution-lock services | `task`<br>`actor`<br>`run`<br>`idempotency`<br>`fault` | 6 | run preparation and issue-status decision evidence with checkout receipt | Intentionally no model tool; the production checkout receipt still needs the additive semantic-receipt envelope. |
|
||||
| [`st` — Status](#behavior-group-st-status) | always tools + control-plane arbitration | `answer_status_question`<br>`finish_task`<br>`block_task`<br>`request_review` | `reconcile_run`<br>`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`<br>`comments`<br>`interactions`<br>`blockers`<br>`audit`<br>`run` | 8 | semantic operation receipt, work assessment, issue-status decision, and terminal causality | Production semantic binding and additive typed operation/conflict receipts remain unimplemented. |
|
||||
| [`cm` — Comments](#behavior-group-cm-comments) | always tools | `get_task_history`<br>`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`<br>`comments`<br>`actor`<br>`idempotency`<br>`audit` | 6 | bounded read result or idempotent comment-write receipt plus audit reference | Active-task binding is unbound; cross-task comment mutation is deliberately outside V1. |
|
||||
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Project and goal operations are scenario-only; every real service binding is unbound. |
|
||||
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals`<br>`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher. |
|
||||
| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task` | `route_wake` | company issue create, child issue, assignment, and wake services | `company`<br>`task`<br>`actor`<br>`blockers`<br>`wake`<br>`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. |
|
||||
| [`bl` — Blockers](#behavior-group-bl-blockers) | always/optional tools + control plane | `block_task`<br>`set_dependencies` | `schedule_blocker_wake`<br>`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`<br>`blockers`<br>`wake`<br>`actor`<br>`audit`<br>`fault` | 5 | dependency diff, block receipt, attention routing, and issue-status decision | set_dependencies is production-bound for the active issue; block_task remains unbound, and cancelled-blocker receipts still need typed additive evidence. |
|
||||
| [`dp` — Documents and plans](#behavior-group-dp-documents-and-plans) | always tools; restore optional; destructive lifecycle control-plane-only | `list_documents`<br>`read_document`<br>`list_document_revisions`<br>`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`<br>`documents`<br>`interactions`<br>`idempotency`<br>`audit`<br>`fault` | 3 | bounded reads and revision-safe write/conflict/denial receipts with revision lineage | restore_document_revision is an approved optional-tool gap; lock/unlock/delete are intentionally control-plane-only. |
|
||||
|
|
@ -176,7 +179,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
|
|||
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `route_wake`<br>`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`<br>`task`<br>`approvals`<br>`actor`<br>`wake`<br>`audit`<br>`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
|
||||
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`<br>`artifacts`<br>`workProducts`<br>`workspace`<br>`audit`<br>`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
|
||||
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project` | `append_audit_record` | project, case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Project creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
|
||||
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
|
||||
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
|
||||
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
|
||||
|
|
@ -453,10 +456,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
|
|||
### Catalog split and deliberate replacement
|
||||
|
||||
- Scenario/eval catalog: **37** operations.
|
||||
- Live dispatcher catalog: **30** operations.
|
||||
- Shared: **24**; union/canonical authority: **43**.
|
||||
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_projects`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
|
||||
- Live-only: `call_api`, `get_agent`, `get_approval`, `get_approval_context`, `schedule_wake`, `search_api`.
|
||||
- Live dispatcher catalog: **33** operations.
|
||||
- Shared: **25**; union/canonical authority: **45**.
|
||||
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
|
||||
- Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `list_project_repositories`, `schedule_wake`, `search_api`.
|
||||
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
|
||||
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface CodexNativeSessionBackendOptions {
|
|||
| "activeTurnId"
|
||||
>;
|
||||
}) => CodexAppServerTransport;
|
||||
/** Current server constraints; does not commit task status before the turn ends. */
|
||||
completionFeedback?: (result: import("../protocol/replay-contract.js").PrpStructuredRunResult) => Promise<string>;
|
||||
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
|
||||
dynamicToolHandler?: (call: {
|
||||
tool: string;
|
||||
|
|
@ -162,6 +164,7 @@ function createTransportBackedNativeSessionBackend(
|
|||
transportFactory: options.transportFactory,
|
||||
dynamicTools: options.dynamicTools,
|
||||
dynamicToolHandler: options.dynamicToolHandler,
|
||||
completionFeedback: options.completionFeedback,
|
||||
environment: options.environment,
|
||||
workingDirectoryAuthority: options.workingDirectoryAuthority,
|
||||
driverIdentity,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function createNativeSessionBackend(
|
|||
): NativeSessionBackend {
|
||||
if (options.codexTransportFactory) {
|
||||
return createRunnerdNativeSessionBackend(input, {
|
||||
completionFeedback: options.completionFeedback,
|
||||
runnerInstanceId: options.runnerInstanceId,
|
||||
onSpawn: options.onSpawn,
|
||||
dynamicTools: options.dynamicTools,
|
||||
|
|
@ -99,6 +100,7 @@ export function createNativeSessionBackend(
|
|||
}
|
||||
|
||||
return createCodexNativeSessionBackend(input, {
|
||||
completionFeedback: options.completionFeedback,
|
||||
runnerInstanceId: options.runnerInstanceId,
|
||||
onSpawn: options.onSpawn,
|
||||
dynamicTools: options.dynamicTools,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function nativeSystemInstructions(input: NativeExecutionInput): string {
|
|||
|
||||
export function nativeTaskConstraints(input: NativeExecutionInput): string[] {
|
||||
const finalResponseConstraint =
|
||||
"Invoke paperclip_finish or paperclip_block exactly once before writing the complete user-facing final response. Use paperclip_finish with yielded and a response_wake continuation only when explicitly waiting for the next response. After the semantic tool succeeds, write that response exactly once and do not call another tool.";
|
||||
"Invoke paperclip_finish or paperclip_block exactly once before writing the complete user-facing final response. Use paperclip_finish with yielded and a response_wake continuation only when explicitly waiting for the next response. If the tool rejects an incomplete report, correct it and retry. When it succeeds, read its outcome and explain any pending approval with the supplied link and required action. Do not claim the task is done when completion is still gated. Then write the final response exactly once and do not call another tool.";
|
||||
const answeredQuestions = Array.isArray(input.interactionResponses)
|
||||
? input.interactionResponses.flatMap((response, responseIndex) => {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperat
|
|||
.sort((left, right) => left.operationId.localeCompare(right.operationId)),
|
||||
);
|
||||
const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation]));
|
||||
if (byId.size !== 43) throw new Error(`expected 43 canonical semantic operations, found ${byId.size}`);
|
||||
if (byId.size !== PAPERCLIP_PROTOCOL_ACTIONS.length) throw new Error("Duplicate canonical semantic operation ID");
|
||||
export function capabilityCanonicalOperation(operationId: string): CapabilityCanonicalOperation | undefined { return byId.get(operationId); }
|
||||
export function capabilityCanonicalOperationsForSurface(surface: CapabilityCatalogSurface): readonly CapabilityCanonicalOperation[] { return CAPABILITY_CANONICAL_OPERATIONS.filter((operation) => operation.surfaces.includes(surface)); }
|
||||
export function capabilityCanonicalOperationIds(): readonly string[] { return CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId); }
|
||||
|
|
|
|||
|
|
@ -20,16 +20,18 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
it("pins the reconciled op-set relationship between the two catalogs", () => {
|
||||
const summary = capabilityCatalogReconciliation();
|
||||
expect(summary.scenarioCount).toBe(37);
|
||||
expect(summary.liveCount).toBe(30);
|
||||
expect(summary.sharedCount).toBe(24);
|
||||
expect(summary.unionCount).toBe(43);
|
||||
expect(summary.liveCount).toBe(33);
|
||||
expect(summary.sharedCount).toBe(25);
|
||||
expect(summary.unionCount).toBe(45);
|
||||
// Any operation added to or removed from either catalog without a
|
||||
// reconciliation decision changes these exact sets and fails the gate.
|
||||
expect(summary.liveOnly).toEqual([
|
||||
"call_api",
|
||||
"create_project",
|
||||
"get_agent",
|
||||
"get_approval",
|
||||
"get_approval_context",
|
||||
"list_project_repositories",
|
||||
"schedule_wake",
|
||||
"search_api",
|
||||
]);
|
||||
|
|
@ -40,7 +42,6 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
"list_cases",
|
||||
"list_company_skills",
|
||||
"list_goals",
|
||||
"list_projects",
|
||||
"list_routines",
|
||||
"list_secret_metadata",
|
||||
"manage_routine",
|
||||
|
|
@ -51,7 +52,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
});
|
||||
|
||||
it("is the single source both catalogs derive their operation set from", () => {
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(43);
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(45);
|
||||
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
|
||||
// Neither catalog may contain an operation absent from the canonical source.
|
||||
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
|
||||
|
|
@ -85,7 +86,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
});
|
||||
|
||||
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(43);
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(45);
|
||||
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
|
||||
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
|
||||
expect(Array.isArray(operation.requiredClaims)).toBe(true);
|
||||
|
|
@ -111,8 +112,8 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
|||
it("classifies real binding status so generic_api_request is never product coverage", () => {
|
||||
const summary = capabilityCatalogReconciliation();
|
||||
expect(summary.byRealBindingStatus).toEqual({
|
||||
live_codex: 29,
|
||||
scenario_mock: 13,
|
||||
live_codex: 32,
|
||||
scenario_mock: 12,
|
||||
test_only: 1,
|
||||
});
|
||||
expect(capabilityCanonicalOperation("generic_api_request")?.realBindingStatus).toBe("test_only");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
|
|||
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTaskAction } from "../protocol-actions/create-task.js";
|
||||
import { createProjectAction } from "../protocol-actions/create-project.js";
|
||||
|
||||
import {
|
||||
PAPERCLIP_SEMANTIC_ACTION_CATALOG,
|
||||
|
|
@ -18,12 +20,44 @@ const packageRoot = resolve(
|
|||
);
|
||||
|
||||
describe("semantic action catalog", () => {
|
||||
it("limits project repository URLs to HTTPS GitHub repository paths on both tool surfaces", () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
|
||||
for (const schema of [createProjectAction.live.descriptor.inputSchema, paperclipSemanticAction("create_project")!.inputSchema]) {
|
||||
const validate = ajv.compile(schema);
|
||||
const input = { name: "Project", idempotencyKey: "create-project-1" };
|
||||
expect(validate({ ...input, repositoryUrls: ["https://github.com/org/repo", "https://github.com/org/other.git/"] })).toBe(true);
|
||||
for (const url of [
|
||||
"http://github.com/org/repo", "file:///etc/passwd", "data:text/plain,repo",
|
||||
"https://localhost/org/repo", "https://127.0.0.1/org/repo", "https://10.0.0.1/org/repo",
|
||||
"https://github.com.evil.test/org/repo", "https://token@github.com/org/repo",
|
||||
"https://github.com:8443/org/repo", "https://github.com/org/repo?token=secret",
|
||||
"https://github.com/org/repo#fragment", "https://github.com/org/repo/tree/main",
|
||||
"https://github.com/../repo", "https://github.com/org/..",
|
||||
]) expect(validate({ ...input, repositoryUrls: [url] }), url).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts project handoff receipts and preserves ordinary child task receipts", () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
|
||||
const validate = ajv.compile(createTaskAction.live.descriptor.outputSchema);
|
||||
const receipt = {
|
||||
commandId: "create-task-1", disposition: "applied", stateRevision: 1,
|
||||
entityRefs: ["task-1"], scheduledWakeIds: ["wake-1"],
|
||||
task: { id: "task-1", identifier: "CHAT-1", parentId: null, projectId: "project-1", status: "todo", assigneeActorId: "agent-1" },
|
||||
};
|
||||
expect(validate(receipt), JSON.stringify(validate.errors)).toBe(true);
|
||||
const { projectId: _projectId, ...childTask } = receipt.task;
|
||||
expect(validate({ ...receipt, task: { ...childTask, parentId: "parent-1" } })).toBe(true);
|
||||
expect(validate({ ...receipt, task: { ...receipt.task, projectId: 42 } })).toBe(false);
|
||||
expect(validate({ ...receipt, task: { ...receipt.task, parentId: "" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("defines one immutable v1 declaration for each Codex-spine action", () => {
|
||||
const operationIds = PAPERCLIP_SEMANTIC_ACTION_CATALOG.map(
|
||||
(action) => action.operationId,
|
||||
);
|
||||
|
||||
expect(operationIds).toHaveLength(29);
|
||||
expect(operationIds).toHaveLength(32);
|
||||
expect(new Set(operationIds).size).toBe(operationIds.length);
|
||||
expect(operationIds).not.toContain("generic_api_request");
|
||||
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
} from "./semantic-action-types.js";
|
||||
import { searchApiAction } from "../protocol-actions/search-api.js";
|
||||
import { callApiAction } from "../protocol-actions/call-api.js";
|
||||
import { projectRepositoryUrlSchema } from "../protocol-actions/create-project.js";
|
||||
|
||||
const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const;
|
||||
const WORK_MODES = ["standard", "planning", "skill_test"] as const;
|
||||
|
|
@ -428,10 +429,44 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
|||
),
|
||||
outputSchema: operationReceipt,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "list_projects",
|
||||
title: "List projects",
|
||||
requiredClaims: ["discovery:projects:read"],
|
||||
description: "Inspect available company projects before selecting a project for new work.",
|
||||
placement: "optional",
|
||||
inputSchema: object({}),
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "list_project_repositories",
|
||||
title: "List available repositories",
|
||||
description: "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
|
||||
placement: "optional",
|
||||
inputSchema: object({}),
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "create_project",
|
||||
title: "Create project",
|
||||
description: "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
|
||||
placement: "optional", effect: "write", allowedModes: STANDARD_MODE,
|
||||
inputSchema: object({
|
||||
...idempotency, name: text("Project name.", 500), description: nullableText("Project outcome and context."),
|
||||
repositoryIds: stringArray("Authorized repository IDs from list_project_repositories; may contain multiple repositories."),
|
||||
repositoryUrls: {
|
||||
type: "array", items: projectRepositoryUrlSchema, maxItems: 100, uniqueItems: true,
|
||||
description: "Existing HTTPS GitHub repository URLs, including repos absent from the catalog.",
|
||||
},
|
||||
workspace: openObject, status: { enum: ["backlog", "planned", "in_progress", "completed", "cancelled"] },
|
||||
goalId: nullableText("Goal ID."), goalIds: stringArray("Goal IDs."), leadAgentId: nullableText("Lead agent ID."),
|
||||
targetDate: nullableText("Target date."), color: nullableText("Project color."), icon: nullableText("Project icon."),
|
||||
env: openObject, executionWorkspacePolicy: openObject, archivedAt: nullableText("Archive timestamp."),
|
||||
}, ["idempotencyKey", "name"]),
|
||||
outputSchema: openObject,
|
||||
}),
|
||||
descriptor({
|
||||
operationId: "create_task",
|
||||
title: "Create child task",
|
||||
description: "Create one child task under the active task.",
|
||||
title: "Create task",
|
||||
description: "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.",
|
||||
placement: "optional",
|
||||
effect: "write",
|
||||
requiredClaims: ["delegation:tasks:create"],
|
||||
|
|
@ -439,7 +474,9 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
|||
inputSchema: object(
|
||||
{
|
||||
...idempotency,
|
||||
title: text("Child task title.", 500),
|
||||
title: text("Task title.", 500),
|
||||
projectId: nullableText("Project identifier for the new task."),
|
||||
initialPlan: nullableText("Relevant markdown plan to persist on the new task before it starts."),
|
||||
description: nullableText("Child task description."),
|
||||
assigneeActorId: nullableText("Optional actor assignee.", 200),
|
||||
priority: { enum: ["critical", "high", "medium", "low"] },
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export type PaperclipSemanticActionId =
|
|||
| "get_workspace_runtime"
|
||||
| "control_workspace_service"
|
||||
| "set_dependencies"
|
||||
| "create_project"
|
||||
| "list_project_repositories"
|
||||
| "list_projects"
|
||||
| "create_task"
|
||||
| "request_approval"
|
||||
| "decide_approval"
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ it("renews one authenticated connection for three weeks without replacing its au
|
|||
await core.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it.each(["expired", "revoked", "wrong-run", "wrong-connection", "wrong-epoch", "future-expiry"])(
|
||||
"cannot renew a lease with %s authority",
|
||||
|
|
@ -1542,9 +1542,10 @@ describe.sequential("DurablePrpControlPlane", () => {
|
|||
let authority: DurablePrpControlPlane | undefined;
|
||||
let launched = false;
|
||||
const diagnostics: string[] = [];
|
||||
// The launcher below is synthetic; use the current executable only as
|
||||
// its artifact identity, without depending on a staged Rust build.
|
||||
const runnerBinary = process.execPath;
|
||||
// The launcher never executes this file. Use a small artifact so cold
|
||||
// reads of the Linux Node executable do not consume the failure deadline.
|
||||
const runnerBinary = resolve(root, "synthetic-runner");
|
||||
writeFileSync(runnerBinary, "synthetic runner artifact\n", { mode: 0o600 });
|
||||
const runnerDigest = `sha256:${createHash("sha256").update(readFileSync(runnerBinary)).digest("hex")}`;
|
||||
const handler = vi.fn(async () => ({ success: true, contentItems: [] }));
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js";
|
||||
|
||||
|
|
@ -1311,6 +1312,116 @@ describe("Codex ACPX runtime adapter", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("observes prompt admission rejection when the sidecar consumes only events and the result", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const failure = new Error("Recovered provider could not start the prompt");
|
||||
vi.mocked(runtime.startTurn).mockImplementation(() => ({
|
||||
requestId: "turn-recovered-failure",
|
||||
promptStarted: Promise.reject(failure),
|
||||
events: { async *[Symbol.asyncIterator]() { throw failure; } },
|
||||
result: Promise.reject(failure),
|
||||
cancel: vi.fn(),
|
||||
closeStream: vi.fn(),
|
||||
}));
|
||||
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
||||
createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime,
|
||||
});
|
||||
const turn = port.startTurn({ text: "Resume", requestId: "turn-recovered-failure" });
|
||||
const eventDrain = (async () => { for await (const _event of turn.events) { /* drain */ } })();
|
||||
await expect(eventDrain).rejects.toBe(failure);
|
||||
// The sidecar does not await promptStarted. Leave it unconsumed across a
|
||||
// full event-loop turn so an unobserved derived rejection fails this test.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// Observing internally must not replace failure with successful admission.
|
||||
await expect(turn.result).rejects.toBe(failure);
|
||||
await expect(turn.promptStarted).rejects.toBe(failure);
|
||||
await port.close({ reason: "test complete" });
|
||||
});
|
||||
|
||||
it("verifies a lazy recovered provider spawned by model selection before returning", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const command = fakeCommand();
|
||||
vi.mocked(command.spawn).mockReturnValue(fakeChild());
|
||||
let runtimeOptions: AcpRuntimeOptions | undefined;
|
||||
let acknowledgeOwnership!: () => void;
|
||||
const ownership = new Promise<void>((resolve) => { acknowledgeOwnership = resolve; });
|
||||
vi.mocked(runtime.setConfigOption!).mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
runtimeOptions?.spawnAgent?.({ command: "ignored", args: ["--stdio"], options: {} });
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(openOptions(command), {
|
||||
createRegistry: () => registry(), createStore: () => store(),
|
||||
awaitProviderOwnership: () => ownership,
|
||||
awaitProviderExit: providerOwnershipEstablished,
|
||||
createRuntime: (options) => { runtimeOptions = options; return runtime; },
|
||||
});
|
||||
let admitted = false;
|
||||
const selection = port.setModel!("gpt-5.6-sol").then(() => { admitted = true; });
|
||||
void selection.catch(() => undefined);
|
||||
await vi.waitFor(() => expect(command.spawn).toHaveBeenCalledOnce());
|
||||
expect(admitted).toBe(false);
|
||||
acknowledgeOwnership();
|
||||
await selection;
|
||||
expect(admitted).toBe(true);
|
||||
expect(() => runtimeOptions?.spawnAgent?.({ command: "ignored", args: [], options: {} }))
|
||||
.toThrow("provider spawned after ownership admission was sealed");
|
||||
await port.close({ reason: "test complete" });
|
||||
});
|
||||
|
||||
it("uses a fresh single-use command after a cold model control consumes its launch", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const freshCommand = () => {
|
||||
const command = fakeCommand();
|
||||
vi.mocked(command.spawn).mockReturnValueOnce(fakeChild()).mockImplementation(() => {
|
||||
throw new Error("Verified ACPX command lease is closed");
|
||||
});
|
||||
return command;
|
||||
};
|
||||
const first = freshCommand();
|
||||
const second = freshCommand();
|
||||
const openCommand = vi.fn(async () => second);
|
||||
const owner = createAcpxCommandLeaseOwner(first, openCommand);
|
||||
let runtimeOptions: AcpRuntimeOptions;
|
||||
vi.mocked(runtime.setConfigOption!).mockImplementation(async () => {
|
||||
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
||||
});
|
||||
vi.mocked(runtime.startTurn).mockImplementation(() => {
|
||||
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
||||
return {
|
||||
requestId: "cold-turn",
|
||||
promptStarted: Promise.resolve(),
|
||||
events: { async *[Symbol.asyncIterator]() {} },
|
||||
result: Promise.resolve({ status: "completed" }),
|
||||
cancel: vi.fn(),
|
||||
closeStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(
|
||||
{
|
||||
...openOptions(owner.command),
|
||||
refreshConsumedCommand: owner.refreshConsumedCommand,
|
||||
},
|
||||
{
|
||||
createRegistry: () => registry(),
|
||||
createStore: () => store(),
|
||||
awaitProviderOwnership: providerOwnershipEstablished,
|
||||
awaitProviderExit: providerOwnershipEstablished,
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions = options;
|
||||
return runtime;
|
||||
},
|
||||
},
|
||||
);
|
||||
await port.setModel!("gpt-5.6-sol");
|
||||
expect(openCommand).toHaveBeenCalledOnce();
|
||||
const turn = port.startTurn({ text: "Resume", requestId: "cold-turn" });
|
||||
await expect(turn.result).resolves.toMatchObject({ status: "completed" });
|
||||
expect(first.spawn).toHaveBeenCalledOnce();
|
||||
expect(second.spawn).toHaveBeenCalledOnce();
|
||||
await port.close({ reason: "test complete" });
|
||||
await owner.command.close();
|
||||
});
|
||||
|
||||
it("admits a verified provider that starts with the first recovered turn", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const child = fakeChild();
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
update.goal === null ? null : structuredClone(update.goal),
|
||||
);
|
||||
};
|
||||
const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand };
|
||||
const runtimeOptions: GoalAwareAcpRuntimeOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionStore,
|
||||
|
|
@ -327,6 +328,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
// handshake cannot create a provider process after authority is gone.
|
||||
options.signal?.throwIfAborted();
|
||||
options.assertWorkspaceHeld?.();
|
||||
commandLaunches.count += 1;
|
||||
return children.add(
|
||||
options.command.spawn(input.args, input.options, {
|
||||
credentialFenceFds,
|
||||
|
|
@ -431,6 +433,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
children,
|
||||
runtimeCloseTimeoutMs,
|
||||
goalState,
|
||||
commandLaunches,
|
||||
);
|
||||
} catch (error) {
|
||||
const cleanupReason = "ACPX runtime identity validation failed";
|
||||
|
|
@ -857,6 +860,7 @@ function runtimePort(
|
|||
children: SpawnedChildSet,
|
||||
runtimeCloseTimeoutMs: number,
|
||||
goalState: AcpxRuntimeGoalState,
|
||||
commandLaunches: { count: number; refreshConsumedCommand?: () => Promise<void> },
|
||||
): AcpxRuntimePort {
|
||||
type RuntimeCloseAttempt = {
|
||||
readonly outcome: Promise<unknown | null>;
|
||||
|
|
@ -1156,11 +1160,26 @@ function runtimePort(
|
|||
...(runtime.setConfigOption
|
||||
? {
|
||||
async setModel(model: string) {
|
||||
await runtime.setConfigOption?.({
|
||||
handle,
|
||||
key: "model",
|
||||
value: model,
|
||||
});
|
||||
// A restored handle can be lazy: selecting the pinned model may
|
||||
// launch its first provider before any prompt. Admit that spawn
|
||||
// only for this control call, and verify ownership before return.
|
||||
const finishOwnershipAdmission =
|
||||
children.beginLifetimeOwnershipAdmission();
|
||||
const spawnsBeforeControl = commandLaunches.count;
|
||||
try {
|
||||
await runtime.setConfigOption?.({
|
||||
handle,
|
||||
key: "model",
|
||||
value: model,
|
||||
});
|
||||
} finally {
|
||||
await finishOwnershipAdmission();
|
||||
}
|
||||
// Cold ACP config calls open and close a temporary connection.
|
||||
// A later prompt needs a newly verified single-use launch snapshot.
|
||||
if (commandLaunches.count > spawnsBeforeControl) {
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
@ -1212,11 +1231,20 @@ function turnWithVerifiedLifetimeOwnership(
|
|||
finishOwnershipAdmission(),
|
||||
);
|
||||
void ownershipVerified.catch(() => undefined);
|
||||
const promptStarted = ownershipVerified.then(() => turn.promptStarted);
|
||||
const result = ownershipVerified.then(() => turn.result);
|
||||
// Some consumers (including the sidecar) drain events and await the result
|
||||
// without awaiting this optional admission signal. Observe its rejection
|
||||
// immediately so a failed cold start cannot terminate the host process as an
|
||||
// unhandled rejection. Keep the original rejected promise for consumers.
|
||||
void promptStarted.catch(() => undefined);
|
||||
// Event drains can fail before their caller reaches the result promise.
|
||||
void result.catch(() => undefined);
|
||||
return {
|
||||
requestId: turn.requestId,
|
||||
promptStarted: ownershipVerified.then(() => turn.promptStarted),
|
||||
promptStarted,
|
||||
events: eventsAfterLifetimeOwnership(turn.events, ownershipVerified),
|
||||
result: ownershipVerified.then(() => turn.result),
|
||||
result,
|
||||
cancel: (input) => turn.cancel(input),
|
||||
closeStream: (input) => turn.closeStream(input),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import type { ChildProcess } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
function lease() {
|
||||
let consumed = false;
|
||||
return {
|
||||
spawn: vi.fn(() => {
|
||||
if (consumed) throw new Error("single-use command already consumed");
|
||||
consumed = true;
|
||||
return {} as ChildProcess;
|
||||
}),
|
||||
close: vi.fn(async () => {
|
||||
consumed = true;
|
||||
}),
|
||||
} satisfies VerifiedAcpxCommandLease;
|
||||
}
|
||||
|
||||
describe("ACPX verified command lease owner", () => {
|
||||
it("refreshes only consumed snapshots and preserves single-use spawn enforcement", async () => {
|
||||
const first = lease();
|
||||
const second = lease();
|
||||
const open = vi.fn(async () => second);
|
||||
const owner = createAcpxCommandLeaseOwner(first, open);
|
||||
await owner.refreshConsumedCommand();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
owner.command.spawn();
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await Promise.all([owner.refreshConsumedCommand(), owner.refreshConsumedCommand()]);
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
owner.command.spawn();
|
||||
expect(second.spawn).toHaveBeenCalledOnce();
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await owner.command.close();
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
expect(second.close).toHaveBeenCalledOnce();
|
||||
expect(() => owner.command.spawn()).toThrow("closing");
|
||||
await expect(owner.refreshConsumedCommand()).rejects.toThrow("closing");
|
||||
});
|
||||
|
||||
it("retains a replacement acquired during shutdown and retries its failed cleanup", async () => {
|
||||
const first = lease();
|
||||
const replacement = lease();
|
||||
replacement.close.mockRejectedValueOnce(new Error("close failed"));
|
||||
let acquired!: (value: VerifiedAcpxCommandLease) => void;
|
||||
const owner = createAcpxCommandLeaseOwner(
|
||||
first,
|
||||
() => new Promise((resolve) => {
|
||||
acquired = resolve;
|
||||
}),
|
||||
);
|
||||
owner.command.spawn();
|
||||
const refresh = owner.refreshConsumedCommand();
|
||||
const rejectedRefresh = expect(refresh).rejects.toThrow("closed during refresh");
|
||||
await Promise.resolve();
|
||||
const close = owner.command.close();
|
||||
const rejectedClose = expect(close).rejects.toThrow("leases did not close");
|
||||
acquired(replacement);
|
||||
await rejectedRefresh;
|
||||
await rejectedClose;
|
||||
expect(replacement.spawn).not.toHaveBeenCalled();
|
||||
await owner.command.close();
|
||||
expect(replacement.close).toHaveBeenCalledTimes(2);
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails closed when fresh command verification fails", async () => {
|
||||
const initial = lease();
|
||||
const owner = createAcpxCommandLeaseOwner(initial, async () => {
|
||||
throw new Error("installation changed");
|
||||
});
|
||||
owner.command.spawn();
|
||||
await expect(owner.refreshConsumedCommand()).rejects.toThrow("installation changed");
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await owner.command.close();
|
||||
expect(initial.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
/** Keep each launch single-use while owning replacements for transient ACP controls. */
|
||||
export function createAcpxCommandLeaseOwner(
|
||||
initial: VerifiedAcpxCommandLease,
|
||||
openCommand: () => Promise<VerifiedAcpxCommandLease>,
|
||||
) {
|
||||
const leases = new Set([initial]);
|
||||
let current = initial;
|
||||
let consumed = false;
|
||||
let closing = false;
|
||||
let refresh: Promise<void> | null = null;
|
||||
const command: VerifiedAcpxCommandLease = {
|
||||
spawn(...args) {
|
||||
if (closing) throw new Error("Verified ACPX command owner is closing");
|
||||
consumed = true;
|
||||
return current.spawn(...args);
|
||||
},
|
||||
async close() {
|
||||
closing = true;
|
||||
// Late acquisitions remain owned. Retry every lease whose close fails.
|
||||
await refresh?.catch(() => undefined);
|
||||
const failures: unknown[] = [];
|
||||
for (const lease of leases) {
|
||||
try {
|
||||
await lease.close();
|
||||
leases.delete(lease);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length) throw new AggregateError(failures, "ACPX command leases did not close");
|
||||
},
|
||||
};
|
||||
return {
|
||||
command,
|
||||
async refreshConsumedCommand(): Promise<void> {
|
||||
if (closing) throw new Error("Verified ACPX command owner is closing");
|
||||
if (!consumed) return;
|
||||
if (!refresh) {
|
||||
refresh = Promise.resolve()
|
||||
.then(openCommand)
|
||||
.then((replacement) => {
|
||||
leases.add(replacement);
|
||||
if (closing) throw new Error("Verified ACPX command owner closed during refresh");
|
||||
current = replacement;
|
||||
consumed = false;
|
||||
});
|
||||
}
|
||||
const pending = refresh;
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (refresh === pending) refresh = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type VerifiedAcpxCommandLease,
|
||||
type VerifiedAcpxInstallation,
|
||||
} from "./installation-integrity.js";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import {
|
||||
requireVerifiedAcpxModel,
|
||||
type AcpxModelStatus,
|
||||
|
|
@ -117,6 +118,8 @@ export interface AcpxRuntimePort {
|
|||
|
||||
export interface AcpxRuntimePortOpenOptions {
|
||||
command: VerifiedAcpxCommandLease;
|
||||
/** Replace a consumed launch snapshot after an ephemeral control session. */
|
||||
refreshConsumedCommand?: () => Promise<void>;
|
||||
profile: QualifiedAcpxProfile;
|
||||
cwd: string;
|
||||
stateDirectory: string;
|
||||
|
|
@ -396,6 +399,11 @@ export class AcpxRuntimeHost {
|
|||
reportFailure: (failure) =>
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
const commandOwner = createAcpxCommandLeaseOwner(
|
||||
command,
|
||||
() => installation.openCommand(),
|
||||
);
|
||||
command = commandOwner.command;
|
||||
toolBridge = options.semanticTools
|
||||
? await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
|
|
@ -416,6 +424,7 @@ export class AcpxRuntimeHost {
|
|||
options.assertWorkspaceHeld?.();
|
||||
return dependencies.openRuntime({
|
||||
command: command!,
|
||||
refreshConsumedCommand: commandOwner.refreshConsumedCommand,
|
||||
profile,
|
||||
cwd: binding.workspacePath,
|
||||
stateDirectory: sandbox.stateDirectory,
|
||||
|
|
|
|||
|
|
@ -916,6 +916,7 @@ export class CodexAppServerDriver implements HarnessDriver {
|
|||
goalCapability: this.#goalCapability,
|
||||
dynamicTools: this.#providerDynamicTools(),
|
||||
dynamicToolHandler: this.#options.dynamicToolHandler,
|
||||
completionFeedback: this.#options.completionFeedback,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,25 @@ import {
|
|||
import { RUNNERD_CANONICAL_ITEM } from "./codex-driver-values.js";
|
||||
|
||||
describe("Codex app-server Codex driver", () => {
|
||||
it("returns current approval feedback and permits correcting a rejected completion report", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const feedback = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Name the reviewer decision or finish the remaining work."))
|
||||
.mockResolvedValue("Task remains in review. Accept [Publish](/approvals/approval-1) before completion.");
|
||||
const session = await makeDriver([transport], { completionFeedback: feedback }).openSession({
|
||||
runId: "run-feedback", normalizedSessionId: "feedback-session", workingDirectory: WORKSPACE,
|
||||
});
|
||||
await session.startTurn({ message: { role: "user", text: "Finish" } });
|
||||
const call = (callId: string) => transport.invoke({ id: callId, method: "item/tool/call",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", callId, tool: "paperclip_finish", arguments: result } });
|
||||
expect(await call("first")).toMatchObject({ success: false });
|
||||
expect((await session.snapshot()).semanticResult).toBeNull();
|
||||
expect(await call("corrected")).toMatchObject({ success: true,
|
||||
contentItems: [{ type: "inputText", text: expect.stringContaining("/approvals/approval-1") }] });
|
||||
expect((await session.snapshot()).semanticResult?.result).toEqual(result);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("accepts an explicit response-wake yield through paperclip_finish", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const session = await makeDriver([transport]).openSession({
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface CodexAppServerDriverOptions {
|
|||
turnId: string;
|
||||
arguments: unknown;
|
||||
}) => Promise<unknown>;
|
||||
/** Current server constraints; does not commit task status before the turn ends. */
|
||||
completionFeedback?: (result: import("../../protocol/replay-contract.js").PrpStructuredRunResult) => Promise<string>;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
/** Filesystem that authoritatively admits the workspace path. */
|
||||
workingDirectoryAuthority?: CodexWorkingDirectoryAuthority;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
async function authenticatedRunner(
|
||||
core: DurablePrpControlPlane,
|
||||
identity: DurableRecoveryIdentity,
|
||||
runnerBinary: string,
|
||||
) {
|
||||
const framed = (domain: string, parts: Buffer[]) => {
|
||||
const values = [Buffer.from(domain), Buffer.from([0])];
|
||||
|
|
@ -84,7 +85,7 @@ async function authenticatedRunner(
|
|||
protocolMax: 1,
|
||||
...identity,
|
||||
runnerVersion: "0.3.0",
|
||||
runnerDigest: `sha256:${createHash("sha256").update(readFileSync(process.execPath)).digest("hex")}`,
|
||||
runnerDigest: `sha256:${createHash("sha256").update(readFileSync(runnerBinary)).digest("hex")}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -427,6 +428,10 @@ describe("Codex protocol integrity propagation", () => {
|
|||
const directory = mkdtempSync(
|
||||
join(tmpdir(), "paperclip-composed-integrity-"),
|
||||
);
|
||||
// Only the process launcher is synthetic; hash a small fixture instead
|
||||
// of cold-reading the host Node executable during the protocol deadline.
|
||||
const runnerBinary = join(directory, "synthetic-runner");
|
||||
writeFileSync(runnerBinary, "synthetic runner artifact\n", { mode: 0o600 });
|
||||
const identity: DurableRecoveryIdentity = {
|
||||
runnerInstanceId: `composed-runner-${scenario}`,
|
||||
environmentLeaseId: `composed-lease-${scenario}`,
|
||||
|
|
@ -526,7 +531,7 @@ describe("Codex protocol integrity propagation", () => {
|
|||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
stateDirectory: directory,
|
||||
prpIdentity: identity,
|
||||
runnerBinary: process.execPath,
|
||||
runnerBinary,
|
||||
codexCommand: process.execPath,
|
||||
codexArgs: [],
|
||||
sourceCodexHome: null,
|
||||
|
|
@ -575,7 +580,7 @@ describe("Codex protocol integrity propagation", () => {
|
|||
try {
|
||||
await vi.waitFor(() => expect(launch).toHaveBeenCalledTimes(1));
|
||||
const core = authority!;
|
||||
client = await authenticatedRunner(core, identity);
|
||||
client = await authenticatedRunner(core, identity, runnerBinary);
|
||||
const commandResult = async (
|
||||
type: string,
|
||||
result: Record<string, unknown> = {},
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue