Merge origin/master test coverage consolidation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 10:47:30 -05:00
commit e2825f0f14
44 files changed
+3461 -15031

No files matched your search

+404
View File
@@ -0,0 +1,404 @@
# Test audit: remove redundant proof and repeated fixtures
Date: 2026-10-12
Baseline: `fb21a3f30a`
Reference: [OpenClaw test audit](https://github.com/openclaw/openclaw/blob/main/.agents/skills/test-audit/SKILL.md)
The combined cleanup removes **11,976 net test/support lines, 41 unused
production lines, and 1,073 registered cases**. Four obsolete ACPX
characterization files and the old Codex driver monolith are retired. Unique
contracts remain in their current owners. The latest round passes all 1,421
affected/sibling cases, with caught mutations and restored passing controls.
Workspace typecheck and build pass. The previous round's completed owner and
chat proof is recorded separately below. This is a reviewed subset of the
repository, not an exhaustive review or a complete default-suite run.
## PR delivery verification
The cleanup is published in [PR #15978](https://github.com/paperclipai/paperclip/pull/15978).
The three cleanup commits rebased onto `914b70572c` without changing their
patches. At code revision `454e3a91ff`, fresh `pnpm -r typecheck` and
`pnpm build` pass.
The local `pnpm test:run` started at that code revision. During its first
general-server invocation, one unchanged tool-access case timed out at
15,035ms: `rejects unavailable Workspace gmail.read (other profile advertised:
true)`. The invocation was stopped after the reported failure and exited 130.
It did not complete the 882 general-server files or reach the remaining
workspace and 158 serialized suites. **There is no full local default-suite
pass.** The timing discrepancy remains unresolved; earlier passes alone do
not establish its cause.
Commit `37eb57418b` only corrects a coverage-guide link to the retained HTTP
route suite. It arrived during the local default invocation, so that run is
not an immutable run of the later head, although its executable sources are
unchanged. The complete tool-access owner then runs at immutable
`37eb57418b` and passes all **419 cases**, including the timed-out matrix row,
with its existing assertions, fixtures and timeout. UI token gates also pass.
No test was skipped, removed or given a longer budget in response to the failure.
At `37eb57418b`, all remote CI and security checks pass, Greptile completes a
fresh **5/5** review, and there are zero unresolved review threads. The review
fix and this delivery record change Markdown only. Check the PR for the final
head and its required CI/review results; the local failure remains disclosed
in the PR verification section.
## Second expansion
Baseline: `8538e024d7`. Refreshed `origin/master` remains at `fb21a3f30a`.
Three auditors nominated owner batches; original-source and proposal SHA-256
guards were checked before cutover. This round changes tests, one test-gate
file selector, documentation, and scoped test ownership guidance. It changes
zero application source lines.
| Owner batch | Net test/support lines removed | Net registered cases removed |
| --- | ---: | ---: |
| Codex monolith retirement and test-gate selector | 3,780 | 64 |
| Sandbox/SSH execution target fixtures | 976 | 0 |
| Tool request/audit and heartbeat recovery consolidation | 367 | 4 |
| Issue workspace inheritance duplicates | 175 | 2 |
| **New round** | **5,298** | **70** |
| Earlier rounds | 6,678 | 1,003 |
| **Combined** | **11,976** | **1,073** |
These figures exclude Markdown. The Codex row contains 3,779 test lines and
one removed prerequisite-selector line. The earlier 41 application lines
were the unused WorkspaceRuntimeControls compatibility path; no additional
application code was removed in this round. No measured speedup is claimed.
### New retired proof and keepers
- Codex: `06f8773097` (#12371) split the driver tests but retained the
4,153-line monolith. Its 70 declarations have 56 exact keepers, four
evolved stronger keepers, and ten unique migrations. All parameter rows
survive. Move tool refresh to recovery; continuation/effort/controls/goals
to context-control; direct-chat tools and unknown outcomes to
semantic-results. Preserve exact host-HOME equality as well as descendant
denial, the -32004 goal-policy denial and exact availability event, and
stronger diff/path and rejected/missing-result contracts. The monolith's
76 registered cases become 12 migrated rows; the nine-file baseline has
214 cases and the eight retained owners have 150.
- Test-gate routing: remove only SH-1's stale monolith filename. Its
existing lifecycle keeper still proves additive instructions. Required
patterns, source-digest receipts, other prerequisites and grading stay.
Update four docs to reachable suite filters/owners. The runner package
has its own Vitest config and is not in the root project list.
- Adapter fixtures: consolidate fresh roots/workspaces, repeated sandbox
targets, bridge defaults and SSH targets. Expand the actual new helpers
with their actual arguments/defaults and compare the entire executable
AST to the committed originals. Every case, all 523 expectation statements,
custom runner, body limit, timeout, callback, await and cleanup remains.
Helpers allocate fresh objects/runners; all 41 converted bridge calls
resolve target.remoteCwd to the original local remoteCwd expression.
- Tool requests: combine two audit-only cases with their exact same
behavioral request/actor/policy owners. Move both complete activity and
dedicated-audit assertion blocks. Preserve the missing-ID HTTP 404 in
the existing live lifecycle. Repair the misleading non-test-origin
negative, whose random missing ID never reached the origin guard, using
the real persisted agent-owned request in the connected-MCP gateway case.
Its company/connection/agent/run/issue preconditions reach the intended guard.
- Heartbeat: combine the weaker no-state accepted-plan recovery case with
the dropped-accept-time-wake keeper. Carry every full-context/retry
assertion. Consolidate only identical DB seed rows in five retained
siblings; retain historical-run, answered-question, board authority and
post-resolution timing scenarios.
- Issues: remove the second exact inheritance and explicit-override
declarations. Both groups initialize the same real DB/service/schema and
per-case experimental inputs. Keep every unique generic-project,
cross-project, source-inheritance, update and runtime-config case.
### Second expansion preservation and validation
Independent reviews cover all four boundary groups. They found no remaining
preservation blockers. Codex review independently compares all 70 mappings:
66 callback/table/budget ASTs are identical under the one workspace alias,
including the ten migrations; four evolved keepers preserve/strengthen the
original contracts. Eight shared helpers match; the fake transport's existing
optional providerIdentity is absent in these cases. The existing disposable,
canonical workspace preserves cwd identity and containment tests.
Adapter helper expansion, issue full-file comparison after the two exact
deletions, server audit-block comparison and recovery helper expansion retain
all surrounding executable statements. No generated expectation, fake owner
receipt, broad skip or longer timeout replaces behavioral proof.
- Baseline: **1,459 cases pass** across all nominated complete files and the
stock-harness gate test. The initial sandboxed attempt hit localhost
`listen EPERM` and consequent skips; unrestricted fixture runs pass every case.
- Candidate: **1,211 server/adapter cases, 182 Codex/transport/console cases,
and 28 stock-harness cases pass**, zero failed/skipped cases. The 182 include
all 150 retained Codex/diff owners plus 32 transport/console siblings.
- Mutation: bypass only getTestCallStatus's origin clause. The real persisted
request resolves and the new rejection assertion fails. Lookup/company/
connection preconditions remain valid. Restore source byte for byte; control
passes.
- Mutation: misclassify Codex goal policy denial as unsupported. The retained
real availability-event assertion fails. Restore source byte for byte;
control passes.
- Workspace `pnpm -r typecheck`, `pnpm build`, runner E2E-support typecheck,
and `git diff --check` pass. No UI source changed in this round.
- The full default repository suite remains unverified; this is complete
affected-owner/sibling proof. A full default run is still required before
claiming a full local pass. These commits were local at the end of that
round; later PR verification is recorded above.
### Retained second-round nominees
Keep native-session-runtime, runnerd transport, ACPX translation and browser
model suites: they exercise distinct lifecycle, authentication, protocol or
separate implementation owners. Keep sandbox queue versus real HTTP2 layers,
credential/stream/order guards, and unrelated tool/company/security cases.
No additional full-layer retirement was justified in those boundaries.
### Codex declaration ledger
Each exact case name identifies its protected contract; callback/table preservation was checked against the committed baseline. D deletes redundant old proof; C moves or consolidates it.
| Old line / exact case | Mark | Keeper | Evidence |
| --- | --- | --- | --- |
| 330: refreshes authorized tools on resume without replacing the provider thread | C move | `codex-app-server-driver.recovery.test.ts:1305` | identical body/table under cwd alias |
| 433: parses a complete Codex turn diff snapshot with bounded file statistics | D | `codex-turn-diff.test.ts:17` | identical body/table under cwd alias |
| 466: bounds aggregate Codex diffs and rejects unsafe workspace paths | C stronger keeper | `codex-turn-diff.test.ts:50` | explicit superset/migration above |
| 496: persists process ownership before sending provider requests | D | `codex-app-server-driver.lifecycle.test.ts:265` | identical body/table under cwd alias |
| 533: sends direct chat as plain text and permits a follow-up turn | D | `codex-app-server-driver.lifecycle.test.ts:417` | identical body/table under cwd alias |
| 579: forwards the persisted native model to the runner transport | D | `codex-app-server-driver.lifecycle.test.ts:463` | identical body/table under cwd alias |
| 600: adds Paperclip developer instructions without replacing the Codex base and enables selected skills | D | `codex-app-server-driver.lifecycle.test.ts:486` | identical body/table under cwd alias |
| 630: passes the common typed-event contract and reports one provider turn terminal | D | `codex-app-server-driver.lifecycle.test.ts:574` | identical body/table under cwd alias |
| 845: admits a strictly bound semantic result from the durable runner | D | `codex-app-server-driver.events.test.ts:47` | identical body/table under cwd alias |
| 879: correlates rehydrated notifications with the final canonical PRP event ids | D | `codex-app-server-driver.events.test.ts:81` | identical body/table under cwd alias |
| 935: maps canonical workspace snapshots with monotonic revisions and one terminal diff | D | `codex-app-server-driver.events.test.ts:137` | identical body/table under cwd alias |
| 1052: finalizes an authoritative empty workspace snapshot when a turn is interrupted | D | `codex-app-server-driver.events.test.ts:254` | identical body/table under cwd alias |
| 1095: accepts a thread usage snapshot replayed before a resumed turn starts | D | `codex-app-server-driver.events.test.ts:319` | identical body/table under cwd alias |
| 1129: normalizes provider message and reasoning phases onto every streamed item event | D | `codex-app-server-driver.events.test.ts:353` | identical body/table under cwd alias |
| 1264: captures an exact skillless model/environment snapshot with credentials absent | D | `codex-app-server-driver.context-control.test.ts:82` | identical body/table under cwd alias |
| 1356: pins approval policy %s for both thread start and resume | D | `codex-app-server-driver.context-control.test.ts:174` | identical body/table under cwd alias |
| 1383: requires trusted continuation metadata before omitting task context (%s) | C move | `codex-app-server-driver.context-control.test.ts:692` | identical body/table under cwd alias |
| 1402: allows eval fixtures to opt out of Codex collaboration instructions | D | `codex-app-server-driver.context-control.test.ts:201` | identical body/table under cwd alias |
| 1423: negotiates genuine plan mode with collaboration instructions and read-only workspace access | D | `codex-app-server-driver.context-control.test.ts:222` | identical body/table under cwd alias |
| 1466: sends the selected reasoning effort with the Codex turn | C move | `codex-app-server-driver.context-control.test.ts:711` | identical body/table under cwd alias |
| 1478: fails closed when the installed app-server does not confirm plan mode | D | `codex-app-server-driver.context-control.test.ts:265` | identical body/table under cwd alias |
| 1492: refuses to turn host credential roots into model-writable workspaces | C stronger keeper | `codex-app-server-driver.context-control.test.ts:279` | explicit superset/migration above |
| 1528: uses negotiated ACP controls through the Codex transport facade | C move | `codex-app-server-driver.context-control.test.ts:723` | identical body/table under cwd alias |
| 1549: keeps native follow-up disabled for the Codex app-server driver | C move | `codex-app-server-driver.context-control.test.ts:744` | identical body/table under cwd alias |
| 1560: steers and interrupts an active turn without replacing the session | D | `codex-app-server-driver.context-control.test.ts:315` | identical body/table under cwd alias |
| 1590: loads the deterministic Live console wire fixture | D | `codex-app-server-driver.context-control.test.ts:345` | identical body/table under cwd alias |
| 1612: holds browser-resolved upstream requests and returns the exact fixture responses | D | `codex-app-server-driver.context-control.test.ts:367` | identical body/table under cwd alias |
| 1673: acknowledges same-turn steering and rejects stale or child steering | D | `codex-app-server-driver.context-control.test.ts:428` | identical body/table under cwd alias |
| 1792: queues an interrupt before turn identity and reports a terminal race precisely | D | `codex-app-server-driver.context-control.test.ts:547` | identical body/table under cwd alias |
| 1827: maps all goal operations and advertises an exact unsupported state | C stronger keeper | `codex-app-server-driver.context-control.test.ts:582` | explicit superset/migration above |
| 1907: keeps goal support advertised when the probe fails transiently | D | `codex-app-server-driver.context-control.test.ts:662` | identical body/table under cwd alias |
| 1937: preserves an inactive goal status while editing its objective | C move | `codex-app-server-driver.context-control.test.ts:755` | identical body/table under cwd alias |
| 1964: rolls back only a definitely rejected idle goal autostart | C move | `codex-app-server-driver.context-control.test.ts:782` | identical body/table under cwd alias |
| 2014: preserves a provider-neutral goal action subset through the Codex transport facade | C move | `codex-app-server-driver.context-control.test.ts:832` | identical body/table under cwd alias |
| 2059: validates runtime request resolutions against the kind of request they answer | D | `codex-app-server-driver.runtime-requests.test.ts:47` | identical body/table under cwd alias |
| 2176: replays the DOT-185 requestUserInput shape as one canonical three-question request | D | `codex-app-server-driver.runtime-requests.test.ts:164` | identical body/table under cwd alias |
| 2282: rejects an explicit malformed Codex form without falling back to v1 | D | `codex-app-server-driver.runtime-requests.test.ts:317` | identical body/table under cwd alias |
| 2324: emits one canonical outcome payload for every terminal runtime request fact | D | `codex-app-server-driver.runtime-requests.test.ts:359` | identical body/table under cwd alias |
| 2410: expires a canonical input with its full question set when the provider transport is lost | D | `codex-app-server-driver.runtime-requests.test.ts:445` | identical body/table under cwd alias |
| 2469: hands a live canonical input off exactly once when the durable window expires | D | `codex-app-server-driver.runtime-requests.test.ts:504` | identical body/table under cwd alias |
| 2551: exposes and dispatches only explicit chat tools across fresh and resumed direct chat | C move | `codex-app-server-driver.semantic-results.test.ts:866` | identical body/table under cwd alias |
| 2683: lets an answer claimed before expiry win the terminal-event race | D | `codex-app-server-driver.runtime-requests.test.ts:644` | identical body/table under cwd alias |
| 2758: redacts browser-visible request details and diagnostics from the fixture markers | D | `codex-app-server-driver.runtime-requests.test.ts:719` | identical body/table under cwd alias |
| 2796: makes duplicate semantic completion idempotent and rejects changed payloads | D | `codex-app-server-driver.semantic-results.test.ts:151` | identical body/table under cwd alias |
| 2873: accepts a normalized runnerd echo of a tool-committed semantic result | D | `codex-app-server-driver.semantic-results.test.ts:226` | identical body/table under cwd alias |
| 2928: advertises and dispatches run-authorized control-plane tools | D | `codex-app-server-driver.semantic-results.test.ts:288` | identical body/table under cwd alias |
| 2988: preserves the dynamic tool outcome boundary (%s) | C move | `codex-app-server-driver.semantic-results.test.ts:998` | identical body/table under cwd alias |
| 3011: fails closed when an agent message changes a tool-committed result | D | `codex-app-server-driver.semantic-results.test.ts:348` | identical body/table under cwd alias |
| 3058: rejects a tool result that changes an agent-message commitment | D | `codex-app-server-driver.semantic-results.test.ts:450` | identical body/table under cwd alias |
| 3109: treats a canonically identical cross-channel result as a no-op | D | `codex-app-server-driver.semantic-results.test.ts:501` | identical body/table under cwd alias |
| 3153: fails closed when a live terminal embeds a changed semantic result | D | `codex-app-server-driver.semantic-results.test.ts:545` | identical body/table under cwd alias |
| 3205: rejects a semantic result for $label without committing it | D | `codex-app-server-driver.semantic-results.test.ts:597` | identical body/table under cwd alias |
| 3244: rejects pre-turn, cross-thread, and post-terminal notifications | D | `codex-app-server-driver.semantic-results.test.ts:636` | identical body/table under cwd alias |
| 3328: rejects duplicate and conflicting terminal facts | D | `codex-app-server-driver.semantic-results.test.ts:720` | identical body/table under cwd alias |
| 3362: rejects oversized semantic results without retaining provider payloads | D | `codex-app-server-driver.semantic-results.test.ts:754` | identical body/table under cwd alias |
| 3404: normalizes finish and block tools into one canonical result contract | D | `codex-app-server-driver.semantic-results.test.ts:796` | identical body/table under cwd alias |
| 3475: resumes and reconciles the exact provider thread after transport loss | D | `codex-app-server-driver.recovery.test.ts:297` | identical body/table under cwd alias |
| 3507: ignores an old terminal turn when the persisted active turn is still running | D | `codex-app-server-driver.recovery.test.ts:365` | identical body/table under cwd alias |
| 3548: preserves proposal idempotency and rejects a changed result after transport loss | D | `codex-app-server-driver.recovery.test.ts:406` | identical body/table under cwd alias |
| 3632: rejects changed terminal result content discovered during recovery | D | `codex-app-server-driver.recovery.test.ts:490` | identical body/table under cwd alias |
| 3703: does not re-emit an already observed terminal after recovery | D | `codex-app-server-driver.recovery.test.ts:561` | identical body/table under cwd alias |
| 3758: fails recovery explicitly when the persisted active turn is missing or replaced | D | `codex-app-server-driver.recovery.test.ts:1186` | identical body/table under cwd alias |
| 3799: reconciles a turn completed during transport loss without replacing either session identity | D | `codex-app-server-driver.recovery.test.ts:1227` | identical body/table under cwd alias |
| 3877: degrades unsupported operations with explicit redacted diagnostics | D | `codex-app-server-driver.conformance.test.ts:47` | identical body/table under cwd alias |
| 3912: rejects proposals that do not match the exact task envelope | D | `codex-app-server-driver.conformance.test.ts:82` | identical body/table under cwd alias |
| 3947: keeps a completed runtime successful for an accepted %s advisory proposal | D | `codex-app-server-driver.conformance.test.ts:117` | identical body/table under cwd alias |
| 4001: records rejected and missing proposals as recovery evidence instead of review status | C stronger keeper | `codex-app-server-driver.conformance.test.ts:171` | explicit superset/migration above |
| 4033: preserves four independent stable identities under controller ownership | D | `codex-app-server-driver.conformance.test.ts:210` | identical body/table under cwd alias |
| 4054: validates persisted identity, uniqueness, terminals, and line bounds before replay | D | `codex-app-server-driver.conformance.test.ts:231` | identical body/table under cwd alias |
| 4103: degrades declared unsupported capabilities without Codex-specific core branches | D | `codex-app-server-driver.conformance.test.ts:280` | identical body/table under cwd alias |
## Aggressive follow-up
The follow-up baseline is `3951d69f9b`, which contains the small first pass.
`origin/master` was refreshed and remained at `fb21a3f30a`. Three read-only
auditors examined adapter lifecycle, native execution, and UI ownership.
Proposals were prepared outside the checkout while baseline tests ran, then
applied with original-source SHA-256 guards.
| Owner batch | Net test/support lines removed | Production lines removed | Net registered cases removed |
| --- | ---: | ---: | ---: |
| ACPX characterization layer | 3,283 | 0 | 44 |
| Chat database read fixtures | 1,612 | 0 | 0 |
| Native executor, resume, and runner API | 802 | 0 | 943 |
| UI fixture consolidation and obsolete compatibility | 698 | 41 | 7 |
| Exact workspace/Daytona duplicate cases | 137 | 0 | 2 |
| Initial pass | 146 | 0 | 7 |
| **Total** | **6,678** | **41** | **1,003** |
These figures exclude documentation. Savings are measured from the Git diff,
not formatting estimates. The case counts include migrated/table-expanded
keepers, not just deleted declarations. No wall-time speedup is attributed to
the change: baseline and candidate runs had different cache and machine load.
### Retired layers and their keepers
- ACPX: remove startup, turn, settlement, and composed-run characterization
suites (3,643 gross lines). They copied the engine harness for #11461's
refactor; #11576 completed the coordinator extraction. The current
`execute.test.ts`, fault matrix, sequence, cancellation, session-store, and
workspace teardown suites own their remaining proof. Unique Stop-phase
regressions from #14990 remain, with actual callback/gate observations.
Preserve bridge credential isolation, current prompt/turn inputs, fingerprint
dimensions, retry order, close/cancel policy, bridge-stop before sync-back,
lease release, and native restore/last-finalize assertions in those keepers.
- Chat: replace 607 repeated real database query chains with 22 typed,
file-local query owners. Every table, column, parameter, caller await,
ordering, limit, polling loop, action, and assertion remains. Independent
expansion of the helpers produces a normalized AST identical to the entire
original program. All 1,237 integration cases execute before and after.
- Native: consolidate repeated scoped-runtime fixtures while retaining every
executor declaration and parameter row, including 1,010 complete assertion
chains. Replace six copied historical catalog constructors with independently
checked immutable digests in the existing resume table. Restore the valid
context before testing changed agent identity so that the negative cannot
pass through an earlier, unrelated instruction mismatch.
- Runner API: remove the second identical REST-route matrix. Baseline
collection executes 943 rows twice; the keeper executes each same row once.
The full file changes from 1,994 to 1,051 passing cases. No catalog entries
or public route/actor/response contract are removed.
- UI: remove five exact duplicate cases and two tests of an internal legacy
workspace API with no production callers or published source export. Both
shipped workspace pages use the retained `sections` interface. Consolidate
Secrets rendering, fresh Daytona capabilities, and four literal runtime
status rows without changing their actions, waits, or outcomes. Two AWS
error negatives now actually receive the sensitive provider details they
assert are absent.
- Workspace/Daytona: remove a second byte-equivalent worktree dependency case
and an overlapping-execute case whose body duplicated the retained lease
tombstone test. All other provisioning and teardown guards remain.
### Preservation review and final proof
Independent reviewers checked each of the four large owner proposals against
its original tests and production callers. Review caught and repaired lost
existing-file overwrite proof and a prompt guard that could accept
`String(undefined)`. Both fingerprints in the dimension matrix and the exact
session-key keeper now also require nonempty strings. No remaining preservation
blocker was found.
Three additional production mutations were caught at assertions: dropping the
runtime prompt, skipping existing-file writes during native restore, and
spreading unsafe secret-provider details into the UI. Every production file was
restored byte for byte. The two AWS negative cases fail on the unsafe-details
mutation. All four focused controls pass after restoration. The earlier HTTP
mutation proof remains below.
- Complete affected owners and siblings: **22 files, 2,817 cases pass**.
- Complete chat integration: **1,237 cases pass**, both baseline and candidate.
- ACPX baseline: 335 cases pass; native/UI baseline: 939 pass; runner API
baseline: 1,994 pass. Additional workspace/Daytona/UI baseline records 508
passes and 10 pending cases with a failed workspace file but no failed case.
That baseline inconsistency is not claimed repaired; the complete retained
workspace file subsequently passes all 175 cases with built prerequisites.
- `pnpm -r typecheck`, `pnpm build`, token gates, and diff whitespace checks pass.
- The full default repository suite remains unverified. The completed tests
above are full files at the affected owners; they are not a claim that all
repository tests ran. A full default run remains required for a PR-ready
handoff.
## Initial pass evidence
### Scope and findings
This is a repository inventory and a small evidence-backed cleanup, not a review
of every test. The baseline has 2,816 tracked `*.test.*` / `*.spec.*` files:
1,038 server, 725 UI, 697 packages, 216 test harnesses, 66 CLI, 50 scripts,
21 GitHub tooling, and 3 skill tooling files. These include opt-in suites;
the count does not describe the default Vitest run alone.
A text scan found 382 files that combine file reads with containment/matching
assertions: 156 packages, 133 server, 42 harnesses, 18 UI, 13 CLI, 13 scripts,
6 GitHub tooling, and 1 skill tooling file. The scan also matches fixture reads
and executable checks. **382 is a review queue, not a deletion estimate.**
The useful question is which independent regression a test catches that the
retained owner proof misses. File reads, snapshots, mocks, and slow tests are
not sufficient reasons to remove a test.
### Applied decisions
| Candidate | Decision and retained proof |
| --- | --- |
| `server/src/__tests__/issue-interactions-read-only-contract.test.ts`: route source check | Remove the file after adding terminal-expiry and activity-write assertions to the existing real HTTP read-path test in `issue-thread-interaction-routes.test.ts`. Check both `in_review` and `done`, the JSON response, listing, and absence of recovery. |
| Same file: two whitespace rows, three forbidden-name rows, two missing-boundary rows | Remove all seven synthetic cases. They test a local regex/assertion helper against hand-written route strings, not production behavior. Removing the helper removes their purpose. |
| Retry cases in `ui/src/api/chatEndpoints.test.ts` and `agents.test.ts` | Consolidate under `agents.test.ts`, the production API owner. Preserve the exact company-scoped request, ordinary and durable receipts, distinct stopped-successor errors, deferred dispatch, legacy receipts, both skipped-wake forms, and single-dispatch checks. |
| Slack manifest replay in `ui/src/pages/apps/chat/chat-ui-contract.test.ts` | Remove duplicate scope/event assertions already pinned by the shared owner snapshots. Move the unique agent-view, bot presence, and app-home assertions to the existing shared manifest test. Keep the UI's manifest integration checks. |
| Same UI file: sanitizer implementation substrings | Remove the checks for `replaceAll` and `encodeURIComponent`. Keep the UI integration assertions and `chat-setup-error.test.ts`, which exercises raw, escaped, encoded, and malformed UTF-16 secrets. |
| Same UI file: v8 wireframe generator/import strings | Remove the design-plan implementation checks. Keep the shipped GitHub setup assertions, wizard behavior suite, and PEM file/late-read tests. Design generator version names are not a product contract. |
The source-only route test originated in #10414; its synthetic parser cases
arrived in #13038, alongside the real HTTP test. The UI retry coverage came from
#13038 and #13765. The current chat source-check owner was last changed in
#15416. None of the removed support code has a production caller.
Change size before this report: **146 net test lines removed**, one test file
retired, seven fewer executed cases across the affected route suites, and zero
production lines changed. No runtime speedup is claimed: cold transforms,
prerequisite builds, and a baseline timeout prevent a fair wall-time comparison.
### Preservation evidence
- UI baseline: 48 cases pass across the two API files, chat UI source contract,
chooser routing, and setup-error suite. The shared manifest has four cases.
- After cleanup: those six UI/shared suites pass, 52 cases total.
- Route baseline: the eight source-check cases pass; 84 of 85 HTTP cases pass.
The unchanged standard-work plan-confirmation case times out at 15 seconds
during the full file run. Isolating it with the original read-path case passes
both. This does not establish a flake repair; its test and timeout remain intact.
- Three temporary mutations add historical expiry, terminal expiry, or an
activity write to the production GET handler. Each makes the retained HTTP
test fail at its side-effect assertion. Production source is restored byte
for byte after every mutation, including failure cleanup.
- UI token gates and `git diff --check` pass.
- Final complete HTTP route file: all 86 cases pass. Together with the 52
UI/shared cases, focused validation passes 138 cases.
- `pnpm -r typecheck` and `pnpm build` pass.
- `pnpm test:run` was started, then interrupted after about five minutes while
still running its first general-server file, the 75,178-line chat integration
suite. It exited 130; there is no full-suite pass or failure conclusion.
The default runner has 158 separately serialized suites after its general
lanes. A complete run remains a prerequisite for claiming a PR-ready handoff.
### Next review boundaries
1. Remaining `chat-ui-contract.test.ts` assertions: exact retry call syntax,
tabs, lifecycle handlers, and provider fields. Replace them only after
rendered tests exercise the selected run, navigation, error, or credential
outcome. Provider guidance and permission bytes need independent proof.
2. Component-only helper exports: `shouldRenderComposerHandoffPreview`,
`replaceResolvedInteraction`, and model-transition helpers. Their direct
tests currently protect distinct behavior. The existing rendered suites do
not cover every assertion, so deletion is not yet justified.
3. Server source guards: compare each with its route/service owner before
pruning. Authentication ordering and company boundaries deserve particular
care; a source guard can protect routes missing a behavioral negative case.
Retain protocol-schema cross-checks, package/build metadata, migration replay,
dependency singleton checks, provider permission snapshots, and theme contrast
tests. They compare independent contracts or outcomes. The telemetry README
guard is also retained; changing that path requires its own strict review.
For another batch, pin a fresh baseline, assign one production boundary,
record every deletion's keeper first, and prove the keeper catches the relevant
mutation. Do not optimize for test counts or remove baseline failures for green.
+1 -1
View File
@@ -148,7 +148,7 @@ Real provider and model checks follow [Task reviews](../doc/connections/TASK-REV
### `skill-test`
Automated: [read-only interaction contract](../server/src/__tests__/issue-interactions-read-only-contract.test.ts)
Automated: [read-only interaction contract](../server/src/__tests__/issue-thread-interaction-routes.test.ts)
checks that fetching interactions does not mutate them. Shared card suites cover
rendering. Neither proves the complete Skill Studio host or its permission matrix.
@@ -0,0 +1,22 @@
# ACPX lifecycle test ownership
The engine's primary behavioral boundary is `createAcpxEngineExecutor` in
`execute.test.ts`. `run-fault-matrix.test.ts` owns resource acquisition/failure
outcomes. Sequence, cancellation, session-store, and workspace restore tests
retain their separate ordering and persistence contracts.
- Extend the existing harness or literal case table before creating another
full executor/bridge/sandbox mock graph. Temporary characterization suites
must have a cutover plan; migrate their unique assertions when the refactor
finishes rather than keeping both layers indefinitely.
- A provider adapter suite needs a distinct provider configuration, credential,
prompt, parser, or transport contract. Do not replay shared lifecycle proof
merely under different adapter names.
- Verify actual close, cancel, bridge-stop, restore, and lease-release callbacks
when guarding physical cleanup. Ledger dispositions alone do not establish
that the cleanup ran or that ordering is correct.
- Keep stalled-phase/Stop tests at the live engine boundary. Pure sequence
tests cannot replace callback wiring while real awaits are held open.
- Before retiring a layer, map every unique assertion to a keeper, obtain an
independent preservation review, and use focused mutations for repaired gaps.
Never remove a baseline failure to make the suite green.
@@ -1,876 +0,0 @@
import fs 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 { AcpRuntimeOptions } from "acpx/runtime";
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
import {
prepareAdapterExecutionTargetRuntime,
startAdapterExecutionTargetPaperclipBridge,
startAdapterExecutionTargetProcessSessionBridge,
} from "@paperclipai/adapter-utils/execution-target";
// This file is a characterization test. It pins the engine boundary's CURRENT
// behavior; it never changes production code. Each test states the observed
// contract of `executeAcpxEngine` as data, so a later refactor that alters an
// exit path's result form, its finalization set, or its warm-lane decision
// fails here.
//
// Adapter-boundary automatic CLI fallback note (File 1b): the codex, claude, and
// gemini adapters wrap this engine and fall back to their CLI lane when an
// AUTOMATIC ACP selection fails, but RETHROW when ACP was EXPLICITLY selected.
// Those three adapters live in higher packages that depend on this one, so an
// adapter-utils test cannot import them (a reverse dependency does not exist in
// package.json). That branch already has REAL coverage in each adapter package:
// - packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts
// - packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts
// - packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts
// and each adapter's `normalizeEngine` / `resolveXxxExecutionEngineForRun`
// classification (explicit acp/cli vs automatic acp) is pinned in each
// package's acp.test.ts. This file pins the engine-level exit paths those
// adapters wrap.
// Wrap the staging seam + both sandbox bridges in call-recording spies that
// still delegate to the real implementations (a runner-backed sandbox test
// exercises them end-to-end against a local runner). This lets the staging
// tests assert the exact `runtimeRootDir`/`workspaceLocalDir`/`assets` the
// engine threads without changing any real behavior for the other tests.
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
return {
...actual,
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
};
});
import {
createAcpxEngineExecutor,
type AcpxEngineExecutorOptions,
} from "./execute.js";
import { runChildProcess } from "../server-utils.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
// A remote run stages a process-session bridge whose detached event writer can
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
// when the run's own best-effort `client.remove(sessionDir)` (which production
// catch-wraps) has already returned. Under CI load that trailing write can land
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
// same way production tolerates it, instead of failing the just-passed test.
await Promise.all(
tempRoots.splice(0).map((root) =>
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
),
);
});
function createLocalSandboxRunner(
onExecute?: (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
}) => void,
) {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
onExecute?.(input);
const command = input.command === "bash" ? "/bin/bash" : input.command;
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
onSpawn: input.onSpawn
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
: undefined,
});
},
};
}
function buildRuntime(
onSetConfigOption?: (input: { key: string; value: string }) => void,
onEnsureSession?: (input: Record<string, unknown>) => void,
) {
return {
ensureSession: async (input: Record<string, unknown>) => {
onEnsureSession?.(input);
return ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
});
},
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
setConfigOption: async (input: { key: string; value: string }) => {
onSetConfigOption?.(input);
},
close: async () => {},
};
}
async function runExecutor(
config: Record<string, unknown>,
options: {
context?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
authToken?: string;
executionTarget?: Record<string, unknown>;
runtimeMcp?: AdapterRuntimeMcpAccess;
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
} = {},
) {
const runtimeOptions: Record<string, unknown>[] = [];
const configOptions: Array<{ key: string; value: string }> = [];
const sessionInputs: Record<string, unknown>[] = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
...(options.prepareRemoteManagedHome
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
: {}),
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
return buildRuntime(
({ key, value }) => configOptions.push({ key, value }),
(input) => sessionInputs.push(input),
) as never;
},
});
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config,
context: options.context ?? {},
executionTransport: options.executionTransport,
authToken: options.authToken,
executionTarget: options.executionTarget,
runtimeMcp: options.runtimeMcp,
startupTraceContext: options.startupTraceContext,
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async (payload: unknown) => {
meta.push(payload as Record<string, unknown>);
},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
events.push(event);
},
} as never);
expect(result.exitCode).toBe(0);
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
}
// A remote sandbox setup that stages the host worktree through the real local
// runner, so a run reaches the post-build window with live bridges and a held
// staging lease.
async function setupRemoteSandbox() {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
const executionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
};
return { root, stateDir, localCwd, remoteCwd, executionTarget };
}
const okHandle = {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
function completedTurn() {
return {
events: (async function* () {})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
}
function throwingTurn() {
return {
events: (async function* () {
throw new Error("turn upstream boom");
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
}
// A context whose only prompt-read field throws when read, so the prompt build
// fails after the session handshake succeeds and before the turn starts.
function throwingHandoffContext(): Record<string, unknown> {
const context: Record<string, unknown> = {};
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
enumerable: false,
get() {
throw new Error("prompt build boom");
},
});
return context;
}
// Stub both sandbox bridges with stop spies collected per start, so a test can
// assert the bridges stopped without running the real bridge transport.
function stubBridges() {
const paperclipStops: Array<ReturnType<typeof vi.fn>> = [];
const processStops: Array<ReturnType<typeof vi.fn>> = [];
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
const stop = vi.fn(async () => {});
paperclipStops.push(stop);
return { env: {}, stop } as never;
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
const stop = vi.fn(async () => {});
processStops.push(stop);
return { agentCommand: null, stop } as never;
});
const anyStopped = (stops: Array<ReturnType<typeof vi.fn>>) =>
stops.some((stop) => stop.mock.calls.length > 0);
const stoppedCount = (stops: Array<ReturnType<typeof vi.fn>>) =>
stops.filter((stop) => stop.mock.calls.length > 0).length;
return { paperclipStops, processStops, anyStopped, stoppedCount };
}
function remoteArgs(
stateDir: string,
localCwd: string,
executionTarget: unknown,
overrides: Record<string, unknown> = {},
) {
return {
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
context: {},
authToken: "real-run-jwt",
executionTarget,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
...overrides,
};
}
describe("composed ACPX run: engine-boundary result form per exit path", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns (never throws) a create_runtime error result when the post-build runtime construction fails", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () => {
throw new Error("createRuntime boom");
},
});
// The engine RETURNS a settled error result on this path; it does not throw.
const result = await execute({
runId: "boundary-create-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.signal).toBe(null);
expect(result.timedOut).toBe(false);
expect(result.resultJson?.phase).toBe("create_runtime");
expect(result.summary).toContain("createRuntime boom");
});
it("returns an ensure_session error result when the handshake fails", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => {
throw new Error("ensureSession boom");
},
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-ensure-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
});
it("returns a runtime-error result with no clearSession and no errorMeta when ensureSession yields no handle", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
// A runtime that returns no session handle drives the missing-handle path.
ensureSession: async () => undefined,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-missing-handle",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
expect(result.errorCode).toBe("acpx_runtime_error");
// The missing-handle result carries neither clearSession nor errorMeta, unlike
// the other pre-turn error paths.
expect(result.clearSession).toBeUndefined();
expect(result.errorMeta).toBeUndefined();
});
it("returns a configure_session error result that carries the requested session fields", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
// A custom agent with a requested model applies a session config option;
// a throwing setConfigOption drives the configure_session failure path.
setConfigOption: async () => {
throw new Error("setConfigOption boom");
},
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-configure-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
model: "custom-model-x",
stateDir: path.join(root, "state"),
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("configure_session");
// The configure_session result echoes the requested session identity fields.
expect(result.resultJson).toHaveProperty("agent");
expect(result.resultJson?.requestedModel).toBe("custom-model-x");
expect(result.resultJson).toHaveProperty("requestedThinkingEffort");
expect(result.resultJson).toHaveProperty("fastMode");
});
it("returns a prepare_turn error result when the prompt build fails before the turn starts", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-prepare-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: throwingHandoffContext(),
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("prepare_turn");
});
it("returns a turn error result with acpx_turn_failed when the running turn fails", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => throwingTurn(),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "boundary-turn-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("turn");
expect(result.errorCode).toBe("acpx_turn_failed");
});
it("returns the terminal turn-success shape (the one non-error-shaped return)", async () => {
const root = await makeTempRoot();
const execute = createAcpxEngineExecutor({
createRuntime: () => buildRuntime() as never,
});
const result = await execute({
runId: "boundary-success",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
// A completed, non-timed-out turn returns exitCode 0, no signal, no errorCode,
// and the session identity fields, with resultJson.status = "completed".
expect(result.exitCode).toBe(0);
expect(result.signal).toBe(null);
expect(result.timedOut).toBe(false);
expect(result.errorCode).toBe(null);
expect(result.resultJson?.status).toBe("completed");
expect(result.sessionId).toBe("backend-session");
expect(result.sessionDisplayId).toBe("agent-session");
expect(result.sessionParams).toBeTruthy();
});
it("THROWS (does not return) when buildRuntime fails before it settles", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
// A staging failure throws inside buildRuntime before it settles, so the engine
// rethrows through the `if (!buildRuntimeSettled) throw err` guard. This is one
// of exactly two engine-boundary throw paths.
vi.mocked(prepareAdapterExecutionTargetRuntime).mockImplementationOnce(async () => {
throw new Error("staging boom");
});
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => buildRuntime() as never,
});
await expect(
execute({
runId: "boundary-build-throw",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never),
).rejects.toThrow("staging boom");
});
it("THROWS (does not return) on a partial bridge failure and stops the started sibling bridge once", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
// The paperclip bridge fails while the concurrently-started process-session
// bridge resolves a live handle. This is the second engine-boundary throw path;
// the abandon path must stop the started sibling so no bridge leaks.
const stop = vi.fn(async () => {});
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
throw new Error("paperclip bridge boom");
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
async () => ({ agentCommand: null, stop }) as never,
);
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => buildRuntime() as never,
});
await expect(
execute({
runId: "boundary-bridge-throw",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never),
).rejects.toThrow("paperclip bridge boom");
expect(stop).toHaveBeenCalledTimes(1);
});
});
describe("composed ACPX run: finalization set fires exactly once per exit path", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("stops both bridges once and releases the staging lease on every exit path", async () => {
const scenarios: Array<{
name: string;
createRuntime: AcpxEngineExecutorOptions["createRuntime"];
config?: Record<string, unknown>;
context?: Record<string, unknown>;
}> = [
{
name: "create_runtime",
createRuntime: () => {
throw new Error("createRuntime boom");
},
},
{
name: "ensure_session",
createRuntime: () =>
({
ensureSession: async () => {
throw new Error("ensure boom");
},
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
},
{
name: "configure_session",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
setConfigOption: async () => {
throw new Error("config boom");
},
close: async () => {},
}) as never,
config: { agent: "custom", agentCommand: "node ./fake-acp.js", model: "custom-model-x" },
},
{
name: "prepare_turn",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
context: throwingHandoffContext(),
},
{
name: "turn",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => throwingTurn(),
close: async () => {},
}) as never,
},
{
name: "success",
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
close: async () => {},
}) as never,
},
];
for (const scenario of scenarios) {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const { paperclipStops, processStops, stoppedCount } = stubBridges();
const stagingLocks = new Map<string, Promise<unknown>>();
const execute = createAcpxEngineExecutor({
stagingLocks,
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: scenario.createRuntime,
});
const overrides: Record<string, unknown> = {};
if (scenario.config) {
overrides.config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
cwd: localCwd,
...scenario.config,
};
}
if (scenario.context) overrides.context = scenario.context;
await execute({
runId: `finalize-${scenario.name}`,
...remoteArgs(stateDir, localCwd, executionTarget, overrides),
} as never).catch(() => {});
// The finalization set fires exactly once: each bridge stops once and the
// per-session staging lease releases, so the lock map never strands the next
// same-session run.
expect(stoppedCount(paperclipStops), `paperclip bridge must stop once on ${scenario.name}`).toBe(1);
expect(stoppedCount(processStops), `process-session bridge must stop once on ${scenario.name}`).toBe(1);
expect(stagingLocks.size, `lease must release on ${scenario.name}`).toBe(0);
}
});
it("does not re-run the turn teardown when the result mapping throws after the close", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const { paperclipStops, processStops, stoppedCount } = stubBridges();
let closeCount = 0;
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
// A completed turn whose result mapping throws after the close is read.
result: Promise.resolve({
status: "completed",
get stopReason(): string {
throw new Error("mapping boom");
},
}),
cancel: async () => {},
}),
close: async () => {
closeCount += 1;
},
}) as never,
});
await execute({
runId: "finalize-map-throw",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never).catch(() => {});
// The completed turn closed the runtime once; the mapping throw did not re-run
// the teardown through the turn catch.
expect(closeCount).toBe(1);
expect(stoppedCount(paperclipStops)).toBe(1);
expect(stoppedCount(processStops)).toBe(1);
});
});
describe("composed ACPX run: host-lane warm handle set", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("closes and relaunches a persistent local runtime, so the next compatible run re-creates it", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let createCount = 0;
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => {
createCount += 1;
return buildRuntime() as never;
},
});
const config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
mode: "persistent",
warmHandleIdleMs: 60_000,
};
const first = await execute({
runId: "warm-save-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(first.exitCode).toBe(0);
// Amendment B: a persistent local completed turn closes and relaunches the
// runtime instead of warm-saving it (the run-minted API key is never revoked).
expect(warmHandles.size).toBe(0);
expect(createCount).toBe(1);
const second = await execute({
runId: "warm-save-2",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: first.sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(second.exitCode).toBe(0);
// No warm runtime survived, so the compatible second run re-creates it.
expect(createCount).toBe(2);
});
it("closes (does not warm-save) a completed non-persistent runtime, so the next run re-creates", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let createCount = 0;
let closeCount = 0;
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => {
createCount += 1;
return {
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
setConfigOption: async () => {},
close: async () => {
closeCount += 1;
},
} as never;
},
});
// No persistent mode, so the completed turn closes the runtime instead of
// warm-saving it.
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir };
const first = await execute({
runId: "warm-none-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(first.exitCode).toBe(0);
expect(warmHandles.size).toBe(0);
expect(closeCount).toBe(1);
const second = await execute({
runId: "warm-none-2",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: first.sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
expect(second.exitCode).toBe(0);
// No warm handle survived, so the second run constructs a fresh runtime.
expect(createCount).toBe(2);
});
it("runs a fresh acp.handshake on the second run and re-creates the runtime (no warm reuse)", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let createCount = 0;
const warmHandles = new Map();
const secondEvents: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => {
createCount += 1;
return buildRuntime() as never;
},
});
const config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
mode: "persistent",
warmHandleIdleMs: 60_000,
};
const first = await execute({
runId: "warm-hit-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
await execute({
runId: "warm-hit-2",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
secondEvents.push(event);
},
} as never);
// Amendment B: the first run closed and relaunched, so no warm handle survives.
// The second run runs a fresh acp.handshake (outcome ok, not skipped) and
// re-creates the runtime.
const handshakeEvents = secondEvents.filter(
(event) => event.eventType === "run.startup.step" && event.payload?.step === "acp.handshake",
);
expect(handshakeEvents).toHaveLength(1);
expect(handshakeEvents[0]!.payload?.outcome).not.toBe("skipped");
expect(createCount).toBe(2);
});
});
@@ -392,6 +392,54 @@ const ALLOWED_TURN_SPAN_ATTRIBUTE_KEYS = new Set<string>([
]);
describe("shared ACPX engine runtime behavior", () => {
it("passes the current prompt, session, cancellation and diagnostic inputs to the runtime turn", async () => {
const root = await makeTempRoot();
const { result, turnInputs, meta } = await runExecutor({
agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"),
cwd: root, timeoutSec: 120,
});
expect(turnInputs).toHaveLength(1);
expect(turnInputs[0]).toMatchObject({
handle: { backendSessionId: "backend-session", agentSessionId: "agent-session", runtimeSessionName: "runtime-session" },
text: meta[0]?.prompt, mode: "prompt", requestId: "run-1", timeoutMs: 120_000,
onTerminalSessionFailure: expect.any(Function),
});
expect(meta[0]?.prompt).toBeTypeOf("string");
expect(turnInputs[0]?.text).toBeTypeOf("string");
expect((meta[0]?.prompt as string).length).toBeGreaterThan(0);
expect((turnInputs[0]?.text as string).length).toBeGreaterThan(0);
expect(turnInputs[0]?.signal).toBeInstanceOf(AbortSignal);
expect((turnInputs[0]?.signal as AbortSignal).aborted).toBe(false);
expect(result.summary).toBe("end_turn");
expect(result.sessionParams?.configFingerprint).toBeTypeOf("string");
expect((result.sessionParams?.configFingerprint as string).length).toBeGreaterThan(0);
expect(result.sessionParams?.sessionKey).toBe(`paperclip:company-1:agent-1:default:${result.sessionParams?.configFingerprint}`);
});
it.each(["agentCommand", "cwd", "model", "thinkingEffort", "mode", "fastMode"] as const)(
"folds configured %s into the engine session fingerprint",
async (dimension) => {
const root = await makeTempRoot();
const config = {
agent: dimension === "fastMode" ? "codex" : "custom", agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"), cwd: root,
env: { CODEX_HOME: path.join(root, "codex-home") },
paperclipRuntimeSkills: [], paperclipSkillSync: { desiredSkills: [] },
};
const changed: Record<string, unknown> = {
agentCommand: "node ./other-acp.js", cwd: path.join(root, "other-workspace"), model: "test-model",
thinkingEffort: "high", mode: "oneshot", fastMode: true,
};
const first = await runExecutor(config);
const second = await runExecutor({ ...config, [dimension]: changed[dimension] });
expect(first.result.sessionParams?.configFingerprint).toBeTypeOf("string");
expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string");
expect((first.result.sessionParams?.configFingerprint as string).length).toBeGreaterThan(0);
expect((second.result.sessionParams?.configFingerprint as string).length).toBeGreaterThan(0);
expect(second.result.sessionParams?.configFingerprint).not.toBe(first.result.sessionParams?.configFingerprint);
},
);
it("integrates the env-free store while delivering current credentials and options to the provider", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@@ -1002,10 +1050,11 @@ describe("shared ACPX engine runtime behavior", () => {
events: (async function* () {
yield {
type: "text_delta",
text: "streamed hello",
text: " streamed ",
stream: "output",
tag: "agent_message_chunk",
};
yield { type: "text_delta", text: "hello ", stream: "output", tag: "agent_message_chunk" };
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
@@ -1031,15 +1080,13 @@ describe("shared ACPX engine runtime behavior", () => {
} as never);
expect(result.exitCode).toBe(0);
expect(logs).toContainEqual({
stream: "stdout",
text: `${JSON.stringify({
type: "acpx.text_delta",
text: "streamed hello",
channel: "output",
tag: "agent_message_chunk",
})}\n`,
});
expect(result.summary).toBe("streamed hello");
for (const text of [" streamed ", "hello "]) {
expect(logs).toContainEqual({
stream: "stdout",
text: `${JSON.stringify({ type: "acpx.text_delta", text, channel: "output", tag: "agent_message_chunk" })}\n`,
});
}
});
it("defaults run summaries to the final output segment without thought text", async () => {
@@ -1619,6 +1666,11 @@ describe("shared ACPX engine runtime behavior", () => {
} as never);
expect(result.exitCode).not.toBe(0);
if (status === "failed") {
expect(result.errorCode).toBe("acpx_turn_failed");
expect(result.summary).toBe("fixture failure");
expect(result.resultJson?.stopReason).toBe("fixture failure");
}
expect(result.usageComplete).toBe(false);
expect(onUsage).toHaveBeenLastCalledWith(expect.objectContaining({ complete: false, costUsd: 0.31 }));
expect(result.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
@@ -3266,6 +3318,7 @@ describe("gemini ACP flag selection", () => {
"Run exceeded the adapter execution timeout (timeoutSec=1, configured via adapterConfig.timeoutSec). " +
"Set adapterConfig.timeoutSec to raise it.";
expect(result.timedOut).toBe(true);
expect(result.signal).toBe("SIGTERM");
expect(result.errorCode).toBe("acpx_timeout");
expect(result.resultJson?.adapterExecutionTimeout).toEqual({ timeoutSec: 1, source: "configured" });
expect(result.errorMessage).toBe(expectedMessage);
@@ -3531,8 +3584,10 @@ describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () =
// Decode the process-session LAUNCH payload (the base64 command blob) — the
// in-sandbox process env is carried there, NOT in the exec's own `env`.
let launchPayload: Record<string, unknown> | null = null;
let bridgeExecEnv: Record<string, string> | undefined;
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
bridgeExecEnv = input.env;
const script = input.args?.[1] ?? "";
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
if (match) {
@@ -3567,6 +3622,8 @@ describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () =
expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
expect(bridgeExecEnv?.PAPERCLIP_SANDBOX_EXEC_CHANNEL).toBe("bridge");
expect(bridgeExecEnv?.PAPERCLIP_API_KEY).toBeUndefined();
});
it("publishes referenced-project workspace hints repointed at their staged sandbox directories", async () => {
@@ -3703,12 +3760,17 @@ describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () =
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
it("test_remote_warm_handle_reused_after_cwd_change", async () => {
it("recreates the remote runtime while resuming after the cwd is staged", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
let created = 0;
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
createRuntime: () => buildRuntime(undefined, (input) => ensureInputs.push(input)) as never,
warmHandles,
createRuntime: () => {
created += 1;
return buildRuntime(undefined, (input) => ensureInputs.push(input)) as never;
},
});
const base = {
agent: { id: "agent-1", companyId: "company-1" },
@@ -3736,6 +3798,9 @@ describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () =
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(created).toBe(2);
expect(warmHandles.size).toBe(0);
expect(ensureInputs).toHaveLength(2);
// Both runs resolve session/new to the in-sandbox cwd...
expect(ensureInputs[0]?.cwd).toBe(remoteCwd);
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
@@ -3868,6 +3933,7 @@ describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", (
// The seam's asset was threaded through the shared staging seam...
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
expect(stageArgs.workspaceLocalDir).toBe(localCwd);
expect(stageArgs.assets).toEqual([
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
]);
@@ -6259,10 +6325,11 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const warmHandles = new Map();
let created = 0;
const secondEvents: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => buildRuntime() as never,
createRuntime: () => { created += 1; return buildRuntime() as never; },
});
const config = {
agent: "custom",
@@ -6300,6 +6367,7 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
const handshakeEvents = secondEvents.filter(
(event) => event.eventType === "run.startup.step" && event.payload?.step === "acp.handshake",
);
expect(created).toBe(2);
expect(handshakeEvents).toHaveLength(1);
expect(handshakeEvents[0]!.payload?.outcome).not.toBe("skipped");
});
@@ -6837,6 +6905,229 @@ describe("ACPX engine run lifecycle corrections (F3: one teardown error policy)"
};
}
it.each(["close_session", "stop_transport", "instruction_collection", "workspace_restore", "phase_reporting", "close_error_reporting"])(
"keeps %s visible while its real settlement await is stalled",
async (blockedPhase) => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
let resume!: () => void;
let entered!: () => void;
const gate = new Promise<void>((resolve) => { resume = resolve; });
const reached = new Promise<void>((resolve) => { entered = resolve; });
const block = () => { entered(); return gate; };
const scopes = new Map<symbol, string>();
if (blockedPhase === "stop_transport") {
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => ({ env: {}, stop: block }) as never);
}
const execute = createAcpxEngineExecutor({
createRuntime: () => ({
ensureSession: async () => okHandle,
startTurn: () => blockedPhase === "close_error_reporting" ? throwingTurn() : completedTurn(),
close: blockedPhase === "close_session" ? block : async () => {
if (blockedPhase === "close_error_reporting") throw new Error("close fixture failed");
},
}) as never,
prepareRemoteManagedHome: async (input) => ({
stagedRuntime: await input.stage([]),
teardown: async () => {
if (blockedPhase === "workspace_restore") await block();
return { ok: true };
},
}),
});
const execution = execute({
runId: "pending-settlement-fixture",
...remoteArgs(stateDir, localCwd, executionTarget),
onProviderStopped: blockedPhase === "instruction_collection" ? block : async () => {},
onExecutionPhase: (phase: string) => {
const token = Symbol();
scopes.set(token, phase);
return () => { scopes.delete(token); };
},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
if (blockedPhase === "phase_reporting" && event.eventType === "run.phase.timing" && event.payload?.phase === "end_session") await block();
},
onLog: async (_stream: string, text: string) => {
if (blockedPhase === "close_error_reporting" && text.includes("close fixture failed")) await block();
},
} as never);
try {
await reached;
expect([...scopes.values()].at(-1)).toBe(blockedPhase === "close_error_reporting" ? "phase_reporting" : blockedPhase);
} finally {
resume();
expect((await execution).exitCode).toBe(blockedPhase === "close_error_reporting" ? 1 : 0);
}
expect(scopes.size).toBe(0);
},
);
it("keeps the primary Stop cancellation visible until its await settles", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const controller = new AbortController();
let started!: () => void;
let cancelled!: () => void;
let resume!: () => void;
const turnStarted = new Promise<void>((resolve) => { started = resolve; });
const cancellationStarted = new Promise<void>((resolve) => { cancelled = resolve; });
const gate = new Promise<void>((resolve) => { resume = resolve; });
const scopes = new Map<symbol, string>();
const execute = createAcpxEngineExecutor({
createRuntime: () => ({
ensureSession: async () => okHandle,
startTurn: () => {
started();
return {
events: (async function* () { await gate; })(),
result: gate.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
cancel: async () => { cancelled(); await gate; },
};
},
close: async () => {},
}) as never,
});
const execution = execute({
runId: "pending-cancel-fixture",
...remoteArgs(stateDir, localCwd, executionTarget),
signal: controller.signal,
onExecutionPhase: (phase: string) => {
const token = Symbol();
scopes.set(token, phase);
return () => { scopes.delete(token); };
},
} as never);
try {
await turnStarted;
controller.abort();
await cancellationStarted;
expect([...scopes.values()].at(-1)).toBe("cancel_turn");
} finally {
resume();
expect((await execution).exitCode).toBe(1);
}
expect(scopes.size).toBe(0);
});
it("test_clean_completed_remote_teardown_runs_bridge_stop_then_sync_back_then_lease_release", async () => {
// cleanupRemoteBridges (execute.ts:2306-2326) fixes the sub-order for a clean
// exit: stop both bridges (allSettled) → run the managed-home sync-back
// (remoteManagedHomeTeardown) → release the per-session staging lease LAST in
// a finally. Record the order through spies threaded into the stubbed bridges
// and the seam teardown, and read the still-held lease during the sync-back.
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const order: string[] = [];
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => ({
env: {},
stop: vi.fn(async () => {
order.push("bridge-stop");
}),
}) as never);
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => ({
agentCommand: null,
stop: vi.fn(async () => {
order.push("bridge-stop");
}),
}) as never);
const stagingLocks = new Map<string, Promise<unknown>>();
let leaseSizeDuringSyncBack = -1;
const execute = createAcpxEngineExecutor({
stagingLocks,
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => completedTurn(),
close: vi.fn(async () => {}),
}) as never,
prepareRemoteManagedHome: async (input) => {
const stagedRuntime = await input.stage([]);
return {
stagedRuntime,
teardown: async () => {
order.push("sync-back");
// The lease is still held while the sync-back runs; it releases only
// afterward, in the cleanupRemoteBridges finally.
leaseSizeDuringSyncBack = stagingLocks.size;
return { ok: true };
},
};
},
});
const result = await execute({
runId: "clean-order",
...remoteArgs(stateDir, localCwd, executionTarget),
} as never);
expect(result.exitCode).toBe(0);
// Both bridge stops precede the sync-back; the sync-back is the last recorded
// step before the lease release.
expect(order).toEqual(["bridge-stop", "bridge-stop", "sync-back"]);
// The lease was still held during the sync-back...
expect(leaseSizeDuringSyncBack).toBe(1);
// ...and released last, so the lock map never strands the next run.
expect(stagingLocks.size).toBe(0);
});
it("test_terminal_close_reason_and_discard_persistent_state_per_outcome", async () => {
const cases: Array<{
status: "completed" | "failed" | "cancelled";
reason: string;
discard: boolean;
exitCode: number;
}> = [
{ status: "completed", reason: "paperclip completed turn cleanup", discard: false, exitCode: 0 },
{ status: "failed", reason: "paperclip turn failed", discard: false, exitCode: 1 },
{ status: "cancelled", reason: "paperclip turn cancelled", discard: true, exitCode: 1 },
];
for (const testCase of cases) {
const root = await makeTempRoot();
const closeSpy = vi.fn(async () => {});
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result:
testCase.status === "failed"
? Promise.resolve({ status: "failed", error: new Error("turn boom") })
: testCase.status === "cancelled"
? Promise.resolve({ status: "cancelled", stopReason: "cancelled" })
: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
close: closeSpy,
}) as never,
});
const result = await execute({
runId: `terminal-${testCase.status}`,
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode, `${testCase.status} exit`).toBe(testCase.exitCode);
expect(closeSpy, `${testCase.status} closes once`).toHaveBeenCalledTimes(1);
expect((closeSpy.mock.calls[0]! as unknown[])[0], `${testCase.status} close args`).toMatchObject({
reason: testCase.reason,
discardPersistentState: testCase.discard,
});
}
});
it("collects instructions after confirmed close on a thrown provider turn, before workspace restore", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const { paperclipStops, anyStopped } = stubBridges();
@@ -47,6 +47,14 @@ import { LedgerStateError, type ResourceId } from "./run-contracts.js";
import type { SettlementDispositionReport } from "./run-coordinator.js";
const tempRoots: string[] = [];
const bridgeStops: Array<ReturnType<typeof vi.fn>> = [];
const stagingLockMaps: Array<Map<string, Promise<unknown>>> = [];
function makeStagingLocks() {
const locks = new Map<string, Promise<unknown>>();
stagingLockMaps.push(locks);
return locks;
}
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-fault-"));
@@ -151,16 +159,17 @@ function throwingHandoffContext(): Record<string, unknown> {
// handles without running the real bridge transport. `stopRejects` makes the
// bridge stop reject during settlement.
function stubBridges(options: { stopRejects?: boolean } = {}) {
const stop = options.stopRejects
? vi.fn(async () => {
throw new Error("bridge stop boom");
})
const makeStop = () => options.stopRejects
? vi.fn(async () => { throw new Error("bridge stop boom"); })
: vi.fn(async () => {});
const controlStop = makeStop();
const agentStop = makeStop();
bridgeStops.push(controlStop, agentStop);
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementation(async () => {
return { env: {}, stop } as never;
return { env: {}, stop: controlStop } as never;
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementation(async () => {
return { agentCommand: null, stop } as never;
return { agentCommand: null, stop: agentStop } as never;
});
}
@@ -261,6 +270,10 @@ function assertDispositionReport(
expect(ids.length, "no resource is reported twice").toBe(new Set(ids).size);
// The report keys equal the acquired subset of the six resource ids.
expect(new Set(ids)).toEqual(new Set(expected.acquired));
// Reports alone can claim cleanup without invoking the actual cleanup callbacks.
if (ids.includes("control_bridge")) expect(bridgeStops[0]).toHaveBeenCalledTimes(1);
if (ids.includes("agent_bridge")) expect(bridgeStops[1]).toHaveBeenCalledTimes(1);
for (const locks of stagingLockMaps) expect(locks.size).toBe(0);
const transferred = expected.transferred ?? null;
for (const record of records) {
expect(["finalized", "transferred"]).toContain(record.disposition);
@@ -289,6 +302,8 @@ const SANDBOX_WITH_MANAGED_HOME: ResourceId[] = [
describe("composed ACPX run fault matrix", () => {
beforeEach(() => {
vi.clearAllMocks();
bridgeStops.length = 0;
stagingLockMaps.length = 0;
});
// Case 1 — a build failure throws before the ledger acquires any resource.
@@ -302,7 +317,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
@@ -329,7 +344,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
@@ -350,7 +365,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => {
throw new Error("createRuntime boom");
},
@@ -363,6 +378,9 @@ describe("composed ACPX run fault matrix", () => {
} as never);
expect(result.exitCode).toBe(1);
expect(result.signal).toBeNull();
expect(result.timedOut).toBe(false);
expect(result.summary).toContain("createRuntime boom");
expect(result.resultJson?.phase).toBe("create_runtime");
assertDispositionReport(capture.last(), { acquired: SANDBOX_PRE_RUNTIME });
});
@@ -372,17 +390,18 @@ describe("composed ACPX run fault matrix", () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const close = vi.fn(async () => {});
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () =>
({
ensureSession: async () => {
throw new Error("ensureSession boom");
},
startTurn: () => completedTurn(),
close: async () => {},
close,
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
@@ -393,6 +412,7 @@ describe("composed ACPX run fault matrix", () => {
} as never);
expect(result.exitCode).toBe(1);
expect(close).toHaveBeenCalledTimes(1);
expect(result.resultJson?.phase).toBe("ensure_session");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
@@ -406,7 +426,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () =>
({
ensureSession: async () => undefined,
@@ -434,10 +454,11 @@ describe("composed ACPX run fault matrix", () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const close = vi.fn(async () => {});
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
@@ -445,7 +466,7 @@ describe("composed ACPX run fault matrix", () => {
setConfigOption: async () => {
throw new Error("setConfigOption boom");
},
close: async () => {},
close,
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
@@ -464,7 +485,11 @@ describe("composed ACPX run fault matrix", () => {
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("configure_session");
expect(close).toHaveBeenCalledTimes(1);
expect(result.resultJson).toMatchObject({
phase: "configure_session", agent: "custom", requestedModel: "custom-model-x",
requestedThinkingEffort: null, fastMode: false,
});
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
});
@@ -476,7 +501,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
@@ -517,16 +542,20 @@ describe("composed ACPX run fault matrix", () => {
// succeeds. The runtime is created once, so acp_runtime registers once.
const capture = captureDisposition();
let created = 0;
let started = 0;
const ensureInputs: Record<string, unknown>[] = [];
const logs: string[] = [];
const secondExecute = createAcpxEngineExecutor({
onSettlementDisposition: capture.onSettlementDisposition,
createRuntime: () => {
created += 1;
return {
ensureSession: async (session: Record<string, unknown>) => {
ensureInputs.push(session);
if (session.resumeSessionId) throw new Error("resume session not found");
return okHandle;
},
startTurn: () => completedTurn(),
startTurn: () => { started += 1; return completedTurn(); },
setConfigOption: async () => {},
close: async () => {},
} as never;
@@ -538,13 +567,19 @@ describe("composed ACPX run fault matrix", () => {
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
config,
context: {},
onLog: async () => {},
onLog: async (_stream: string, text: string) => { logs.push(text); },
onMeta: async () => {},
} as never);
expect(second.exitCode).toBe(0);
// The runtime was created exactly once across the resume and the fresh retry.
expect(created).toBe(1);
expect(ensureInputs).toHaveLength(2);
expect(ensureInputs[0]?.resumeSessionId).toBe(first.sessionId);
expect(ensureInputs[1]?.resumeSessionId).toBeUndefined();
expect(started).toBe(1);
expect(second.clearSession).toBe(true);
expect(logs.some((text) => text.includes("is unavailable; retrying with a fresh session"))).toBe(true);
// The host lane acquires only the runtime, and it appears once.
assertDispositionReport(capture.last(), { acquired: ["acp_runtime"] });
});
@@ -555,15 +590,16 @@ describe("composed ACPX run fault matrix", () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
stubBridges();
const capture = captureDisposition();
const order: string[] = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
startTurn: () => throwingTurn(),
close: async () => {},
startTurn: () => ({ ...throwingTurn(), cancel: async () => { order.push("cancel"); } }),
close: async () => { order.push("close"); },
}) as never,
onSettlementDisposition: capture.onSettlementDisposition,
});
@@ -574,6 +610,7 @@ describe("composed ACPX run fault matrix", () => {
} as never);
expect(result.exitCode).toBe(1);
expect(order).toEqual(["cancel", "close"]);
expect(result.resultJson?.phase).toBe("turn");
expect(result.errorCode).toBe("acpx_turn_failed");
assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_RUNTIME });
@@ -591,7 +628,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
@@ -629,7 +666,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () =>
({
ensureSession: async () => okHandle,
@@ -658,7 +695,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
prepareRemoteManagedHome: managedHomeSeed(),
onSettlementDisposition: capture.onSettlementDisposition,
@@ -670,6 +707,13 @@ describe("composed ACPX run fault matrix", () => {
} as never);
expect(result.exitCode).toBe(0);
expect(result.signal).toBeNull();
expect(result.timedOut).toBe(false);
expect(result.errorCode).toBeNull();
expect(result.sessionId).toBe("backend-session");
expect(result.sessionDisplayId).toBe("agent-session");
expect(result.sessionParams).toBeTruthy();
expect(result.summary).toBe("end_turn");
expect(result.resultJson?.status).toBe("completed");
assertDispositionReport(capture.last(), {
acquired: SANDBOX_WITH_MANAGED_HOME,
@@ -686,7 +730,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
onSettlementDisposition: capture.onSettlementDisposition,
});
@@ -714,7 +758,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
prepareRemoteManagedHome: managedHomeSeed({ teardownRejects: true }),
onSettlementDisposition: capture.onSettlementDisposition,
@@ -742,7 +786,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
prepareRemoteManagedHome: managedHomeSeed(),
});
@@ -770,7 +814,7 @@ describe("composed ACPX run fault matrix", () => {
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
stagingLocks: makeStagingLocks(),
createRuntime: () => completedRuntime(),
prepareRemoteManagedHome: managedHomeSeedWithClassifiedTeardownFailure(eaccesError),
});
@@ -915,6 +959,8 @@ describe("composed ACPX run fault matrix", () => {
describe("composed ACPX run per-phase telemetry", () => {
beforeEach(() => {
vi.clearAllMocks();
bridgeStops.length = 0;
stagingLockMaps.length = 0;
});
it("emits a run.phase.timing event for each lifecycle phase a clean host run reaches", async () => {
File diff suppressed because it is too large. Load diff
@@ -1,917 +0,0 @@
import fs 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 { AcpRuntimeOptions } from "acpx/runtime";
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
import {
prepareAdapterExecutionTargetRuntime,
startAdapterExecutionTargetPaperclipBridge,
startAdapterExecutionTargetProcessSessionBridge,
} from "@paperclipai/adapter-utils/execution-target";
// Wrap the staging seam + both sandbox bridges in call-recording spies that
// still delegate to the real implementations. This copies the execute.test.ts
// harness verbatim so a startup test asserts the exact staging args and bridge
// hand-off the engine threads without changing any real behavior.
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
return {
...actual,
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
};
});
import { createAcpxEngineExecutor, type AcpxEngineExecutorOptions } from "./execute.js";
import { runChildProcess } from "../server-utils.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
// A remote run stages a process-session bridge whose detached event writer can
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
// when the run's own best-effort `client.remove(sessionDir)` (which production
// catch-wraps) has already returned. Under CI load that trailing write can land
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
// same way production tolerates it, instead of failing the just-passed test.
await Promise.all(
tempRoots.splice(0).map((root) =>
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
),
);
});
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
void pathExists;
function createLocalSandboxRunner(
onExecute?: (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
}) => void,
) {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
onExecute?.(input);
const command = input.command === "bash" ? "/bin/bash" : input.command;
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
onSpawn: input.onSpawn
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
: undefined,
});
},
};
}
function buildRuntime(
onSetConfigOption?: (input: { key: string; value: string }) => void,
onEnsureSession?: (input: Record<string, unknown>) => void,
) {
return {
ensureSession: async (input: Record<string, unknown>) => {
onEnsureSession?.(input);
return ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
});
},
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
setConfigOption: async (input: { key: string; value: string }) => {
onSetConfigOption?.(input);
},
close: async () => {},
};
}
async function runExecutor(
config: Record<string, unknown>,
options: {
context?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
authToken?: string;
executionTarget?: Record<string, unknown>;
runtimeMcp?: AdapterRuntimeMcpAccess;
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
} = {},
) {
const runtimeOptions: Record<string, unknown>[] = [];
const configOptions: Array<{ key: string; value: string }> = [];
const sessionInputs: Record<string, unknown>[] = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
...(options.prepareRemoteManagedHome
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
: {}),
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
return buildRuntime(
({ key, value }) => configOptions.push({ key, value }),
(input) => sessionInputs.push(input),
) as never;
},
});
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config,
context: options.context ?? {},
executionTransport: options.executionTransport,
authToken: options.authToken,
executionTarget: options.executionTarget,
runtimeMcp: options.runtimeMcp,
startupTraceContext: options.startupTraceContext,
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async (payload: unknown) => {
meta.push(payload as Record<string, unknown>);
},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
events.push(event);
},
} as never);
expect(result.exitCode).toBe(0);
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
}
// The staging-seam describe helper from execute.test.ts (~:2152). It builds a
// runner-backed remote target: the local runner extracts the staged tar into
// `remoteCwd`, so the run really ships the HOST worktree into the sandbox.
async function setupRemoteSandbox() {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
// A file present only in the HOST worktree proves the workspace is shipped
// into the sandbox: the local runner extracts the staged tar into remoteCwd.
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
const runner = createLocalSandboxRunner();
const executionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner,
};
return { root, stateDir, localCwd, remoteCwd, executionTarget };
}
// Read the `configFingerprint` off a settled run result.
function fpOf(result: { sessionParams?: unknown }): string | undefined {
return (result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
}
const okHandle = {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
function completedTurn() {
return {
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
}
describe("ACPX engine startup characterization", () => {
// Item 1 + 8: the remote launch env and its finalization point.
describe("remote launch environment values", () => {
beforeEach(() => vi.clearAllMocks());
it("mints the bridge launch env into the process-session command payload", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
// Decode the process-session LAUNCH payload (the base64 command blob). The
// in-sandbox process env rides there, not in the exec's own `env`.
let launchPayload: Record<string, unknown> | null = null;
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
const script = input.args?.[1] ?? "";
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
if (match) {
launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<
string,
unknown
>;
}
}
});
await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{ authToken: "real-run-jwt", executionTarget },
);
// The launch payload carries the MERGED paperclip bridge env: the queue
// transport mode, a loopback bridge base URL, and a minted bridge token.
const payloadEnv = ((launchPayload as Record<string, unknown> | null)?.env ?? {}) as Record<
string,
unknown
>;
expect(payloadEnv).toMatchObject({ PAPERCLIP_API_BRIDGE_MODE: "queue_v1" });
expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
// The minted bridge token is present and is NOT the host run JWT.
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
});
it("finalizes the launch env at the bridge merge: the process env carries the merged bridge values", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
// Capture the launch payload AND the bridge-channel exec's own `env`.
let launchPayload: Record<string, unknown> | null = null;
let bridgeExecEnv: Record<string, string> | undefined;
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
bridgeExecEnv = input.env;
const script = input.args?.[1] ?? "";
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
if (match) {
launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<
string,
unknown
>;
}
}
});
await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{ authToken: "real-run-jwt", executionTarget },
);
// The launch payload is the finalized carrier of the process env. It already
// holds the merged bridge values at the point the run emits it, so no later
// write mutates the process env after the bridge merge.
const payloadEnv = ((launchPayload as Record<string, unknown> | null)?.env ?? {}) as Record<
string,
unknown
>;
expect(payloadEnv.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
// The bridge-channel exec's OWN env is the sandbox transport channel, not the
// agent process env: it does not carry the minted agent bridge key. This pins
// that the merged agent env lives only in the finalized launch payload.
expect(bridgeExecEnv?.PAPERCLIP_SANDBOX_EXEC_CHANNEL).toBe("bridge");
expect(bridgeExecEnv?.PAPERCLIP_API_KEY).toBeUndefined();
});
});
// Item 2: the 17 fingerprint fields folded into `configFingerprint`, and the
// outer session key form that embeds it.
describe("session fingerprint and session key", () => {
beforeEach(() => vi.clearAllMocks());
it("forms the session key as paperclip:company:agent:taskKey:fingerprint and embeds the fingerprint", async () => {
const root = await makeTempRoot();
const { result } = await runExecutor({
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
cwd: path.join(root, "workspace"),
});
const fp = fpOf(result);
expect(fp).toBeTypeOf("string");
expect(fp).toBeTruthy();
// No taskId/issueId/workspaceId in the default context, so taskKey is "default".
const sessionKey = (result.sessionParams as { sessionKey?: string }).sessionKey;
expect(sessionKey).toBe(`paperclip:company-1:agent-1:default:${fp}`);
});
it("keeps the fingerprint stable across two identical runs and a same-config new wake", async () => {
const root = await makeTempRoot();
const baseConfig = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
cwd: path.join(root, "workspace"),
};
const first = await runExecutor(baseConfig, {
context: { taskId: "issue-1", wakeReason: "issue_assigned" },
});
const identical = await runExecutor(baseConfig, {
context: { taskId: "issue-1", wakeReason: "issue_assigned" },
});
// A fresh heartbeat with a different wake reason but the same config env.
const newWake = await runExecutor(baseConfig, {
context: { taskId: "issue-1", wakeReason: "comment", wakeCommentId: "c-9" },
});
expect(fpOf(first.result)).toBeTruthy();
expect(fpOf(identical.result)).toBe(fpOf(first.result));
// Per-wake PAPERCLIP_* churn does not reset the session fingerprint.
expect(fpOf(newWake.result)).toBe(fpOf(first.result));
});
it("busts the fingerprint when any representative folded dimension changes", async () => {
const root = await makeTempRoot();
const cwd = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const context = { context: { taskId: "issue-1", wakeReason: "issue_assigned" } };
const base = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd };
const baseFp = fpOf((await runExecutor(base, context)).result);
expect(baseFp).toBeTruthy();
// Each edit changes exactly one folded dimension and must bust the fingerprint.
// agentCommand.
expect(fpOf((await runExecutor({ ...base, agentCommand: "node ./other-acp.js" }, context)).result)).not.toBe(baseFp);
// cwd.
expect(fpOf((await runExecutor({ ...base, cwd: path.join(root, "other-cwd") }, context)).result)).not.toBe(baseFp);
// requestedModel.
expect(fpOf((await runExecutor({ ...base, model: "some-model" }, context)).result)).not.toBe(baseFp);
// requestedThinkingEffort.
expect(fpOf((await runExecutor({ ...base, thinkingEffort: "high" }, context)).result)).not.toBe(baseFp);
// mode.
expect(fpOf((await runExecutor({ ...base, mode: "oneshot" }, context)).result)).not.toBe(baseFp);
// adapterEnvHash (a resolved adapter env value).
expect(fpOf((await runExecutor({ ...base, env: { FOO: "bar" } }, context)).result)).not.toBe(baseFp);
// mcpServers identity (injected runtime MCP set).
expect(
fpOf(
(
await runExecutor(base, {
...context,
runtimeMcp: {
getServers: () => [
{ name: "github", url: "https://x.test/mcp", connectionId: "c-1", token: "t-1" },
],
},
})
).result,
),
).not.toBe(baseFp);
// secretManifestHash.
expect(
fpOf(
(
await runExecutor(base, {
...context,
context: {
taskId: "issue-1",
wakeReason: "issue_assigned",
paperclipSecrets: {
manifest: [
{
configPath: "env.API_TOKEN",
envKey: "API_TOKEN",
secretId: "secret-1",
bindingId: "binding-1",
secretKey: "api-token",
version: 1,
provider: "local_encrypted",
},
],
},
},
})
).result,
),
).not.toBe(baseFp);
// additionalSourcesIdentity (referenced-project set).
expect(
fpOf(
(
await runExecutor(base, {
...context,
context: {
taskId: "issue-1",
wakeReason: "issue_assigned",
paperclipWorkspace: {
cwd,
realization: {
additional: [
{
path: "/host/project-a",
projectId: "a",
projectWorkspaceId: "ws-a",
repoUrl: "https://example.test/a.git",
repoRef: "ref-a-1",
},
],
},
},
},
})
).result,
),
).not.toBe(baseFp);
// fastMode folds only for codex, so pin it with a codex-vs-codex pair.
const codexBase = { agent: "codex", agentCommand: "node ./fake-acp.js", stateDir, cwd };
const codexFp = fpOf((await runExecutor(codexBase, context)).result);
expect(fpOf((await runExecutor({ ...codexBase, fastMode: true }, context)).result)).not.toBe(codexFp);
});
});
// Item 3: the staging seam call, its arguments, and its order (workspace then
// assets, serial). Modeled on the PR-1 staging-seam tests.
describe("staging seam calls, arguments, and order", () => {
beforeEach(() => vi.clearAllMocks());
it("stages the host workspace with no assets, exactly once, before the process launch", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const { sessionInputs, events } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{ authToken: "real-run-jwt", executionTarget },
);
// The staging seam crossed exactly once.
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
// The HOST worktree is shipped first; no per-adapter home asset in this lane.
expect(stageArgs.workspaceLocalDir).toBe(localCwd);
expect(stageArgs.assets ?? []).toEqual([]);
expect(stageArgs.installCommand ?? null).toBeNull();
expect(stageArgs.target).toMatchObject({ kind: "remote", transport: "sandbox" });
// The workspace really landed in the sandbox workspace dir.
await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
// A per-step timing event proves the sync ran inside its timed boundary.
const stageEvent = events.find(
(event) => event.eventType === "run.startup.step" && event.payload?.step === "stage.sync",
);
expect(stageEvent).toBeTruthy();
// And session/new binds to the in-sandbox workspace cwd the seam returned.
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
it("threads a managed-home asset through the same seam after the workspace", async () => {
const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const managedHomeDir = path.join(root, "managed-home");
await fs.mkdir(managedHomeDir, { recursive: true });
await fs.writeFile(path.join(managedHomeDir, "config.json"), "{}", "utf8");
await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{
authToken: "real-run-jwt",
executionTarget,
prepareRemoteManagedHome: async (input) => {
const stagedRuntime = await input.stage([
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
]);
return { stagedRuntime };
},
},
);
// The seam's home asset is threaded through the SAME staging seam, keyed by
// the workspace-local dir. Workspace ships first; the asset rides alongside it.
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
expect(stageArgs.workspaceLocalDir).toBe(localCwd);
expect(stageArgs.assets).toEqual([
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
]);
});
});
// Item 4: the two-bridge overlap and the ACP-initialization ordering.
describe("two-bridge overlap and ACP initialization order", () => {
beforeEach(() => vi.clearAllMocks());
it("defers the process bridge env, shares one runtimeRootDir, and runs session/new on the sandbox cwd", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const { sessionInputs, runtimeOptions } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{ authToken: "real-run-jwt", executionTarget },
);
// The process-session bridge receives its launch env as a DEFERRED thunk, the
// seam that lets its env-independent setup overlap the paperclip bridge start.
const processArgs = vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mock.calls[0]![0];
expect(typeof processArgs.env).toBe("function");
// Both bridges receive the SAME real (non-null) runtimeRootDir from staging.
const paperclipArgs = vi.mocked(startAdapterExecutionTargetPaperclipBridge).mock.calls[0]![0];
expect(paperclipArgs.runtimeRootDir).toBeTruthy();
expect(String(paperclipArgs.runtimeRootDir)).toContain(".paperclip-runtime");
expect(processArgs.runtimeRootDir).toBe(paperclipArgs.runtimeRootDir);
// The ACP runtime + session/new both bind to the in-sandbox workspace cwd,
// which the run resolves only after the bridges bring the sandbox up.
expect(runtimeOptions[0]?.cwd).toBe(remoteCwd);
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
expect(sessionInputs[0]?.cwd).not.toBe(localCwd);
});
});
// Item 5 + 6: every startup exit path, its result phase, and the cleanup-call
// set (bridges stop / lease releases / runtime closes).
describe("startup exit paths and cleanup", () => {
beforeEach(() => vi.clearAllMocks());
it("create_runtime failure: settles an error result, stops both bridges, releases the lease", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const paperclipStop = vi.fn(async () => {});
const processStop = vi.fn(async () => {});
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
async () => ({ env: {}, stop: paperclipStop }) as never,
);
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
async () => ({ agentCommand: null, stop: processStop }) as never,
);
const stagingLocks = new Map<string, Promise<unknown>>();
const execute = createAcpxEngineExecutor({
stagingLocks,
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => {
throw new Error("createRuntime boom");
},
});
const result = await execute({
runId: "run-create-fail-remote",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
context: {},
authToken: "real-run-jwt",
executionTarget,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never);
// The post-build runtime-creation failure returns a settled error result.
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("create_runtime");
// Both live bridges stop exactly once and the per-session lease releases.
expect(paperclipStop).toHaveBeenCalledTimes(1);
expect(processStop).toHaveBeenCalledTimes(1);
expect(stagingLocks.size).toBe(0);
});
it("partial-bridge failure: throws and stops the concurrently-started bridge exactly once", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const stop = vi.fn(async () => {});
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
throw new Error("paperclip bridge boom");
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
async () => ({ agentCommand: null, stop }) as never,
);
const execute = createAcpxEngineExecutor({
createRuntime: () => buildRuntime() as never,
});
// A partial bridge failure inside buildRuntime is one of the only two throw
// paths, so the run rethrows instead of settling a result.
await expect(
execute({
runId: "run-bridge-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
context: {},
authToken: "real-run-jwt",
executionTarget,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never),
).rejects.toThrow("paperclip bridge boom");
// The concurrently-started process-session bridge was stopped exactly once.
expect(stop).toHaveBeenCalledTimes(1);
});
it("cold ensure_session throw: ensure_session error, and the runtime closes (the cold-handshake leak is fixed)", async () => {
const root = await makeTempRoot();
const closeSpy = vi.fn(async () => {});
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => {
throw new Error("ensureSession boom");
},
startTurn: () => completedTurn(),
close: closeSpy,
}) as never,
});
const result = await execute({
runId: "handshake-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
// The runtime enters the ledger the moment it is created, so a cold
// `ensureSession` throw (before any handle exists) still closes it through the
// settlement `endSession` step. This closes the former cold-handshake leak.
expect(closeSpy).toHaveBeenCalledTimes(1);
});
it("missing session handle: ensure_session error and the minimal runtime closes", async () => {
const root = await makeTempRoot();
const closeSpy = vi.fn(async () => {});
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
// A runtime that returns no session handle drives the missing-handle path.
ensureSession: async () => undefined,
startTurn: () => completedTurn(),
close: closeSpy,
}) as never,
});
const result = await execute({
runId: "missing-handle",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("ensure_session");
expect(result.errorCode).toBe("acpx_runtime_error");
expect(closeSpy).toHaveBeenCalledTimes(1);
});
it("configure_session failure: configure_session error and the runtime closes", async () => {
const root = await makeTempRoot();
const closeSpy = vi.fn(async () => {});
const execute = createAcpxEngineExecutor({
createRuntime: () =>
({
ensureSession: async () => okHandle,
// Gemini model/effort drive a session config option; a throwing setter
// fails the configure_session phase after the handshake succeeds.
setConfigOption: async () => {
throw new Error("setConfigOption boom");
},
startTurn: () => completedTurn(),
close: closeSpy,
}) as never,
});
const result = await execute({
runId: "configure-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "gemini",
model: "gemini-2.5-pro",
thinkingEffort: "high",
stateDir: path.join(root, "state"),
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.resultJson?.phase).toBe("configure_session");
expect(closeSpy).toHaveBeenCalledTimes(1);
});
it("persistent host failure: the first run closes and relaunches, so a failing second run re-creates and closes", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const startedAt = "2026-01-01T00:00:00.000Z";
const closeSpy = vi.fn(async () => {});
let created = 0;
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: (options) => {
created += 1;
const opts = options as AcpRuntimeOptions & {
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
};
return {
ensureSession: async () => {
await opts.onAgentSpawn?.({ pid: 4242, startedAt });
return okHandle;
},
startTurn: () => completedTurn(),
close: closeSpy,
} as never;
},
});
const config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
mode: "persistent",
warmHandleIdleMs: 60_000,
};
const first = await execute({
runId: "warm-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
} as never);
expect(first.exitCode).toBe(0);
// Amendment B: the first clean persistent turn closes and relaunches instead
// of warm-saving the runtime, so no warm handle survives.
expect(warmHandles.size).toBe(0);
expect(created).toBe(1);
// The second run finds no warm handle, so it re-creates the runtime and fails
// while persisting process identity during ensure_session.
const second = await execute({
runId: "warm-2",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: first.sessionParams },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {
throw new Error("onSpawn boom");
},
} as never);
// The second run built runtime #2 (no warm reuse), closed it on the failure,
// and reported the ensure_session phase. Both runtimes closed (one per run).
expect(created).toBe(2);
expect(second.exitCode).toBe(1);
expect(second.resultJson?.phase).toBe("ensure_session");
expect(closeSpy).toHaveBeenCalledTimes(2);
expect(warmHandles.size).toBe(0);
});
});
// Item 7: the per-lane resource set.
describe("per-lane resource set", () => {
beforeEach(() => vi.clearAllMocks());
it("local lane: crosses no staging seam, starts no bridge, keeps session/new on the host cwd", async () => {
const root = await makeTempRoot();
const localCwd = path.join(root, "worktree");
await fs.mkdir(localCwd, { recursive: true });
const { sessionInputs, runtimeOptions } = await runExecutor({
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
cwd: localCwd,
});
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).not.toHaveBeenCalled();
expect(vi.mocked(startAdapterExecutionTargetPaperclipBridge)).not.toHaveBeenCalled();
expect(vi.mocked(startAdapterExecutionTargetProcessSessionBridge)).not.toHaveBeenCalled();
expect(sessionInputs[0]?.cwd).toBe(localCwd);
expect(runtimeOptions[0]?.cwd).toBe(localCwd);
});
it("persistent host lane: closes and relaunches the handle, so a second run re-creates the runtime", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let created = 0;
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles,
createRuntime: () => {
created += 1;
return buildRuntime() as never;
},
});
const config = {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
mode: "persistent",
warmHandleIdleMs: 60_000,
};
const base = {
agent: { id: "agent-1", companyId: "company-1" },
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
onSpawn: async () => {},
};
const first = await execute({ runId: "host-warm-1", runtime: {}, ...base } as never);
const second = await execute({
runId: "host-warm-2",
runtime: { sessionParams: first.sessionParams },
...base,
} as never);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
// Amendment B: the host lane never warm-saves a live runtime (the run-minted
// API key is never revoked), so the first run closes and relaunches. No warm
// handle survives, so the second run re-creates runtime #2.
expect(created).toBe(2);
expect(warmHandles.size).toBe(0);
});
it("remote process-session lane: does NOT warm-save the handle, so a second run re-creates the runtime", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
let created = 0;
const ensureInputs: Array<Record<string, unknown>> = [];
const warmHandles = new Map();
const execute = createAcpxEngineExecutor({
warmHandles,
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => {
created += 1;
return buildRuntime(undefined, (input) => ensureInputs.push(input)) as never;
},
});
const base = {
agent: { id: "agent-1", companyId: "company-1" },
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
cwd: localCwd,
mode: "persistent",
warmHandleIdleMs: 60_000,
},
context: {},
authToken: "real-run-jwt",
executionTarget,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
};
const first = await execute({ runId: "remote-warm-1", runtime: {}, ...base } as never);
const second = await execute({
runId: "remote-warm-2",
runtime: { sessionParams: first.sessionParams },
...base,
} as never);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
// The remote process-session lane never keeps the handle warm, so the second
// run re-creates the runtime and runs a fresh handshake instead of reusing one.
expect(created).toBe(2);
expect(warmHandles.size).toBe(0);
expect(ensureInputs).toHaveLength(2);
});
});
});
@@ -1,686 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils";
import {
prepareAdapterExecutionTargetRuntime,
startAdapterExecutionTargetPaperclipBridge,
startAdapterExecutionTargetProcessSessionBridge,
} from "@paperclipai/adapter-utils/execution-target";
// Wrap the staging seam + both sandbox bridges in call-recording spies that
// still delegate to the real implementations. This mirrors the execute.test.ts
// harness so the turn characterization tests share the same mocked module graph.
vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => {
const actual = await importActual<typeof import("@paperclipai/adapter-utils/execution-target")>();
return {
...actual,
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge),
startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge),
};
});
import {
createAcpxEngineExecutor,
summarizeAcpxTurnUsage,
type AcpxEngineExecutorOptions,
} from "./execute.js";
import { runChildProcess } from "../server-utils.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
// A remote run stages a process-session bridge whose detached event writer can
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
// when the run's own best-effort `client.remove(sessionDir)` (which production
// catch-wraps) has already returned. Under CI load that trailing write can land
// between this recursive delete's directory snapshot and its `rmdir`, surfacing as
// `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the
// same way production tolerates it, instead of failing the just-passed test.
await Promise.all(
tempRoots.splice(0).map((root) =>
fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
),
);
});
function createLocalSandboxRunner(
onExecute?: (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
}) => void,
) {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
onExecute?.(input);
const command = input.command === "bash" ? "/bin/bash" : input.command;
return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
onSpawn: input.onSpawn
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
: undefined,
});
},
};
}
function buildRuntime(
onSetConfigOption?: (input: { key: string; value: string }) => void,
onEnsureSession?: (input: Record<string, unknown>) => void,
) {
return {
ensureSession: async (input: Record<string, unknown>) => {
onEnsureSession?.(input);
return ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
});
},
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
setConfigOption: async (input: { key: string; value: string }) => {
onSetConfigOption?.(input);
},
close: async () => {},
};
}
async function runExecutor(
config: Record<string, unknown>,
options: {
context?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
authToken?: string;
executionTarget?: Record<string, unknown>;
runtimeMcp?: AdapterRuntimeMcpAccess;
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
} = {},
) {
const runtimeOptions: Record<string, unknown>[] = [];
const configOptions: Array<{ key: string; value: string }> = [];
const sessionInputs: Record<string, unknown>[] = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
...(options.prepareRemoteManagedHome
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
: {}),
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
return buildRuntime(
({ key, value }) => configOptions.push({ key, value }),
(input) => sessionInputs.push(input),
) as never;
},
});
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config,
context: options.context ?? {},
executionTransport: options.executionTransport,
authToken: options.authToken,
executionTarget: options.executionTarget,
runtimeMcp: options.runtimeMcp,
startupTraceContext: options.startupTraceContext,
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async (payload: unknown) => {
meta.push(payload as Record<string, unknown>);
},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
events.push(event);
},
} as never);
expect(result.exitCode).toBe(0);
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
}
// A stub ACP runtime that yields a controlled event stream and terminal result.
// The tests supply the events/result/cancel behavior; the harness above only
// drives always-happy sessions, so the turn tests build their own runtime here.
function turnRuntime(input: {
events: () => AsyncGenerator<Record<string, unknown>>;
result: Promise<Record<string, unknown>>;
onStartTurn?: (turnInput: Record<string, unknown>) => void;
onCancel?: (reason: string) => void;
onClose?: () => void;
getStatus?: () => Promise<Record<string, unknown>>;
onEnsureSession?: (session: Record<string, unknown>) => void;
ensureSession?: (session: Record<string, unknown>) => Promise<Record<string, unknown>>;
}) {
const runtime: Record<string, unknown> = {
ensureSession:
input.ensureSession ??
(async (session: Record<string, unknown>) => {
input.onEnsureSession?.(session);
return {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
}),
startTurn: (turnInput: Record<string, unknown>) => {
input.onStartTurn?.(turnInput);
return {
events: input.events(),
result: input.result,
cancel: async ({ reason }: { reason: string }) => {
input.onCancel?.(reason);
},
};
},
close: async () => {
input.onClose?.();
},
};
if (input.getStatus) runtime.getStatus = input.getStatus;
return runtime;
}
describe("ACPX engine turn characterization", () => {
// The stub session handle every ensureSession returns. startTurn must receive
// this exact handle object.
const SESSION_HANDLE = {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
it("passes the turn inputs and terminal failure callback to startTurn", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let captured: Record<string, unknown> | null = null;
let metaPrompt = "";
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
onStartTurn: (turnInput) => {
captured = turnInput;
},
events: async function* () {
yield { type: "done", stopReason: "end_turn" };
},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
}) as never,
});
const result = await execute({
runId: "run-six-inputs",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, timeoutSec: 120 },
context: {},
onLog: async () => {},
onMeta: async (payload: { prompt?: string }) => {
metaPrompt = payload.prompt ?? "";
},
} as never);
expect(result.exitCode).toBe(0);
const input = captured!;
// The handle is the exact session handle ensureSession returned.
expect(input.handle).toEqual(SESSION_HANDLE);
// The text is the built run prompt, the same string reported to onMeta.
expect(input.text).toBe(metaPrompt);
expect(typeof input.text).toBe("string");
expect((input.text as string).length).toBeGreaterThan(0);
// The turn always runs in prompt mode.
expect(input.mode).toBe("prompt");
// The request id is the run id.
expect(input.requestId).toBe("run-six-inputs");
// A positive timeoutSec becomes timeoutMs in milliseconds.
expect(input.timeoutMs).toBe(120_000);
// The abort signal is present and not yet aborted.
const signal = input.signal as AbortSignal;
expect(signal).toBeInstanceOf(AbortSignal);
expect(signal.aborted).toBe(false);
// Every adapter receives diagnostics, even without a failure classifier.
expect(input.onTerminalSessionFailure).toBeTypeOf("function");
expect(Object.keys(input).sort()).toEqual(
["handle", "mode", "onTerminalSessionFailure", "requestId", "signal", "text", "timeoutMs"].sort(),
);
});
it("joins text_delta events into the trimmed result summary", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
events: async function* () {
yield { type: "text_delta", text: " Hello, ", stream: "output", tag: "agent_message_chunk" };
yield { type: "text_delta", text: "world ", stream: "output", tag: "agent_message_chunk" };
yield { type: "done", stopReason: "end_turn" };
},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
}) as never,
});
const result = await execute({
runId: "run-summary",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
// The summary is the concatenation of the deltas, trimmed.
expect(result.summary).toBe("Hello, world");
});
it("falls back to the stop reason for the summary when no text streams", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
events: async function* () {
yield { type: "done", stopReason: "end_turn" };
},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
}) as never,
});
const result = await execute({
runId: "run-no-text",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
// With no streamed text, the summary is the terminal stop reason.
expect(result.summary).toBe("end_turn");
});
it("pins one event sequence across the log transcript and structured event transports", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const logs: Array<{ stream: string; text: string }> = [];
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
events: async function* () {
yield { type: "text_delta", text: "streamed hello", stream: "output", tag: "agent_message_chunk" };
yield { type: "done", stopReason: "end_turn" };
},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
}) as never,
});
const result = await execute({
runId: "run-transports",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
context: {},
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async () => {},
onEvent: async (event: { eventType: string; payload?: Record<string, unknown> }) => {
events.push(event);
},
} as never);
expect(result.exitCode).toBe(0);
// Transport 1 — the stdout transcript carries the text_delta as an acpx record.
expect(logs).toContainEqual({
stream: "stdout",
text: `${JSON.stringify({
type: "acpx.text_delta",
text: "streamed hello",
channel: "output",
tag: "agent_message_chunk",
})}\n`,
});
// The joined text also lands in the result summary.
expect(result.summary).toBe("streamed hello");
// Transport 2 — the structured onEvent stream reflects the run bring-up
// sequence as run.startup.step events, including the acp handshake.
const steps = events.filter((event) => event.eventType === "run.startup.step");
const stepNames = steps.map((event) => String(event.payload?.step));
expect(stepNames).toContain("acp.handshake");
expect(stepNames).toContain("workspace.resolve");
});
it("aborts a hung turn on the wall-clock timer and cancels with the timeout message", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
const cancelReasons: string[] = [];
let releaseTurn: (() => void) | null = null;
const turnCancelled = new Promise<void>((resolve) => {
releaseTurn = resolve;
});
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
// The stream never yields on its own. Only the wall-clock timer's cancel
// unblocks it, simulating a hung run.
events: async function* () {
await turnCancelled;
},
result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })),
onCancel: (reason) => {
cancelReasons.push(reason);
releaseTurn?.();
},
}) as never,
});
const result = await execute({
runId: "run-timeout",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd, timeoutSec: 1 },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
const expectedMessage =
"Run exceeded the adapter execution timeout (timeoutSec=1, configured via adapterConfig.timeoutSec). " +
"Set adapterConfig.timeoutSec to raise it.";
expect(result.timedOut).toBe(true);
expect(result.signal).toBe("SIGTERM");
expect(result.errorCode).toBe("acpx_timeout");
expect(result.errorMessage).toBe(expectedMessage);
// The cancel ran with the formatted timeout message.
expect(cancelReasons).toContain(expectedMessage);
}, 15_000);
it("cancels the turn before closing the runtime when the turn throws", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const order: string[] = [];
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
// The event stream throws mid-turn, so the catch path runs teardown.
events: async function* () {
throw new Error("turn boom");
},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
onCancel: () => order.push("cancel"),
onClose: () => order.push("close"),
}) as never,
});
const result = await execute({
runId: "run-throw",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
// A failure after startTurn returned reports phase "turn".
expect((result.resultJson as Record<string, unknown>)?.phase).toBe("turn");
// Cancel runs before close, in that exact order.
expect(order).toEqual(["cancel", "close"]);
});
it("retries the session resume once and never retries the turn", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd };
const ensureInputs: Array<Record<string, unknown>> = [];
let startTurnCalls = 0;
let resumeLogged = false;
// A first clean run mints the session params the second run resumes from.
const firstExecute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
onEnsureSession: (session) => ensureInputs.push(session),
onStartTurn: () => {
startTurnCalls += 1;
},
events: async function* () {
yield { type: "done", stopReason: "end_turn" };
},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
}) as never,
});
const first = await firstExecute({
runId: "run-resume-a",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config,
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(first.exitCode).toBe(0);
// The second run resumes. Its first ensureSession (the resume) fails with a
// resume-shaped error; the fresh retry (no resumeSessionId) succeeds.
const secondExecute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
ensureSession: async (session: Record<string, unknown>) => {
ensureInputs.push(session);
if (session.resumeSessionId) {
throw new Error("resume session not found");
}
return {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
},
onStartTurn: () => {
startTurnCalls += 1;
},
events: async function* () {
yield { type: "done", stopReason: "end_turn" };
},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
}) as never,
});
const second = await secondExecute({
runId: "run-resume-b",
agent: { id: "agent-1", companyId: "company-1" },
runtime: { sessionParams: (first as { sessionParams?: unknown }).sessionParams },
config,
context: {},
onLog: async (_stream: string, text: string) => {
if (text.includes("is unavailable; retrying with a fresh session")) resumeLogged = true;
},
onMeta: async () => {},
} as never);
expect(second.exitCode).toBe(0);
// The first run's single ensureSession, plus the second run's resume and
// fresh retry, make three ensureSession calls total; two on the second run.
expect(ensureInputs).toHaveLength(3);
expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId);
expect(ensureInputs[2]?.resumeSessionId).toBeUndefined();
// The turn is never retried: one startTurn per run, two across both runs.
expect(startTurnCalls).toBe(2);
// The engine logged the resume fallback.
expect(resumeLogged).toBe(true);
// The fresh retry clears the stale session for the caller.
expect(second.clearSession).toBe(true);
});
it("maps a failed terminal to acpx_turn_failed and folds in the reported usage", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
events: async function* () {
yield {
type: "status",
text: "usage",
tag: "usage_update",
cost: { amount: 0.31, currency: "USD" },
breakdown: { inputTokens: 40, outputTokens: 700, cachedReadTokens: 60 },
};
yield { type: "done", stopReason: "failed" };
},
result: Promise.resolve({ status: "failed", error: new Error("boom") }),
}) as never,
});
const result = await execute({
runId: "run-failed",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_turn_failed");
// The failed error message becomes the terminal stop reason and the summary.
expect(result.summary).toBe("boom");
expect((result.resultJson as Record<string, unknown>)?.stopReason).toBe("boom");
// The usage math folds the usage_update event into per-run usage and cost.
expect(result.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
expect(result.usageBasis).toBe("per_run");
expect(result.costUsd).toBeCloseTo(0.31);
});
it("computes usage the same way summarizeAcpxTurnUsage does for the event fallback", () => {
// Pin the exported helper the turn path calls: with no getStatus snapshots the
// event breakdown and cost drive the per-run usage.
const summary = summarizeAcpxTurnUsage({
preStatus: null,
postStatus: null,
eventBreakdown: { inputTokens: 40, outputTokens: 700, cachedReadTokens: 60 },
eventCostUsd: 0.31,
});
expect(summary.usage).toEqual({ inputTokens: 40, outputTokens: 700, cachedInputTokens: 60 });
expect(summary.costUsd).toBeCloseTo(0.31);
});
it("returns a prepare_turn error result when the prompt build throws", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
let startTurnCalls = 0;
let closes = 0;
const execute = createAcpxEngineExecutor({
createRuntime: () =>
turnRuntime({
onStartTurn: () => {
startTurnCalls += 1;
},
onClose: () => {
closes += 1;
},
events: async function* () {},
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
}) as never,
});
// A throwing accessor on a field only buildPrompt reads makes the prompt build
// fail after the session handshake succeeds but before startTurn runs.
const context: Record<string, unknown> = {};
Object.defineProperty(context, "paperclipSessionHandoffMarkdown", {
enumerable: false,
get() {
throw new Error("prompt build boom");
},
});
const result = await execute({
runId: "run-prepare-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir },
context,
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
// The pre-turn failure reports phase "prepare_turn".
expect((result.resultJson as Record<string, unknown>)?.phase).toBe("prepare_turn");
// The turn never started, and the runtime closed once.
expect(startTurnCalls).toBe(0);
expect(closes).toBe(1);
});
it("threads the same happy-path turn through the shared runExecutor harness", async () => {
// A smoke pin on the copied harness: the always-happy buildRuntime turn exits
// clean with the done stop reason as the summary.
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const { result } = await runExecutor({
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
});
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("end_turn");
});
});
File diff suppressed because it is too large. Load diff
@@ -11,8 +11,27 @@ import {
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
type AdapterSshExecutionTarget,
} from "./execution-target.js";
function sshExecutionTarget(): AdapterSshExecutionTarget {
return {
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
};
}
describe("runAdapterExecutionTargetShellCommand", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -27,21 +46,7 @@ describe("runAdapterExecutionTargetShellCommand", () => {
await runAdapterExecutionTargetShellCommand(
"run-1",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
sshExecutionTarget(),
`printf '%s\\n' "$HOME" && echo "it's ok"`,
{
cwd: "/tmp/local",
@@ -74,21 +79,7 @@ describe("runAdapterExecutionTargetShellCommand", () => {
await runAdapterExecutionTargetShellCommand(
"run-1b",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
sshExecutionTarget(),
"env",
{
cwd: "/tmp/local",
@@ -122,21 +113,7 @@ describe("runAdapterExecutionTargetShellCommand", () => {
const result = await runAdapterExecutionTargetShellCommand(
"run-2",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
sshExecutionTarget(),
"sleep 10",
{
cwd: "/tmp/local",
@@ -167,21 +144,7 @@ describe("runAdapterExecutionTargetShellCommand", () => {
const result = await runAdapterExecutionTargetShellCommand(
"run-3",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
sshExecutionTarget(),
"false",
{
cwd: "/tmp/local",
@@ -272,21 +235,7 @@ describe("runAdapterExecutionTargetProcess", () => {
await runAdapterExecutionTargetProcess(
"run-ssh-process",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
sshExecutionTarget(),
"agent-cli",
["--json"],
{
@@ -367,21 +316,7 @@ describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => {
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId: "run-skip",
target: {
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
target: sshExecutionTarget(),
installCommand: "npm install -g @google/gemini-cli",
cwd: "/tmp/local",
env: {},
@@ -392,21 +327,7 @@ describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => {
});
describe("resolveAdapterExecutionTargetCwd", () => {
const sshTarget = {
kind: "remote" as const,
transport: "ssh" as const,
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
};
const sshTarget = sshExecutionTarget();
it("falls back to the remote cwd when no adapter cwd is configured", () => {
expect(resolveAdapterExecutionTargetCwd(sshTarget, "", "/Users/host/repo/server")).toBe(
@@ -667,6 +667,7 @@ describe("sandbox managed runtime", () => {
},
};
const runtimeStatuses: string[] = [];
const run = vi.spyOn(client, "run");
attachFallbackSyncIn(client);
const prepared = await prepareSandboxManagedRuntime({
@@ -725,6 +726,7 @@ describe("sandbox managed runtime", () => {
]));
await expect(readFile(path.join(localWorkspaceDir, cloneStaging), "utf8")).resolves.toBe("Incomplete private clone");
expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing workspace");
expect(run.mock.calls.some(([command]) => command.includes("workspace-download.tar"))).toBe(true);
});
it("falls back to the host-known byte count when the provider reports 0 for inbound workspace sync", async () => {
@@ -1062,6 +1064,7 @@ describe("sandbox managed runtime", () => {
await expect(readFile(path.join(localWorkspaceDir, "deleted.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(localWorkspaceDir, "node_modules", "cache.bin"), "utf8")).resolves.toBe("do not upload\n");
expect(runtimeStatuses.at(-1)?.phase).toBe("finalize");
expect(downloadedTars).toHaveLength(1);
const downloadMembers = await listTarMembers(rootDir, "workspace-download-list.tar", downloadedTars[0]!.bytes);
expect(downloadMembers.some((entry) => entry === ".git" || entry.startsWith(".git/"))).toBe(false);
@@ -4101,6 +4104,26 @@ describe("sandbox managed runtime outbound coordinator", () => {
expect(control.settled).toEqual(expect.arrayContaining(["workspace", "home", "private-asset"]));
});
it("restores non-git workspace bytes through native syncOut without a fallback tar", async () => {
const { workspaceDir, remoteWorkspaceDir } = await makeOutboundDirs([]);
const client = makeGatedOutboundClient(false, async (_label, run) => run());
const syncOut = vi.spyOn(client, "syncOut");
const run = vi.spyOn(client, "run");
const prepared = await prepareSandboxManagedRuntime({
spec: makeSpec(remoteWorkspaceDir), adapterKey: "test-adapter", client, workspaceLocalDir: workspaceDir,
});
await writeFile(path.join(remoteWorkspaceDir, "file.txt"), "updated native bytes\n", "utf8");
await writeFile(path.join(remoteWorkspaceDir, "remote-only.txt"), "native sync\n", "utf8");
await prepared.restoreWorkspace();
expect(syncOut).toHaveBeenCalledTimes(1);
expect(syncOut.mock.calls[0]?.[0].flatMap((operation) => operation.files)).toEqual([
expect.objectContaining({ kind: "directory", sourcePath: remoteWorkspaceDir }),
]);
await expect(readFile(path.join(workspaceDir, "file.txt"), "utf8")).resolves.toBe("updated native bytes\n");
await expect(readFile(path.join(workspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("native sync\n");
expect(run.mock.calls.some(([command]) => command.includes("workspace-download.tar"))).toBe(false);
});
it("with concurrency permitted, an asset restore starts while the workspace restore is held open", async () => {
const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home"]);
const { control, gate } = makeOutboundControl();
@@ -283,7 +283,7 @@ model or rely on an LLM choosing the desired timing.
| Concurrent writes held at filesystem commit; authority restart | One write, one audit record, same receipt; revoked authorization rejected | Server `agent-instruction-tools.integration.test.ts` |
| Failure after file rename or rollback while saving the receipt | Durable attempt and audit evidence survive; no success claim and no second write | Server `agent-instruction-tools.integration.test.ts` |
| Receipt commits but its database acknowledgement is lost | Exact original result replays; one file-update audit and one attempt record | Server `agent-instruction-tools.integration.test.ts` |
| Tool handler throws an unknown-outcome error versus a validation error | Unknown propagates without a completed tool event; validation returns an ordinary failed response | `codex-app-server-driver.test.ts` |
| Tool handler throws an unknown-outcome error versus a validation error | Unknown propagates without a completed tool event; validation returns an ordinary failed response | `codex-app-server-driver.semantic-results.test.ts` |
| Invalid arguments, oversized input, or stale base; restart and replay | Original failure replays with zero writes, even after the base becomes valid | Server `agent-instruction-tools.integration.test.ts` |
| Failure receipt cannot be stored | No unrecorded final failure; attempt remains unsettled and never reexecutes | Server `agent-instruction-tools.integration.test.ts` |
| Long Unicode input and result; corrupted input digest | Exact bytes across persistence/wire; no copied input in result; bad proof blocks settlement | Rust durable-state tests; `durable-prp-control-plane.test.ts` |
@@ -130,6 +130,6 @@ Run deterministic conformance first, then the real Codex boundary:
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts \
src/drivers/codex/codex-app-server-driver \
src/mock-core/live-console-demo-server.test.ts
```
@@ -42,7 +42,7 @@ use a separate clean operator workspace rather than bypassing that guard.
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/app-server-transport.test.ts \
src/drivers/codex/codex-app-server-driver.test.ts
src/drivers/codex/codex-app-server-driver
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
@@ -14,11 +14,11 @@ From the repository root, run:
```sh
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/codex-app-server-driver.test.ts \
src/drivers/codex/codex-app-server-driver \
src/mock-core/live-console-demo-server.test.ts
```
Expected result: both files pass. The tests cover requests, goals, lineage,
Expected result: the Codex driver suites and Live console server suite pass. The tests cover requests, goals, lineage,
steering, interrupt races, reconnect, replay, and redaction.
## Step 2: Start the server
@@ -0,0 +1,21 @@
# Codex driver test ownership
The driver tests are split by contract:
- lifecycle: startup, session lifetime, shutdown and provider admission
- context-control: instructions, models, permissions, goals and turn controls
- events: canonical event and provider-notification projection
- runtime-requests: question and approval request/response protocols
- semantic-results: dynamic tools, completion receipts and unknown outcomes
- recovery: snapshot, resume, reconciliation and current tool refresh
- conformance: replay validation and controller-owned result contracts
Use the existing suites and `codex-app-server-driver.test-support.ts`. The
retired `codex-app-server-driver.test.ts` duplicated the split suites; do not
recreate it or replay one complete contract in multiple files. When splitting
another test file, move its cases and retire the old copies in the same change.
Preserve table rows, waits, actions and assertions, including failure cases.
Keep provider protocol and native lifecycle layers independent when they test
different owners. Test workspaces must use the disposable real `WORKSPACE`
fixture; do not use a managed Codex checkout as model-writable workspace.
@@ -289,18 +289,20 @@ describe("Codex app-server Codex driver", () => {
workingDirectory: WORKSPACE,
}),
).rejects.toThrow("cannot overlap host CODEX_HOME");
await expect(
makeDriver([], {
environment: {
HOME: `${WORKSPACE}/host-home`,
CODEX_HOME: "/isolated/codex",
},
}).openSession({
runId: "run-host-home",
normalizedSessionId: "normalized-host-home",
workingDirectory: WORKSPACE,
}),
).rejects.toThrow("cannot contain the host HOME");
for (const hostHome of [WORKSPACE, `${WORKSPACE}/host-home`]) {
await expect(
makeDriver([], {
environment: {
HOME: hostHome,
CODEX_HOME: "/isolated/codex",
},
}).openSession({
runId: "run-host-home",
normalizedSessionId: "normalized-host-home",
workingDirectory: WORKSPACE,
}),
).rejects.toThrow("cannot contain the host HOME");
}
await expect(
makeDriver([]).openSession({
runId: "run-root",
@@ -614,15 +616,16 @@ describe("Codex app-server Codex driver", () => {
// Both denials a real app-server sends: the method is absent, and the
// build has the feature switched off.
for (const denial of [
new CodexRpcError(
'{"code":-32601,"message":"method not found"}',
CODEX_METHOD_NOT_FOUND,
),
new CodexRpcError(
'{"code":-32600,"message":"goals feature is disabled"}',
CODEX_INVALID_REQUEST,
),
for (const { denial, availability } of [
{ denial: new CodexRpcError(
'{"code":-32601,"message":"method not found"}', CODEX_METHOD_NOT_FOUND,
), availability: "unsupported" },
{ denial: new CodexRpcError(
'{"code":-32600,"message":"goals feature is disabled"}', CODEX_INVALID_REQUEST,
), availability: "policy_disabled" },
{ denial: new CodexRpcError(
'{"code":-32004,"message":"goals feature is disabled by policy"}', -32_004,
), availability: "policy_disabled" },
]) {
const unsupportedTransport = new FakeCodexTransport();
unsupportedTransport.rejectMethods.set("thread/goal/get", denial);
@@ -635,6 +638,11 @@ describe("Codex app-server Codex driver", () => {
expect((await unsupportedDriver.descriptor()).capabilities).toMatchObject(
{ goals: false },
);
const unsupportedEvents = unsupported.events()[Symbol.asyncIterator]();
await unsupportedEvents.next();
await expect(unsupportedEvents.next()).resolves.toMatchObject({
value: { eventType: "session.capabilities.updated", payload: { sessionGoals: { availability } } },
});
await expect(
unsupported.goal?.({ action: "get" }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
@@ -681,4 +689,189 @@ describe("Codex app-server Codex driver", () => {
}
});
it.each([false, true])("requires trusted continuation metadata before omitting task context (%s)", async (continuation) => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport], {
skillInputs: [{ type: "skill", name: "first-task", path: "/skills/first-task/SKILL.md" }],
}).openSession({ runId: "run-delta", normalizedSessionId: "session-delta", workingDirectory: WORKSPACE });
const text = JSON.stringify({ schema: "paperclip.native-continuation.v1", events: '{"messages":[{"body":"Go ahead"}]}', completion: { revision: "2", criterionIds: ["comment"] } });
await session.startTurn({ message: { role: "user", text }, ...(continuation ? { continuation: true as const } : {}) });
const params = transport.calls.find((call) => call.method === "turn/start")!.params;
if (continuation) {
expect(params.input).toEqual([{ type: "text", text, text_elements: [] }]);
expect(JSON.stringify(params.input)).not.toContain("constraints");
expect(JSON.stringify(params.input)).not.toContain("first-task");
} else {
expect(JSON.stringify(params.input)).toContain("constraints");
expect(params.input).toContainEqual({ type: "skill", name: "first-task", path: "/skills/first-task/SKILL.md" });
}
await session.close({ reason: "test complete" });
});
it("sends the selected reasoning effort with the Codex turn", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport], { model: "gpt-6-astra", reasoningEffort: "ultra" }).openSession({
runId: "run-effort",
normalizedSessionId: "session-effort",
workingDirectory: WORKSPACE,
});
await session.startTurn({ message: { role: "user", text: "Continue" } });
expect(transport.calls.find((call) => call.method === "turn/start")?.params).toMatchObject({ effort: "ultra" });
await session.close({ reason: "test complete" });
});
it("uses negotiated ACP controls through the Codex transport facade", async () => {
let controls = { steering: false, queuedFollowUp: false };
const transport = Object.assign(new FakeCodexTransport(), { turnControlCapabilities: () => ({ ...controls }) });
const session = await makeDriver([transport], {
driverIdentity: { kind: "acpx_runtime", displayName: "Pi ACP", version: "test" },
capabilities: { steering: false },
}).openSession({ runId: "run-pi", normalizedSessionId: "session-pi", workingDirectory: WORKSPACE });
try {
expect(session.turnControlCapabilities?.()).toEqual(controls);
const { turnId } = await session.startTurn({ message: { role: "user", text: "Work" } });
await expect(session.steer?.({ turnId, message: { role: "user", text: "Change" } })).rejects.toThrow("not negotiated");
controls = { steering: true, queuedFollowUp: true };
expect(session.turnControlCapabilities?.()).toEqual(controls);
const input = { turnId, correlationId: "queued-1", mode: "follow_up" as const, message: { role: "user" as const, text: "Then validate" } };
await session.steer?.(input);
expect(transport.calls.find(call => call.method === "turn/steer")?.params).toMatchObject({ expectedTurnId: turnId, mode: "follow_up", correlationId: "queued-1" });
await expect(session.steer?.(input)).rejects.toThrow("already acknowledged");
expect(transport.calls.filter(call => call.method === "turn/steer")).toHaveLength(1);
} finally { await session.close({ reason: "verified" }); }
});
it("keeps native follow-up disabled for the Codex app-server driver", async () => {
const transport = Object.assign(new FakeCodexTransport(), { turnControlCapabilities: () => ({ steering: true, queuedFollowUp: true }) });
const session = await makeDriver([transport]).openSession({ runId: "run-codex", normalizedSessionId: "session-codex", workingDirectory: WORKSPACE });
try {
expect(session.turnControlCapabilities?.()).toEqual({ steering: true, queuedFollowUp: false });
const { turnId } = await session.startTurn({ message: { role: "user", text: "Work" } });
await expect(session.steer?.({ turnId, mode: "follow_up", message: { role: "user", text: "Queue" } })).rejects.toThrow("not negotiated");
expect(transport.calls.filter(call => call.method === "turn/steer")).toHaveLength(0);
} finally { await session.close({ reason: "verified" }); }
});
it("preserves an inactive goal status while editing its objective", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-goal-inactive-edit",
normalizedSessionId: "normalized-goal-inactive-edit",
workingDirectory: WORKSPACE,
});
await session.goal?.({
action: "set",
objective: "Updated while paused",
status: "paused",
});
expect(
transport.calls.filter(({ method }) => method === "thread/goal/set"),
).toContainEqual({
method: "thread/goal/set",
params: {
threadId: "thread-1",
objective: "Updated while paused",
status: "paused",
},
});
await session.close({ reason: "fixture complete" });
});
it("rolls back only a definitely rejected idle goal autostart", async () => {
const definiteTransport = new FakeCodexTransport();
const definiteSession = await makeDriver([definiteTransport]).openSession({
runId: "run-goal-autostart-definite-rejection",
normalizedSessionId: "normalized-goal-autostart-definite-rejection",
workingDirectory: WORKSPACE,
});
definiteTransport.rejectMethods.set(
"thread/goal/set",
new CodexRpcError('{"code":-32603,"message":"internal error"}', -32_603),
);
await expect(
definiteSession.goal?.({
action: "set",
objective: "Rejected before a turn starts",
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
definiteTransport.rejectMethods.delete("thread/goal/set");
await expect(
definiteSession.startTurn({
message: { role: "user", text: "A normal turn may still start." },
}),
).resolves.toMatchObject({ turnId: "turn-1" });
await definiteSession.close({ reason: "fixture complete" });
const ambiguousTransport = new FakeCodexTransport();
const ambiguousSession = await makeDriver([ambiguousTransport]).openSession({
runId: "run-goal-autostart-ambiguous",
normalizedSessionId: "normalized-goal-autostart-ambiguous",
workingDirectory: WORKSPACE,
});
ambiguousTransport.rejectMethods.set(
"thread/goal/set",
new Error("codex app-server transport closed"),
);
await expect(
ambiguousSession.goal?.({
action: "set",
objective: "The provider may have started this goal",
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
ambiguousTransport.rejectMethods.delete("thread/goal/set");
await expect(
ambiguousSession.startTurn({
message: { role: "user", text: "Do not create competing work." },
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
await ambiguousSession.close({ reason: "fixture complete" });
});
it("preserves a provider-neutral goal action subset through the Codex transport facade", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport], {
goalCapability: {
actions: ["set", "clear"],
autonomousUpdates: true,
persistentAcrossResume: true,
maxObjectiveChars: 4_000,
tokenBudgetControl: false,
usageReporting: true,
},
});
const session = await driver.openSession({
runId: "run-goals-action-subset",
normalizedSessionId: "normalized-goals-action-subset",
workingDirectory: WORKSPACE,
});
const events = session.events()[Symbol.asyncIterator]();
await events.next();
await expect(events.next()).resolves.toMatchObject({
value: {
eventType: "session.capabilities.updated",
payload: {
sessionGoals: {
availability: "available",
actions: ["set", "clear"],
tokenBudgetControl: false,
},
},
},
});
await expect(
session.goal?.({ action: "pause" }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
expect(
transport.calls.filter(({ method }) => method === "thread/goal/set"),
).toHaveLength(0);
await expect(
session.goal?.({ action: "set", objective: "Finish the task" }),
).resolves.toMatchObject({ objective: "Finish the task" });
await expect(session.goal?.({ action: "clear" })).resolves.toBeNull();
await session.close({ reason: "fixture complete" });
});
});
@@ -1302,4 +1302,29 @@ describe("Codex app-server Codex driver", () => {
});
});
it("refreshes authorized tools on resume without replacing the provider thread", async () => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const original = await makeDriver([first], { conversationMode: "prepared", dynamicTools: [] }).openSession({
runId: "run-connect", normalizedSessionId: "normalized-connect", workingDirectory: WORKSPACE,
});
const snapshot = await original.snapshot();
await original.close({ reason: "waiting for connection" });
const githubTool = { name: "github_search", description: "Search authorized repositories", inputSchema: { type: "object" } };
const handler = vi.fn(async () => ({ repositories: ["paperclip"] }));
const resumedDriver = makeDriver([second], { conversationMode: "prepared", dynamicTools: [githubTool], dynamicToolHandler: handler });
expect((await resumedDriver.descriptor()).capabilities.toolRefreshOnResume).toBe(true);
const recovery = await resumedDriver.recoverSession(snapshot);
expect(recovery.recovered).toBe(true);
await recovery.session!.startTurn({ message: { role: "user", text: "Continue with GitHub connected" } });
expect(second.calls.some(call => call.method === "thread/start")).toBe(false);
expect(second.calls.find(call => call.method === "thread/resume")?.params).toMatchObject({ threadId: "thread-1", dynamicTools: expect.arrayContaining([githubTool]) });
const result = await second.invoke({ id: "rpc-github", method: "item/tool/call", params: {
threadId: "thread-1", turnId: "turn-1", callId: "github-call", tool: "github_search", arguments: { query: "paperclip" },
} });
expect(result).toMatchObject({ success: true });
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "github_search", threadId: "thread-1" }));
await recovery.session?.close({ reason: "test done" });
});
});
@@ -1,3 +1,4 @@
import { SemanticToolOutcomeUnknownError } from "../../contracts/native-session-backend.js";
import {
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA,
CODEX_INVALID_REQUEST,
@@ -862,4 +863,159 @@ describe("Codex app-server Codex driver", () => {
?.payload,
).toMatchObject({ reportedWorkDisposition: "blocked" });
});
it("exposes and dispatches only explicit chat tools across fresh and resumed direct chat", async () => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const registerDeliverable = {
name: "register_deliverable",
description: "Prepare one requested file.",
inputSchema: { type: "object", properties: {} },
};
const readCurrentWakeComments = {
name: "read_current_wake_comments",
description: "Read only comments bound into the current wake.",
inputSchema: { type: "object", properties: {} },
};
const requestHumanInput = {
name: "request_human_input",
description: "Ask one structured question through Paperclip.",
inputSchema: { type: "object", properties: {} },
};
const listChatAttachments = {
name: "list_chat_attachments",
description: "List same-conversation attachment metadata.",
inputSchema: { type: "object", properties: {} },
};
const reuseChatAttachment = {
name: "reuse_chat_attachment",
description: "Prepare one same-conversation attachment again.",
inputSchema: { type: "object", properties: {} },
};
const readChatAttachment = {
name: "read_chat_attachment",
description: "Read one same-conversation file without resending it.",
inputSchema: { type: "object", properties: {} },
};
const handler = vi.fn(async (call) => ({
interaction: { id: "interaction-direct-question", status: "pending" },
callId: call.callId,
}));
const driver = makeDriver([first, second], {
conversationMode: "direct",
dynamicTools: [
registerDeliverable,
readCurrentWakeComments,
requestHumanInput,
listChatAttachments,
reuseChatAttachment,
readChatAttachment,
{
name: "report_progress",
description: "Must remain unavailable in direct chat.",
inputSchema: { type: "object", properties: {} },
},
],
dynamicToolHandler: handler,
});
const original = await driver.openSession({
runId: "run-direct-file",
normalizedSessionId: "normalized-direct-file",
workingDirectory: WORKSPACE,
});
await original.startTurn({
message: { role: "user", text: "Please return one file." },
});
const snapshot = await original.snapshot();
await original.close({ reason: "transport lost" });
expect(
first.calls.find((call) => call.method === "thread/start")?.params
.dynamicTools,
).toEqual([
registerDeliverable,
readCurrentWakeComments,
requestHumanInput,
listChatAttachments,
reuseChatAttachment,
readChatAttachment,
]);
const freshQuestion = await first.invoke({
id: "rpc-direct-question-fresh",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-direct-question-fresh",
tool: "request_human_input",
arguments: { interactionKind: "questions" },
},
});
expect(freshQuestion).toMatchObject({ success: true });
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
tool: "request_human_input",
callId: "call-direct-question-fresh",
arguments: { interactionKind: "questions" },
}),
);
const recovery = await driver.recoverSession?.(snapshot);
expect(recovery).toMatchObject({ recovered: true });
expect(
second.calls.find((call) => call.method === "thread/resume")?.params
.dynamicTools,
).toEqual([
registerDeliverable,
readCurrentWakeComments,
requestHumanInput,
listChatAttachments,
reuseChatAttachment,
readChatAttachment,
]);
const resumedQuestion = await second.invoke({
id: "rpc-direct-question-resumed",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-direct-question-resumed",
tool: "request_human_input",
arguments: { interactionKind: "confirmation" },
},
});
expect(resumedQuestion).toMatchObject({ success: true });
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
tool: "request_human_input",
callId: "call-direct-question-resumed",
arguments: { interactionKind: "confirmation" },
}),
);
await recovery?.session?.close({ reason: "test complete" });
});
it.each(["unknown", "validation"])("preserves the dynamic tool outcome boundary (%s)", async (outcome) => {
const transport = new FakeCodexTransport();
const error = outcome === "unknown"
? new SemanticToolOutcomeUnknownError("write may have committed")
: new Error("invalid instruction input");
const handler = vi.fn(async () => { throw error; });
const session = await makeDriver([transport], {
dynamicTools: [{ name: "update_agent_instructions", description: "Write instructions.", inputSchema: { type: "object" } }],
dynamicToolHandler: handler,
}).openSession({ runId: "run-uncertain-write", normalizedSessionId: "session-uncertain-write", workingDirectory: WORKSPACE });
await session.startTurn({ message: { role: "user", text: "Update instructions." } });
const response = transport.invoke({ id: "rpc-write", method: "item/tool/call", params: {
threadId: "thread-1", turnId: "turn-1", callId: "uncertain-write", tool: "update_agent_instructions", arguments: {},
} });
if (outcome === "unknown") await expect(response).rejects.toBe(error);
else await expect(response).resolves.toMatchObject({ success: false });
transport.push("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } });
const events = await collectUntilTerminal(session.events());
const completions = events.filter((event) => event.eventType === "item.completed" && event.itemId === "uncertain-write");
expect(completions).toHaveLength(outcome === "unknown" ? 0 : 1);
expect(handler).toHaveBeenCalledTimes(1);
});
});
File diff suppressed because it is too large. Load diff
@@ -3909,41 +3909,7 @@ describe("Daytona sandbox provider plugin", () => {
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
});
it("rejects a late execute after the release tombstone is set", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox({ id: "lease-a" });
let stopStarted = false;
let resolveRelease!: () => void;
const releaseGate = new Promise<void>((resolve) => {
resolveRelease = resolve;
});
sandbox.stop.mockImplementation(() => {
stopStarted = true;
return releaseGate;
});
mockGet.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
const releasePromise = plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: "lease-a",
config: { timeoutMs: 300000, reuseLease: true },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(stopStarted).toBe(true);
const overlappingExec = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
await expect(overlappingExec).rejects.toThrow(/no longer active/);
resolveRelease();
await releasePromise;
expect(mockGet).toHaveBeenCalledTimes(1);
expect(sandbox.stop).toHaveBeenCalledTimes(1);
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
});
it("cancels active work before waiting for a stalled execute to drain", async () => {
process.env.DAYTONA_API_KEY = "host-key";
@@ -30,6 +30,12 @@ describe("Slack app manifest", () => {
expect(manual.settings.event_subscriptions.request_url).toBe(input.webhookUrl);
expect(manual.settings.interactivity).toEqual({ is_enabled: true, request_url: input.webhookUrl });
expect(manual.settings.socket_mode_enabled).toBe(false);
expect(manual.features).toHaveProperty("agent_view");
expect(manual.features).not.toHaveProperty("assistant_view");
expect(manual.features.bot_user).not.toHaveProperty("always_online");
expect(manual.features.app_home).toEqual({
home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false,
});
// The reviewed provider permissions/events stay pinned across both creation paths.
expect(manual.oauth_config.scopes.bot).toMatchSnapshot();
expect(manual.settings.event_subscriptions.bot_events).toMatchSnapshot();
File diff suppressed because it is too large. Load diff
@@ -10882,158 +10882,42 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
},
);
it("requeues accepted interaction continuations stranded in_review without execution state", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
async function seedResolvedInteractionRecoveryFixture(input: {
title: string;
kind?: "request_confirmation" | "ask_user_questions";
issueStatus?: "in_review" | "in_progress";
agentName?: string;
}) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), interactionId = randomUUID();
const resolvedAt = new Date("2026-03-19T00:05:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
id: companyId, name: "Paperclip", issuePrefix, defaultResponsibleUserId: "responsible-user", requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
},
permissions: {},
id: agentId, companyId, name: input.agentName ?? "CodexCoder", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } }, permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Accepted plan never resumed",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
id: issueId, companyId, title: input.title, status: input.issueStatus ?? "in_review", priority: "medium", assigneeAgentId: agentId,
responsibleUserId: "responsible-user", issueNumber: 1, identifier: `${issuePrefix}-1`,
});
const kind = input.kind ?? "request_confirmation";
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
createdByAgentId: agentId,
resolvedByUserId: "responsible-user",
resolvedAt,
updatedAt: resolvedAt,
payload: { version: 1, prompt: "Approve the plan?" },
result: { outcome: "accepted" },
id: interactionId, companyId, issueId, kind, status: kind === "ask_user_questions" ? "answered" : "accepted",
continuationPolicy: "wake_assignee_on_accept", createdByAgentId: agentId, resolvedByUserId: "responsible-user", resolvedAt, updatedAt: resolvedAt,
payload: kind === "ask_user_questions" ? { version: 1, questions: [{
id: "format", prompt: "Choose a format", selectionMode: "single", required: true, options: [{ id: "markdown", label: "Markdown" }],
}] } : { version: 1, prompt: "Approve the plan?" },
result: kind === "ask_user_questions" ? { version: 1, answers: [{ questionId: "format", optionIds: ["markdown"] }] } : { outcome: "accepted" },
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const run = await db
.select({
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
retryOfRunId: heartbeatRuns.retryOfRunId,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(run?.agentId).toBe(agentId);
expect(run?.retryOfRunId).toBeNull();
expect(run?.contextSnapshot).toMatchObject({
issueId,
taskId: issueId,
wakeReason: "issue_continuation_needed",
retryReason: "issue_continuation_needed",
source: "issue.interaction_continuation_recovery",
interactionId,
interactionKind: "request_confirmation",
interactionStatus: "accepted",
interactionContinuationPolicy: "wake_assignee_on_accept",
interactionResolvedAt: resolvedAt.toISOString(),
});
});
return { companyId, agentId, issueId, interactionId, resolvedAt };
}
it("recovers an answered question with its interaction-specific continuation context", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const resolvedAt = new Date("2026-03-19T00:05:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "OpenCodeCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Answered question never resumed",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "ask_user_questions",
status: "answered",
continuationPolicy: "wake_assignee_on_accept",
createdByAgentId: agentId,
resolvedByUserId: "responsible-user",
resolvedAt,
updatedAt: resolvedAt,
payload: {
version: 1,
questions: [
{
id: "format",
prompt: "Choose a format",
selectionMode: "single",
required: true,
options: [{ id: "markdown", label: "Markdown" }],
},
],
},
result: {
version: 1,
answers: [{ questionId: "format", optionIds: ["markdown"] }],
},
});
const { companyId, agentId, issueId, interactionId, resolvedAt } =
await seedResolvedInteractionRecoveryFixture({ title: "Answered question never resumed", kind: "ask_user_questions", issueStatus: "in_progress", agentName: "OpenCodeCoder" });
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
@@ -11142,58 +11026,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
it("counts five historical review-park cancellations against the upgraded disposition-repair ceiling", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const resolvedAt = new Date("2026-03-19T00:05:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Accepted plan cancellation loop",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
createdByAgentId: agentId,
resolvedByUserId: "responsible-user",
resolvedAt,
updatedAt: resolvedAt,
payload: { version: 1, prompt: "Approve the plan?" },
result: { outcome: "accepted" },
});
const { companyId, agentId, issueId, interactionId, resolvedAt } =
await seedResolvedInteractionRecoveryFixture({ title: "Accepted plan cancellation loop" });
for (let attempt = 1; attempt <= 5; attempt += 1) {
const finishedAt = new Date(resolvedAt.getTime() + attempt * 60_000);
await db.insert(heartbeatRuns).values({
@@ -11284,59 +11118,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
it("skips accepted interaction recovery after its continuation succeeds", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const resolvedAt = new Date("2026-03-19T00:05:00.000Z");
const { companyId, agentId, issueId, interactionId, resolvedAt } =
await seedResolvedInteractionRecoveryFixture({ title: "Accepted plan already resumed" });
const succeededAt = new Date("2026-03-19T00:06:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Accepted plan already resumed",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
createdByAgentId: agentId,
resolvedByUserId: "responsible-user",
resolvedAt,
updatedAt: resolvedAt,
payload: { version: 1, prompt: "Approve the plan?" },
result: { outcome: "accepted" },
});
await db.insert(heartbeatRuns).values({
id: randomUUID(),
companyId,
@@ -11373,59 +11157,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
it("requeues accepted interaction continuations even when a later successful run is unrelated", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const resolvedAt = new Date("2026-03-19T00:05:00.000Z");
const { companyId, agentId, issueId, interactionId, resolvedAt } =
await seedResolvedInteractionRecoveryFixture({ title: "Accepted plan masked by unrelated run" });
const unrelatedRunAt = new Date("2026-03-19T00:06:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Accepted plan masked by unrelated run",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
createdByAgentId: agentId,
resolvedByUserId: "responsible-user",
resolvedAt,
updatedAt: resolvedAt,
payload: { version: 1, prompt: "Approve the plan?" },
result: { outcome: "accepted" },
});
await db.insert(heartbeatRuns).values({
id: randomUUID(),
companyId,
@@ -11474,58 +11208,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
// *no* wake request and *no* run at all. Pre-P1 the recovery sweep skipped in_review issues
// lacking an execution policy, so this limbo persisted forever. The sweep now requeues it.
it("recovers a plan approval whose accept-time continuation wake enqueue was silently dropped", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const resolvedAt = new Date("2026-03-19T00:05:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Approved plan whose wake enqueue was dropped",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
createdByAgentId: agentId,
resolvedByUserId: "responsible-user",
resolvedAt,
updatedAt: resolvedAt,
payload: { version: 1, prompt: "Approve the plan?" },
result: { outcome: "accepted" },
});
const { companyId, agentId, issueId, interactionId, resolvedAt } =
await seedResolvedInteractionRecoveryFixture({ title: "Approved plan whose wake enqueue was dropped" });
// Precondition of the silent-enqueue-drop bug: the accept produced no wake and no run.
const priorWakeups = await db
@@ -11549,15 +11233,23 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.select({
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
retryOfRunId: heartbeatRuns.retryOfRunId,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId))
.then((rows) => rows[0] ?? null);
expect(run?.agentId).toBe(agentId);
expect(run?.retryOfRunId).toBeNull();
expect(run?.contextSnapshot).toMatchObject({
issueId,
taskId: issueId,
wakeReason: "issue_continuation_needed",
retryReason: "issue_continuation_needed",
interactionId,
interactionKind: "request_confirmation",
interactionStatus: "accepted",
interactionContinuationPolicy: "wake_assignee_on_accept",
interactionResolvedAt: resolvedAt.toISOString(),
source: "issue.interaction_continuation_recovery",
});
@@ -17089,5 +16781,4 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
});
@@ -1,79 +0,0 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
function assertReadOnlyInteractionsGet(source: string) {
const start = source.search(
/router\.get\(\s*["']\/issues\/:id\/interactions["']/,
);
expect(start).toBeGreaterThanOrEqual(0);
const followingRoutes = source.slice(start);
const end = followingRoutes.search(
/router\.post\(\s*["']\/issues\/:id\/interactions["']/,
);
expect(end).toBeGreaterThan(0);
const handler = followingRoutes.slice(0, end);
expect(handler).toContain("listForIssue");
expect(handler).not.toContain("expireRequestConfirmations");
expect(handler).not.toContain("expirePendingInteractions");
expect(handler).not.toContain("logActivity");
}
describe("issue interactions GET contract", () => {
it("does not perform expiry sweeps or activity writes", () => {
const source = fs.readFileSync(
new URL("../routes/issues.ts", import.meta.url),
"utf8",
);
assertReadOnlyInteractionsGet(source);
});
it.each(["", "\n "])(
"accepts a read-only GET with route whitespace %j",
(whitespace) => {
assertReadOnlyInteractionsGet(`
router.get(${whitespace}"/issues/:id/interactions", async (req, res) => {
res.json(await service.listForIssue(req.params.id));
});
router.post(${whitespace}"/issues/:id/interactions", async () => {
await service.expirePendingInteractions();
await logActivity();
});
`);
},
);
it.each([
"expireRequestConfirmations",
"expirePendingInteractions",
"logActivity",
])("still rejects %s inside a formatted GET", (mutation) => {
expect(() =>
assertReadOnlyInteractionsGet(`
router.get(
"/issues/:id/interactions", async (req, res) => {
await service.${mutation}();
res.json(await service.listForIssue(req.params.id));
},
);
router.post(
"/issues/:id/interactions", async () => {},
);
`),
).toThrow();
});
it.each(["get", "post"])(
"fails closed if the %s boundary is missing",
(missingMethod) => {
const routes = ["get", "post"]
.filter((method) => method !== missingMethod)
.map(
(method) =>
`router.${method}("/issues/:id/interactions", () => service.listForIssue());`,
)
.join("\n");
expect(() => assertReadOnlyInteractionsGet(routes)).toThrow();
},
);
});
@@ -629,18 +629,21 @@ describe("issue thread interaction routes", () => {
}), expect.anything());
});
it("does not run historical-comment catch-up or queue recovery from the interaction read path", async () => {
it.each(["in_review", "done"])("keeps interaction reads free of expiry, activity writes, and recovery while %s", async (status) => {
mockIssueService.getById.mockResolvedValue(createIssue({
status: "in_review",
status,
assigneeAgentId: ASSIGNEE_AGENT_ID,
}));
mockInteractionService.listForIssue.mockResolvedValue([]);
await request(await createApp())
.get("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
.expect(200);
.expect(200, []);
expect(mockInteractionService.listForIssue).toHaveBeenCalledExactlyOnceWith(ISSUE_ID);
expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).not.toHaveBeenCalled();
expect(mockInteractionService.expirePendingInteractionsForTerminalIssue).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
-175
View File
@@ -5187,82 +5187,6 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
await tempDb?.cleanup();
});
it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
sharedWorkspaceKey: "workspace-key",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
providerRef: `/tmp/${executionWorkspaceId}`,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
});
expect(child.parentId).toBe(parentIssueId);
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "isolated_workspace",
workspaceRuntime: { profile: "agent" },
});
});
it("preserves the parent project when a generic child create inherits workspace linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@@ -5463,105 +5387,6 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const parentIssueId = randomUUID();
const parentProjectWorkspaceId = randomUUID();
const parentExecutionWorkspaceId = randomUUID();
const explicitProjectWorkspaceId = randomUUID();
const explicitExecutionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values([
{
id: parentProjectWorkspaceId,
companyId,
projectId,
name: "Parent workspace",
},
{
id: explicitProjectWorkspaceId,
companyId,
projectId,
name: "Explicit workspace",
},
]);
await db.insert(executionWorkspaces).values([
{
id: parentExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Parent worktree",
status: "active",
providerType: "git_worktree",
},
{
id: explicitExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId: explicitProjectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Explicit shared workspace",
status: "active",
providerType: "local_fs",
},
]);
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId: parentProjectWorkspaceId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId: parentExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const child = await svc.create(companyId, {
parentId: parentIssueId,
projectId,
title: "Child issue",
projectWorkspaceId: explicitProjectWorkspaceId,
executionWorkspaceId: explicitExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "shared_workspace",
},
});
expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId);
expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
expect(child.executionWorkspaceSettings).toEqual({
mode: "shared_workspace",
});
});
it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
+48 -119
View File
@@ -3619,6 +3619,49 @@ describeEmbeddedPostgres("tool access service", () => {
}),
]),
);
const gatewayAudit = await db
.select()
.from(activityLog)
.where(
and(
eq(activityLog.companyId, company.id),
eq(activityLog.action, "tool_gateway.approval_requested"),
),
);
expect(gatewayAudit).toEqual(
expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
agentId: agent.id,
details: expect.objectContaining({
source: "test",
actionRequestId: res.body.actionRequestId,
invocationId: res.body.invocationId,
}),
}),
]),
);
const dedicatedAudit = await db
.select()
.from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.companyId, company.id));
expect(dedicatedAudit).toEqual(
expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
details: expect.objectContaining({
source: "test",
agentId: agent.id,
actionRequestId: res.body.actionRequestId,
runId: null,
}),
}),
]),
);
});
it("cancels an ask-first test request when approval signing is unavailable", async () => {
@@ -3672,77 +3715,6 @@ describeEmbeddedPostgres("tool access service", () => {
});
});
it("audits ask-first test calls with the real board actor and selected agent", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Ask first ${randomUUID()}`,
policyType: "require_approval",
priority: 100,
selectors: { connectionId: connection.id },
});
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({
agentId: agent.id,
toolName: "send_email",
parameters: { to: "a@example.com" },
})
.expect(200);
const gatewayAudit = await db
.select()
.from(activityLog)
.where(
and(
eq(activityLog.companyId, company.id),
eq(activityLog.action, "tool_gateway.approval_requested"),
),
);
expect(gatewayAudit).toEqual(
expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
agentId: agent.id,
details: expect.objectContaining({
source: "test",
actionRequestId: res.body.actionRequestId,
invocationId: res.body.invocationId,
}),
}),
]),
);
const dedicatedAudit = await db
.select()
.from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.companyId, company.id));
expect(dedicatedAudit).toEqual(
expect.arrayContaining([
expect.objectContaining({
actorType: "user",
actorId: userId,
details: expect.objectContaining({
source: "test",
agentId: agent.id,
actionRequestId: res.body.actionRequestId,
runId: null,
}),
}),
]),
);
});
it("drives an ask-first test call through its live lifecycle (waiting → approved/done with the real result)", async () => {
const company = await createCompany(db);
@@ -3778,6 +3750,10 @@ describeEmbeddedPostgres("tool access service", () => {
const actionRequestId = created.body.actionRequestId as string;
expect(actionRequestId).toEqual(expect.any(String));
await request(app)
.get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`)
.expect(404);
// 2. Status starts as "waiting" and surfaces the redacted "Where" snapshot.
const waiting = await request(app)
.get(
@@ -3949,24 +3925,6 @@ describeEmbeddedPostgres("tool access service", () => {
});
});
it("404s a single-id test-call status fetch for a non-test-origin action request", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const { connection } = await createRemoteToolFixture(db, company.id);
const gateway = createToolGatewayService(db, {
toolActionSigningSecret: "test-secret",
});
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
gateway,
);
await request(app)
.get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`)
.expect(404);
});
it("returns off for blocked test calls without executing the remote tool", async () => {
const company = await createCompany(db);
@@ -4014,35 +3972,6 @@ describeEmbeddedPostgres("tool access service", () => {
agentId: agent.id,
runId: null,
});
});
it("audits blocked test calls with the real board actor and selected agent", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
await grantBoardUser(db, company.id, userId, ["tools:use"]);
const agent = await createAgent(db, company.id);
const { connection } = await createRemoteToolFixture(db, company.id);
await db.insert(toolPolicies).values({
companyId: company.id,
name: `Block ${randomUUID()}`,
policyType: "block",
priority: 100,
selectors: { connectionId: connection.id },
});
const app = createRouteApp(
db,
boardSessionActor(company.id, "operator", userId),
createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }),
);
const res = await request(app)
.post(`/api/tool-connections/${connection.id}/test-calls`)
.send({
agentId: agent.id,
toolName: "send_email",
parameters: { to: "a@example.com" },
})
.expect(200);
const gatewayAudit = await db
.select()
@@ -4089,6 +4018,7 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
it("denies test calls through agents the board user cannot task", async () => {
const company = await createCompany(db);
const userId = `tool-tester-${randomUUID()}`;
@@ -5536,7 +5466,6 @@ describeEmbeddedPostgres("tool access service", () => {
it("retires saved Composio broker and child records without touching credentials until removal", async () => {
const company = await createCompany(db);
const { parent, child } = await createComposioParentAndChild(db, company.id);
+13
View File
@@ -3640,6 +3640,11 @@ rl.on("line", (line) => {
.from(toolInvocations)
.where(eq(toolInvocations.id, approvalRequest.invocationId));
expect(approvalInvocation).toMatchObject({
companyId: company.id,
actorType: "agent",
agentId: agent.id,
runId: run.id,
issueId: issue.id,
status: "awaiting_approval",
policyDecision: "require_approval",
connectionId: approvalTool.connection.id,
@@ -3648,6 +3653,14 @@ rl.on("line", (line) => {
upstreamToolName: "kv_set",
});
// Matching persisted company/connection rows must still be hidden from
// the Test tab when a real agent run owns the approval.
await expect(gateway.getTestCallStatus({
companyId: company.id,
connectionId: approvalTool.connection.id,
actionRequestId: approvalRequest.id,
})).rejects.toMatchObject({ status: 404, reasonCode: "action_request_not_found" });
await db
.update(issueThreadInteractions)
.set({
@@ -2588,110 +2588,7 @@ process.stderr.write(${JSON.stringify(stderr)}, () => { process.exitCode = ${cod
}
});
it(
"provisions worktree-local pnpm node_modules instead of reusing base-repo links",
async () => {
const repoRoot = await createTempRepo();
await writeRegisteredSourceConfig(repoRoot);
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true });
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "package.json"),
JSON.stringify(
{
name: "workspace-root",
private: true,
packageManager: "pnpm@9.15.4",
},
null,
2,
),
"utf8",
);
await fs.writeFile(
path.join(repoRoot, "pnpm-workspace.yaml"),
["packages:", " - packages/*", " - server", ""].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(repoRoot, "packages", "shared", "package.json"),
JSON.stringify(
{
name: "@repo/shared",
version: "1.0.0",
private: true,
type: "module",
exports: "./index.js",
},
null,
2,
),
"utf8",
);
await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify(
{
name: "server",
private: true,
type: "module",
dependencies: {
"@repo/shared": "workspace:*",
},
},
null,
2,
),
"utf8",
);
await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8");
await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh"));
await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755);
await runPnpm(repoRoot, ["install"]);
await runGit(repoRoot, ["add", "."]);
await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]);
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-551",
title: "Provision local workspace dependencies",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false);
expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false);
await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe(
await fs.realpath(path.join(workspace.cwd, "packages", "shared")),
);
await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe(
await fs.realpath(path.join(repoRoot, "packages", "shared")),
);
},
15_000,
);
it("records worktree setup and provision operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();
+1 -1
View File
@@ -1,4 +1,3 @@
import { nativeChatWorkspaceCwd } from "./native-runtime/native-chat-workspace.js";
import { prepareHeartbeatRuntimeServiceIntents, startHeartbeatRuntimeServices, recordHeartbeatAdapterRuntimeServices } from "./heartbeat/runtime-services.js";
import { prepareHeartbeatExecutionConfiguration } from "./heartbeat/execution-configuration.js";
import { createHeartbeatRunOutput } from "./heartbeat/run-output.js";
@@ -349,6 +348,7 @@ import {
resolveHeartbeatNativeRuntimeMode,
} from "./native-runtime/index.js";
import { nativeChatWorkspaceCwd } from "./native-runtime/native-chat-workspace.js";
import {
parseNativeExecutionInput,
type NativeExecutionInput,
File diff suppressed because it is too large. Load diff
@@ -79,211 +79,13 @@ const previousRunId = "40000000-0000-4000-8000-000000000004";
const currentRunId = "50000000-0000-4000-8000-000000000005";
const normalizedSessionId = "60000000-0000-4000-8000-000000000006";
const CONDITIONAL_READER_TOOL_CONTRACT_FINGERPRINT = `sha256:${createHash(
"sha256",
)
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v1",
tools: [
{ name: "register_deliverable", version: 1 },
{
name: "read_current_wake_comments",
semanticContract: "paperclip.server-current-wake-comments.v1",
version: 1,
},
],
}),
)
.digest("hex")}`;
const PRE_CHAT_ATTACHMENT_REUSE_TOOL_CONTRACT_FINGERPRINT = `sha256:${createHash(
"sha256",
)
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v2",
executionTargetKind: "local",
advertisementPolicy: {
readCurrentWakeComments: "always_advertised_binding_gated.v1",
registerDeliverable: "local_workspace_only.v1",
},
tools: [
{ name: "register_deliverable", version: 1 },
{
name: "read_current_wake_comments",
semanticContract: "paperclip.server-current-wake-comments.v1",
version: 1,
},
],
}),
)
.digest("hex")}`;
const PRE_CHAT_ATTACHMENT_READ_TOOL_CONTRACT_FINGERPRINT = `sha256:${createHash(
"sha256",
)
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v3",
executionTargetKind: "local",
advertisementPolicy: {
readCurrentWakeComments: "always_advertised_binding_gated.v1",
historicalChatAttachments:
"always_advertised_conversation_binding_gated.v1",
registerDeliverable: "local_workspace_only.v1",
},
tools: [
{ name: "register_deliverable", version: 1 },
{
name: "read_current_wake_comments",
semanticContract: "paperclip.server-current-wake-comments.v1",
version: 1,
},
{
name: "list_chat_attachments",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
{
name: "reuse_chat_attachment",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
],
}),
)
.digest("hex")}`;
const PRE_STRUCTURED_HUMAN_INPUT_TOOL_CONTRACT_FINGERPRINT = `sha256:${createHash(
"sha256",
)
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v4",
executionTargetKind: "local",
advertisementPolicy: {
readCurrentWakeComments: "always_advertised_binding_gated.v1",
historicalChatAttachments:
"always_advertised_conversation_binding_gated.v1",
registerDeliverable: "local_workspace_only.v1",
readChatAttachment: "always_advertised_run_scope_local_staging.v1",
},
tools: [
{ name: "register_deliverable", version: 1 },
{
name: "read_current_wake_comments",
semanticContract: "paperclip.server-current-wake-comments.v1",
version: 1,
},
{
name: "list_chat_attachments",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
{
name: "reuse_chat_attachment",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
{
name: "read_chat_attachment",
semanticContract: "paperclip.server-chat-attachment-read.v1",
version: 1,
},
],
}),
)
.digest("hex")}`;
const PRE_RESPONSE_WAKE_YIELD_TOOL_CONTRACT_FINGERPRINT = `sha256:${createHash(
"sha256",
)
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v5",
executionTargetKind: "local",
advertisementPolicy: {
readCurrentWakeComments: "always_advertised_binding_gated.v1",
historicalChatAttachments:
"always_advertised_conversation_binding_gated.v1",
registerDeliverable: "local_workspace_only.v1",
readChatAttachment: "always_advertised_run_scope_local_staging.v1",
structuredHumanInput:
"always_advertised_run_issue_agent_binding_gated.v1",
},
tools: [
{ name: "register_deliverable", version: 1 },
{
name: "read_current_wake_comments",
semanticContract: "paperclip.server-current-wake-comments.v1",
version: 1,
},
{
name: "list_chat_attachments",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
{
name: "reuse_chat_attachment",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
{
name: "read_chat_attachment",
semanticContract: "paperclip.server-chat-attachment-read.v1",
version: 1,
},
{ name: "request_human_input", version: 1 },
],
}),
)
.digest("hex")}`;
const PRE_EXPLICIT_CHAT_ATTACHMENT_GUIDANCE_TOOL_CONTRACT_FINGERPRINT = `sha256:${createHash(
"sha256",
)
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v7",
executionTargetKind: "local",
advertisementPolicy: {
readCurrentWakeComments: "always_advertised_binding_gated.v1",
historicalChatAttachments:
"always_advertised_conversation_binding_gated.v1",
registerDeliverable: "local_workspace_only.v1",
readChatAttachment: "always_advertised_run_scope_local_staging.v1",
structuredHumanInput:
"always_advertised_run_issue_agent_binding_gated.v1",
semanticCompletion: "finish_response_wake_concrete_object.v2",
},
tools: [
{ name: "register_deliverable", version: 1 },
{
name: "read_current_wake_comments",
semanticContract: "paperclip.server-current-wake-comments.v1",
version: 1,
},
{
name: "list_chat_attachments",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
{
name: "reuse_chat_attachment",
semanticContract: "paperclip.server-chat-attachment-reuse.v1",
version: 1,
},
{
name: "read_chat_attachment",
semanticContract: "paperclip.server-chat-attachment-read.v1",
version: 1,
},
{ name: "request_human_input", version: 1 },
],
}),
)
.digest("hex")}`;
function execution(
runId: string,
@@ -440,6 +242,8 @@ describe("capability-gated connection tool refresh", () => {
current.runtimeContext.instructions.bundle.digest = "b".repeat(64);
current.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(current.runtimeContext);
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).toBeNull();
current.runtimeContext = nativeRuntimeContextFixture();
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).not.toBeNull();
current.binding.agentId = randomUUID();
expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).toBeNull();
});
@@ -1918,6 +1722,12 @@ describe("rebindNativeSessionCheckpoint", () => {
});
it.each([
{ contract: "conditional current-wake reader", retainedFingerprint: "sha256:fe7a2e17ca825228ea2f51f23acdc82db65c91da5ec6add330fefa39b65e1c85" },
{ contract: "same-conversation attachment reuse", retainedFingerprint: "sha256:2b84f2105c2cea10e0e5f37cf4a6ba9bf7fec90d8c8c19c655a0e21d3a8dab5f" },
{ contract: "historical file inspection", retainedFingerprint: "sha256:9b558a575dfa4e3dfaf97b7139b47d1676ab3e0307953ec85f2af0b3f443567c" },
{ contract: "structured human questions", retainedFingerprint: "sha256:1c3cd7e3223f6ed28e1c44a0ba3c961dd4c0c52e52c9c1100a4e41a83384259c" },
{ contract: "response-wake completion yield", retainedFingerprint: "sha256:09887bbf1f6a3674fcf529d872fe2853cc1d6554376019dd1a9b8d607d7eb7fb" },
{ contract: "explicit chat-attachment invocation guidance", retainedFingerprint: "sha256:d797c8bc1baef8407a8c80f2c4b28da63b2a4258a47cf52ee9b1fca45535f5e7" },
{
contract: "completion descriptions",
// Deployed v8 / finish_response_wake_concrete_object.v2 local catalog.
@@ -1950,7 +1760,13 @@ describe("rebindNativeSessionCheckpoint", () => {
},
])(
"refreshes retained $contract without changing the Paperclip task or prior history",
({ retainedFingerprint }) => {
({ contract, retainedFingerprint }) => {
if (contract === "explicit chat-attachment invocation guidance") {
expect(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION.description).toContain("Call with {}");
expect(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION.description).toContain("1 through 50");
expect(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION.description).toContain("All four arguments are required");
expect(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION.description).toContain("same key unchanged on every retry");
}
expect(NATIVE_TOOL_CONTRACT_FINGERPRINT).not.toBe(retainedFingerprint);
const prior = previousRun({
nativeToolContractFingerprint: retainedFingerprint,
@@ -2001,35 +1817,7 @@ describe("rebindNativeSessionCheckpoint", () => {
},
);
it("rotates a provider thread created while the current-wake reader was conditionally advertised", () => {
expect(CONDITIONAL_READER_TOOL_CONTRACT_FINGERPRINT).not.toBe(
NATIVE_TOOL_CONTRACT_FINGERPRINT,
);
expect(
rebindNativeSessionCheckpoint({
previousRun: previousRun({
nativeToolContractFingerprint:
CONDITIONAL_READER_TOOL_CONTRACT_FINGERPRINT,
}),
currentExecution: execution(currentRunId),
}),
).toBeNull();
});
it("rotates a provider thread that predates same-conversation attachment reuse", () => {
expect(PRE_CHAT_ATTACHMENT_REUSE_TOOL_CONTRACT_FINGERPRINT).not.toBe(
NATIVE_TOOL_CONTRACT_FINGERPRINT,
);
expect(
rebindNativeSessionCheckpoint({
previousRun: previousRun({
nativeToolContractFingerprint:
PRE_CHAT_ATTACHMENT_REUSE_TOOL_CONTRACT_FINGERPRINT,
}),
currentExecution: execution(currentRunId),
}),
).toBeNull();
});
it("binds the provider tool catalog to the local or remote execution target", () => {
const remoteFingerprint = nativeToolContractFingerprintForTarget("remote");
@@ -2052,77 +1840,10 @@ describe("rebindNativeSessionCheckpoint", () => {
).not.toBeNull();
});
it("rotates provider threads whose retained catalog cannot inspect historical files", () => {
expect(PRE_CHAT_ATTACHMENT_READ_TOOL_CONTRACT_FINGERPRINT).not.toBe(
NATIVE_TOOL_CONTRACT_FINGERPRINT,
);
expect(
rebindNativeSessionCheckpoint({
previousRun: previousRun({
nativeToolContractFingerprint:
PRE_CHAT_ATTACHMENT_READ_TOOL_CONTRACT_FINGERPRINT,
}),
currentExecution: execution(currentRunId),
}),
).toBeNull();
});
it("rotates direct provider threads whose retained catalog cannot ask structured questions", () => {
expect(PRE_STRUCTURED_HUMAN_INPUT_TOOL_CONTRACT_FINGERPRINT).not.toBe(
NATIVE_TOOL_CONTRACT_FINGERPRINT,
);
expect(
rebindNativeSessionCheckpoint({
previousRun: previousRun({
nativeToolContractFingerprint:
PRE_STRUCTURED_HUMAN_INPUT_TOOL_CONTRACT_FINGERPRINT,
}),
currentExecution: execution(currentRunId),
}),
).toBeNull();
});
it("rotates direct provider threads whose retained completion tool cannot yield for a response", () => {
expect(PRE_RESPONSE_WAKE_YIELD_TOOL_CONTRACT_FINGERPRINT).not.toBe(
NATIVE_TOOL_CONTRACT_FINGERPRINT,
);
expect(
rebindNativeSessionCheckpoint({
previousRun: previousRun({
nativeToolContractFingerprint:
PRE_RESPONSE_WAKE_YIELD_TOOL_CONTRACT_FINGERPRINT,
}),
currentExecution: execution(currentRunId),
}),
).toBeNull();
});
it("rotates provider threads whose retained attachment tools lack explicit invocation guidance", () => {
expect(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION.description).toContain(
"Call with {}",
);
expect(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION.description).toContain(
"1 through 50",
);
expect(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION.description).toContain(
"All four arguments are required",
);
expect(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION.description).toContain(
"same key unchanged on every retry",
);
expect(
PRE_EXPLICIT_CHAT_ATTACHMENT_GUIDANCE_TOOL_CONTRACT_FINGERPRINT,
).not.toBe(NATIVE_TOOL_CONTRACT_FINGERPRINT);
expect(
rebindNativeSessionCheckpoint({
previousRun: previousRun({
nativeToolContractFingerprint:
PRE_EXPLICIT_CHAT_ATTACHMENT_GUIDANCE_TOOL_CONTRACT_FINGERPRINT,
}),
currentExecution: execution(currentRunId),
}),
).toBeNull();
});
it("permits legacy retry rebinding only before the replacement acquired authority", () => {
const source = {
@@ -107,31 +107,7 @@ describe("runner API catalog", () => {
expect(operation.responses).toBeDefined();
expect(operation.authorization.actor).toBeTruthy();
});
it.each(
runnerApiCatalog().filter((operation) => operation.transport === "rest"),
)(
"resolves the catalog route $operationId inside the bound origin",
(operation) => {
const pathParams = Object.fromEntries(
operation.parameters
.filter((parameter) => parameter.in === "path")
.map((parameter) => [
parameter.name,
parameter.name === "companyId" ? context.companyId : "fixture-id",
]),
);
const url = runnerApiUrl(
operation,
{ operationId: operation.operationId, pathParams },
context,
"https://paperclip.test",
);
expect(url.origin).toBe("https://paperclip.test");
expect(url.pathname).not.toContain("{");
expect(operation.responses).toBeDefined();
expect(operation.authorization.actor).toBeTruthy();
},
);
it("ranks natural language, explains dedicated alternatives, and supports exact lookup", () => {
expect(
searchRunnerApi({ query: "create project" }).results.map(
@@ -10,7 +10,6 @@ import { readStockInstructionVariant } from "./stock-harness-instruction-variant
const root = resolve(import.meta.dirname, "../..");
export const stockHarnessGates = [
{ id: "SH-1", name: "Native Codex additive instructions", cwd: "packages/paperclip-runner", files: [
"src/drivers/codex/codex-app-server-driver.test.ts",
"src/drivers/codex/codex-app-server-driver.lifecycle.test.ts",
"src/live/runnerd-codex-transport.test.ts",
"src/live/live-session.test.ts",
+29 -5
View File
@@ -5,6 +5,17 @@ import { api } from "./client";
afterEach(() => vi.restoreAllMocks());
describe("agentsApi.retryFailedRun", () => {
it("sends only the selected failed run and preserves a retry waiting for dispatch", async () => {
const post = vi.spyOn(api, "post").mockResolvedValue({
actionId: "retry-action", status: "queued", runId: null, issueId: "issue-1",
});
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
.resolves.toEqual({ runId: null, issueId: "issue-1" });
expect(post).toHaveBeenCalledExactlyOnceWith("/agents/agent-1/wakeup?companyId=company-1", {
source: "on_demand", triggerDetail: "manual", reason: "retry_failed_run", failedRunId: "original-failure",
});
});
it.each(["failed", "timed_out", "cancelled", "interrupted"])(
"reports a replayed %s retry instead of silently succeeding",
async (status) => {
@@ -22,14 +33,27 @@ describe("agentsApi.retryFailedRun", () => {
expect(post).toHaveBeenCalledOnce();
});
it("preserves a durable chat retry that is waiting for dispatch", async () => {
vi.spyOn(api, "post").mockResolvedValue({ actionId: "retry-action", status: "queued", runId: null, issueId: "issue-1" });
it.each(["queued", "deferred", "running", "succeeded"])("accepts a durable %s chat retry receipt", async (status) => {
const post = vi.spyOn(api, "post").mockResolvedValue({ actionId: "retry-action", status, runId: "successor", issueId: "issue-1" });
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
.resolves.toEqual({ runId: null, issueId: "issue-1" });
.resolves.toEqual({ runId: "successor", issueId: "issue-1" });
expect(post).toHaveBeenCalledOnce();
});
it("reports a skipped wake", async () => {
vi.spyOn(api, "post").mockResolvedValue({ skipped: true, message: "Task execution is paused." });
it.each(["failed", "cancelled"])("reports a %s chat retry that could not start", async (status) => {
vi.spyOn(api, "post").mockResolvedValue({ actionId: "retry-action", status, runId: null, issueId: "issue-1" });
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
.rejects.toThrow("This retry could not start");
});
it("accepts an ordinary run receipt without a status", async () => {
vi.spyOn(api, "post").mockResolvedValue({ id: "successor" });
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
.resolves.toEqual({ runId: "successor", issueId: null });
});
it.each([{ skipped: true }, { status: "skipped" }])("reports skipped-wake guidance for %j", async (receipt) => {
vi.spyOn(api, "post").mockResolvedValue({ ...receipt, message: "Task execution is paused." });
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
.rejects.toThrow("Task execution is paused.");
});
-70
View File
@@ -9,76 +9,6 @@ const mockApi = vi.hoisted(() => ({
}));
vi.mock("./client", () => ({ api: mockApi }));
import { chatEndpointsApi } from "./chatEndpoints";
import { agentsApi } from "./agents";
describe("exact failed chat run retry", () => {
beforeEach(() => Object.values(mockApi).forEach((mock) => mock.mockReset()));
it("sends the selected failed run, never copied task or comment context", async () => {
mockApi.post.mockResolvedValue({
actionId: "retry-1",
issueId: "issue-1",
runId: null,
status: "deferred",
});
await expect(
agentsApi.retryFailedRun("agent-1", "failed-run-1", "company-1"),
).resolves.toEqual({ runId: null, issueId: "issue-1" });
expect(mockApi.post).toHaveBeenCalledExactlyOnceWith(
"/agents/agent-1/wakeup?companyId=company-1",
{
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
failedRunId: "failed-run-1",
},
);
});
it.each(["queued", "deferred", "running", "succeeded"])(
"accepts durable %s retry receipts",
async (status) => {
mockApi.post.mockResolvedValue({
actionId: "retry-1",
issueId: "issue-1",
runId: "new-run-1",
status,
});
await expect(
agentsApi.retryFailedRun("agent-1", "failed-run-1", "company-1"),
).resolves.toEqual({ runId: "new-run-1", issueId: "issue-1" });
},
);
it.each(["failed", "cancelled"])(
"does not report a %s retry as successful",
async (status) => {
mockApi.post.mockResolvedValue({
actionId: "retry-1",
issueId: "issue-1",
runId: null,
status,
});
await expect(
agentsApi.retryFailedRun("agent-1", "failed-run-1", "company-1"),
).rejects.toThrow("This retry could not start");
},
);
it("keeps ordinary non-chat retry results and skipped guidance", async () => {
mockApi.post.mockResolvedValueOnce({ id: "ordinary-run-1" });
await expect(
agentsApi.retryFailedRun("agent-1", "failed-run-1", "company-1"),
).resolves.toEqual({ runId: "ordinary-run-1", issueId: null });
mockApi.post.mockResolvedValueOnce({
status: "skipped",
message: "The agent is paused.",
});
await expect(
agentsApi.retryFailedRun("agent-1", "failed-run-1", "company-1"),
).rejects.toThrow("The agent is paused.");
});
});
describe("chatEndpointsApi", () => {
beforeEach(() => Object.values(mockApi).forEach((mock) => mock.mockReset()));
@@ -7,7 +7,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkspaceRuntimeService } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceRuntimeControlItems,
buildWorkspaceRuntimeControlSections,
buildWorkspaceServiceControlEntries,
resolveWorkspaceServiceControlRequests,
@@ -177,108 +176,6 @@ describe("buildWorkspaceRuntimeControlSections", () => {
}),
]);
});
it("surfaces running stale runtime services separately from updated commands", () => {
const sections = buildWorkspaceRuntimeControlSections({
runtimeConfig: {
commands: [
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
],
},
runtimeServices: [
createRuntimeService({
id: "service-web",
serviceName: "web",
status: "running",
command: "pnpm dev",
}),
],
canStartServices: true,
canRunJobs: true,
});
expect(sections.services).toEqual([
expect.objectContaining({
title: "web",
statusLabel: "stopped",
command: "pnpm dev:once --tailscale-auth",
runtimeServiceId: null,
}),
]);
expect(sections.otherServices).toEqual([
expect.objectContaining({
title: "web",
statusLabel: "running",
command: "pnpm dev",
runtimeServiceId: "service-web",
disabledReason: "This runtime service no longer matches a configured workspace command.",
}),
]);
});
it("surfaces running stale runtime services separately from updated commands", () => {
const sections = buildWorkspaceRuntimeControlSections({
runtimeConfig: {
commands: [
{ id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" },
],
},
runtimeServices: [
createRuntimeService({
id: "service-web",
serviceName: "web",
status: "running",
command: "pnpm dev",
}),
],
canStartServices: true,
canRunJobs: true,
});
expect(sections.services).toEqual([
expect.objectContaining({
title: "web",
statusLabel: "stopped",
command: "pnpm dev:once --tailscale-auth",
runtimeServiceId: null,
}),
]);
expect(sections.otherServices).toEqual([
expect.objectContaining({
title: "web",
statusLabel: "running",
command: "pnpm dev",
runtimeServiceId: "service-web",
disabledReason: "This runtime service no longer matches a configured workspace command.",
}),
]);
});
});
describe("buildWorkspaceRuntimeControlItems", () => {
it("keeps the legacy flat export shape for stale importers", () => {
const items = buildWorkspaceRuntimeControlItems({
runtimeConfig: {
commands: [
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
{ id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" },
],
},
runtimeServices: [
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
],
canStartServices: true,
canRunJobs: true,
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
title: "web",
status: "running",
statusLabel: "running",
runtimeServiceId: "service-web",
});
});
});
describe("WorkspaceRuntimeControls", () => {
@@ -628,36 +525,6 @@ describe("WorkspaceRuntimeControls", () => {
act(() => root.unmount());
});
it("accepts the legacy items prop without crashing", () => {
const items = buildWorkspaceRuntimeControlItems({
runtimeConfig: {
commands: [
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
],
},
runtimeServices: [],
canStartServices: false,
});
const root = createRoot(container);
act(() => {
root.render(withQueryClient(
<WorkspaceRuntimeControls
items={items}
emptyMessage="No runtime services have been started yet."
disabledHint="Add runtime settings first."
onAction={vi.fn()}
/>,
));
});
expect(container.textContent).toContain("Services");
expect(container.textContent).toContain("Add runtime settings first.");
expect(Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim())).toEqual(["Start"]);
act(() => root.unmount());
});
});
describe("buildWorkspaceServiceControlEntries", () => {
+8 -49
View File
@@ -51,30 +51,12 @@ export type WorkspaceRuntimeControlSections = {
otherServices: WorkspaceRuntimeControlItem[];
};
type LegacyWorkspaceRuntimeControlItem = WorkspaceRuntimeControlItem & {
status?: string | null;
};
type WorkspaceRuntimeControlsProps = {
sections: WorkspaceRuntimeControlSections;
items?: never;
isPending?: boolean;
pendingRequest?: WorkspaceRuntimeControlRequest | null;
serviceEmptyMessage?: string;
jobEmptyMessage?: string;
emptyMessage?: never;
disabledHint?: string | null;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
className?: string;
square?: boolean;
} | {
sections?: never;
items: LegacyWorkspaceRuntimeControlItem[];
isPending?: boolean;
pendingRequest?: WorkspaceRuntimeControlRequest | null;
serviceEmptyMessage?: never;
jobEmptyMessage?: never;
emptyMessage?: string;
disabledHint?: string | null;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
className?: string;
@@ -194,18 +176,6 @@ export function buildWorkspaceRuntimeControlSections(input: {
};
}
export function buildWorkspaceRuntimeControlItems(input: {
runtimeConfig: Record<string, unknown> | null | undefined;
runtimeServices: WorkspaceRuntimeService[] | null | undefined;
canStartServices: boolean;
canRunJobs?: boolean;
}): LegacyWorkspaceRuntimeControlItem[] {
return buildWorkspaceRuntimeControlSections(input).services.map((item) => ({
...item,
status: item.statusLabel,
}));
}
export function getRunningRuntimeServiceUrl(
sections: WorkspaceRuntimeControlSections,
) {
@@ -537,27 +507,16 @@ function CommandSection({
export function WorkspaceRuntimeControls({
sections,
items,
isPending = false,
pendingRequest = null,
serviceEmptyMessage = "No services are configured for this workspace.",
jobEmptyMessage = "No one-shot jobs are configured for this workspace.",
emptyMessage,
disabledHint = null,
onAction,
className,
square,
}: WorkspaceRuntimeControlsProps) {
const resolvedSections = sections ?? {
services: (items ?? []).map((item) => ({
...item,
statusLabel: item.statusLabel ?? item.status ?? "stopped",
})),
jobs: [],
otherServices: [],
};
const resolvedServiceEmptyMessage = emptyMessage ?? serviceEmptyMessage;
const runningCount = [...resolvedSections.services, ...resolvedSections.otherServices].filter(
const runningCount = [...sections.services, ...sections.otherServices].filter(
(item) => item.statusLabel === "running" || item.statusLabel === "starting",
).length;
const visibleDisabledHint = runningCount > 0 || disabledHint === null ? null : disabledHint;
@@ -580,8 +539,8 @@ export function WorkspaceRuntimeControls({
{runningCount > 0 ? `${runningCount} services running` : "No services running"}
</Badge>
<span className="text-xs text-muted-foreground">
{resolvedSections.jobs.length > 0
? `${resolvedSections.jobs.length} job${resolvedSections.jobs.length === 1 ? "" : "s"} available to run on demand.`
{sections.jobs.length > 0
? `${sections.jobs.length} job${sections.jobs.length === 1 ? "" : "s"} available to run on demand.`
: "Each command can be controlled independently."}
</span>
</div>
@@ -592,8 +551,8 @@ export function WorkspaceRuntimeControls({
<CommandSection
title="Services"
description="Long-running commands that Paperclip can supervise for this workspace."
items={resolvedSections.services}
emptyMessage={resolvedServiceEmptyMessage}
items={sections.services}
emptyMessage={serviceEmptyMessage}
disabledHint={visibleDisabledHint}
isPending={isPending}
pendingRequest={pendingRequest}
@@ -604,7 +563,7 @@ export function WorkspaceRuntimeControls({
<CommandSection
title="Jobs"
description="One-shot commands that run now and exit when they finish."
items={resolvedSections.jobs}
items={sections.jobs}
emptyMessage={jobEmptyMessage}
isPending={isPending}
pendingRequest={pendingRequest}
@@ -612,11 +571,11 @@ export function WorkspaceRuntimeControls({
square={square}
/>
{resolvedSections.otherServices.length > 0 ? (
{sections.otherServices.length > 0 ? (
<CommandSection
title="Untracked services"
description="Running services that no longer match the current workspace command config."
items={resolvedSections.otherServices}
items={sections.otherServices}
emptyMessage=""
isPending={isPending}
pendingRequest={pendingRequest}
@@ -328,12 +328,17 @@ describe("TaskChatProtocolCard", () => {
});
});
it("shows an unhealthy active runtime as unhealthy", () => {
it.each([
["active", "unhealthy", ["Unhealthy"], ["Running"]],
["active", "unknown", [], ["Running", "Unhealthy"]],
["open", "unhealthy", ["Unhealthy"], ["Running"]],
["closed", "unhealthy", ["Stopped"], ["Unhealthy"]],
] as const)("renders %s runtime with %s health", (status, healthStatus, visible, absent) => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "active",
healthStatus: "unhealthy",
status,
healthStatus,
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
@@ -343,85 +348,13 @@ describe("TaskChatProtocolCard", () => {
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · active",
subtitle: `runtime service · ${status}`,
href: product.url,
workProduct: product,
});
expect(container.textContent).toContain("Unhealthy");
expect(container.textContent).not.toContain("Running");
});
it("does not show running for an active runtime with unknown health", () => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "active",
healthStatus: "unknown",
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · active",
href: product.url,
workProduct: product,
});
expect(container.textContent).not.toContain("Running");
expect(container.textContent).not.toContain("Unhealthy");
});
it("shows an unhealthy runtime with a non-standard open status as unhealthy", () => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "open",
healthStatus: "unhealthy",
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · open",
href: product.url,
workProduct: product,
});
expect(container.textContent).toContain("Unhealthy");
expect(container.textContent).not.toContain("Running");
});
it("shows a closed runtime as stopped even when its last health check failed", () => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "closed",
healthStatus: "unhealthy",
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · closed",
href: product.url,
workProduct: product,
});
expect(container.textContent).toContain("Stopped");
expect(container.textContent).not.toContain("Unhealthy");
for (const label of visible) expect(container.textContent).toContain(label);
for (const label of absent) expect(container.textContent).not.toContain(label);
});
it("renders provider plan steps and status", () => {
+6 -102
View File
@@ -878,18 +878,7 @@ describe("CompanyEnvironments — test provider button", () => {
adapters: [],
drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
daytona: supportedDaytonaCapabilities().sandboxProviders.daytona,
e2b: {
status: "supported",
supportsSavedProbe: true,
@@ -957,24 +946,7 @@ describe("CompanyEnvironments — test provider button", () => {
mockEnvironmentsApi.list.mockResolvedValue([
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
]);
mockEnvironmentsApi.capabilities.mockResolvedValue({
adapters: [],
drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
},
});
mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities());
mockEnvironmentsApi.customImageTemplate.mockImplementation(async () => ({
activeTemplate: null,
activeSession,
@@ -1142,24 +1114,7 @@ describe("CompanyEnvironments — test provider button", () => {
mockEnvironmentsApi.list.mockResolvedValue([
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
]);
mockEnvironmentsApi.capabilities.mockResolvedValue({
adapters: [],
drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
},
});
mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities());
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
activeTemplate: null,
activeSession: createSession(),
@@ -1255,24 +1210,7 @@ describe("CompanyEnvironments — test provider button", () => {
mockEnvironmentsApi.list.mockResolvedValue([
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
]);
mockEnvironmentsApi.capabilities.mockResolvedValue({
adapters: [],
drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
},
});
mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities());
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
activeTemplate: createTemplate({ id: activeTemplateId }),
activeSession: null,
@@ -1487,24 +1425,7 @@ describe("CompanyEnvironments — test provider button", () => {
mockEnvironmentsApi.list.mockResolvedValue([
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
]);
mockEnvironmentsApi.capabilities.mockResolvedValue({
adapters: [],
drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
},
});
mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities());
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
activeTemplate: createTemplate({ id: "template-active" }),
activeSession: null,
@@ -1539,24 +1460,7 @@ describe("CompanyEnvironments — test provider button", () => {
mockEnvironmentsApi.list.mockResolvedValue([
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
]);
mockEnvironmentsApi.capabilities.mockResolvedValue({
adapters: [],
drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
},
});
mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities());
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
activeTemplate: createTemplate({ id: "template-active" }),
activeTemplateMatchesConfig: false,
+47 -449
View File
@@ -361,6 +361,28 @@ async function openAwsVaultDialog() {
await flushReact();
}
async function renderSecrets(
container: HTMLDivElement,
{
path = "/",
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
}: { path?: string; queryClient?: QueryClient } = {},
) {
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter initialEntries={[path]}>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
return root;
}
describe("Secrets page layout", () => {
let container: HTMLDivElement;
@@ -398,22 +420,7 @@ describe("Secrets page layout", () => {
});
it("uses the shared search/filter/tab affordances and keeps vault sections quiet", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
expect(container.querySelector('input[data-page-search-target="true"][aria-label="Search secrets"]')).not.toBeNull();
expect(container.textContent).toContain("Use secrets by binding them to runtime environment variables.");
@@ -459,22 +466,7 @@ describe("Secrets page layout", () => {
});
it("refreshes existing AWS secrets from a provider vault card", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
@@ -512,22 +504,7 @@ describe("Secrets page layout", () => {
it("warns that removing a provider vault only removes Paperclip config", async () => {
mockSecretsApi.removeProviderConfig.mockResolvedValueOnce(providerConfigs[1]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
@@ -596,22 +573,7 @@ describe("Secrets page layout", () => {
],
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const referencesButton = container.querySelector(
'button[aria-label="Actions for OPENAI_API_KEY"]',
@@ -649,22 +611,7 @@ describe("Secrets page layout", () => {
mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", bindings: [] });
mockSecretsApi.accessEvents.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/company/settings/secrets?secret=secret-openai"]}>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container, { path: "/company/settings/secrets?secret=secret-openai" });
// The detail sheet is open without any clicks: the deep link drives selection.
const copyLinkButton = Array.from(document.body.querySelectorAll("button")).find(
@@ -694,22 +641,7 @@ describe("Secrets page layout", () => {
mockSecretsApi.accessEvents.mockResolvedValue([]);
mockSecretsApi.rotate.mockResolvedValue({ ...externalSecret, latestVersion: 2 });
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/company/settings/secrets?secret=secret-neon"]}>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container, { path: "/company/settings/secrets?secret=secret-neon" });
// AWS supports write-through, so the primary action is a value update.
const updateValueButton = Array.from(document.body.querySelectorAll("button")).find(
@@ -753,22 +685,7 @@ describe("Secrets page layout", () => {
it("merges company secrets and each-user definitions into the Secrets list", async () => {
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([makeUserSecretDefinition()]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
expect(container.textContent).toContain("OPENAI_API_KEY");
expect(container.textContent).toContain("Personal GitHub token");
@@ -801,22 +718,7 @@ describe("Secrets page layout", () => {
it("creates an each-user secret from the unified New secret dialog", async () => {
const definition = makeUserSecretDefinition({ name: "Personal GitHub token" });
mockSecretsApi.createUserSecretDefinition.mockResolvedValueOnce(definition);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
@@ -894,22 +796,7 @@ describe("Secrets page layout", () => {
it("opens the New secret dialog when provider queries fail", async () => {
mockSecretsApi.providers.mockRejectedValueOnce(new ApiError("Providers unavailable", 403, null));
mockSecretsApi.providerConfigs.mockRejectedValueOnce(new ApiError("Provider vaults unavailable", 403, null));
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
@@ -931,22 +818,7 @@ describe("Secrets page layout", () => {
const definition = makeUserSecretDefinition();
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([definition]);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition, secret: null }]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const definitionRow = Array.from(container.querySelectorAll("[role='row']")).find(
(row) => row.textContent?.includes("Personal GitHub token"),
@@ -990,22 +862,7 @@ describe("Secrets page layout", () => {
});
it("keeps the new secret value textarea width-constrained for long tokens", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
@@ -1034,6 +891,7 @@ describe("Secrets page layout", () => {
mockSecretsApi.create.mockRejectedValueOnce(
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
details: {
rawProviderMessage,
code: "access_denied",
provider: "aws_secrets_manager",
operation: "secret.create",
@@ -1048,22 +906,7 @@ describe("Secrets page layout", () => {
},
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
@@ -1105,22 +948,7 @@ describe("Secrets page layout", () => {
it("renders generic secret creation failures with a stable selector", async () => {
mockSecretsApi.create.mockRejectedValueOnce(new ApiError("Secret creation failed", 500, null));
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
@@ -1155,22 +983,7 @@ describe("Secrets page layout", () => {
it("discovers AWS provider vault candidates and applies selected values as prefill", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview());
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
await openAwsVaultDialog();
const discoveryButton = document.querySelector(
@@ -1237,6 +1050,7 @@ describe("Secrets page layout", () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce(
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
details: {
rawProviderMessage,
code: "access_denied",
provider: "aws_secrets_manager",
operation: "secret_provider_config.discovery.preview",
@@ -1252,22 +1066,7 @@ describe("Secrets page layout", () => {
},
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
await openAwsVaultDialog();
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
@@ -1320,22 +1119,7 @@ describe("Secrets page layout", () => {
},
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
await openAwsVaultDialog();
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
@@ -1363,22 +1147,7 @@ describe("Secrets page layout", () => {
});
it("auto-generates the key from the name and keeps it read-only until Edit", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
@@ -1459,22 +1228,7 @@ describe("Secrets page layout", () => {
id === "agent-coder" ? coder : reviewer,
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
const companyRow = Array.from(container.querySelectorAll("[role='row']")).find(
(row) => row.textContent?.includes("OPENAI_API_KEY"),
@@ -1551,22 +1305,7 @@ describe("Secrets page layout", () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const root = await renderSecrets(container);
await openAwsVaultDialog();
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
@@ -1608,21 +1347,8 @@ describe("Secrets folder view (PAP-14698)", () => {
mockAgentsApi.list.mockResolvedValue([]);
}
async function renderAt(path: string) {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<MemoryRouter initialEntries={[path]}>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
return root;
function renderAt(path: string) {
return renderSecrets(container, { path });
}
beforeEach(() => {
@@ -1831,122 +1557,6 @@ describe("Secrets folder view (PAP-14698)", () => {
await act(async () => root.unmount());
});
it("creates a company secret from a folder prefix and derives the key from the full name", async () => {
mockSecretsApi.create.mockResolvedValue(
makeCompanySecret({ id: "created", name: "dev/github/oauth/clientsecret/deeper" }),
);
const root = await renderAt("/?path=dev/github/oauth");
const newSecretButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "New secret",
) as HTMLButtonElement;
await act(async () => newSecretButton.click());
await flushReact();
expect(document.body.textContent).toContain("dev/github/oauth/");
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
expect(nameInput.placeholder).toBe("clientsecret");
expect(nameInput.value).toBe("");
await act(async () => setInputValue(nameInput, "clientsecret/deeper"));
await flushReact();
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
"dev-github-oauth-clientsecret-deeper",
);
await act(async () =>
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value"),
);
const createButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Create secret",
) as HTMLButtonElement;
await act(async () => createButton.click());
await flushReact();
expect(mockSecretsApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ name: "dev/github/oauth/clientsecret/deeper" }),
);
await act(async () => root.unmount());
});
it("keeps the folder prefix for Each user and exposes the full name when the chip is removed", async () => {
const root = await renderAt("/?path=dev/github/oauth");
const newSecretButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "New secret",
) as HTMLButtonElement;
await act(async () => newSecretButton.click());
await flushReact();
const eachUserTab = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Each user",
) as HTMLButtonElement;
await act(async () => {
eachUserTab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
eachUserTab.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
eachUserTab.click();
});
await flushReact();
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
await act(async () => setInputValue(nameInput, "personal-token"));
await flushReact();
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
"DEV_GITHUB_OAUTH_PERSONAL_TOKEN",
);
const removePrefix = document.querySelector(
'button[aria-label="Remove folder prefix"]',
) as HTMLButtonElement;
await act(async () => removePrefix.click());
await flushReact();
expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe(
"dev/github/oauth/personal-token",
);
expect(document.querySelector('button[aria-label="Remove folder prefix"]')).toBeNull();
await act(async () => root.unmount());
});
it("validates New folder inline and stages the trimmed segment in the URL-backed folder view", async () => {
const root = await renderAt("/?path=dev/github/oauth");
const newFolderButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "New folder",
) as HTMLButtonElement;
await act(async () => newFolderButton.click());
await flushReact();
const folderInput = container.querySelector('input[aria-label="Folder name"]') as HTMLInputElement;
await act(async () => setInputValue(folderInput, "bad/name"));
const createFolderButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Create folder",
) as HTMLButtonElement;
await act(async () => createFolderButton.click());
await flushReact();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Folder name cannot contain slashes.",
);
await act(async () => setInputValue(folderInput, " staged "));
await flushReact();
await act(async () => createFolderButton.click());
await waitForReact(() =>
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
node.textContent?.includes("staged"),
),
);
expect(
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
node.textContent?.includes("staged"),
),
).toBe(true);
expect(container.textContent).toContain("No secrets in this folder yet.");
expect(container.querySelector('input[aria-label="Folder name"]')).toBeNull();
await act(async () => root.unmount());
});
it("Flat toggle reproduces the raw, ungrouped list", async () => {
const root = await renderAt("/");
@@ -1999,22 +1609,10 @@ describe("Secrets operator-hidden tabs", () => {
mockAgentsApi.list.mockResolvedValue([]);
}
async function renderWithHiddenSettings(hiddenSettings: string[]) {
const root = createRoot(container);
function renderWithHiddenSettings(hiddenSettings: string[]) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.health, { hiddenSettings } as never);
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
return root;
return renderSecrets(container, { queryClient });
}
beforeEach(() => {
@@ -1,4 +1,3 @@
import { buildSlackAppManifest } from "@paperclipai/shared";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
@@ -241,23 +240,6 @@ describe("chat connector UI contract", () => {
expect(setup).toContain("Verify Slack connection");
expect(setup).toContain("buildSlackAppManifest");
expect(setup).toContain("defaultSlackAppConfiguration");
const manifest = buildSlackAppManifest({
app: { appName: "Maya", botName: "maya", command: "/maya" },
agentName: "Maya",
webhookUrl: "https://paperclip.example/api/chat-webhooks/test/slack",
});
expect(manifest.settings.event_subscriptions.bot_events).toEqual(expect.arrayContaining([
"member_joined_channel", "member_left_channel", "channel_left", "group_left",
"group_archive", "group_unarchive", "group_rename", "app_uninstalled",
"agent_session_stopped", "reaction_added", "reaction_removed",
]));
expect(manifest.oauth_config.scopes.bot).toEqual(expect.arrayContaining(["reactions:write", "reactions:read", "assistant:write"]));
expect(manifest.features).toHaveProperty("agent_view");
expect(manifest.features).not.toHaveProperty("assistant_view");
expect(manifest.features.bot_user).not.toHaveProperty("always_online");
expect(manifest.features.app_home).toEqual({
home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false,
});
expect(setup).not.toContain("No credentials");
expect(setup).not.toContain("managed Microsoft app");
expect(setup).toContain("endpoint.providerAccountId && !repairing");
@@ -270,23 +252,14 @@ describe("chat connector UI contract", () => {
it("keeps provider setup failures visible without rendering submitted credentials", () => {
const setup = source("./ChatEndpointSetup.tsx");
const setupError = source("./chat-setup-error.ts");
expect(setup).toContain("sanitizedSetupErrorMessage");
expect(setup).toContain('role="alert"');
expect(setupError).toContain('message.replaceAll(candidate, "[redacted]")');
expect(setupError).toContain("return encodeURIComponent(value)");
expect(setup).toContain("onMutate: () => setSetupError(null)");
expect(setup).not.toContain('title: "Connection failed"');
});
it("keeps GitHub setup on the shipped customer-owned App path", () => {
const setup = source("./ChatEndpointSetup.tsx");
const generator = source(
"../../../../../doc/plans/chat-adapters/generate-wireframes-v8.mjs",
);
const setupData = source(
"../../../../../doc/plans/chat-adapters/setup-wireframe-data-v8.mjs",
);
expect(setup).toContain("Generate webhook secret");
expect(setup).toContain("GitHub App ID");
expect(setup).toContain("Private key (PEM)");
@@ -299,8 +272,5 @@ describe("chat connector UI contract", () => {
expect(setup).toContain('type="password"');
expect(setup).toContain('event.clipboardData.getData("text")');
expect(setup).not.toContain("WebkitTextSecurity");
expect(generator).toContain("./setup-wireframe-data-v8.mjs");
expect(generator).not.toContain("./setup-wireframe-data-v6.mjs");
expect(setupData).not.toContain('primary: "Create in GitHub"');
});
});