mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
Merge origin/master test coverage consolidation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
e2825f0f14
44 files changed
+3461
-15031
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
+214
-21
@@ -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" });
|
||||
});
|
||||
|
||||
});
|
||||
+156
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user