diff --git a/doc/plans/2026-10-12-test-audit-round6-controls.json b/doc/plans/2026-10-12-test-audit-round6-controls.json new file mode 100644 index 00000000000..e633f2f14b1 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-round6-controls.json @@ -0,0 +1,304 @@ +{ + "auditBaseline": "a090035cafb29c06f6fca2dbe43561ad866b7c1b", + "stackBase": "58c5c4aa241c85f4665a3159c192888bbb2a5cfa", + "auditCodeCommit": "d8de9422f0703d4508a9dc880b67a07f13e0f110", + "savings": { + "netTestLinesRemoved": 2831, + "redundantExecutionsRemoved": 12, + "productionLinesRemoved": 0 + }, + "declarations": { + "total": 995, + "R": 619, + "F": 2, + "C": 370, + "D": 4 + }, + "actualExpandedHelperCalls": 625, + "packets": { + "adapter": { + "declarationDecisions": { + "R": 184, + "F": 1, + "C": 92, + "D": 2 + }, + "actualExpandedHelperCalls": 134, + "manifestSha256": "cb6f36fa4b836b7c16e23ce6f62326a15e27eed407742b0e09d4779f40f7c7b1", + "declarationLedgerSha256": "89bc46ce2eb796524adfdeaaa50c382b39ee7c555224fe2d4f0cb1be8ba036b1", + "expansionProofSha256": "8b3883f723147d2bcd53e0288d76fbf47b817d187a8fd79e4f85b258a840ad16" + }, + "heartbeat": { + "declarationDecisions": { + "R": 141, + "F": 0, + "C": 130, + "D": 0 + }, + "actualExpandedHelperCalls": 282, + "manifestSha256": "85934e19feb6aed9247c09ff2057ab4acd1b9d5951e534047c597cbcc936417b", + "declarationLedgerSha256": "a6a5cd83ecc969c53e662a7e10b715741a11efc97bc28e52ab5e30aec8313d60", + "expansionProofSha256": "56e5b59358099059bc48204f74509a2bff8256ae91deeeea0f960575b0d221c1" + }, + "ui": { + "declarationDecisions": { + "R": 276, + "F": 1, + "C": 147, + "D": 1 + }, + "actualExpandedHelperCalls": 209, + "manifestSha256": "0dd858bc5b8b2c3c23a6772ae0fea5087191af9a549a36d9db8f23be8e539127", + "declarationLedgerSha256": "ae284c6efbf141895df4dc54de49013f65eac40c9389ec29914089e9c29c739d", + "expansionProofSha256": "8bd16e860d15f25defa34dd49b6d7e48ac7bf1e0dccaa4c6bae5979562d60ad4" + }, + "capability": { + "declarationDecisions": { + "R": 18, + "F": 0, + "C": 1, + "D": 1 + }, + "actualExpandedHelperCalls": 0, + "manifestSha256": "72fdcb9282dd7875bbc6df559fa8896c856151e60843657cdfe70054cd8b8924", + "declarationLedgerSha256": "ed2a5abcbba3fd17deca8b8158a4e90f3eeb8abcc8af0061a65dda63c3edf884" + } + }, + "files": [ + { + "path": "packages/adapter-utils/src/execution-target-sandbox.test.ts", + "originalSha256": "cf7db1f718e260cc62da670a48b5d35715bbb25cd7312e49433d73e2bbe1800c", + "executedCandidateSha256": "a3c8b8749df78efef34e9df476114ad09a7cbc8dd14e8ddbe50a40011ccde350", + "finalSha256": "7c1e921b0dcc3fce8bb14df98c739a35d938f75795986fcaf8a9f7dac70134be", + "testLinesRemoved": 191 + }, + { + "path": "packages/adapter-utils/src/sandbox-callback-bridge.test.ts", + "originalSha256": "2f687f0a26f0e1e32a92a464be46d5bfc6a3f821dda4b85d3cdaf7cef30f1216", + "executedCandidateSha256": "bb0f00b084ab342472ef72a1a4a20a03170a26ff7a66b8e7b571f92f9921402f", + "finalSha256": "79173674831037bde5938e1b2c37122f70bf4f63bd6a0df1a9ae2b2d4f94a25c", + "testLinesRemoved": 214 + }, + { + "path": "packages/adapter-utils/src/sandbox-managed-runtime.test.ts", + "originalSha256": "e5f81914f167dd36879823e25807e1f0098694f1748b34371f37d0d5c00597d6", + "executedCandidateSha256": "0751d74fd6b4396f5b84206ce33c45a680913c256ed3a9a97a305014d6046216", + "finalSha256": "1c575bfd8e391886a5a23facfa19ed149732139c908f19fb6fad650083270e25", + "testLinesRemoved": 470 + }, + { + "path": "server/src/__tests__/environment-execution-target-capabilities.test.ts", + "originalSha256": "7b89d2107451a0efc6e78664f9e8305cba940008a058d076d83963cf6cda4cbb", + "executedCandidateSha256": "b5da63ebba187ea7e9ac49524c9e5f6acbb149ab2c635ded2964a26b22e085ed", + "finalSha256": "b5da63ebba187ea7e9ac49524c9e5f6acbb149ab2c635ded2964a26b22e085ed", + "testLinesRemoved": 22 + }, + { + "path": "server/src/__tests__/heartbeat-comment-wake-batching.test.ts", + "originalSha256": "21d7fc3642316f55c2eff83c482aab5649673cbe8cf82fe4ed111e1cae4ba5be", + "executedCandidateSha256": "288cf2d50a3a31133889b9c2c7b65d3a5eae4ef198b27162583016558a4afebd", + "finalSha256": "288cf2d50a3a31133889b9c2c7b65d3a5eae4ef198b27162583016558a4afebd", + "testLinesRemoved": 688 + }, + { + "path": "server/src/__tests__/heartbeat-process-recovery.test.ts", + "originalSha256": "33b4140b1dff1ad5ed916463e997742c64dfa4b5e66e65c5658c22aa86ac9dc2", + "executedCandidateSha256": "bf7c888eaba44a56d12823bb948026d5db44f2249a75a6cf083343edbd7c0673", + "finalSha256": "bf7c888eaba44a56d12823bb948026d5db44f2249a75a6cf083343edbd7c0673", + "testLinesRemoved": 684 + }, + { + "path": "ui/src/components/IssueProperties.test.tsx", + "originalSha256": "8d604724e4c9c423b560d13dae6e939113f248b050905542970ee0660dfeab98", + "executedCandidateSha256": "2dd0c39f6be93e054ed7691984b68c25c32b9313dcfdacac8576d6e02d97128b", + "finalSha256": "2dd0c39f6be93e054ed7691984b68c25c32b9313dcfdacac8576d6e02d97128b", + "testLinesRemoved": 26 + }, + { + "path": "ui/src/components/TaskChatThread.test.tsx", + "originalSha256": "fc4707ae6a4b16ebf887b277d44f7432c7be3644b8eeb9388caa19a83405ffe4", + "executedCandidateSha256": "aa92b96c1f6fc2a87c4f99bdb4fc76eb8f839bcceb664114c11b3ee87dd392fa", + "finalSha256": "aa92b96c1f6fc2a87c4f99bdb4fc76eb8f839bcceb664114c11b3ee87dd392fa", + "testLinesRemoved": 250 + }, + { + "path": "ui/src/components/task-chat/TaskChatComposer.test.tsx", + "originalSha256": "3443b48fa7d7d4d11942ffe283ebd6d441e5bc2aa4cb5f30c87f6a98050b0a48", + "executedCandidateSha256": "93a07bec0a1d6d449754bbf856d557ea283c20e71ae8e5c1ff29ff2b53abd238", + "finalSha256": "93a07bec0a1d6d449754bbf856d557ea283c20e71ae8e5c1ff29ff2b53abd238", + "testLinesRemoved": 92 + }, + { + "path": "ui/src/pages/apps/AppsConnect.test.tsx", + "originalSha256": "7a3164ecef2d66c8a84d12cde1a0d7a0888ada5ca9c2f851e2a65420749091c7", + "executedCandidateSha256": "fcd9b439de18c5435b3ea6c6fc0f3de73f5880533f82652508b41cfaecc471b6", + "finalSha256": "fcd9b439de18c5435b3ea6c6fc0f3de73f5880533f82652508b41cfaecc471b6", + "testLinesRemoved": 194 + } + ], + "completeFileRuns": [ + { + "run": "baseline", + "completeFiles": 30, + "passed": 1841, + "failed": 0, + "pending": 0, + "jsonSha256": "98cf86a556acc308c9d9dfd1b5fa9d32b7156be6b92f77cd820608120c4875a3", + "wallSeconds": 692.28 + }, + { + "run": "extra-baseline", + "completeFiles": 9, + "passed": 219, + "failed": 0, + "pending": 0, + "jsonSha256": "bdba4be5d0390309f2e90cfe6f83f350c6e0cb69f76d1be0d6b9d84d02a0454d", + "wallSeconds": 29.05 + }, + { + "run": "candidate", + "completeFiles": 30, + "passed": 1831, + "failed": 0, + "pending": 0, + "jsonSha256": "1ca64e93260e51d2621dcb5e6ac996554b87db871be44643014ecc3b05d126be", + "wallSeconds": 429.46 + }, + { + "run": "extra-candidate", + "completeFiles": 9, + "passed": 217, + "failed": 0, + "pending": 0, + "jsonSha256": "817daf21df1ffa8a3daad411ad14210a15b445e14f0a93392595f5ac3a84a420", + "wallSeconds": 15.48 + } + ], + "mutationControls": { + "acceptedIntendedFailures": [ + { + "id": "F-actual-bridge-token-log-exclusion-retained", + "testTitle": "keeps sentinel route, query, body, and tokens off the http2 telemetry and logs", + "guard": "actual issued bridge-token exclusion from logDump", + "jsonSha256": "feaa61e1d9cebc1dbb2190dbed10c77901e4bd2c24e59871a11198d8dbf0d623", + "logSha256": "2b5ef18cb9ae39c203318fea74b448ab4752b26b50815afbbaf553d20d604def", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + }, + { + "id": "C-missing-preface-cleanup-oracle-retained", + "testTitle": "test_fallback_to_queue_v1_happens_at_most_once_per_run", + "guard": "provider cleanup count before finally", + "jsonSha256": "d593dbe4bdfcc23958c1ebc5dabc7d62d5c9b042d1133ff5648ed98285c2173a", + "logSha256": "0eeea70f7b19da9b992caab26e32b393f8caad7b6a2673050a3153576c3f72cb", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + }, + { + "id": "C-host-READY-newline-compatibility-retained", + "testTitle": "test_http2_gateway_writes_no_frame_between_ready_and_the_preface", + "guard": "independent host READY newline compatibility", + "jsonSha256": "4207fb4cbc0fd5dc6f51213e045f913ba7ec711e50132e4238a5ff1ae367ba82", + "logSha256": "894e44ae03faf6c35fd239e94a1cb469efbc4ac26373231ec5d1fdc3bf5db971", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + }, + { + "id": "C-real-scratch-wipe-retained", + "testTitle": "the workspace wipe keeps an in-flight scratch tarball at the root but removes a stale sibling", + "guard": "seeded scratch-file .resolves bytes preservation", + "jsonSha256": "f2c2eb5d881f8bf294a6cc7344836315c0af53b3222facf0a523420a551f5671", + "logSha256": "2227abb5cef178d176dc3161e4ead11e9a0be84b85982f5b3a62b3c7181d3fb9", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + }, + { + "id": "self-authored-comment-liveness-bypass-retained", + "testTitle": "cancels a deferred comment wake when its only queued comment was authored by the finishing run", + "guard": "exact cancelled wake self-authorship error", + "jsonSha256": "37db35d7e74e24546d93113301da10b34faba82556af855f26c1a202b9c9ebfe", + "logSha256": "9d9efb43f8d0ac1999cf749a87573df607fc13fad89945e97a42f25f48180790", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + }, + { + "id": "F-reachable-PostHog-auth-methods-retained", + "testTitle": "renders the same PostHog method choices in page and task-dialog hosts", + "guard": "reachable OAuth aria-checked fixed assertion after Change", + "jsonSha256": "e05ddaa20035263df996b93af9e8af10af9ca60d91d9e12fff4659d2312a7a36", + "logSha256": "0d20720ae732945ccea500b0ca127d69bef8f93b8d64fefb17a64c377a83084f", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + }, + { + "id": "post-drain-success-terminal-receipt-failed-retained", + "testTitle": "cancels a deferred comment wake when its only queued comment was authored by the finishing run", + "guard": "persisted exact firstRun id/succeeded matcher", + "jsonSha256": "e467fb18bc4b9857073a4e9fa2114f72690a87598664df7062e137bd88e03d0b", + "logSha256": "a823e1cc7c64c14ce7063f7c46d21ce91dd76529c149fc840d5cac47fd1ce98e", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + }, + { + "id": "C-rejected-capability-snapshot-retained", + "testTitle": "never forces the persistent session when the resolution rejects", + "guard": "rejected-resolution snapshot toBeUndefined before execute", + "jsonSha256": "0af6106ffb09a5161e94bab6a41a89e27207bcc3bda656bdc6ce10e2b4ddfa5b", + "logSha256": "1474419f5cfeaf68845d2b7d7d26433b54e549cb296507fe0b63574c2389f694", + "failedAtIntendedAssertion": true, + "notSetupOrTimeout": true + } + ], + "originalWeakControlsPassed": 2, + "positiveUniqueKeeperCasesPassed": 7, + "allProductionOwnersRestored": true, + "restoredProductionOwners": [ + { + "path": "packages/adapter-utils/src/duplex-frame-codec.ts", + "currentSha256": "97efc3275ab763b79f2afcbcdf4d6ad2a187e713c91c35c0186b6aa8828816b9", + "equalsPinnedBaselineBytes": true + }, + { + "path": "packages/adapter-utils/src/execution-target.ts", + "currentSha256": "9f61140f7c27698e7837b97dd78f9b3056045a41527da06bb0cee9f11c9f62a6", + "equalsPinnedBaselineBytes": true + }, + { + "path": "packages/adapter-utils/src/sandbox-managed-runtime.ts", + "currentSha256": "2b6093b6481d9ef1f30a9c5798d2d9b4d3c8355867dad4b2bb685a554413b49d", + "equalsPinnedBaselineBytes": true + }, + { + "path": "server/src/modules/wake-queue/adapters/postgres.ts", + "currentSha256": "86529cb3acf96b165148654d76980dd1aaef69571b16662cf041e2f7530c8851", + "equalsPinnedBaselineBytes": true + }, + { + "path": "server/src/services/environment-execution-target.ts", + "currentSha256": "7f78af7a3726f25068648938f686a2adb5a28445b61a9eb6626960e22a18798a", + "equalsPinnedBaselineBytes": true + }, + { + "path": "server/src/services/heartbeat/run-completion.ts", + "currentSha256": "0136bfae6e3ea5dd0af3beaff535f414158d2eedc9a86ad385ef49e68014d9d7", + "equalsPinnedBaselineBytes": true + }, + { + "path": "ui/src/features/connections/ConnectionSetupFlow.tsx", + "currentSha256": "cf61ae14cf58c496712fb003aae61453f3e907fc22a49b3bd170b3b2a3a9eb23", + "equalsPinnedBaselineBytes": true + } + ], + "coreRestorationReceiptSha256": "97a9bcdaaf480029ef05eeb9226acbf652ab516a2eee9554e111589f4baf892f", + "uiRestorationReceiptSha256": "2eef6a3afa1f876eef9c2fef3b82c2a0d0bb5711b9a9c55cc739b9ddab8f7c3e", + "rejectedEarlierReceiptMutation": "Changing the successful gateway receipt before queue drain timed out before the intended assertion. Excluded from accepted proof. The accepted replacement changes the persisted receipt atomically after real drain in the existing release transaction.", + "independentReviewSha256": "fadc62d0af5cae5c1347824cf8b7f86f4ae48a8aa5828f041a4b7292c0480982" + }, + "formattingIdentity": { + "proofSha256": "4cd2af912973bf1a68a9ae34b1d50cc69f6e38d9b209a1fc8f6822a22075c339", + "completeRuntimeAstIncludingLiteralRawTextEqual": true, + "allStringTemplateRegexSourceBytesEqual": true, + "note": "The three adapter test files were tidied after the positive and negative runtime runs. Final source hashes differ only in leading indentation and one duplicate blank import line. Full literal-aware AST equality links the executed bytes to the final bytes; all 134 actual helper expansions were checked again." + }, + "scope": "Owner and sibling preservation evidence. Whole-workspace verification and latest delivery-head CI/Greptile outcomes are recorded in the pull request." +} diff --git a/doc/plans/2026-10-12-test-audit-round6.md b/doc/plans/2026-10-12-test-audit-round6.md new file mode 100644 index 00000000000..73720827dfb --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-round6.md @@ -0,0 +1,96 @@ +# Test audit, Round 6 — preservation report + +Round 6 removes **2,831 net test lines and 12 redundant executions**, repairs two false proofs, and changes **zero production lines**. Complete owner/sibling baselines passed **2,060/2,060**; candidates passed **2,048/2,048** across the same **39 complete files**, with no failures or skips. These counts establish preserved selected-suite coverage, not a wall-time speed improvement. + +Audit baseline: `a090035cafb29c06f6fca2dbe43561ad866b7c1b` includes the prior cleanup and current master. Audit code commit: `d8de9422f0703d4508a9dc880b67a07f13e0f110`. The initial delivery replayed only this round's identical test changes on stacked base `58c5c4aa241c85f4665a3159c192888bbb2a5cfa`. After the parent squash merge, the same round's changes were replayed onto merged master `dabb8c5daf430a1987af611e2e7ca4c7b23ef7d4`. This report records preservation evidence; workspace checks and current delivery/CI status belong in the PR. + +The audit followed OpenClaw's [test-audit skill](https://github.com/openclaw/openclaw/blob/main/.agents/skills/test-audit/SKILL.md) and [campaign process](https://github.com/openclaw/openclaw/blob/main/.agents/skills/test-audit/CAMPAIGN.md): baseline complete files, read owners/callers/siblings, classify each declaration, nominate exact keepers, independently review, then run candidates and deliberate production regressions. + +## Exact scope + +Only these ten existing test files change; no application, API, workflow, lockfile, telemetry, style or token contract changes. + +| Test file | Net lines removed | +|---|---:| +| `packages/adapter-utils/src/execution-target-sandbox.test.ts` | 191 | +| `packages/adapter-utils/src/sandbox-managed-runtime.test.ts` | 470 | +| `packages/adapter-utils/src/sandbox-callback-bridge.test.ts` | 214 | +| `server/src/__tests__/heartbeat-process-recovery.test.ts` | 684 | +| `server/src/__tests__/heartbeat-comment-wake-batching.test.ts` | 688 | +| `server/src/__tests__/environment-execution-target-capabilities.test.ts` | 22 | +| `ui/src/components/TaskChatThread.test.tsx` | 250 | +| `ui/src/components/task-chat/TaskChatComposer.test.tsx` | 92 | +| `ui/src/components/IssueProperties.test.tsx` | 26 | +| `ui/src/pages/apps/AppsConnect.test.tsx` | 194 | + +Totals: sandbox **875**, heartbeat **1,372**, UI **562**, capabilities **22**. The final sandbox total includes removal of one duplicate blank import line during indentation cleanup; the other savings include helper costs and repaired expectations. + +All **995 original declarations** have decisions: **R619** retained, **F2** repaired, **C370** consolidated, **D4** deleted as duplicate or test-owned proof. C includes fixture/query/render consolidation and case migrations; it does not mean 370 executions were removed. Every remaining table, timeout, retry and distinct security/ownership/transport/platform/lifecycle contract remains. + +## All twelve retired executions and their keepers + +Owner labels below refer to the exact files in the scope table. Each row is one execution, including the two declarations retired from the handwritten latch group. + +| Owner | Retired case | Retained keeper and preserved observation | +|---|---|---| +| execution-target-sandbox | `marks the orderly completion and reports a success for a healthy channel` | `test_close_after_orderly_completion_keeps_the_run_result`: actual bridge settle/read around provider exit, success and no loss event/counter. Retired case exercised only a handwritten fake. | +| execution-target-sandbox | `reports the failure and does not mark for a latched loss` | `fails a clean CLI completion closed when the bridge channel was lost mid-turn`, alongside retained actual bridge loss cases: typed failure translation remains; retired latch assertion exercised only the fake. | +| execution-target-sandbox | `cannot clear the loss latch with a later completion` | Same clean-CLI failure keeper: identical failed injected disposition, exitCode and typed error, plus provider_exit stderr. Extra mark affected only the handwritten fake. | +| execution-target-sandbox | `test_a_missing_preface_aborts_the_open_and_falls_back_to_queue_v1` | `test_fallback_to_queue_v1_happens_at_most_once_per_run`: same READY-without-preface path; migrated provider close/stop receipt **before finally**, one open and one queue fallback. | +| execution-target-sandbox | `encodes the READY frame to the exact byte-for-byte wire format` | callback-bridge `test_http2_gateway_writes_no_frame_between_ready_and_the_preface`: real generated gateway, literal version/key order/newline/preface, plus migrated host-encoder compatibility. | +| sandbox-managed-runtime | `the workspace wipe command preserves in-flight sync scratch tarballs (.paperclip-upload-*)` | `the workspace wipe keeps an in-flight scratch tarball at the root but removes a stale sibling`: executes the real shell wipe and checks independently seeded bytes and stale-file absence. | +| sandbox-callback-bridge | `maps an indeterminate host outcome to a non-retryable 409 in the in-sandbox bridge server` | execution-target-sandbox `forwards the host indeterminate-outcome header so the sandbox server maps the 504 to a non-retryable 409`: actual request/status/header/body. Separate actual HTTP/2 mapping remains. | +| heartbeat-comment-wake-batching | `cancels a deferred wake containing only a comment authored by the closing run` | `cancels a deferred comment wake when its only queued comment was authored by the finishing run`: exact persisted wake cancellation/error, migrated total-run count, and restored exact first-run succeeded receipt. | +| AppsConnect | `shows an in-flight state while Paperclip prepares Notion sign-in` | `keeps the same steps when Notion sign-in takes over the wizard`: identical pending setup/actions; all four original assertions retained, with three migrated, plus no-stepper checks. | +| AppsConnect | `uses the gallery app name automatically` | `leaving the default name connects with the app name`: identical GitHub/key/submit inputs and both original assertions, plus navigation. | +| environment-execution-target-capabilities | `omits the snapshot when the resolution rejects` | `never forces the persistent session when the resolution rejects`: identical rejected target; migrated undefined-snapshot assertion before actual runner delegation, then forceSession=false. | +| environment-execution-target-capabilities | `carries no snapshot for session-output streaming when the resolution rejects` | Same rejection keeper and identical setup; separate positive streaming, native-sync rejection and concurrent-sync controls remain. | + +One additional assertion-only cut removes a literal test-owned `push("provisioned")`/equality in the generic asset round-trip. Real provisioned/helper bytes and the restore callback's actual remote-file receipt remain. A restore-diagnostic title was corrected; its assertions and ordering behavior remain unchanged. + +## Two false proofs repaired + +| Keeper | Original weakness | Repaired public observation | +|---|---|---| +| `keeps sentinel route, query, body, and tokens off the http2 telemetry and logs` | Negative sentinel inventory omitted the actual generated bridge token. | After real HTTP/2 readiness/request success, require the issued token to be nonempty and exclude those actual bytes from both recorder JSON and onLog chunks. Host token/route/query/body/forwarded Authorization checks remain. | +| `renders the same PostHog method choices in page and task-dialog hosts` | Compared subject-derived optional button states; real controls are collapsed radios with aria-checked, so missing controls could equal missing controls. | Open Change in both actual hosts; fixed selected/unselected radio expectations, required-key Connect gate, real typed key, and original dialog primary-action receipt. | + +For each repair, the pinned original weak case **passed the same production regression** that made the repaired keeper fail at its intended assertion. No fabricated token, receipt, copied expected inventory or subject-derived expectation supplies the repaired oracle. + +## Independent review and expansion + +Independent reviewers expanded **625 calls from actual candidate helper bodies**: sandbox **134**, heartbeat **282**, UI **209**. Complete ordered runtime programs equal the originals outside the specifically recorded retirements, migrations and F bodies. Review separately checked lexical callee/free-reference bindings, immutable arguments, callback captures, field/JSX spread order, fresh nested allocations, genuine DB/process receipts, all outer awaits, timer/wait budgets, transaction/action ordering and cleanup. No expected assertions are generated by fixture helpers. No added helper comments or sub-1,000-to-over-1,000 file crossings occur. + +**Review caught and restored a real heartbeat preservation gap:** the first proposal migrated cancellation and total-run counts but omitted the retired case's exact first-run `succeeded` receipt. The final keeper copies the original DB read, waits for real queue release and a settled row, then directly asserts `{ id: firstRun!.id, status: "succeeded" }` before cleanup. Waiting only for success would hide a failed receipt behind a timeout; the final settled-state wait permits the direct guard to fail. The correction was independently approved before integration. + +The sandbox files were tidied **after** runtime controls. The candidate runs used the earlier bytes; final hashes differ only by leading indentation and one blank import line. `tidy-proof.json` proves complete AST identity including literal raw text and exact string/template/regex source bytes, and all 134 helper expansions were rechecked. The durable controls record connects each executed hash to its final hash; it does not claim the final formatting bytes were separately runtime-tested. + +## Accepted regression evidence + +All eight deliberate production regressions failed at their intended retained guard, with no setup/worker/timeout failure: + +| Regression | Observed guard | +|---|---| +| Actual issued bridge token added to ready log | Actual-token exclusion from logDump. | +| Missing-preface close bypassed | Provider close/stop count before finally received zero. | +| Host READY encoder adds a newline | Host encoder versus independently literal-pinned generated gateway bytes. | +| Scratch exclusion removed from real wipe | Independently seeded scratch read failed with ENOENT at its resolves-byte assertion. | +| Self-authored deferred input kept live | Exact wake cancellation error differed. | +| PostHog authentication choices removed | Fixed OAuth aria-checked assertion received undefined after Change. | +| First-run success receipt corrupted after real drain | Exact persisted first-run matcher received failed instead of succeeded. | +| Rejected capability resolution fabricates a snapshot | Undefined-snapshot assertion received an object before runner execution. | + +Seven unmutated positive keeper controls passed, and both original weak controls passed under their respective regressions. All production owner bytes were restored. **One earlier receipt mutation was rejected and excluded:** changing success before queue drain timed out before the intended assertion. The accepted replacement alters the persisted receipt atomically after real drain in the existing release transaction and reaches the direct status guard. + +## Durable provenance + +[Controls and source/packet digests](2026-10-12-test-audit-round6-controls.json) retain the complete-file run JSON digests, final/executed source hashes, accepted mutation JSON/log digests, restoration receipt and formatting identity digest. Packet manifest / expansion SHA256 values are: + +| Packet | Manifest SHA256 | Expansion SHA256 | +|---|---|---| +| Sandbox | `cb6f36fa4b836b7c16e23ce6f62326a15e27eed407742b0e09d4779f40f7c7b1` | `8b3883f723147d2bcd53e0288d76fbf47b817d187a8fd79e4f85b258a840ad16` | +| Heartbeat | `85934e19feb6aed9247c09ff2057ab4acd1b9d5951e534047c597cbcc936417b` | `56e5b59358099059bc48204f74509a2bff8256ae91deeeea0f960575b0d221c1` | +| UI | `0dd858bc5b8b2c3c23a6772ae0fea5087191af9a549a36d9db8f23be8e539127` | `8bd16e860d15f25defa34dd49b6d7e48ac7bf1e0dccaa4c6bae5979562d60ad4` | +| Capabilities | `72fdcb9282dd7875bbc6df559fa8896c856151e60843657cdfe70054cd8b8924` | No helper expansion; exact semantic preservation review. | + +Full source ledgers, verifiers and raw logs were temporary audit packets. This report and its adjacent controls JSON retain compact accepted decisions and provenance; digests identify those packets but do not retain their full payloads. Broad workspace and latest delivery-head checks remain separate PR evidence. diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index a8a37861970..464e0bf4f30 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -11,7 +11,6 @@ import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { getSandboxDuplexGatewayCodecSource } from "./sandbox-callback-bridge.js"; import { createBridgeBodyReservation, getBridgeBodyReservedBytesForTest, @@ -119,6 +118,28 @@ function createRecordingTraceContext(): { describe("sandbox adapter execution targets", () => { const cleanupDirs: string[] = []; + function processSessionInput( + runId: string, + target: AdapterSandboxExecutionTarget, + runtimeRootDir: string, + command: string, + args: string[], + cwd: string, + ): Parameters[0] { + return { + runId, + target, + runtimeRootDir, + adapterKey: "acpx", + command, + args, + cwd, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }; + } + async function createSandboxRoot(prefix: string): Promise { const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix)); cleanupDirs.push(rootDir); @@ -512,18 +533,14 @@ describe("sandbox adapter execution targets", () => { }; const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir, runner); - const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-makedir", + const bridge = await startAdapterExecutionTargetProcessSessionBridge(processSessionInput( + "run-process-session-makedir", target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - }); + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + )); expect(bridge).not.toBeNull(); try { @@ -769,16 +786,14 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir, runner); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-poll-parent", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput( + "run-process-session-poll-parent", + target, + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + ), getRuntimeParentContext: () => runParentToken, }); expect(bridge).not.toBeNull(); @@ -824,18 +839,14 @@ describe("sandbox adapter execution targets", () => { }; const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir, runner); - const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-poll-nogetter", + const bridge = await startAdapterExecutionTargetProcessSessionBridge(processSessionInput( + "run-process-session-poll-nogetter", target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - }); + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + )); expect(bridge).not.toBeNull(); bridgeStarted = true; @@ -886,16 +897,14 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir, runner); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-stdin-parent", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput( + "run-process-session-stdin-parent", + target, + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + ), getRuntimeParentContext: () => currentParent as never, }); expect(bridge).not.toBeNull(); @@ -960,17 +969,14 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-sendinput-span", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - // Record each wrapper span name, then run the wrapped work. + ...processSessionInput( + "run-process-session-sendinput-span", + target, + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + ), runtimeSpan: async (name, work) => { spanNames.push(name); if (name === "sandbox.agentSession.sendInput") resolveSendInput(); @@ -1024,17 +1030,14 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-poll-span", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - // Record each wrapper span name, then run the wrapped work. + ...processSessionInput( + "run-process-session-poll-span", + target, + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + ), runtimeSpan: async (name, work) => { spanNames.push(name); if (name === "sandbox.agentSession.pollOutput") resolvePoll(); @@ -1066,18 +1069,7 @@ describe("sandbox adapter execution targets", () => { ); const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); - const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - }); + const bridge = await startAdapterExecutionTargetProcessSessionBridge(processSessionInput("run-process-session", target, path.posix.join(rootDir, ".paperclip-runtime", "acpx"), process.execPath, [childPath], rootDir)); expect(bridge).not.toBeNull(); try { @@ -1344,18 +1336,14 @@ describe("sandbox adapter execution targets", () => { ); const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); - const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-buffer", + const bridge = await startAdapterExecutionTargetProcessSessionBridge(processSessionInput( + "run-process-session-buffer", target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - }); + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + )); expect(bridge).not.toBeNull(); try { @@ -1382,18 +1370,14 @@ describe("sandbox adapter execution targets", () => { ); const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); - const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-fast-exit", + const bridge = await startAdapterExecutionTargetProcessSessionBridge(processSessionInput( + "run-process-session-fast-exit", target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - }); + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + )); expect(bridge).not.toBeNull(); try { @@ -1412,18 +1396,14 @@ describe("sandbox adapter execution targets", () => { await writeFile(childPath, "process.stdout.write('guarded-out\\n');", "utf8"); const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); - const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-auth", + const bridge = await startAdapterExecutionTargetProcessSessionBridge(processSessionInput( + "run-process-session-auth", target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - }); + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + )); expect(bridge).not.toBeNull(); let squatter: net.Socket | null = null; @@ -1484,18 +1464,14 @@ describe("sandbox adapter execution targets", () => { ); const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); - const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-process-session-stream", + const bridge = await startAdapterExecutionTargetProcessSessionBridge(processSessionInput( + "run-process-session-stream", target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, - }); + path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + process.execPath, + [childPath], + rootDir, + )); expect(bridge).not.toBeNull(); const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] }); @@ -1563,16 +1539,7 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-stream-echo", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput("run-stream-echo", target, path.posix.join(rootDir, ".paperclip-runtime", "acpx"), process.execPath, [childPath], rootDir), streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); @@ -1609,18 +1576,8 @@ describe("sandbox adapter execution targets", () => { const spanNames: string[] = []; const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-stream-span", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput("run-stream-span", target, path.posix.join(rootDir, ".paperclip-runtime", "acpx"), process.execPath, [childPath], rootDir), streamOutputViaSession: true, - // Record each wrapper span name, then run the wrapped work. runtimeSpan: async (name, work) => { spanNames.push(name); return work(); @@ -1660,16 +1617,7 @@ describe("sandbox adapter execution targets", () => { const runtimeSpan = createRuntimeSpanRunner(traceContext, () => runRootContext); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-stream-parent", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput("run-stream-parent", target, path.posix.join(rootDir, ".paperclip-runtime", "acpx"), process.execPath, [childPath], rootDir), streamOutputViaSession: true, runtimeSpan, }); @@ -1761,16 +1709,7 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-stream-buffer", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput("run-stream-buffer", target, path.posix.join(rootDir, ".paperclip-runtime", "acpx"), process.execPath, [childPath], rootDir), streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); @@ -1802,16 +1741,7 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-stream-fast-exit", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput("run-stream-fast-exit", target, path.posix.join(rootDir, ".paperclip-runtime", "acpx"), process.execPath, [childPath], rootDir), streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); @@ -1847,16 +1777,7 @@ describe("sandbox adapter execution targets", () => { const target: AdapterSandboxExecutionTarget = localSandboxTarget(rootDir); const bridge = await startAdapterExecutionTargetProcessSessionBridge({ - runId: "run-stream-live", - target, - runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), - adapterKey: "acpx", - command: process.execPath, - args: [childPath], - cwd: rootDir, - env: {}, - timeoutSec: 5, - onLog: async () => {}, + ...processSessionInput("run-stream-live", target, path.posix.join(rootDir, ".paperclip-runtime", "acpx"), process.execPath, [childPath], rootDir), streamOutputViaSession: true, }); expect(bridge).not.toBeNull(); @@ -4483,7 +4404,8 @@ describe("sandbox adapter execution targets", () => { // sentinel reaches any of them on the http2 path. const telemetryDump = JSON.stringify({ spans, counters, events }); const logDump = logLines.join(""); - for (const sentinel of sentinels) { + expect(bridgeToken).not.toBe(""); + for (const sentinel of [...sentinels, bridgeToken]) { expect(telemetryDump).not.toContain(sentinel); expect(logDump).not.toContain(sentinel); } @@ -4981,41 +4903,6 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("test_a_missing_preface_aborts_the_open_and_falls_back_to_queue_v1", async () => { - const { rootDir, remoteCwd } = await createSandboxWorkspace("paperclip-http2-no-preface-"); - const api = await startRecordingApiServer(); - // The gateway sends a valid READY line, but no HTTP/2 client preface ever - // follows (a suppressed or a stalled sandbox client). Readiness passes, - // then the bounded preface scan finds nothing before its bound; the host - // must abort the open and select the file bridge instead of hanging or - // leaking the channel. - const { runner, control } = makeHttp2SelectionRunner((ctx) => { - ctx.emitReady(); - // No connectHttp2() call: the client preface never arrives. - }); - const { recorder, counters } = createRecordingDuplexRecorder(); - const target: AdapterSandboxExecutionTarget = duplexSandboxTarget(remoteCwd, runner); - - const bridge = await startTestDuplexBridge("run-no-preface", target, api.origin, { - // A short readiness timeout, so the preface-missing bound (which reapplies - // this same value) fires quickly in the test. - duplexReadinessTimeoutMs: 500, - duplexObservabilityRecorder: recorder, - }); - try { - expect(bridge).not.toBeNull(); - expect(control.openCount).toBe(1); - // The missing preface aborted the open; the file bridge serves. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); - const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); - expect(fallback?.dimensions.fallback_reason).toBe("preface_missing"); - // The bounded cleanup left no live provider session. - expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); - } finally { - await bridge?.stop(); - await api.close(); - } - }, 20000); it("test_disabled_flag_selects_queue_v1", async () => { const { rootDir, remoteCwd } = await createSandboxWorkspace("paperclip-http2-disabled-"); @@ -5074,6 +4961,7 @@ describe("sandbox adapter execution targets", () => { const fallbacks = counters.filter((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallbacks).toHaveLength(1); expect(fallbacks[0]?.dimensions.fallback_reason).toBe("preface_missing"); + expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { await bridge?.stop(); await api.close(); @@ -5974,39 +5862,6 @@ describe("sandbox adapter execution targets", () => { }); -// The names the embedded codec source declares. A test wraps the source and -// reads these names back. The generated gateway never decodes, so the embedded -// copy declares only the frame version and the encode function. -interface EmbeddedCodec { - encodeDuplexFrame: (frame: unknown) => string; - DUPLEX_FRAME_VERSION: number; -} - -// This describe block covers the zero-dependency codec every generated -// gateway embeds (`DUPLEX_GATEWAY_CODEC_SOURCE`). The `http2_v1` gateway -// embeds this same codec source to send its one READY line, so this coverage -// stays live for the active transport. -describe("embedded sandbox gateway codec", () => { - it("encodes the READY frame to the exact byte-for-byte wire format", () => { - // `JSON.stringify` writes object keys in insertion order, so this pins the - // exact key order the gateway writes: version, then type, then nonce. A - // reordered or reformatted call site would change the bytes on the wire - // without failing a looser, parse-then-compare assertion. - const codecFactory = new Function( - `${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, DUPLEX_FRAME_VERSION };`, - ) as unknown as () => EmbeddedCodec; - const codec = codecFactory(); - - expect(codec.DUPLEX_FRAME_VERSION).toBe(DUPLEX_FRAME_VERSION); - const nonce = "fixed-test-nonce"; - const encoded = codec.encodeDuplexFrame({ version: codec.DUPLEX_FRAME_VERSION, type: "ready", nonce }); - expect(encoded).toBe(`{"version":2,"type":"ready","nonce":"${nonce}"}\n`); - - // The host encode side produces the same bytes for the same frame, so the - // two copies stay wire compatible on the one frame the gateway still sends. - expect(encoded).toBe(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce })); - }); -}); /** Wait for the pending microtasks and macrotasks to settle. */ function flushMacrotasks(): Promise { @@ -6106,31 +5961,6 @@ function createFakeBridgeTransport() { }; } -describe("settleRunDisposition atomic read and mark", () => { - it("marks the orderly completion and reports a success for a healthy channel", async () => { - const broker = createFakeBridgeTransport(); - - // The one atomic step marks the orderly completion and reads the success. - expect(broker.settleRunDisposition()).toEqual({ failed: false, lossReason: null }); - // A later teardown loss orders after the mark, so it stays a normal teardown. - broker.emitExit(); - await flushMacrotasks(); - expect(broker.runDisposition).toEqual({ failed: false, lossReason: null }); - }); - - it("reports the failure and does not mark for a latched loss", async () => { - const broker = createFakeBridgeTransport(); - - // A loss ordered before any orderly completion latches the failure. - broker.emitExit(); - await flushMacrotasks(); - // The atomic step reads the failure and no-ops the mark, so a later - // completion cannot clear the latch. - expect(broker.settleRunDisposition()).toEqual({ failed: true, lossReason: "provider_exit" }); - broker.markOrderlyCompletion(); - expect(broker.runDisposition).toEqual({ failed: true, lossReason: "provider_exit" }); - }); -}); describe("CLI-lane run-disposition seam", () => { const CLEAN_RESULT = { @@ -6243,27 +6073,6 @@ describe("CLI-lane run-disposition seam", () => { expect(broker.runDisposition.failed).toBe(false); }); - it("cannot clear the loss latch with a later completion", async () => { - const broker = createFakeBridgeTransport(); - // The loss latches before the CLI process exits. - broker.emitExit(); - await flushMacrotasks(); - // A later orderly completion cannot clear the latch. - broker.markOrderlyCompletion(); - - const runner = mockRunner({ ...CLEAN_RESULT }); - const result = await runAdapterExecutionTargetProcess("run-cli-latch", sandboxTarget(runner), "agent-cli", [], { - cwd: "/local", - env: {}, - timeoutSec: 5, - graceSec: 1, - onLog: async () => {}, - settleRunDisposition: () => broker.settleRunDisposition(), - }); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe(DUPLEX_CHANNEL_LOST_ERROR_CODE); - }); it("leaves an already-failed CLI result unchanged and never settles the disposition", async () => { const broker = createFakeBridgeTransport(); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index e7e639bd0a8..dbdc636b2db 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -24,6 +24,7 @@ import { startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js"; import type { SandboxCallbackBridgeQueueClient } from "./sandbox-callback-bridge.js"; +import { DUPLEX_FRAME_VERSION, encodeDuplexFrame } from "./duplex-frame-codec.js"; import { createHttp2BridgeServer } from "./http2-bridge-server.js"; import type { Http2BridgeForwardRequest, Http2BridgeForwardResult } from "./http2-bridge-server.js"; import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; @@ -34,6 +35,64 @@ const execFile = promisify(execFileCallback); describe("sandbox callback bridge", () => { const cleanupDirs: string[] = []; + function queueRuntimeInput( + runner: ReturnType, + remoteWorkspaceDir: string, + localWorkspaceDir: string, + bridgeAsset: Awaited>, + ): Parameters[0] { + return { + runner, + spec: { + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + }, + adapterKey: "codex", + workspaceLocalDir: localWorkspaceDir, + assets: [ + { + key: "bridge", + localDir: bridgeAsset.localDir, + }, + ], + }; + } + + function createWorkerWaiter() { + return async (predicate: () => boolean, timeoutMs: number) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("waitFor timed out"); + }; + } + + function virtualQueueReadClient( + directories: ReturnType, + requestBodies: Map, + ): Pick { + return { + makeDir: async () => {}, + makeDirs: async () => {}, + listJsonFiles: async (dir) => + dir === directories.requestsDir + ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() + : [], + readTextFile: async (remotePath) => { + const body = requestBodies.get(remotePath); + if (body === undefined) { + throw new Error(`missing request ${remotePath}`); + } + return body; + }, + writeTextFile: async () => {}, + }; + } + const cleanupFns: Array<() => Promise> = []; function createExecRunner() { @@ -146,21 +205,7 @@ describe("sandbox callback bridge", () => { const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [ - { - key: "bridge", - localDir: bridgeAsset.localDir, - }, - ], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -938,16 +983,7 @@ describe("sandbox callback bridge", () => { const runner = createExecRunner(); const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -1031,16 +1067,7 @@ describe("sandbox callback bridge", () => { const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -1129,16 +1156,7 @@ describe("sandbox callback bridge", () => { const runner = createExecRunner(); const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); const bridgeToken = createSandboxCallbackBridgeToken(); @@ -1181,16 +1199,7 @@ describe("sandbox callback bridge", () => { const runner = createExecRunner(); const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -1953,16 +1962,7 @@ describe("sandbox callback bridge", () => { // not write a 503 there; a 503 makes the caller retry while the original // mutation still completes, applying it twice. The test proves no 503 lands // and the handler's real response is delivered exactly once. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -1983,20 +1983,7 @@ describe("sandbox callback bridge", () => { let handlerCompleted = false; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { responseWrites.push({ path: remotePath, status: JSON.parse(body.trim()).status }); return { wrote: true }; @@ -2066,16 +2053,7 @@ describe("sandbox callback bridge", () => { // operation started, so the handler finalizes a non-retryable 504, not a // retryable 502; the caller then does not retry a mutation that may have // committed. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2089,20 +2067,7 @@ describe("sandbox callback bridge", () => { const requestRemovals: string[] = []; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { responseWrites.push({ path: remotePath, status: JSON.parse(body.trim()).status }); return { wrote: true }; @@ -2163,16 +2128,7 @@ describe("sandbox callback bridge", () => { // gets a terminal response even when the handler ignores the abort. The 504 // is non-retryable, so the caller does not retry a mutation that may have // committed. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2186,20 +2142,7 @@ describe("sandbox callback bridge", () => { const requestRemovals: string[] = []; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { responseWrites.push({ path: remotePath, status: JSON.parse(body.trim()).status, body }); return { wrote: true }; @@ -2261,16 +2204,7 @@ describe("sandbox callback bridge", () => { // host operation that is in flight. The handler completes late, after the // abort. Its response must be a non-retryable 504, never a retryable 502 or // 503; a retry of either would apply the mutation twice. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2284,20 +2218,7 @@ describe("sandbox callback bridge", () => { const requestRemovals: string[] = []; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { responseWrites.push({ path: remotePath, status: JSON.parse(body.trim()).status, body }); return { wrote: true }; @@ -2366,16 +2287,7 @@ describe("sandbox callback bridge", () => { // handler, so the caller waits to its own deadline and gets a generic 502. The // recovery path retries the write and removes the request file only after the // write lands. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2390,20 +2302,7 @@ describe("sandbox callback bridge", () => { let writeAttempts = 0; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { writeAttempts += 1; // The first backstop write fails; the retry then succeeds. @@ -2464,16 +2363,7 @@ describe("sandbox callback bridge", () => { // once. `finalize` must retry the write and deliver the real response. It // must not fence out recovery and leave the request for a retryable 503, // which would let the caller repeat a possibly-committed mutation. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2488,20 +2378,7 @@ describe("sandbox callback bridge", () => { let writeAttempts = 0; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { writeAttempts += 1; // The first handler-response write fails; the retry then succeeds. @@ -2555,16 +2432,7 @@ describe("sandbox callback bridge", () => { // recovery still delivers a non-retryable terminal response. Without the // re-arm, a rejected write leaves the request for a retryable 503 and a hung // write strands the caller until its own deadline. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2579,20 +2447,7 @@ describe("sandbox callback bridge", () => { let writeAttempts = 0; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { writeAttempts += 1; // Fail every `finalize` write attempt. The re-armed 504 backstop then @@ -2657,16 +2512,7 @@ describe("sandbox callback bridge", () => { // unclaimed, so its host mutation never ran; the 503 stays retry-safe. (A // hung poll no longer triggers this pass — the loop backs off and retries // the poll instead, and a sustained hang is the watchdog's job.) - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2753,16 +2599,7 @@ describe("sandbox callback bridge", () => { // of dropping it. Without the fix, the recovery removed the request before its // single write attempt, so a failed write left no queue state; a later // recovery pass found nothing and the caller waited until its own deadline. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2838,33 +2675,12 @@ describe("sandbox callback bridge", () => { await worker.stop({ drainTimeoutMs: 10 }); }); - it("maps an indeterminate host outcome to a non-retryable 409 in the in-sandbox bridge server", () => { - // The in-sandbox server returns the host response to the sandbox caller. A 5xx - // status is retryable by convention, so the server must not forward the - // indeterminate 504 as a retry-safe status. It maps the indeterminate outcome - // to a non-retryable 409, so a caller that retries 5xx does not repeat a - // possibly-committed mutation. The outcome header and body still forward, so a - // caller that reads them still sees the indeterminate result. - const source = getSandboxCallbackBridgeServerSource(); - expect(source).toContain("x-paperclip-bridge-outcome"); - expect(source).toContain('=== "indeterminate"'); - expect(source).toContain("res.statusCode = 409"); - }); it("abandons a request before its handler starts, so the mutation never runs", async () => { // Prove the reverse race. When the recovery path claims a request before the // handler starts its host operation, the handler must bail without running // the mutation. A retry after the 503 then applies the mutation once. - const waitFor = async (predicate: () => boolean, timeoutMs: number) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error("waitFor timed out"); - }; + const waitFor = createWorkerWaiter(); const queueDir = "/virtual-bridge/queue"; const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -2877,20 +2693,7 @@ describe("sandbox callback bridge", () => { const responseWrites: Array<{ path: string; status: number }> = []; const client: SandboxCallbackBridgeQueueClient = { - makeDir: async () => {}, - makeDirs: async () => {}, - listJsonFiles: async (dir) => - dir === directories.requestsDir - ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() - : [], - readTextFile: async (remotePath) => { - const body = requestBodies.get(remotePath); - if (body === undefined) { - throw new Error(`missing request ${remotePath}`); - } - return body; - }, - writeTextFile: async () => {}, + ...virtualQueueReadClient(directories, requestBodies), writeResponseFile: async (remotePath, body) => { responseWrites.push({ path: remotePath, status: JSON.parse(body.trim()).status }); return { wrote: true }; @@ -3044,13 +2847,7 @@ describe("sandbox callback bridge", () => { const runner = createExecRunner(); const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); return { runner, @@ -3405,6 +3202,7 @@ describe("sandbox callback bridge", () => { // would change the bytes on the wire without failing a looser assertion. const readyLine = firstBytes.subarray(0, newlineIndex).toString("utf8"); expect(readyLine).toBe(`{"version":2,"type":"ready","nonce":"${nonce}"}`); + expect(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce })).toBe(`${readyLine}\n`); const afterReady = firstBytes.subarray(newlineIndex + 1, newlineIndex + 1 + preface.length); expect(afterReady).toEqual(preface); }, 15_000); @@ -3861,13 +3659,7 @@ describe("sandbox callback bridge", () => { const runner = createExecRunner(); const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); const directories = sandboxCallbackBridgeDirectories(queueDir); @@ -3917,13 +3709,7 @@ describe("sandbox callback bridge", () => { const runner = createExecRunner(); const bridgeAsset = await createSandboxCallbackBridgeAsset(); cleanupFns.push(bridgeAsset.cleanup); - const prepared = await prepareCommandManagedRuntime({ - runner, - spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, - adapterKey: "codex", - workspaceLocalDir: localWorkspaceDir, - assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], - }); + const prepared = await prepareCommandManagedRuntime(queueRuntimeInput(runner, remoteWorkspaceDir, localWorkspaceDir, bridgeAsset)); const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); const bridgeToken = createSandboxCallbackBridgeToken(); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 6dc82ebf4d8..352ed11242a 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -340,6 +340,79 @@ function createRecordingTraceContext(): { return { traceContext, spans }; } +function sandboxSpec( + sandboxId: string, + remoteCwd: string, +): Parameters[0]["spec"] { + return { + transport: "sandbox", + provider: "test", + sandboxId, + remoteCwd, + timeoutMs: 30_000, + apiKey: null, + }; +} + +function sandboxRuntimeInput( + spec: Parameters[0]["spec"], + adapterKey: string, + client: SandboxManagedRuntimeClient, + workspaceLocalDir: string, +): Parameters[0] { + return { + spec, + adapterKey, + client, + workspaceLocalDir, + }; +} + +function plainFilesystemClient(): SandboxManagedRuntimeClient { + return { + makeDir: async (remotePath) => { + await mkdir(remotePath, { recursive: true }); + }, + writeFile: async (remotePath, bytes) => { + await mkdir(path.dirname(remotePath), { recursive: true }); + await writeFile(remotePath, Buffer.from(bytes)); + }, + readFile: async (remotePath) => await readFile(remotePath), + listFiles: async () => [], + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + }; +} + +function recordingFilesystemClient( + directWrites: string[], + directRuns: string[], +): SandboxManagedRuntimeClient { + return { + makeDir: async (remotePath) => { + await mkdir(remotePath, { recursive: true }); + }, + writeFile: async (remotePath, bytes) => { + directWrites.push(remotePath); + await mkdir(path.dirname(remotePath), { recursive: true }); + await writeFile(remotePath, Buffer.from(bytes)); + }, + readFile: async (remotePath) => await readFile(remotePath), + listFiles: async () => [], + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + directRuns.push(command); + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + }; +} + describe("sandbox managed runtime", () => { const cleanupDirs: string[] = []; @@ -370,7 +443,7 @@ describe("sandbox managed runtime", () => { await writeFile(path.join(local, secondPath, "host-config.txt"), "excluded by operator"); const seed = { workspaceArchivePath: path.join(root, "workspace.tar"), gitArchivePath: path.join(root, "git.tar") }; const input = { - spec: { transport: "sandbox", provider: "test", sandboxId: "two-repos", remoteCwd: remote, timeoutMs: 30_000, apiKey: null }, + spec: sandboxSpec("two-repos", remote), client: makeFilesystemClient(), adapterKey: "test", workspaceLocalDir: local, workspaceDurableSeed: seed, workspaceExclude: [`${secondPath}/host-config.txt`], @@ -441,17 +514,7 @@ describe("sandbox managed runtime", () => { const syncIn = vi.spyOn(client, "syncIn"); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-warm", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-warm", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), workspaceInboundMode: "adopt_remote", }); @@ -492,17 +555,7 @@ describe("sandbox managed runtime", () => { ); const first = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-first", - remoteCwd: firstRemoteDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client: makeFilesystemClient(), - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-first", firstRemoteDir), "test-adapter", makeFilesystemClient(), localWorkspaceDir), workspaceInboundMode: "host_current", workspaceDurableSeed: durableSeed, }); @@ -519,17 +572,7 @@ describe("sandbox managed runtime", () => { "utf8", ); await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-replacement", - remoteCwd: replacementRemoteDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client: makeFilesystemClient(), - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-replacement", replacementRemoteDir), "test-adapter", makeFilesystemClient(), localWorkspaceDir), workspaceInboundMode: "durable_seed", workspaceDurableSeed: durableSeed, workspaceBaseline: first.workspaceSyncSnapshot!.baseline, @@ -671,17 +714,7 @@ describe("sandbox managed runtime", () => { attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), workspaceExclude: [".claude"], preserveAbsentOnRestore: [".claude"], onRuntimeProgress: async (status) => { @@ -753,17 +786,7 @@ describe("sandbox managed runtime", () => { const lines: string[] = []; await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), onProgress: (line) => { lines.push(line); }, }); @@ -792,17 +815,7 @@ describe("sandbox managed runtime", () => { const client = makeFilesystemClient(); await expect( prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), assets: [{ key: reservedKey, localDir: localAssetsDir }], }), ).rejects.toThrow(/collides with a reserved runtime archive name/); @@ -828,17 +841,7 @@ describe("sandbox managed runtime", () => { const client = makeFilesystemClient(); await expect( prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), assets: [{ key: unsafeKey, localDir: localAssetsDir }], }), ).rejects.toThrow(/is not a simple path segment/); @@ -868,12 +871,7 @@ describe("sandbox managed runtime", () => { await git(host, ["commit", "-am", "upstream"]); await git(host, ["checkout", "work"]); - const prepared = await prepareSandboxManagedRuntime({ - spec: { transport: "sandbox", provider: "test", sandboxId: "rebase", remoteCwd: remote, timeoutMs: 30_000, apiKey: null }, - adapterKey: "test-adapter", - client: makeFilesystemClient(), - workspaceLocalDir: host, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("rebase", remote), "test-adapter", makeFilesystemClient(), host)); await git(remote, ["config", "user.name", "Paperclip Test"]); await git(remote, ["config", "user.email", "test@paperclip.dev"]); await git(remote, ["fetch", "--unshallow", host, "+refs/heads/*:refs/remotes/source/*"]); @@ -915,12 +913,7 @@ describe("sandbox managed runtime", () => { await writeFile(path.join(host, "tracked.txt"), "change to discard\n"); await git(host, ["commit", "-am", "later change"]); - const prepared = await prepareSandboxManagedRuntime({ - spec: { transport: "sandbox", provider: "test", sandboxId: "reset", remoteCwd: remote, timeoutMs: 30_000, apiKey: null }, - adapterKey: "test-adapter", - client: makeFilesystemClient(), - workspaceLocalDir: host, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("reset", remote), "test-adapter", makeFilesystemClient(), host)); await git(remote, ["fetch", "--unshallow", host, "work"]); await git(remote, ["reset", "--hard", ancestor]); @@ -1001,17 +994,7 @@ describe("sandbox managed runtime", () => { attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), onRuntimeProgress: async (status) => { runtimeStatuses.push({ phase: status.phase, message: status.message }); if (status.phase === "config_sync") { @@ -1137,19 +1120,7 @@ describe("sandbox managed runtime", () => { }; attachFallbackSyncIn(client); - const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); expect(await git(remoteWorkspaceDir, ["status", "--short"])).toContain("D restored.txt"); await git(remoteWorkspaceDir, ["reset", "--hard", "HEAD"]); @@ -1256,19 +1227,7 @@ describe("sandbox managed runtime", () => { }; attachFallbackSyncIn(client); - const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); const workspaceUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "workspace-upload.tar"); expect(workspaceUpload).toBeDefined(); @@ -1353,17 +1312,19 @@ describe("sandbox managed runtime", () => { let realpathSpy: ReturnType | undefined; try { const preparing = prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: path.join(rootDir, "remote"), - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: workspaceInputDir, + ...sandboxRuntimeInput( + { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: path.join(rootDir, "remote"), + timeoutMs: 30_000, + apiKey: null, + }, + "test-adapter", + client, + workspaceInputDir, + ), onRuntimeProgress: async (status) => { if (status.phase !== "config_sync") return; if (change === "root_alias") return; @@ -1448,19 +1409,7 @@ describe("sandbox managed runtime", () => { }; attachFallbackSyncIn(client); - await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir, - }); + await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, workspaceLocalDir)); const workspaceUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "workspace-upload.tar"); expect(workspaceUpload).toBeDefined(); @@ -1510,17 +1459,7 @@ describe("sandbox managed runtime", () => { attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), assets: [{ key: "skills", localDir: localAssetsDir }], }); @@ -1596,17 +1535,7 @@ describe("sandbox managed runtime", () => { attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "codex", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "codex", client, localWorkspaceDir), assets: [{ key: "home", localDir: homeDir, @@ -1689,17 +1618,7 @@ describe("sandbox managed runtime", () => { const lines: string[] = []; attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), assets: [{ key: "skills", localDir: localAssetsDir }], onProgress: (line) => { lines.push(line); @@ -1756,19 +1675,7 @@ describe("sandbox managed runtime", () => { }; attachFallbackSyncIn(client); - const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); await prepared.restoreWorkspace(); expect(downloadedTars).toHaveLength(1); @@ -1814,18 +1721,7 @@ describe("sandbox managed runtime", () => { attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - // No `provision` / `restore` on the asset: it must ride the default path. + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), assets: [{ key: "plain", localDir: localAssetsDir }], }); @@ -1855,43 +1751,16 @@ describe("sandbox managed runtime", () => { await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace\n", "utf8"); await writeFile(path.join(localAssetsDir, "seed.txt"), "seed\n", "utf8"); - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { - await mkdir(remotePath, { recursive: true }); - }, - writeFile: async (remotePath, bytes) => { - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { - await rm(remotePath, { recursive: true, force: true }); - }, - run: async (command) => { - await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); - }, - }; + const client: SandboxManagedRuntimeClient = plainFilesystemClient(); // A minimal shell quoter local to the test's custom extract command; the seam // itself carries no adapter knowledge — the fake asset supplies everything. const q = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; const restored: string[] = []; - const stagedContentSeen: string[] = []; attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "generic-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "generic-adapter", client, localWorkspaceDir), assets: [{ key: "widget", localDir: localAssetsDir, @@ -1915,7 +1784,6 @@ describe("sandbox managed runtime", () => { await expect(readFile(path.join(prepared.assetDirs.widget, "seed.txt"), "utf8")).resolves.toBe("seed\n"); // ...the staged helper file was written to the runtime root and consumed by the custom extract command. await expect(readFile(path.join(prepared.assetDirs.widget, "helper.copied.txt"), "utf8")).resolves.toBe("helper-bytes\n"); - stagedContentSeen.push("provisioned"); // Simulate the sandbox refreshing a file inside the asset dir, then restore. await writeFile(path.join(prepared.assetDirs.widget, "refreshed.txt"), "refreshed-by-sandbox\n", "utf8"); @@ -1923,7 +1791,6 @@ describe("sandbox managed runtime", () => { // restore contribution was invoked with a working remote readFile against assetDir. expect(restored).toEqual(["refreshed-by-sandbox\n"]); - expect(stagedContentSeen).toEqual(["provisioned"]); }); it("rejects a provision stageFile.name that is not a simple basename", async () => { @@ -1964,17 +1831,7 @@ describe("sandbox managed runtime", () => { writtenPaths.length = 0; await expect( prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "generic-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "generic-adapter", client, localWorkspaceDir), assets: [{ key: "widget", localDir: localAssetsDir, @@ -2005,41 +1862,13 @@ describe("sandbox managed runtime", () => { // must make NO direct `writeFile`/`run` exec. These record any leak. const directWrites: string[] = []; const directRuns: string[] = []; - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { - await mkdir(remotePath, { recursive: true }); - }, - writeFile: async (remotePath, bytes) => { - directWrites.push(remotePath); - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { - await rm(remotePath, { recursive: true, force: true }); - }, - run: async (command) => { - directRuns.push(command); - await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); - }, - }; + const client: SandboxManagedRuntimeClient = recordingFilesystemClient(directWrites, directRuns); const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); const q = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "generic-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "generic-adapter", client, localWorkspaceDir), assets: [{ key: "widget", localDir: localAssetsDir, @@ -2111,41 +1940,11 @@ describe("sandbox managed runtime", () => { const directWrites: string[] = []; const directRuns: string[] = []; - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { - await mkdir(remotePath, { recursive: true }); - }, - writeFile: async (remotePath, bytes) => { - directWrites.push(remotePath); - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { - await rm(remotePath, { recursive: true, force: true }); - }, - run: async (command) => { - directRuns.push(command); - await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); - }, - }; + const client: SandboxManagedRuntimeClient = recordingFilesystemClient(directWrites, directRuns); const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); - const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); // Delegated entirely to syncIn. expect(directWrites).toEqual([]); @@ -2174,62 +1973,6 @@ describe("sandbox managed runtime", () => { expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir); }); - it("the workspace wipe command preserves in-flight sync scratch tarballs (.paperclip-upload-*)", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-scratch-shape-")); - cleanupDirs.push(rootDir); - const sourceRepoDir = path.join(rootDir, "source-repo"); - const localWorkspaceDir = path.join(rootDir, "local-workspace"); - const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); - await mkdir(sourceRepoDir, { recursive: true }); - await git(sourceRepoDir, ["init"]); - await git(sourceRepoDir, ["checkout", "-b", "main"]); - await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]); - await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]); - await writeFile(path.join(sourceRepoDir, "tracked.txt"), "tracked\n", "utf8"); - await git(sourceRepoDir, ["add", "tracked.txt"]); - await git(sourceRepoDir, ["commit", "-m", "base"]); - await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); - - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { - await mkdir(remotePath, { recursive: true }); - }, - writeFile: async (remotePath, bytes) => { - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { - await rm(remotePath, { recursive: true, force: true }); - }, - run: async (command) => { - await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); - }, - }; - const captured: SandboxSyncOperation[] = []; - attachNativeRecordingSyncIn(client, captured); - - await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); - - // The wipe `find` runs before the extract. It must preserve the daytona - // scratch prefix so a concurrent referenced-project upload survives the wipe. - const wipeCommand = captured[0].postUploadCommands![0].command; - expect(wipeCommand).toContain("find "); - expect(wipeCommand).toContain("! -name '.paperclip-upload-*'"); - }); it("the workspace wipe keeps an in-flight scratch tarball at the root but removes a stale sibling", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-scratch-race-")); @@ -2253,39 +1996,11 @@ describe("sandbox managed runtime", () => { await writeFile(path.join(remoteWorkspaceDir, ".paperclip-upload-test.tar"), "scratch\n", "utf8"); await writeFile(path.join(remoteWorkspaceDir, "stale-junk.txt"), "junk\n", "utf8"); - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { - await mkdir(remotePath, { recursive: true }); - }, - writeFile: async (remotePath, bytes) => { - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { - await rm(remotePath, { recursive: true, force: true }); - }, - run: async (command) => { - await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); - }, - }; + const client: SandboxManagedRuntimeClient = plainFilesystemClient(); const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); - await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); // The real `find` wipe ran through `sh -c`. The scratch tarball survived and // the unrelated sibling did not. @@ -2316,23 +2031,7 @@ describe("sandbox managed runtime", () => { await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true }); await writeFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "keep.txt"), "keep\n", "utf8"); - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { - await mkdir(remotePath, { recursive: true }); - }, - writeFile: async (remotePath, bytes) => { - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { - await rm(remotePath, { recursive: true, force: true }); - }, - run: async (command) => { - await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); - }, - }; + const client: SandboxManagedRuntimeClient = plainFilesystemClient(); const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); // Count how many times `syncIn` is invoked so the merge collapses the two @@ -2344,19 +2043,7 @@ describe("sandbox managed runtime", () => { return recordingSyncIn(operations); }; - await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + await prepareSandboxManagedRuntime(sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); // One `syncIn` call carrying exactly one operation for the whole workspace. expect(syncInCallCount).toBe(1); @@ -2487,41 +2174,13 @@ describe("sandbox managed runtime", () => { // writeFile/run exec is a collapse regression. const directWrites: string[] = []; const directRuns: string[] = []; - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { - await mkdir(remotePath, { recursive: true }); - }, - writeFile: async (remotePath, bytes) => { - directWrites.push(remotePath); - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { - await rm(remotePath, { recursive: true, force: true }); - }, - run: async (command) => { - directRuns.push(command); - await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); - }, - }; + const client: SandboxManagedRuntimeClient = recordingFilesystemClient(directWrites, directRuns); const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); const q = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "codex", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "codex", client, localWorkspaceDir), assets: [{ key: "home", localDir: homeDir, @@ -2592,32 +2251,12 @@ describe("sandbox managed runtime", () => { await mkdir(assetDir, { recursive: true }); await writeFile(path.join(assetDir, "config.toml"), "model = \"gpt\"\n", "utf8"); - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, - writeFile: async (remotePath, bytes) => { - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); }, - run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, - }; + const client: SandboxManagedRuntimeClient = plainFilesystemClient(); const captured: SandboxSyncOperation[] = []; attachCapturingSyncIn(client, captured); await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), assets: [{ key: "home", localDir: assetDir }], }); @@ -2658,32 +2297,12 @@ describe("sandbox managed runtime", () => { await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); await writeFile(path.join(referencedDir, "notes.md"), "reference\n", "utf8"); - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, - writeFile: async (remotePath, bytes) => { - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); }, - run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, - }; + const client: SandboxManagedRuntimeClient = plainFilesystemClient(); const captured: SandboxSyncOperation[] = []; attachCapturingSyncIn(client, captured); await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), additionalSources: [{ localPath: referencedDir, projectId: "proj-first", ignoreResolution: { kind: "other" } }], }); @@ -2721,32 +2340,12 @@ describe("sandbox managed runtime", () => { // small transfer that would still round down to "0.0 MB". await writeFile(path.join(referencedDir, "notes.md"), Buffer.alloc(300 * 1024, "a")); - const client: SandboxManagedRuntimeClient = { - makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, - writeFile: async (remotePath, bytes) => { - await mkdir(path.dirname(remotePath), { recursive: true }); - await writeFile(remotePath, Buffer.from(bytes)); - }, - readFile: async (remotePath) => await readFile(remotePath), - listFiles: async () => [], - remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); }, - run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, - }; + const client: SandboxManagedRuntimeClient = plainFilesystemClient(); attachCapturingSyncIn(client, []); const lines: string[] = []; await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-1", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-1", remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), additionalSources: [{ localPath: referencedDir, projectId: "proj-first", ignoreResolution: { kind: "other" } }], onProgress: (line) => { lines.push(line); }, }); @@ -3301,17 +2900,7 @@ describe("sandbox managed runtime", () => { }; const prepared = await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-pack", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client: makeFilesystemClient(), - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-pack", remoteWorkspaceDir), "test-adapter", makeFilesystemClient(), localWorkspaceDir), runtimeSpan, }); @@ -3357,17 +2946,7 @@ describe("sandbox managed runtime", () => { "stage.sync", async () => { await prepareSandboxManagedRuntime({ - spec: { - transport: "sandbox", - provider: "test", - sandboxId: "sandbox-nest", - remoteCwd: remoteWorkspaceDir, - timeoutMs: 30_000, - apiKey: null, - }, - adapterKey: "test-adapter", - client: makeFilesystemClient(), - workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(sandboxSpec("sandbox-nest", remoteWorkspaceDir), "test-adapter", makeFilesystemClient(), localWorkspaceDir), runtimeSpan: stageRuntimeSpan, }); }, @@ -3613,10 +3192,7 @@ describe("sandbox managed runtime inbound coordinator", () => { control.hold("workspace"); const prepared = prepareSandboxManagedRuntime({ - spec: makeSpec("/remote/cwd"), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec("/remote/cwd"), "codex", client, workspaceDir), assets: [{ key: "home", localDir: dirOf("home") }], }); prepared.catch(() => undefined); @@ -3770,10 +3346,7 @@ describe("sandbox managed runtime inbound coordinator", () => { // coordinator raises the workspace failure. await expect( prepareSandboxManagedRuntime({ - spec: makeSpec("/remote/cwd"), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec("/remote/cwd"), "codex", client, workspaceDir), assets: [{ key: "asset-a", localDir: dirOf("asset-a") }], }), ).rejects.toThrow("workspace-fail"); @@ -3785,10 +3358,7 @@ describe("sandbox managed runtime inbound coordinator", () => { control.hold("workspace"); const prepared = prepareSandboxManagedRuntime({ - spec: makeSpec("/remote/cwd"), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec("/remote/cwd"), "codex", client, workspaceDir), assets: [{ key: "asset-a", localDir: dirOf("asset-a") }], }); prepared.catch(() => undefined); @@ -3812,10 +3382,7 @@ describe("sandbox managed runtime inbound coordinator", () => { const { runtimeSpan, opened } = makeSpanRecorder(); await prepareSandboxManagedRuntime({ - spec: makeSpec("/remote/cwd"), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec("/remote/cwd"), "codex", client, workspaceDir), assets: [{ key: "home", localDir: dirOf("home") }], additionalSources: [{ localPath: dirOf("proj"), projectId: "proj-1", ignoreResolution: { kind: "other" } }], runtimeSpan, @@ -3836,10 +3403,7 @@ describe("sandbox managed runtime inbound coordinator", () => { control.hold("home"); const prepared = prepareSandboxManagedRuntime({ - spec: makeSpec("/remote/cwd"), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec("/remote/cwd"), "codex", client, workspaceDir), assets: [{ key: "home", localDir: dirOf("home") }], runtimeSpan, }); @@ -4073,7 +3637,7 @@ describe("sandbox managed runtime outbound coordinator", () => { ]); } - it.each([false, true])("records each failed restore phase in stable order (shared error: %s)", async (sharedError) => { + it.each([false, true])("records each failed restore phase and raises the first task error (shared error: %s)", async (sharedError) => { const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home", "private-asset"]); const { control, gate } = makeOutboundControl(); const client = makeGatedOutboundClient(true, gate); @@ -4082,7 +3646,7 @@ describe("sandbox managed runtime outbound coordinator", () => { control.failWith("home", sharedError ? workspaceError : Object.assign(new Error("private credential"), { status: 404 })); control.failWith("private-asset", sharedError ? workspaceError : new Error("private asset error")); const prepared = await prepareSandboxManagedRuntime({ - spec: makeSpec(remoteWorkspaceDir), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec(remoteWorkspaceDir), "codex", client, workspaceDir), assets: [ makeControlledAsset("home", dirOf("home"), control, gate), makeControlledAsset("private-asset", dirOf("private-asset"), control, gate), @@ -4109,9 +3673,7 @@ describe("sandbox managed runtime outbound coordinator", () => { 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, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(makeSpec(remoteWorkspaceDir), "test-adapter", client, 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(); @@ -4131,10 +3693,7 @@ describe("sandbox managed runtime outbound coordinator", () => { control.hold("workspace"); const prepared = await prepareSandboxManagedRuntime({ - spec: makeSpec(remoteWorkspaceDir), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec(remoteWorkspaceDir), "codex", client, workspaceDir), assets: [makeControlledAsset("home", dirOf("home"), control, gate)], }); @@ -4201,10 +3760,7 @@ describe("sandbox managed runtime outbound coordinator", () => { control.hold("workspace"); const prepared = await prepareSandboxManagedRuntime({ - spec: makeSpec(remoteWorkspaceDir), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec(remoteWorkspaceDir), "codex", client, workspaceDir), assets: [makeControlledAsset("asset-a", dirOf("asset-a"), control, gate)], }); @@ -4271,10 +3827,7 @@ describe("sandbox managed runtime outbound coordinator", () => { const { runtimeSpan, opened } = makeSpanRecorder(); const prepared = await prepareSandboxManagedRuntime({ - spec: makeSpec(remoteWorkspaceDir), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec(remoteWorkspaceDir), "codex", client, workspaceDir), assets: [makeControlledAsset("home", dirOf("home"), control, gate)], runtimeSpan, }); @@ -4294,10 +3847,7 @@ describe("sandbox managed runtime outbound coordinator", () => { const { runtimeSpan, openNow } = makeSpanRecorder(); const prepared = await prepareSandboxManagedRuntime({ - spec: makeSpec(remoteWorkspaceDir), - adapterKey: "codex", - client, - workspaceLocalDir: workspaceDir, + ...sandboxRuntimeInput(makeSpec(remoteWorkspaceDir), "codex", client, workspaceDir), assets: [makeControlledAsset("home", dirOf("home"), control, gate)], runtimeSpan, }); @@ -4483,7 +4033,7 @@ describe("sandbox git-bundle export transport", () => { const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-restore-diagnostic-"); const client = makeTransportClient(step !== "workspace_extract", { syncOutOperations: [], readFilePaths: [] }); const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), workspaceRestoreRetryDelaysMs: [0, 0], }); await commitInSandbox(remoteWorkspaceDir); @@ -4527,7 +4077,7 @@ describe("sandbox git-bundle export transport", () => { const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-restore-retry-"); const client = makeTransportClient(true, { syncOutOperations: [], readFilePaths: [] }); const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), workspaceRestoreRetryDelaysMs: [0, 0], }); await commitInSandbox(remoteWorkspaceDir); @@ -4551,7 +4101,7 @@ describe("sandbox git-bundle export transport", () => { const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-restore-retry-limit-"); const client = makeTransportClient(true, { syncOutOperations: [], readFilePaths: [] }); const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), workspaceRestoreRetryDelaysMs: [0, 0], }); await commitInSandbox(remoteWorkspaceDir); @@ -4575,7 +4125,7 @@ describe("sandbox git-bundle export transport", () => { const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-restore-no-retry-"); const client = makeTransportClient(true, { syncOutOperations: [], readFilePaths: [] }); const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), workspaceRestoreRetryDelaysMs: [0, 0], }); await commitInSandbox(remoteWorkspaceDir); @@ -4597,7 +4147,7 @@ describe("sandbox git-bundle export transport", () => { await writeFile(path.join(localWorkspaceDir, "base.txt"), "base\n", "utf8"); const client = makeTransportClient(true, { syncOutOperations: [], readFilePaths: [] }); const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, + ...sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir), workspaceRestoreRetryDelaysMs: [0, 0], }); await writeFile(path.join(remoteWorkspaceDir, "a-first.txt"), "first\n", "utf8"); @@ -4629,12 +4179,7 @@ describe("sandbox git-bundle export transport", () => { const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] }; const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-bundle-native-"); const client = makeTransportClient(true, capture); - const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); await commitInSandbox(remoteWorkspaceDir); await prepared.restoreWorkspace(); @@ -4659,12 +4204,7 @@ describe("sandbox git-bundle export transport", () => { const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] }; const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-bundle-fallback-"); const client = makeTransportClient(false, capture); - const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); await commitInSandbox(remoteWorkspaceDir); await prepared.restoreWorkspace(); @@ -4703,12 +4243,7 @@ describe("sandbox git-bundle export transport", () => { const stagedBase = await git(localWorkspaceDir, ["rev-parse", "HEAD"]); const client = makeTransportClient(true, capture); - const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); await commitInSandbox(remoteWorkspaceDir); const sandboxHead = await git(remoteWorkspaceDir, ["rev-parse", "HEAD"]); @@ -4739,12 +4274,7 @@ describe("sandbox git-bundle export transport", () => { const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] }; const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-restore-bytes-"); const client = makeTransportClient(true, capture); - const prepared = await prepareSandboxManagedRuntime({ - spec: gitSpec(remoteWorkspaceDir), - adapterKey: "test-adapter", - client, - workspaceLocalDir: localWorkspaceDir, - }); + const prepared = await prepareSandboxManagedRuntime(sandboxRuntimeInput(gitSpec(remoteWorkspaceDir), "test-adapter", client, localWorkspaceDir)); // Add a sizeable, incompressible tracked file in the sandbox, so both the // packed git-history bundle and the restored working tree carry real, diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index 22992d53253..549943e65e0 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -398,29 +398,6 @@ describe("a rejected capability resolution fails closed for persistent-session b mockResolveEnvironmentDriverConfigForRuntime.mockReset(); }); - it("omits the snapshot when the resolution rejects", async () => { - // A rejected resolution carries no snapshot; the target never publishes a - // guessed capability set. - const { target } = await buildSandboxTarget({ - snapshot: null, - supportsSync: false, - rejectResolution: true, - }); - expect(target.effectiveCapabilities).toBeUndefined(); - }); - - it("carries no snapshot for session-output streaming when the resolution rejects", async () => { - // A rejected resolution must not read as an open grant that enables - // streaming. The target carries no snapshot, so the bridge keeps the host - // output-file poll path. - const { target } = await buildSandboxTarget({ - snapshot: null, - supportsSync: false, - rejectResolution: true, - }); - expect(target.effectiveCapabilities).toBeUndefined(); - }); - it("never forces the persistent session when the resolution rejects", async () => { // The bridge asks for the session with `useSession`, but a rejected // resolution fails closed, so the seam runs the command one-shot instead. @@ -429,6 +406,7 @@ describe("a rejected capability resolution fails closed for persistent-session b supportsSync: false, rejectResolution: true, }); + expect(target.effectiveCapabilities).toBeUndefined(); await target.runner?.execute({ command: "node", args: ["agent.js"], useSession: true }); const call = execute.mock.calls[0]![0] as { forceSession?: boolean }; expect(call.forceSession).toBe(false); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index eb9c505e5d7..39d75bfc868 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -201,6 +201,98 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { ReturnType > | null = null; + function wakeCompanyRow( + rowId: (typeof companies.$inferInsert)["id"], + issuePrefix: (typeof companies.$inferInsert)["issuePrefix"], + ): typeof companies.$inferInsert { + return { + id: rowId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + defaultResponsibleUserId: "responsible-user", + }; + } + + function wakeUserCommentRow( + companyId: (typeof issueComments.$inferInsert)["companyId"], + issueId: (typeof issueComments.$inferInsert)["issueId"], + authorUserId: (typeof issueComments.$inferInsert)["authorUserId"], + body: (typeof issueComments.$inferInsert)["body"], + ): typeof issueComments.$inferInsert { + return { + companyId, + issueId, + authorUserId, + body, + }; + } + + function wakeGatewayAgentRow( + rowId: (typeof agents.$inferInsert)["id"], + companyId: (typeof agents.$inferInsert)["companyId"], + name: (typeof agents.$inferInsert)["name"], + gateway: { url: string }, + ): typeof agents.$inferInsert { + return { + id: rowId, + companyId, + name, + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }; + } + + function wakeIssueRow( + rowId: (typeof issues.$inferInsert)["id"], + companyId: (typeof issues.$inferInsert)["companyId"], + title: (typeof issues.$inferInsert)["title"], + assigneeAgentId: (typeof issues.$inferInsert)["assigneeAgentId"], + issuePrefix: string, + ): typeof issues.$inferInsert { + return { + id: rowId, + companyId, + title, + status: "todo", + priority: "medium", + responsibleUserId: "responsible-user", + assigneeAgentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }; + } + + function selectDeferredWakeForAgent( + companyId: (typeof agentWakeupRequests.$inferSelect)["companyId"], + agentId: (typeof agentWakeupRequests.$inferSelect)["agentId"], + ) { + return db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + } + beforeAll(async () => { const started = await startEmbeddedPostgresTestDatabase( "paperclip-heartbeat-comment-wake-", @@ -262,13 +354,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; const heartbeat = heartbeatService(db); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await db.insert(agents).values({ id: agentId, @@ -338,17 +424,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(followupRun).toBeNull(); - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, agentId); expect(deferred).not.toBeNull(); expect(deferred?.reason).toBe("issue_execution_deferred"); @@ -384,13 +460,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; const heartbeat = heartbeatService(db); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await db.insert(agents).values({ id: agentId, @@ -460,17 +530,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(followupRun).toBeNull(); - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, agentId); expect(deferred).toMatchObject({ reason: "issue_execution_deferred", @@ -508,56 +568,16 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Gateway Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Batch wake comments", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Batch wake comments", agentId, issuePrefix)); const comment1 = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "First comment", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "First comment")) .returning() .then((rows) => rows[0]); const firstRun = await heartbeat.wakeup(agentId, { @@ -588,22 +608,12 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const comment2 = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "Second comment", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "Second comment")) .returning() .then((rows) => rows[0]); const comment3 = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "Third comment", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "Third comment")) .returning() .then((rows) => rows[0]); @@ -640,31 +650,11 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(thirdRun).toBeNull(); await waitFor(async () => { - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, agentId); return Boolean(deferred); }); - const deferredWake = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferredWake = await selectDeferredWakeForAgent(companyId, agentId); const deferredContext = ( deferredWake?.payload as Record | null @@ -746,48 +736,12 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Gateway Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { "x-openclaw-token": "gateway-token" }, - payloadTemplate: { message: "wake now" }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Discard deferred follow-up", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway)); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Discard deferred follow-up", agentId, issuePrefix)); const firstComment = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "First comment", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "First comment")) .returning() .then((rows) => rows[0]); const firstRun = await heartbeat.wakeup(agentId, { @@ -816,12 +770,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const discardedComment = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "Delete this before the current turn finishes", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "Delete this before the current turn finishes")) .returning() .then((rows) => rows[0]); expect( @@ -902,34 +851,9 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Gateway Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway)); await db.insert(issues).values({ id: issueId, @@ -945,12 +869,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const comment1 = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "Start work", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "Start work")) .returning() .then((rows) => rows[0]); const firstRun = await heartbeat.wakeup(agentId, { @@ -1047,56 +966,16 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Gateway Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Reopen after deferred comment", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Reopen after deferred comment", agentId, issuePrefix)); const comment1 = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "First comment", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "First comment")) .returning() .then((rows) => rows[0]); @@ -1128,12 +1007,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const comment2 = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "Please handle this follow-up after you finish", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "Please handle this follow-up after you finish")) .returning() .then((rows) => rows[0]); @@ -1155,17 +1029,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(deferredRun).toBeNull(); await waitFor(async () => { - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, agentId); return Boolean(deferred); }); @@ -1258,13 +1122,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const wakeReason = "issue_commented"; try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user"]); await db.insert(agents).values([ @@ -1310,17 +1168,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { }, ]); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Agent feedback at completion boundary", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Agent feedback at completion boundary", assigneeAgentId, issuePrefix)); const firstRun = await heartbeat.wakeup(assigneeAgentId, { source: "assignment", @@ -1380,17 +1228,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(deferredRun).toBeNull(); await waitFor(async () => { - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, targetAgentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, targetAgentId); return Boolean(deferred); }); @@ -1491,199 +1329,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { } }, 120_000); - it("cancels a deferred wake containing only a comment authored by the closing run", async () => { - const gateway = await createControlledGatewayServer(); - const companyId = randomUUID(); - const agentId = randomUUID(); - const issueId = randomUUID(); - const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; - const heartbeat = heartbeatService(db); - try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); - - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Local CLI Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); - - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Self-comment must not reopen", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); - - const firstRun = await heartbeat.wakeup(agentId, { - source: "assignment", - triggerDetail: "system", - reason: "issue_assigned", - payload: { issueId }, - contextSnapshot: { - issueId, - taskId: issueId, - wakeReason: "issue_assigned", - }, - requestedByActorType: "system", - requestedByActorId: null, - }); - - expect(firstRun).not.toBeNull(); - await waitFor(async () => { - const run = await db - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, firstRun!.id)) - .then((rows) => rows[0] ?? null); - return run?.status === "running"; - }); - await waitFor(() => gateway.getAgentPayloads().length === 1); - - // Local-CLI agents post comments under user auth, but stamp the heartbeat - // run id on each comment via createdByRunId. Simulate that here: a "user" - // comment that was actually authored by the run that is about to close - // the issue. Without the Path A guard this would trigger a reopen. - const selfComment = await db - .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "local-cli-user", - createdByRunId: firstRun?.id ?? null, - body: "Closing comment from the same run", - }) - .returning() - .then((rows) => rows[0]); - - const deferredRun = await heartbeat.wakeup(agentId, { - source: "automation", - triggerDetail: "system", - reason: "issue_commented", - payload: { issueId, commentId: selfComment.id }, - contextSnapshot: { - issueId, - taskId: issueId, - commentId: selfComment.id, - wakeCommentId: selfComment.id, - wakeReason: "issue_commented", - }, - requestedByActorType: "user", - requestedByActorId: "local-cli-user", - }); - - expect(deferredRun).toBeNull(); - - await waitFor(async () => { - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); - return Boolean(deferred); - }); - - // Running records admission. Wait for provider acceptance before - // simulating completion by that provider, or startup correctly rejects - // the already-closed task before this scenario reaches its follow-up. - await waitFor(() => gateway.getAgentPayloads().length >= 1); - await db - .update(issues) - .set({ - status: "done", - completedAt: new Date(), - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: new Date(), - }) - .where(eq(issues.id, issueId)); - - gateway.releaseFirstWait(); - - await waitFor(async () => { - const run = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, firstRun!.id)) - .then((rows) => rows[0] ?? null); - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - ), - ) - .then( - (rows) => - rows.find((request) => request.status === "cancelled") ?? null, - ); - return ( - run?.status === "succeeded" && - deferred?.error === - "Deferred wake contained only comments authored by the finishing run" - ); - }, 90_000); - - expect(gateway.getAgentPayloads()).toHaveLength(1); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); - expect(runs).toHaveLength(1); - - const issueAfterPromotion = await db - .select({ - status: issues.status, - completedAt: issues.completedAt, - }) - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); - - expect(issueAfterPromotion).toMatchObject({ - status: "done", - }); - expect(issueAfterPromotion?.completedAt).not.toBeNull(); - } finally { - gateway.releaseFirstWait(); - await gateway.close(); - } - }, 120_000); it("promotes an interaction continuation with its full authoritative source comment after removing a coalesced self-comment", async () => { const gateway = await createControlledGatewayServer(async turn => { @@ -1697,47 +1343,12 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "external-user"]); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Local CLI Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Local CLI Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Interaction continuation survives self-comment filtering", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Interaction continuation survives self-comment filtering", agentId, issuePrefix)); const firstRun = await heartbeat.wakeup(agentId, { source: "assignment", @@ -1781,12 +1392,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { ].join("\n"); const sourceComment = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "external-user", - body: fullSourceInstruction, - }) + .values(wakeUserCommentRow(companyId, issueId, "external-user", fullSourceInstruction)) .returning() .then((rows) => rows[0]); @@ -1914,13 +1520,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await db.insert(agents).values({ id: agentId, companyId, @@ -2398,47 +1998,12 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "user-1"]); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Local CLI Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Local CLI Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Human follow-up must survive mixed deferred batches", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Human follow-up must survive mixed deferred batches", agentId, issuePrefix)); const firstRun = await heartbeat.wakeup(agentId, { source: "assignment", @@ -2496,12 +2061,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const humanComment = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "Real follow-up from a human after the run closes", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "Real follow-up from a human after the run closes")) .returning() .then((rows) => rows[0]); @@ -2524,17 +2084,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(secondDeferredRun).toBeNull(); await waitFor(async () => { - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, agentId); return Boolean(deferred); }); @@ -2618,46 +2168,11 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Gateway Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Deleted follow-up must not reopen", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Deleted follow-up must not reopen", agentId, issuePrefix)); const firstRun = await heartbeat.wakeup(agentId, { source: "assignment", @@ -2686,12 +2201,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const queuedComment = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "Please look at this once you finish", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "Please look at this once you finish")) .returning() .then((rows) => rows[0]); @@ -2714,17 +2224,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(deferredRun).toBeNull(); await waitFor(async () => { - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, agentId); return Boolean(deferred); }); @@ -2806,46 +2306,11 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Local CLI Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Local CLI Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Self-authored note must not reopen", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Self-authored note must not reopen", agentId, issuePrefix)); const firstRun = await heartbeat.wakeup(agentId, { source: "assignment", @@ -2902,17 +2367,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(deferredRun).toBeNull(); await waitFor(async () => { - const deferred = await db - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - eq(agentWakeupRequests.agentId, agentId), - eq(agentWakeupRequests.status, "deferred_issue_execution"), - ), - ) - .then((rows) => rows[0] ?? null); + const deferred = await selectDeferredWakeForAgent(companyId, agentId); return Boolean(deferred); }); @@ -2947,11 +2402,16 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { gateway.releaseFirstWait(); await waitFor(async () => { + const run = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, firstRun!.id)) + .then((rows) => rows[0] ?? null); const [wake] = await db .select({ status: agentWakeupRequests.status }) .from(agentWakeupRequests) .where(eq(agentWakeupRequests.id, queuedWake!.id)); - return wake?.status === "cancelled"; + return wake?.status === "cancelled" && run !== null && run.status !== "queued" && run.status !== "running"; }, 90_000); const [cancelledWake] = await db @@ -2967,6 +2427,12 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { // queue must not promote a second run for the same agent to re-read its // own note, and the issue must stay in the state the run's completion left it in. expect(gateway.getAgentPayloads()).toHaveLength(1); + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ id: firstRun!.id, status: "succeeded" }); const issueAfterCompletion = await db .select({ status: issues.status, completedAt: issues.completedAt }) .from(issues) @@ -2992,47 +2458,12 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "user-1"]); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Local CLI Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Local CLI Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Interaction continuation survives self-comment filtering", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Interaction continuation survives self-comment filtering", agentId, issuePrefix)); const firstRun = await heartbeat.wakeup(agentId, { source: "assignment", @@ -3158,47 +2589,12 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user"]); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Gateway Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Require a comment", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Require a comment", agentId, issuePrefix)); const firstRun = await heartbeat.wakeup(agentId, { source: "assignment", @@ -3324,13 +2720,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]); await db.insert(agents).values([ @@ -3407,12 +2797,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const mentionComment = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "@Mentioned Agent please inspect this after the current run.", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "@Mentioned Agent please inspect this after the current run.")) .returning() .then((rows) => rows[0]); @@ -3505,13 +2890,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await db.insert(agents).values([ { @@ -3556,26 +2935,11 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { }, ]); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Mention should not steal execution ownership", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: primaryAgentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Mention should not steal execution ownership", primaryAgentId, issuePrefix)); const mentionComment = await db .insert(issueComments) - .values({ - companyId, - issueId, - authorUserId: "user-1", - body: "@Mentioned Agent please inspect this.", - }) + .values(wakeUserCommentRow(companyId, issueId, "user-1", "@Mentioned Agent please inspect this.")) .returning() .then((rows) => rows[0]); @@ -3633,46 +2997,11 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { const heartbeat = heartbeatService(db); try { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Gateway Agent", - role: "engineer", - status: "idle", - adapterType: "openclaw_gateway", - adapterConfig: { - url: gateway.url, - headers: { - "x-openclaw-token": "gateway-token", - }, - payloadTemplate: { - message: "wake now", - }, - waitTimeoutMs: 2_000, - }, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(wakeGatewayAgentRow(agentId, companyId, "Gateway Agent", gateway)); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Use existing comment", - status: "todo", - priority: "medium", - responsibleUserId: "responsible-user", - assigneeAgentId: agentId, - issueNumber: 1, - identifier: `${issuePrefix}-1`, - }); + await db.insert(issues).values(wakeIssueRow(issueId, companyId, "Use existing comment", agentId, issuePrefix)); const firstRun = await heartbeat.wakeup(agentId, { source: "assignment", @@ -3782,13 +3111,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { // no-ops under suppression and would leave the promoted wake at "queued". const heartbeat = heartbeatService(db, { runtimeEnv: {} }); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await db.insert(agents).values([ { id: finishingAgentId, @@ -4019,13 +3342,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { // no-ops under suppression and would leave the promoted wake at "queued". const heartbeat = heartbeatService(db, { runtimeEnv: {} }); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - defaultResponsibleUserId: "responsible-user", - }); + await db.insert(companies).values(wakeCompanyRow(companyId, issuePrefix)); await db.insert(agents).values([ { id: finishingAgentId, @@ -4096,12 +3413,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { payload: { issueId }, }); - const holdComment = await db.insert(issueComments).values({ - companyId, - issueId, - authorUserId: "hold-user", - body: "Please continue despite the hold", - }).returning().then((rows) => rows[0]!); + const holdComment = await db.insert(issueComments).values(wakeUserCommentRow(companyId, issueId, "hold-user", "Please continue despite the hold")).returning().then((rows) => rows[0]!); const verifiedWakeId = randomUUID(); await db.insert(agentWakeupRequests).values({ id: verifiedWakeId, diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 46c9e3de558..5fc23dd2a86 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -499,6 +499,119 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const childProcesses = new Set(); const cleanupPids = new Set(); + function recoveryCompanyRow( + rowId: (typeof companies.$inferInsert)["id"], + issuePrefix: (typeof companies.$inferInsert)["issuePrefix"], + ): typeof companies.$inferInsert { + return { + id: rowId, + name: "Paperclip", + issuePrefix, + defaultResponsibleUserId: "responsible-user", + requireBoardApprovalForNewAgents: false, + }; + } + + function recoveryAgentRow( + rowId: (typeof agents.$inferInsert)["id"], + companyId: (typeof agents.$inferInsert)["companyId"], + name: (typeof agents.$inferInsert)["name"], + role: (typeof agents.$inferInsert)["role"], + status: (typeof agents.$inferInsert)["status"], + ): typeof agents.$inferInsert { + return { + id: rowId, + companyId, + name, + role, + status, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }; + } + + function selectRecoveryRunsByAgent(agentId: (typeof heartbeatRuns.$inferSelect)["agentId"]) { + return db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + } + + function selectRecoveryIssueById(issueId: (typeof issues.$inferSelect)["id"]) { + return db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + } + + function selectRecoveryCommentsForIssue(issueId: (typeof issueComments.$inferSelect)["issueId"]) { + return db + .select() + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + } + + function selectRecoveryActionForIssue( + companyId: (typeof issueRecoveryActions.$inferSelect)["companyId"], + issueId: (typeof issueRecoveryActions.$inferSelect)["sourceIssueId"], + ) { + return db + .select() + .from(issueRecoveryActions) + .where( + and( + eq(issueRecoveryActions.companyId, companyId), + eq(issueRecoveryActions.sourceIssueId, issueId), + ), + ) + .then((rows) => rows[0] ?? null); + } + + function selectStrandedRecoveryIssuesForCompany(companyId: (typeof issues.$inferSelect)["companyId"]) { + return db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "stranded_issue_recovery"), + ), + ); + } + + function selectRecoveryActionsForIssue(issueId: (typeof issueRecoveryActions.$inferSelect)["sourceIssueId"]) { + return db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + } + + function selectRecoveryWakesByAgent(agentId: (typeof agentWakeupRequests.$inferSelect)["agentId"]) { + return db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.agentId, agentId)); + } + + function selectStrandedRecoveryIssuesForSource( + companyId: (typeof issues.$inferSelect)["companyId"], + issueId: (typeof issues.$inferSelect)["originId"], + ) { + return db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "stranded_issue_recovery"), + eq(issues.originId, issueId), + ), + ); + } + beforeAll(async () => { if (externalTestDatabaseUrl) { db = createDb(externalTestDatabaseUrl); @@ -735,13 +848,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const now = new Date("2026-03-19T00:00: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(companies).values(recoveryCompanyRow(companyId, issuePrefix)); await db.insert(agents).values({ id: agentId, @@ -973,25 +1080,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const now = new Date("2026-03-19T00:00: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(companies).values(recoveryCompanyRow(companyId, issuePrefix)); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(agentId, companyId, "CodexCoder", "engineer", "idle")); await db.insert(agentWakeupRequests).values({ id: wakeupRequestId, @@ -1171,26 +1262,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; const wakeReason = input?.wakeReason ?? "execution_review_requested"; - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - defaultResponsibleUserId: "responsible-user", - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix)); await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexReviewer", - role: "engineer", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(agentId, companyId, "CodexReviewer", "engineer", "idle")); await db.insert(agentWakeupRequests).values({ id: wakeupRequestId, @@ -1265,13 +1340,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const issueId = randomUUID(); 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(companies).values(recoveryCompanyRow(companyId, issuePrefix)); await db.insert(agents).values({ id: agentId, @@ -1458,13 +1527,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const now = new Date("2026-03-19T00:00: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(companies).values(recoveryCompanyRow(companyId, issuePrefix)); await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }); await db.insert(agents).values({ @@ -1578,7 +1641,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { else expect(captured?.diagnostics.execution).not.toHaveProperty("workspaceBaseRefFetch"); const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); expect(issue).toMatchObject({ status: "blocked", executionRunId: null }); - const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const [action] = await selectRecoveryActionsForIssue(issueId); expect(action).toMatchObject({ status: "active", kind: "configuration_validation", ownerType: "board" }); const [wakeup] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeupRequestId)); expect(wakeup.status).toBe("failed"); @@ -1596,17 +1659,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.resumeQueuedRuns(); await waitForRunToSettle(heartbeat, runId, 5_000); await heartbeat.waitForRunExecutionDrain(runId); - const [firstAction] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const [firstAction] = await selectRecoveryActionsForIssue(issueId); const next = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", reason: "retry_failed_run", failedRunId: runId, payload: { issueId }, contextSnapshot: { issueId }, requestedByActorType: "user", requestedByActorId: "responsible-user" }); expect(next).toHaveProperty("id"); await waitForRunToSettle(heartbeat, next!.id, 5_000); await heartbeat.waitForRunExecutionDrain(next!.id); - const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const actions = await selectRecoveryActionsForIssue(issueId); expect(actions).toHaveLength(1); expect(actions[0]).toMatchObject({ id: firstAction.id, status: "active", evidence: { latestRunId: next!.id } }); - const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments.filter(c => c.authorType === "system" && c.metadata?.sourceRunId === next!.id)).toHaveLength(1); expect(comments.filter(c => c.authorType === "system" && c.metadata?.sourceRunId === runId)).toHaveLength(1); expect(mockAdapterExecute).not.toHaveBeenCalled(); @@ -1656,7 +1719,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(mockAdapterExecute).not.toHaveBeenCalled(); const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); expect(issue).toMatchObject({ status: "blocked", executionRunId: null }); - const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const [action] = await selectRecoveryActionsForIssue(issueId); expect(action).toMatchObject({ status: "active", kind: "configuration_validation", cause: "configuration_incomplete", ownerType: "board" }); const [wakeup] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeupRequestId)); expect(wakeup.status).toBe("failed"); @@ -1935,7 +1998,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(mockAdapterExecute.mock.calls.some( ([input]) => (input as { runId?: string } | undefined)?.runId === runId, )).toBe(false); - const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); } finally { build.mockRestore(); @@ -2114,7 +2177,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expectedStatus: "queued", now: new Date() })).toMatchObject({ outcome: "cancelled", errorCode: "execution_reconciliation_required" }); const { settleUnrecoverableExecutions } = await import("../services/execution-recovery-resolution.js"); await settleUnrecoverableExecutions(db); - if (withHold) expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)))[0].status).toBe("active"); + if (withHold) expect((await selectRecoveryActionsForIssue(issueId))[0].status).toBe("active"); expect(isPidAlive(child.pid!)).toBe(true); child.kill("SIGTERM"); await waitForPidExit(child.pid!); @@ -2892,10 +2955,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .where(eq(agentWakeupRequests.id, wakeupRequestId)), ).toMatchObject([{ status: "claimed" }]); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toHaveLength(1); expect(enqueueWakeup).not.toHaveBeenCalled(); expect(mockAdapterExecute).not.toHaveBeenCalled(); @@ -3019,10 +3079,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const result = await heartbeat.reapOrphanedRuns(); expect(result).toEqual({ reaped: 1, runIds: [runId] }); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); expect(runs[0]).toMatchObject({ id: runId, @@ -3053,10 +3110,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(run).toBeNull(); expect(mockAdapterExecute).not.toHaveBeenCalled(); - const requests = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const requests = await selectRecoveryWakesByAgent(agentId); expect(requests).toHaveLength(1); expect(requests[0]).toMatchObject({ companyId, @@ -3066,10 +3120,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { error: null, }); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(0); }); @@ -3104,10 +3155,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.reaped).toBe(1); expect(result.runIds).toEqual([runId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const failedRun = runs.find((row) => row.id === runId); @@ -3141,18 +3189,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }) }), }), ); - const [action] = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const [action] = await selectRecoveryActionsForIssue(issueId); expect(action).toBeUndefined(); expect(retryRun).toMatchObject({ status: "scheduled_retry", scheduledRetryAttempt: 1 }); await heartbeat.reapOrphanedRuns(); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toHaveLength(2); // A replay over the same already-failed run must not report a second // Sentry event for one terminal failure. @@ -3201,15 +3243,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); await heartbeat.reapOrphanedRuns(); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toHaveLength(1); - const actions = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const actions = await selectRecoveryActionsForIssue(issueId); expect(actions).toEqual([ expect.objectContaining({ ownerType: "board", @@ -3828,10 +3864,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { skippedRunIds: [], }); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs.find((run) => run.id === runId)).toMatchObject({ status: "interrupted", errorCode: "server_shutdown_interrupted", @@ -4447,10 +4480,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.interruptedRunIds).toEqual([runId]); expect(result.retryRunIds).toEqual([]); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toEqual([ expect.objectContaining({ id: runId, @@ -4475,10 +4505,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }), ]); expect( - await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)), + await selectRecoveryActionsForIssue(issueId), ).toEqual([ expect.objectContaining({ cause: "legacy_execution_requires_reconciliation", @@ -4517,7 +4544,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); expect(mockDetachNativeSessionsForRestart).toHaveBeenCalledWith([runId]); await expect( - db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)), + selectRecoveryRunsByAgent(agentId), ).resolves.toEqual([ expect.objectContaining({ id: runId, @@ -4598,16 +4625,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.drainRunningRunsForShutdown("SIGTERM"); await heartbeatService(db).reapOrphanedRuns(); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toEqual([expect.objectContaining({ id: runId, status: "interrupted" })]); expect( - await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)), + await selectRecoveryActionsForIssue(issueId), ).toHaveLength(1); }); @@ -4629,19 +4650,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { // The budget stays spent across the restart: no successor run is // queued, by the process-loss retry or by the sweeper. expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toHaveLength(1); // A spent budget is no longer left to sit: the sweeper escalates the // issue to a board-owned recovery action instead of skipping it on // every tick with no live path. The action spawns no run of its own. expect(reconciled.escalated).toBe(1); - const recoveryActions = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const recoveryActions = await selectRecoveryActionsForIssue(issueId); expect(recoveryActions).toHaveLength(1); expect(recoveryActions[0]).toMatchObject({ status: "active", ownerType: "board", ownerAgentId: null }); const escalatedIssue = await db @@ -4654,10 +4669,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const again = await heartbeatService(db).reconcileStrandedAssignedIssues(); expect(again.escalated).toBe(0); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toHaveLength(1); }); @@ -4706,10 +4718,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(isPidAlive(orphan.descendantPid)).toBe(true); expect(mockTerminateLocalService).not.toHaveBeenCalled(); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); expect(runs[0]).toMatchObject({ id: runId, @@ -4734,16 +4743,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.reconcileStrandedAssignedIssues(); expect(mockAdapterExecute).not.toHaveBeenCalled(); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toEqual([expect.objectContaining({ id: runId, status: "failed" })]); expect( - await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)), + await selectRecoveryActionsForIssue(issueId), ).toEqual([ expect.objectContaining({ cause: "legacy_execution_requires_reconciliation", @@ -4792,10 +4795,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.reaped).toBe(1); expect(result.runIds).toEqual([runId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); expect(runs[0]?.status).toBe("failed"); @@ -4810,10 +4810,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { executionRunId: null, checkoutRunId: null, }); - const actions = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const actions = await selectRecoveryActionsForIssue(issueId); expect(actions).toEqual([ expect.objectContaining({ cause: "legacy_execution_requires_reconciliation", @@ -4857,38 +4854,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.reaped).toBe(1); expect(result.runIds).toEqual([runId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); expect(runs[0]?.status).toBe("failed"); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("in_progress"); expect(issue?.executionRunId).toBeNull(); // Terminal run cleanup releases the checkout lock even when paused-tree recovery is suppressed. expect(issue?.checkoutRunId).toBeNull(); - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); expect(recoveryIssues).toHaveLength(0); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(0); }); @@ -4915,7 +4894,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await waitForRunToSettle(heartbeat, runId); await heartbeat.waitForRunExecutionDrain(runId); - const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); expect(runs.find(run => run.id === runId)).toMatchObject({ status: "failed", errorCode: "adapter_failed", resultJson: { errorFamily: "transient_upstream" }, @@ -4924,10 +4903,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { status: "scheduled_retry", scheduledRetryAttempt: 1, }); expect( - await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)), + await selectRecoveryActionsForIssue(issueId), ).toEqual([]); expect(mockAdapterExecute).toHaveBeenCalledTimes(1); }); @@ -5008,10 +4984,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.resumeQueuedRuns(); const runs = await waitForValue(async () => { - const rows = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const rows = await selectRecoveryRunsByAgent(agentId); if (rows.length < 2) return null; // Gate on the *terminal* write of the background recovery, not the // intermediate retry-run commit. recordPlanApprovalResumeFailureRetry @@ -5089,10 +5062,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { executionRunId: retryRun?.id ?? null, }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]).toMatchObject({ authorType: "system", @@ -5254,10 +5224,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { // The real manager waits up to five seconds for worker readiness before // lease acquisition fails and the heartbeat can schedule its retry. const runs = await waitForValue(async () => { - const rows = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const rows = await selectRecoveryRunsByAgent(agentId); return rows.length >= 2 ? rows : null; }, 10_000); expect(runs).toHaveLength(2); @@ -5447,16 +5414,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .where(eq(heartbeatRuns.agentId, agentId)); expect(agentRuns).toEqual([{ id: runId, status: "failed" }]); - const recoveryAction = await db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); + const recoveryAction = await selectRecoveryActionForIssue(companyId, issueId); expect(recoveryAction).toMatchObject({ kind: "configuration_validation", cause: "configuration_incomplete", @@ -5467,10 +5425,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(recoveryAction?.nextAction).toContain("enable the plugin"); const notice = await waitForValue(async () => { - const rows = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const rows = await selectRecoveryCommentsForIssue(issueId); return ( rows.find((comment) => comment.body.includes("paperclip.kubernetes-sandbox-provider"), @@ -5609,10 +5564,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { // may still exist for the issue once it's escalated and rerouted; the // test only cares that *this* run's failure was not classified as // retryable infrastructure.) - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs.some((row) => row.retryOfRunId === runId)).toBe(false); expect( runs.some( @@ -5645,10 +5597,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { sourceIssueId: issueId, }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]).toMatchObject({ authorType: "system", @@ -5746,10 +5695,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { sourceIssueId: issueId, }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]).toMatchObject({ authorType: "system", @@ -5863,10 +5809,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.reaped).toBe(1); expect(result.runIds).toEqual([runId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const failedRun = runs.find((row) => row.id === runId); @@ -5902,10 +5845,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { INTERACTION_CONTINUATION_INFRA_WAKE_REASON, ); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]).toMatchObject({ authorType: "system", @@ -6020,16 +5960,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); expect(issue?.executionRunId).toBeNull(); - const recoveryAction = await db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); + const recoveryAction = await selectRecoveryActionForIssue(companyId, issueId); expect(recoveryAction).toMatchObject({ kind: "workspace_validation", cause: "workspace_validation_failed", @@ -6049,10 +5980,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); const validationComment = await waitForValue(async () => { - const rows = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const rows = await selectRecoveryCommentsForIssue(issueId); return ( rows.find((comment) => comment.body.includes("workspace failed validation"), @@ -6102,7 +6030,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { )); expect(action).toMatchObject({ status: "active", kind: "workspace_validation", ownerType: "board" }); expect(action.nextAction).toContain("workspace"); - const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments.some((comment) => comment.body.includes("workspace failed validation"))).toBe(true); expect(mockCaptureRunFailure.mock.calls.filter(([event]) => event.runId === runId)).toEqual([]); } finally { @@ -6196,7 +6124,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { )); expect(action).toMatchObject({ status: "active", kind: "workspace_validation", ownerType: "board" }); expect(action.nextAction).toContain("workspace"); - const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments.some((comment) => comment.body.includes("workspace failed validation"))).toBe(true); expect(mockCaptureRunFailure.mock.calls.filter(([event]) => event.runId === runId)).toHaveLength(report ? 1 : 0); const calls = (await fs.readFile(callsPath, "utf8")).trim().split("\n").map((line) => JSON.parse(line) as string[]); @@ -6411,16 +6339,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); expect(issue?.executionRunId).toBeNull(); - const recoveryAction = await db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); + const recoveryAction = await selectRecoveryActionForIssue(companyId, issueId); expect(recoveryAction).toMatchObject({ kind: "configuration_validation", cause: "configuration_incomplete", @@ -6432,10 +6351,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(recoveryAction?.nextAction).toContain("bind the missing secret"); const configurationComment = await waitForValue(async () => { - const rows = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const rows = await selectRecoveryCommentsForIssue(issueId); return ( rows.find((comment) => comment.body.includes("secret/env bindings are missing"), @@ -6480,7 +6396,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const heartbeat = heartbeatService(db); await heartbeat.resumeQueuedRuns(); await heartbeat.drainActiveRunExecutions(); - const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + const wakes = await selectRecoveryWakesByAgent(agentId); const repairs = wakes.filter(w => w.reason === "issue_disposition_repair"); expect(repairs).toHaveLength(1); const repair = await heartbeat.getRun(repairs[0].runId!); @@ -6492,7 +6408,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .toEqual({ status: "succeeded", errorCode: null, error: null }); expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done"); expect(mockAdapterExecute).toHaveBeenCalledTimes(2); - expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); + expect(await selectRecoveryCommentsForIssue(issueId)).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); expect(repairs[0].idempotencyKey).toBe(`issue_disposition_repair:${issueId}:${legacyDispositionFingerprint(companyId, issueId, agentId, runId)}:1`); }); @@ -6548,7 +6464,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const heartbeat = heartbeatService(db); await heartbeat.resumeQueuedRuns(); await heartbeat.drainActiveRunExecutions(); - const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + const wakes = await selectRecoveryWakesByAgent(agentId); const repairs = wakes.filter(w => w.reason === "issue_disposition_repair"); expect(repairs).toHaveLength(1); const repair = await heartbeat.getRun(repairs[0].runId!); @@ -6559,7 +6475,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(repair?.status).toBe("succeeded"); expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done"); expect(mockAdapterExecute).toHaveBeenCalledTimes(2); - expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); + expect(await selectRecoveryCommentsForIssue(issueId)).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); expect(repairs[0].idempotencyKey).toBe(`issue_disposition_repair:${issueId}:${legacyDispositionFingerprint(companyId, issueId, agentId, runId)}:1`); expect(wakes.filter(w => w.reason === "finish_successful_run_handoff")).toHaveLength(1); expect(wakes.find(w => w.reason === "finish_successful_run_handoff")?.status).toBe("cancelled"); @@ -6639,7 +6555,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const heartbeat = heartbeatService(db); await heartbeat.resumeQueuedRuns(); await heartbeat.drainActiveRunExecutions(); - const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + const wakes = await selectRecoveryWakesByAgent(agentId); const repairs = wakes.filter(w => w.reason === "issue_disposition_repair"); expect(repairs).toHaveLength(1); const repair = await heartbeat.getRun(repairs[0].runId!); @@ -6650,7 +6566,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(repair?.status).toBe("succeeded"); expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done"); expect(mockAdapterExecute).toHaveBeenCalledTimes(2); - expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); + expect(await selectRecoveryCommentsForIssue(issueId)).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); expect(repairs[0].idempotencyKey).toBe(`issue_disposition_repair:${issueId}:${legacyDispositionFingerprint(companyId, issueId, agentId, runId)}:1`); expect(await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId))).toHaveLength(1); }); @@ -6694,7 +6610,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const heartbeat = heartbeatService(db); await heartbeat.resumeQueuedRuns(); await heartbeat.drainActiveRunExecutions(); - const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + const wakes = await selectRecoveryWakesByAgent(agentId); const repairs = wakes.filter(w => w.reason === "issue_disposition_repair"); expect(repairs).toHaveLength(1); const repair = await heartbeat.getRun(repairs[0].runId!); @@ -6705,7 +6621,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(repair?.status).toBe("succeeded"); expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done"); expect(mockAdapterExecute).toHaveBeenCalledTimes(2); - expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); + expect(await selectRecoveryCommentsForIssue(issueId)).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})])); expect(JSON.stringify(repairs[0].payload)).not.toContain(bearerSecret); expect(JSON.stringify(repairs[0].payload)).not.toContain(apiKeySecret); expect(JSON.stringify(repair?.contextSnapshot?.dispositionRepairInstruction)).not.toContain(bearerSecret); @@ -6766,20 +6682,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { "sk-test-successful-handoff-secret", ); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue?.status).toBe("blocked"); await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual( [], ); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments[0]?.body).toBe( SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY, ); @@ -6904,22 +6813,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { maxHandoffAttempts: 1, }); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue?.status).toBe("in_progress"); expect(sourceIssue?.assigneeAgentId).toBe(agentId); - const recoveryActions = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const recoveryActions = await selectRecoveryActionsForIssue(issueId); expect(recoveryActions).toHaveLength(0); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(0); // A second sweep sees the scheduled retry as the live path and leaves @@ -7000,11 +6899,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { latestRunErrorCode: "server_shutdown_interrupted", recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON, }); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue?.status).toBe("blocked"); }); @@ -7055,27 +6950,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .where(eq(heartbeatRuns.retryOfRunId, runId)); expect(successors).toHaveLength(0); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue?.status).toBe("blocked"); expect(sourceIssue?.assigneeAgentId).toBe(agentId); - const recoveryActions = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const recoveryActions = await selectRecoveryActionsForIssue(issueId); expect(recoveryActions).toHaveLength(1); expect(recoveryActions[0]).toMatchObject({ companyId, status: "active", previousOwnerAgentId: agentId, }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("bounded retry budget"); expect(comments[0]?.body).toContain("no live execution path"); @@ -7164,17 +7049,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const result = await heartbeat.reconcileStrandedAssignedIssues(); expect(result.escalated).toBe(1); expect(result.dispatchRequeued).toBe(0); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue?.status).toBe("blocked"); expect(sourceIssue?.assigneeAgentId).toBe(agentId); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments[0]?.body).toContain("bounded retry budget"); }); @@ -7209,7 +7087,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued).toBe(0); expect(result.successfulContinuationObserved).toBe(0); expect(result.escalated).toBe(1); - const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const [action] = await selectRecoveryActionsForIssue(issueId); expect(action).toMatchObject({ kind: "deliberate_wait_without_target", ownerType: "board", attemptCount: 1 }); expect(action.evidence).toMatchObject({ latestRunId: runId, sourceMaxAttempts: 1 }); expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ status: "blocked", assigneeAgentId: agentId }); @@ -7290,11 +7168,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const umbrella = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const umbrella = await selectRecoveryIssueById(issueId); expect(umbrella?.status).toBe("blocked"); // Original assignee is preserved — no reassignment to a recovery owner. expect(umbrella?.assigneeAgentId).toBe(agentId); @@ -7306,21 +7180,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); // No stranded-recovery action/issue is opened for a deliberate wait. - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); expect(recoveryIssues).toHaveLength(0); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.authorType).toBe("system"); expect(comments[0]?.body).toContain("This task is waiting on"); @@ -7421,11 +7284,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const blocked = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const blocked = await selectRecoveryIssueById(issueId); expect(blocked?.status).toBe("blocked"); expect(blocked?.assigneeAgentId).toBe(agentId); @@ -7433,21 +7292,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const blockers = await sourceBlockerIssueIds(companyId, issueId); expect(blockers).toEqual([openBlockerId]); - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); expect(recoveryIssues).toHaveLength(0); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.authorType).toBe("system"); expect(comments[0]?.body).toContain("This task is waiting on"); @@ -7481,26 +7329,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { [], ); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue).toMatchObject({ status: "in_progress", assigneeAgentId: agentId, }); - const action = await db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); + const action = await selectRecoveryActionForIssue(companyId, issueId); expect(action).toMatchObject({ kind: "deliberate_wait_without_target", status: "active", @@ -7521,10 +7356,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); const repairRun = await waitForValue(async () => { - const rows = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const rows = await selectRecoveryRunsByAgent(agentId); return ( rows.find( (run) => @@ -7856,16 +7688,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.dispositionRepairRequeued).toBe(1); expect(result.escalated).toBe(0); - const action = await db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); + const action = await selectRecoveryActionForIssue(companyId, issueId); expect(action).toMatchObject({ fingerprint: afterDurableChange.fingerprint, attemptCount: 1, @@ -7883,17 +7706,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { runErrorCode: "issue_continuation_waiting_on_review", }); const managerId = randomUUID(); - await db.insert(agents).values({ - id: managerId, - companyId, - name: "Recovery CTO", - role: "cto", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(managerId, companyId, "Recovery CTO", "cto", "idle")); await db .update(agents) .set({ reportsTo: managerId }) @@ -7930,21 +7743,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const [sourceAfter, action, substituteWakes, sourceAttemptSix] = await Promise.all([ - db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null), - db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null), + selectRecoveryIssueById(issueId), + selectRecoveryActionForIssue(companyId, issueId), db .select() .from(agentWakeupRequests) @@ -7989,7 +7789,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { sourceMaxAttempts: 5, }), }); - const notices = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + const notices = await selectRecoveryCommentsForIssue(issueId); expect(notices.some(comment => comment.metadata?.recovery?.kind === "disposition_repair_escalated")).toBe(true); const snapshot = notices.find(comment => comment.metadata?.recovery?.kind === "disposition_repair_escalated")!.metadata!.recovery; expect(snapshot).toEqual({ kind: "disposition_repair_escalated", actionId: action!.id, assigneeAgentId: agentId, attemptCount: 5, maxAttempts: 5, reason: "unchanged_source_state_exhausted" }); @@ -8012,26 +7812,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const result = await heartbeatService(db).reconcileStrandedAssignedIssues(); expect(result.escalated).toBe(1); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue).toMatchObject({ status: "blocked", assigneeAgentId: agentId, }); - const action = await db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); + const action = await selectRecoveryActionForIssue(companyId, issueId); expect(action).toMatchObject({ kind: "deliberate_wait_without_target", status: "active", @@ -8051,17 +7838,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { runStatus: "failed", }); const legacyOwnerId = randomUUID(); - await db.insert(agents).values({ - id: legacyOwnerId, - companyId, - name: "Legacy recovery owner", - role: "cto", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(legacyOwnerId, companyId, "Legacy recovery owner", "cto", "idle")); await db .update(issues) .set({ status: "blocked" }) @@ -8100,10 +7877,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .from(issueRecoveryActions) .where(eq(issueRecoveryActions.id, legacyAction!.id)) .then((rows) => rows[0]), - db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, legacyOwnerId)), + selectRecoveryWakesByAgent(legacyOwnerId), ]); expect(persisted).toMatchObject({ status: "active", @@ -8132,16 +7906,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); const [action, repairWakeups] = await Promise.all([ - db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null), + selectRecoveryActionForIssue(companyId, issueId), db .select() .from(agentWakeupRequests) @@ -8219,7 +7984,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(warnings).toHaveLength(warningFails ? 0 : 1); if (!warningFails) expect(warnings[0]).toMatchObject({ level: "warn", payload: { state: "deferred" } }); expect(JSON.stringify(warnings)).not.toContain("/private/"); - expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).toEqual(expect.arrayContaining([ + expect(await selectRecoveryCommentsForIssue(issueId)).toEqual(expect.arrayContaining([ expect.objectContaining({ body: "Plan saved; awaiting approval." }), ])); expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0]?.status).toBe("pending"); @@ -8363,7 +8128,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(updated!.runId).toBeTruthy(); expect((await restarted.getRun(updated!.runId!))!.contextSnapshot?.wakeCommentIds).toEqual([comment!.id]); await restarted.resumeQueuedRuns(); - expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(2); + expect(await selectRecoveryRunsByAgent(agentId)).toHaveLength(2); }); it.each(["pending", "discarded", "wrong queue"] as const)( @@ -8413,7 +8178,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }, }); await heartbeat.drainActiveRunExecutions(); - const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); const successors = runs.filter((run) => run.id !== runId) .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); expect(successors).toHaveLength(state === "pending" ? 2 : 0); @@ -8422,7 +8187,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { // Only the requested turn's normal completion can drain the other queue. expect(successors[1]!.contextSnapshot?.wakeCommentIds).toEqual([otherComment!.id]); await heartbeat.cancelRun(runId, "Duplicate interrupt"); - expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(3); + expect(await selectRecoveryRunsByAgent(agentId)).toHaveLength(3); } }, ); @@ -8439,8 +8204,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { executionRecovery: { kind: "interrupted", providerStopped: true, sessionPreserved: true, actionOutcomes: "settled" }, } }); await heartbeat.reconcileStrandedAssignedIssues(); - expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(1); - expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0); + expect(await selectRecoveryRunsByAgent(agentId)).toHaveLength(1); + expect(await selectRecoveryActionsForIssue(issueId)).toHaveLength(0); expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, deferred!.id)))[0]?.status).toBe("deferred_issue_execution"); const [go] = await db.insert(issueComments).values({ companyId, issueId, authorUserId: "responsible-user", body: "go" }).returning(); const next = await heartbeat.wakeup(agentId, { source: "automation", reason: "issue_commented", requestedByActorType: "user", requestedByActorId: "responsible-user", @@ -9172,7 +8937,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } }); expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)))[0]) .toMatchObject({ leasePolicy: "retain_on_failure", status: "pending_cleanup" }); - expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(1); + expect(await selectRecoveryActionsForIssue(issueId)).toHaveLength(1); } finally { releaseTermination(); await stopping?.catch(() => undefined); @@ -9598,10 +9363,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakeups = await selectRecoveryWakesByAgent(agentId); expect(wakeups).toHaveLength(1); expect(wakeups[0]).toMatchObject({ companyId, @@ -9618,10 +9380,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { "modelProfile", ); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); expect(runs[0]?.retryOfRunId).toBeNull(); expect(runs[0]?.contextSnapshot).toMatchObject({ @@ -9637,31 +9396,16 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { (runs[0]?.contextSnapshot as Record)?.retryReason, ).toBeUndefined(); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("todo"); - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); expect(recoveryIssues).toHaveLength(0); await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual( [], ); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(0); if (runs[0]?.id) { @@ -9693,15 +9437,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.assignmentDispatched).toBe(0); expect(result.issueIds).toEqual([]); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakeups = await selectRecoveryWakesByAgent(agentId); expect(wakeups).toHaveLength(0); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(0); }); @@ -9744,10 +9482,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued).toBe(0); expect(result.issueIds).toEqual([]); expect(result.skipped).toBe(1); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(0); }); @@ -9799,10 +9534,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.assignmentDispatched + result.continuationRequeued).toBe(1); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); if (runs[0]?.id) { await waitForRunToSettle(heartbeat, runs[0].id); } @@ -9830,10 +9562,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.assignmentDispatched).toBe(1); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); if (runs[0]?.id) { await waitForRunToSettle(heartbeat, runs[0].id); } @@ -9861,15 +9590,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.skipped).toBe(1); expect(result.issueIds).toEqual([]); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakeups = await selectRecoveryWakesByAgent(agentId); expect(wakeups).toHaveLength(1); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(0); }); @@ -10079,11 +9802,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.skipped).toBe(0); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue).toMatchObject({ status: "blocked", assigneeAgentId: agentId, @@ -10126,10 +9845,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const retryRun = runs.find((row) => row.id !== runId); @@ -10180,10 +9896,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const retryRun = runs.find((row) => row.id !== runId); @@ -10243,10 +9956,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); const retryRun = runs.find((row) => row.id !== runId); expect(["queued", "running"]).toContain(retryRun?.status); expect(retryRun).toMatchObject({ @@ -10311,16 +10021,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .from(heartbeatRuns) .where(eq(heartbeatRuns.retryOfRunId, runId)); expect(successors).toHaveLength(0); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue?.status).toBe("blocked"); - const recoveryActions = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const recoveryActions = await selectRecoveryActionsForIssue(issueId); expect(recoveryActions).toHaveLength(1); expect(recoveryActions[0]).toMatchObject({ companyId, @@ -10367,17 +10070,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .set({ status: "cancelled", finishedAt, updatedAt: finishedAt }) .where(eq(agentWakeupRequests.id, wakeupRequestId)); const otherAgentId = randomUUID(); - await db.insert(agents).values({ - id: otherAgentId, - companyId, - name: "CodexImplementor", - role: "engineer", - status: "running", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(otherAgentId, companyId, "CodexImplementor", "engineer", "running")); await db.insert(heartbeatRuns).values({ id: randomUUID(), companyId, @@ -10405,10 +10098,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { .then((rows) => rows[0] ?? null); expect(sourceIssue?.status).toBe("in_review"); expect( - await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)), + await selectRecoveryActionsForIssue(issueId), ).toEqual([]); }); @@ -10437,17 +10127,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }) .where(eq(agentWakeupRequests.id, wakeupRequestId)); - await db.insert(agents).values({ - id: otherAgentId, - companyId, - name: "CodexImplementor", - role: "engineer", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(otherAgentId, companyId, "CodexImplementor", "engineer", "idle")); await db.insert(heartbeatRuns).values({ id: otherRunId, companyId, @@ -10520,17 +10200,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }) .where(eq(agentWakeupRequests.id, wakeupRequestId)); - await db.insert(agents).values({ - id: otherAgentId, - companyId, - name: "CodexImplementor", - role: "engineer", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(otherAgentId, companyId, "CodexImplementor", "engineer", "idle")); await db.insert(agentWakeupRequests).values({ id: otherWakeId, companyId, @@ -10548,10 +10218,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakeups = await selectRecoveryWakesByAgent(agentId); expect( wakeups.some( (wakeup) => @@ -10566,17 +10233,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await seedInReviewParticipantRunFixture(); const otherAgentId = randomUUID(); const otherRunId = randomUUID(); - await db.insert(agents).values({ - id: otherAgentId, - companyId, - name: "CodexImplementor", - role: "engineer", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(otherAgentId, companyId, "CodexImplementor", "engineer", "idle")); await db.insert(heartbeatRuns).values({ id: otherRunId, companyId, @@ -10596,10 +10253,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const heartbeat = heartbeatService(db); await heartbeat.resumeQueuedRuns(); const reviewRecoveryRun = await waitForValue(async () => { - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); return ( runs.find( (row) => @@ -10628,10 +10282,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const settledRun = await waitForRunToSettle(heartbeat, runId, 8_000); expect(settledRun?.status).toBe("succeeded"); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect( runs.some( (row) => @@ -10640,11 +10291,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ), ).toBe(false); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("in_review"); expect(issue?.assigneeAgentId).toBe(agentId); }); @@ -10656,10 +10303,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.resumeQueuedRuns(); const reviewRecoveryRun = await waitForValue(async () => { - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); return ( runs.find( (row) => @@ -10695,11 +10339,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ).not.toHaveProperty("modelProfile"); const sourceIssue = await waitForValue(async () => { - const row = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const row = await selectRecoveryIssueById(issueId); return row?.status === "blocked" ? row : null; }, 8_000); expect(sourceIssue).toMatchObject({ @@ -10729,10 +10369,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { // right after the status check can race an in-flight write. Poll for // each row instead of reading once. const recoveryComment = await waitForValue(async () => { - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); return ( comments.find( (comment) => @@ -10773,17 +10410,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const sourceAssigneeAgentId = randomUUID(); const finishedAt = new Date("2026-03-19T00:05:00.000Z"); - await db.insert(agents).values({ - id: sourceAssigneeAgentId, - companyId, - name: "CodexImplementor", - role: "engineer", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(recoveryAgentRow(sourceAssigneeAgentId, companyId, "CodexImplementor", "engineer", "idle")); await db .update(issues) .set({ @@ -10842,10 +10469,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { status: "in_review", assigneeAgentId: sourceAssigneeAgentId, }); - const [recoveryAction] = await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)); + const [recoveryAction] = await selectRecoveryActionsForIssue(issueId); expect(recoveryAction).toMatchObject({ ownerType: "board", returnOwnerAgentId: sourceAssigneeAgentId, @@ -10885,10 +10509,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const retryRun = runs.find((row) => row.id !== runId); @@ -10904,16 +10525,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryRun?.contextSnapshot as Record, ).not.toHaveProperty("modelProfile"); - const recoveries = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - eq(issues.originId, issueId), - ), - ); + const recoveries = await selectStrandedRecoveryIssuesForSource(companyId, issueId); expect(recoveries).toHaveLength(0); if (retryRun?.id) { @@ -10947,11 +10559,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.skipped).toBeGreaterThanOrEqual(1); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("in_progress"); }, ); @@ -10966,9 +10574,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { 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, - }); + await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix)); await db.insert(agents).values({ id: agentId, companyId, name: input.agentName ?? "CodexCoder", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } }, permissions: {}, @@ -11079,12 +10685,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued).toBe(0); const [issue, runs, persistedAction] = await Promise.all([ - db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null), - db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)), + selectRecoveryIssueById(issueId), + selectRecoveryRunsByAgent(agentId), db .select() .from(issueRecoveryActions) @@ -11138,11 +10740,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.issueIds).toContain(issueId); const [issue, continuationRuns, comments] = await Promise.all([ - db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null), + selectRecoveryIssueById(issueId), db .select({ id: heartbeatRuns.id }) .from(heartbeatRuns) @@ -11164,16 +10762,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect( comments.some((comment) => comment.body.includes("Attempts: 5/5")), ).toBe(true); - const action = await db - .select() - .from(issueRecoveryActions) - .where( - and( - eq(issueRecoveryActions.companyId, companyId), - eq(issueRecoveryActions.sourceIssueId, issueId), - ), - ) - .then((rows) => rows[0] ?? null); + const action = await selectRecoveryActionForIssue(companyId, issueId); expect(action).toMatchObject({ kind: "deliberate_wait_without_target", status: "active", @@ -11458,10 +11047,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const retryRun = runs.find((row) => row.id !== runId); @@ -11498,11 +11084,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); const recoveryAction = await expectSourceScopedStrandedRecoveryAction({ @@ -11518,10 +11100,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { "sk-test-recovery-secret", ); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried dispatch"); expect(comments[0]?.body).not.toContain("sk-test-recovery-secret"); @@ -11600,15 +11179,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); expect(recoveryIssues).toHaveLength(1); expect(recoveryIssues[0]).toMatchObject({ id: issueId, @@ -11632,10 +11203,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); expect(blockerRelations).toHaveLength(0); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain( "stopped automatic stranded-work recovery", @@ -11663,13 +11231,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const blockerIssueId = randomUUID(); const blockedIssueId = randomUUID(); 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(companies).values(recoveryCompanyRow(companyId, issuePrefix)); await db.insert(agents).values([ { id: creatorAgentId, @@ -11732,26 +11294,16 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.orphanBlockersAssigned).toBe(1); expect(result.issueIds).toContain(blockerIssueId); - const blocker = await db - .select() - .from(issues) - .where(eq(issues.id, blockerIssueId)) - .then((rows) => rows[0] ?? null); + const blocker = await selectRecoveryIssueById(blockerIssueId); expect(blocker?.assigneeAgentId).toBe(creatorAgentId); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, blockerIssueId)); + const comments = await selectRecoveryCommentsForIssue(blockerIssueId); expect(comments[0]?.body).toContain("Assigned Orphan Blocker"); expect(comments[0]?.body).toContain( `[${issuePrefix}-2](/${issuePrefix}/issues/${issuePrefix}-2)`, ); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, creatorAgentId)); + const wakeups = await selectRecoveryWakesByAgent(creatorAgentId); expect(wakeups).toEqual([ expect.objectContaining({ reason: "issue_assigned", @@ -11782,10 +11334,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const retryRun = runs.find((row) => row.id !== runId); @@ -11828,15 +11377,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued).toBe(0); expect(result.escalated).toBe(0); expect(result.skipped).toBeGreaterThanOrEqual(1); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakeups = await selectRecoveryWakesByAgent(agentId); expect(wakeups).toHaveLength(1); expect(wakeups[0]?.runId).toBe(runId); }); @@ -11846,24 +11389,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const agentId = randomUUID(); const issueId = randomUUID(); 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: {}, - permissions: {}, - }); + await db.insert(companies).values(recoveryCompanyRow(companyId, issuePrefix)); + await db.insert(agents).values(recoveryAgentRow(agentId, companyId, "CodexCoder", "engineer", "idle")); await db.insert(issues).values({ id: issueId, companyId, @@ -11885,10 +11412,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.skipped).toBe(1); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(0); const [issue] = await db .select() @@ -11952,10 +11476,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(planOnlyRunId).not.toBe(""); const livenessWakes = await waitForValue(async () => { - const rows = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const rows = await selectRecoveryWakesByAgent(agentId); // Follow-up repairs can already exist. Match this plan-only result, // rather than relying on the database's unordered row order. const matching = rows.filter((row) => row.reason === "issue_disposition_repair" @@ -12050,10 +11571,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.resumeQueuedRuns(); const retryRun = await waitForValue(async () => { - const rows = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const rows = await selectRecoveryRunsByAgent(agentId); return ( rows.find( (row) => row.id !== runId && row.livenessState === "advanced", @@ -12065,10 +11583,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } expect(retryRun?.livenessState).toBe("advanced"); - const wakes = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakes = await selectRecoveryWakesByAgent(agentId); expect( wakes.some((row) => row.reason === "run_liveness_continuation"), ).toBe(false); @@ -12087,11 +11602,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); const recoveryAction = await expectSourceScopedStrandedRecoveryAction({ @@ -12104,10 +11615,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { cause: "process_lost", }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); expect(comments[0]?.presentation).toMatchObject({ @@ -12159,10 +11667,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { "- Failure: none recorded", ); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); // The short structured body carries no failure details; the normalized // failure code surfaces only as a metadata row. @@ -12190,17 +11695,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("in_progress"); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const retryRun = runs.find((row) => row.id !== runId); expect( @@ -12259,11 +11757,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); await expectSourceScopedStrandedRecoveryAction({ @@ -12275,10 +11769,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: "issue_continuation_needed", }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); expect(comments[0]?.body).toContain("3× attempts"); @@ -12304,16 +11795,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued).toBe(0); expect(result.escalated).toBe(1); expect( - await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)), + await selectRecoveryRunsByAgent(agentId), ).toHaveLength(1); expect( - await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)), + await selectRecoveryActionsForIssue(issueId), ).toEqual([ expect.objectContaining({ cause: "legacy_execution_requires_reconciliation", @@ -12337,11 +11822,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); await expectSourceScopedStrandedRecoveryAction({ @@ -12353,18 +11834,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: null, }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("non-retryable failure"); expect(comments[0]?.body).toContain("`budget_blocked`"); - const followupRuns = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const followupRuns = await selectRecoveryRunsByAgent(agentId); const continuationRetryRun = followupRuns.find((row) => { const ctx = row.contextSnapshot as Record | null; return ctx?.retryReason === "issue_continuation_needed"; @@ -12394,10 +11869,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const result = await heartbeatService(db).reconcileStrandedAssignedIssues(); expect(result.continuationRequeued).toBe(0); expect(result.escalated).toBe(0); - const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs.map((run) => run.id)).toEqual([runId]); - const wakes = await db.select().from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakes = await selectRecoveryWakesByAgent(agentId); expect(wakes).toHaveLength(1); expect(wakes[0].reason).toBe("issue_assigned"); const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); @@ -12425,11 +11899,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); await expectSourceScopedStrandedRecoveryAction({ @@ -12441,10 +11911,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: null, }); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect( runs.find((row) => { const context = row.contextSnapshot as Record | null; @@ -14108,11 +13575,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const heartbeat = heartbeatService(db); const result = await heartbeat.reconcileStrandedAssignedIssues(); expect(result.continuationRequeued).toBe(0); - expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(1); + expect(await selectRecoveryRunsByAgent(agentId)).toHaveLength(1); // Once the child finishes, normal recovery can continue the lead. await db.update(issues).set({ status: "done" }).where(eq(issues.id, childId)); expect((await heartbeat.reconcileStrandedAssignedIssues()).continuationRequeued).toBe(1); - const next = (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).find((r) => r.id !== runId); + const next = (await selectRecoveryRunsByAgent(agentId)).find((r) => r.id !== runId); if (next) await waitForRunToSettle(heartbeat, next.id); }); @@ -14129,7 +13596,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const heartbeat = heartbeatService(db); const result = await heartbeat.reconcileStrandedAssignedIssues(); expect(result.continuationRequeued).toBe(1); - const next = (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).find((r) => r.id !== runId); + const next = (await selectRecoveryRunsByAgent(agentId)).find((r) => r.id !== runId); if (next) await waitForRunToSettle(heartbeat, next.id); }); @@ -14146,10 +13613,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); const retryRun = runs.find((row) => row.id !== runId); expect( retryRun?.contextSnapshot as Record | undefined, @@ -14189,16 +13653,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); expect(actions).toHaveLength(1); expect(actions[0]?.attemptCount).toBeGreaterThanOrEqual(1); - const recoveries = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - eq(issues.originId, issueId), - ), - ); + const recoveries = await selectStrandedRecoveryIssuesForSource(companyId, issueId); expect(recoveries).toHaveLength(0); await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual( [], @@ -14244,39 +13699,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const recoveryIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const recoveryIssue = await selectRecoveryIssueById(issueId); expect(recoveryIssue?.status).toBe("blocked"); expect(recoveryIssue?.assigneeAgentId).toBe(agentId); expect(recoveryIssue?.originKind).toBe("stranded_issue_recovery"); expect(recoveryIssue?.originId).toBe(sourceIssueId); - const nestedRecoveries = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - eq(issues.originId, issueId), - ), - ); + const nestedRecoveries = await selectStrandedRecoveryIssuesForSource(companyId, issueId); expect(nestedRecoveries).toHaveLength(0); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); expect(runs[0]?.id).toBe(runId); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain( "stopped automatic stranded-work recovery", @@ -14365,43 +13801,19 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(secondResult.escalated).toBe(1); expect(secondResult.issueIds).toEqual([issueId]); - const recoveryIssuesForSource = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - eq(issues.originId, sourceIssueId), - ), - ); + const recoveryIssuesForSource = await selectStrandedRecoveryIssuesForSource(companyId, sourceIssueId); expect(recoveryIssuesForSource.map((issue) => issue.id)).toEqual([issueId]); - const nestedRecoveries = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - eq(issues.originId, issueId), - ), - ); + const nestedRecoveries = await selectStrandedRecoveryIssuesForSource(companyId, issueId); expect(nestedRecoveries).toHaveLength(0); await expect( sourceBlockerIssueIds(companyId, sourceIssueId), ).resolves.toEqual([issueId]); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect( - await db - .select() - .from(issueRecoveryActions) - .where(eq(issueRecoveryActions.sourceIssueId, issueId)), + await selectRecoveryActionsForIssue(issueId), ).toEqual([ expect.objectContaining({ cause: "legacy_execution_requires_reconciliation", @@ -14426,23 +13838,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.skipped).toBe(1); expect(result.issueIds).toEqual([]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("in_progress"); expect(issue?.checkoutRunId).toBeTruthy(); - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); expect(recoveryIssues).toHaveLength(0); const blockerRelations = await db @@ -14457,16 +13857,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); expect(blockerRelations).toHaveLength(0); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(0); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakeups = await selectRecoveryWakesByAgent(agentId); expect(wakeups).toHaveLength(1); }); @@ -14485,23 +13879,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("in_progress"); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(0); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(2); const retryRun = runs.find((row) => row.id !== runId); expect( @@ -14517,10 +13901,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryRun?.contextSnapshot as Record, ).not.toHaveProperty("modelProfile"); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.agentId, agentId)); + const wakeups = await selectRecoveryWakesByAgent(agentId); expect(wakeups).toHaveLength(2); }); @@ -14547,10 +13928,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); const retryRun = runs.find((row) => row.id !== runId); expect( retryRun?.contextSnapshot as Record | undefined, @@ -14591,11 +13969,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); await expectSourceScopedStrandedRecoveryAction({ @@ -14608,10 +13982,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: "issue_continuation_needed", }); - const followupRuns = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const followupRuns = await selectRecoveryRunsByAgent(agentId); expect(followupRuns).toHaveLength(1); }); @@ -14633,30 +14004,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.skipped).toBe(1); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("in_progress"); expect(issue?.monitorNextCheckAt?.toISOString()).toBe( "2026-03-19T01:00:00.000Z", ); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); expect(recoveryIssues).toHaveLength(0); }); @@ -14696,16 +14052,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.skipped).toBe(1); - const source = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const source = await selectRecoveryIssueById(issueId); expect(source?.status).toBe("in_progress"); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); expect(runs).toHaveLength(1); }); @@ -14725,11 +14074,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); const recoveryAction = await expectSourceScopedStrandedRecoveryAction({ @@ -14742,10 +14087,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: "issue_continuation_needed", }); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); + const comments = await selectRecoveryCommentsForIssue(issueId); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("bounded original-owner disposition repair"); expect(comments[0]?.body).toContain("Attempts: 1/1"); @@ -16428,10 +15770,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.escalated).toBe(0); expect(result.issueIds).toEqual([issueId]); - const runs = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); + const runs = await selectRecoveryRunsByAgent(agentId); const retryRun = runs.find((row) => row.id !== runId); expect( retryRun?.contextSnapshot as Record | undefined, @@ -16456,11 +15795,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); const heartbeat = heartbeatService(db); - const sourceIssue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const sourceIssue = await selectRecoveryIssueById(issueId); expect(sourceIssue).toMatchObject({ status: "in_progress", assigneeAgentId: agentId, @@ -16498,19 +15833,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued + result.escalated).toBe(1); expect(result.issueIds).toEqual([issueId]); - const comments = await db - .select() - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); - const recoveryIssues = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "stranded_issue_recovery"), - ), - ); + const comments = await selectRecoveryCommentsForIssue(issueId); + const recoveryIssues = await selectStrandedRecoveryIssuesForCompany(companyId); const followupRuns = await db .select() .from(heartbeatRuns) @@ -16561,7 +15885,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.recentProgressExempted).toBe(0); expect(result.continuationRequeued).toBe(0); expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ status: "blocked", assigneeAgentId: agentId }); - expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(1); + expect(await selectRecoveryRunsByAgent(agentId)).toHaveLength(1); }); it("still escalates stranded-recovery work when the recent comment is older than the exemption window (GGU-809)", async () => { @@ -16589,11 +15913,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.recentProgressExempted).toBe(0); expect(result.continuationRequeued).toBe(0); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("blocked"); }); @@ -16610,11 +15930,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued).toBe(0); expect(result.escalated).toBe(0); - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows) => rows[0] ?? null); + const issue = await selectRecoveryIssueById(issueId); expect(issue?.status).toBe("todo"); const runs = await db diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 189a135323b..44ac2de0a1d 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -475,6 +475,31 @@ function renderProperties(container: HTMLDivElement, props: ComponentProps { + return { + stages: [ + { + id: "00000000-0000-4000-8000-000000000010", + type: "review", + approvalsNeeded: 1, + participants: [{ id: "00000000-0000-4000-8000-000000000012", type: "agent", agentId: "00000000-0000-4000-8000-000000000013", userId: null }], + }, + ], + }; +} + describe("IssueProperties", () => { let container: HTMLDivElement; @@ -2239,15 +2264,7 @@ describe("IssueProperties", () => { it("hides model options when the issue uses the responsible default", async () => { mockAgentsApi.list.mockResolvedValue([ - { - id: "agent-1", - name: "Senior Product Engineer", - role: "engineer", - title: null, - status: "active", - adapterType: "codex_local", - icon: null, - }, + seniorProductEngineer(), ]); const root = renderProperties(container, { @@ -2269,15 +2286,7 @@ describe("IssueProperties", () => { it("edits existing custom assignee model options from the properties pane", async () => { const onUpdate = vi.fn(); mockAgentsApi.list.mockResolvedValue([ - { - id: "agent-1", - name: "Senior Product Engineer", - role: "engineer", - title: null, - status: "active", - adapterType: "codex_local", - icon: null, - }, + seniorProductEngineer(), ]); mockAgentsApi.adapterModels.mockResolvedValue([ { id: "gpt-6-astra", label: "gpt-6-astra" }, @@ -2391,15 +2400,7 @@ describe("IssueProperties", () => { it("clears existing assignee adapter overrides from the properties pane", async () => { const onUpdate = vi.fn(); mockAgentsApi.list.mockResolvedValue([ - { - id: "agent-1", - name: "Senior Product Engineer", - role: "engineer", - title: null, - status: "active", - adapterType: "codex_local", - icon: null, - }, + seniorProductEngineer(), ]); const root = renderProperties(container, { @@ -2663,16 +2664,7 @@ describe("IssueProperties", () => { const onUpdate = vi.fn(); const root = renderProperties(container, { issue: createIssue({ - executionPolicy: createExecutionPolicy({ - stages: [ - { - id: "00000000-0000-4000-8000-000000000010", - type: "review", - approvalsNeeded: 1, - participants: [{ id: "00000000-0000-4000-8000-000000000012", type: "agent", agentId: "00000000-0000-4000-8000-000000000013", userId: null }], - }, - ], - }), + executionPolicy: createExecutionPolicy(reviewPolicyStages()), }), childIssues: [], onUpdate, @@ -2721,16 +2713,7 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ status: "in_progress", - executionPolicy: createExecutionPolicy({ - stages: [ - { - id: "00000000-0000-4000-8000-000000000010", - type: "review", - approvalsNeeded: 1, - participants: [{ id: "00000000-0000-4000-8000-000000000012", type: "agent", agentId: "00000000-0000-4000-8000-000000000013", userId: null }], - }, - ], - }), + executionPolicy: createExecutionPolicy(reviewPolicyStages()), executionState: createExecutionState(), }), childIssues: [], @@ -2747,16 +2730,7 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ status: "in_review", - executionPolicy: createExecutionPolicy({ - stages: [ - { - id: "00000000-0000-4000-8000-000000000010", - type: "review", - approvalsNeeded: 1, - participants: [{ id: "00000000-0000-4000-8000-000000000012", type: "agent", agentId: "00000000-0000-4000-8000-000000000013", userId: null }], - }, - ], - }), + executionPolicy: createExecutionPolicy(reviewPolicyStages()), executionState: createExecutionState({ status: "pending", currentStageType: "review", diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index d7d255d6d82..40677cb3dd3 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -397,14 +397,12 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti }); it("reveals comments while a scheduled retry has no transcript to hydrate", () => { - render( - {}} - linkedRuns={[retryRun]} - />, - ); + renderThread({ + issueId: "issue-1", + comments: createLongThreadComments(), + onAdd: async () => {}, + linkedRuns: [retryRun], + }); expect(container.querySelector('[aria-busy="false"]')).not.toBeNull(); expect( @@ -417,19 +415,19 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti }); it("does not delay the first reveal for an older run outside the loaded comment window", () => { - render( {}} - linkedRuns={[{ + renderThread({ + issueId: "issue-1", + comments: createLongThreadComments(), + onAdd: async () => {}, + linkedRuns: [{ ...retryRun, runId: "older-run", status: "succeeded", createdAt: "2026-08-01T12:00:00.000Z", startedAt: "2026-08-01T12:00:00.000Z", finishedAt: "2026-08-01T12:01:00.000Z", - }]} - />); + }], + }); expect(container.querySelector('[aria-busy="false"]')).not.toBeNull(); }); @@ -498,12 +496,16 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti }); it("preserves the typed disposition notice through the task-chat adapter", () => { - render( {}} issueStatus="blocked" />); + renderThread({ + comments: [{ + id: "typed-recovery", companyId: "company", issueId: "issue", authorType: "system", authorAgentId: null, authorUserId: null, + body: "Unrelated prose", createdAt: new Date(), updatedAt: new Date(), + presentation: { kind: "system_notice", title: "Different wording", tone: "warning", detailsDefaultOpen: false, density: "compact" }, + metadata: { version: 1, sections: [], recovery: { kind: "disposition_repair_escalated", actionId: "action", assigneeAgentId: "agent", attemptCount: 2, maxAttempts: 2, reason: "unchanged_source_state_exhausted" } }, + }], + onAdd: async () => {}, + issueStatus: "blocked", + }); expect(container.querySelector('[data-testid="disposition-recovery-notice"]')).not.toBeNull(); expect(container.textContent).toContain("Two automatic attempts"); expect(container.textContent).not.toContain("Unrelated prose"); @@ -527,12 +529,10 @@ it("keeps an acknowledged optimistic bubble mounted with its canonical comment t render( {}} />); const row = container.querySelector('[data-thread-anchor="optimistic-one"]'); expect(row).not.toBeNull(); - render( - {}} - />, - ); + renderThread({ + comments: [{ ...comment, id: "canonical-one" }], + onAdd: async () => {}, + }); expect(container.querySelector('[data-thread-anchor="optimistic-one"]')).toBe( row, ); @@ -709,28 +709,71 @@ function createLongThreadComments() { })); } +function assistantEntry(ts: string, text: string, channel: string) { + return { + kind: "assistant", + ts: ts, + text: text, + channel: channel, + }; +} + +function nativeRunningRun() { + return { + id: "native-run", + runtimeMode: "native" as const, + status: "running" as const, + invocationSource: "issue" as const, + triggerDetail: null, + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: null, + createdAt: "2026-08-25T18:00:00.000Z", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + }; +} + +function nativePlanRun(id: string): NonNullable["activeRun"]> { + return { + id: id, + status: "running", + invocationSource: "issue", + triggerDetail: null, + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: null, + createdAt: "2026-08-25T18:00:00.000Z", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + runtimeMode: "native", + }; +} + +function renderThread(props: ComponentProps) { + render(); +} + describe("TaskChatThread draft pass-through", () => { it("aligns the desktop thread header with the side-panel tab row", () => { - render( - {}} - />, - ); + renderThread({ + comments: [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body: "Header alignment fixture.", + presentation: null, + metadata: null, + createdAt: new Date("2026-08-15T12:00:00.000Z"), + updatedAt: new Date("2026-08-15T12:00:00.000Z"), + }, + ], + onAdd: async () => {}, + }); const scroller = container.querySelector( '[data-testid="task-chat-scroller"]', @@ -739,26 +782,24 @@ describe("TaskChatThread draft pass-through", () => { }); it("lets the mobile composer dock use the full thread width", () => { - render( - {}} - />, - ); + renderThread({ + comments: [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body: "Waiting for the dependency.", + presentation: null, + metadata: null, + createdAt: new Date("2026-08-15T12:00:00.000Z"), + updatedAt: new Date("2026-08-15T12:00:00.000Z"), + }, + ], + onAdd: async () => {}, + }); const dock = container.querySelector( '[data-testid="task-chat-composer-dock"]', @@ -780,13 +821,11 @@ describe("TaskChatThread draft pass-through", () => { it("forwards draftKey so the composer restores a task's saved draft", () => { localStorage.setItem("task-chat-draft:issue-1", "half-written thought"); - render( - {}} - draftKey="task-chat-draft:issue-1" - />, - ); + renderThread({ + comments: createLongThreadComments(), + onAdd: async () => {}, + draftKey: "task-chat-draft:issue-1", + }); expect( container.querySelector('[data-testid="mock-editor"]')?.textContent, @@ -794,20 +833,16 @@ describe("TaskChatThread draft pass-through", () => { }); it("forwards human profiles to the selected composer assignee", () => { - render( - {}} - enableReassign - reassignOptions={[{ id: "user:user-1", label: "Riley Board" }]} - currentAssigneeValue="user:user-1" - userProfileMap={ - new Map([ - ["user-1", { label: "Riley Board", image: "/riley-avatar.png" }], - ]) - } - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + enableReassign: true, + reassignOptions: [{ id: "user:user-1", label: "Riley Board" }], + currentAssigneeValue: "user:user-1", + userProfileMap: new Map([ + ["user-1", { label: "Riley Board", image: "/riley-avatar.png" }], + ]), + }); expect( container.querySelector('[data-assignee-trigger-avatar="user-1"]'), @@ -863,11 +898,10 @@ describe("TaskChatThread runtime transcript selection", () => { it("selects persisted runtime facts while retaining the log parser as native fallback", () => { nativeTranscriptState.hydratedRunIds = new Set(["native-run"]); - render( - {}} - linkedRuns={[ + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [ { runId: "native-run", runtimeMode: "native", @@ -886,9 +920,8 @@ describe("TaskChatThread runtime transcript selection", () => { createdAt: `2026-08-25T18:0${index + 1}:00.000Z`, startedAt: `2026-08-25T18:0${index + 1}:00.000Z`, })), - ]} - />, - ); + ], + }); const legacyRuns = transcriptHookRuns.legacy.at(-1) as Array<{ id: string; @@ -905,42 +938,18 @@ describe("TaskChatThread runtime transcript selection", () => { it("uses runner-only controls only for an actual native Paperclip Runner run", () => { nativeTranscriptState.transcriptByRun.set("native-run", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Checking the task.", - channel: "progress", - }, - { - kind: "assistant", - ts: "2026-08-25T18:00:02.000Z", - text: "The task is ready.", - channel: "final", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Checking the task.", "progress"), + assistantEntry("2026-08-25T18:00:02.000Z", "The task is ready.", "final"), ]); - const run = { - id: "native-run", - runtimeMode: "native" as const, - status: "running" as const, - invocationSource: "issue" as const, - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - }; + const run = nativeRunningRun(); - render( - {}} - issueStatus="in_progress" - activeRun={run} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: run, + }); expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), @@ -948,27 +957,23 @@ describe("TaskChatThread runtime transcript selection", () => { expect(container.textContent).toContain("Checking the task."); expect(container.textContent).toContain("The task is ready."); - render( - {}} - issueStatus="in_progress" - activeRun={{ ...run, runtimeMode: "legacy" }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { ...run, runtimeMode: "legacy" }, + }); expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), ).toBeNull(); - render( - {}} - issueStatus="in_progress" - activeRun={{ ...run, adapterType: "codex_local" }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { ...run, adapterType: "codex_local" }, + }); expect( container.querySelector('[data-testid="task-chat-runner-turn"]'), @@ -978,16 +983,22 @@ describe("TaskChatThread runtime transcript selection", () => { it.each(["matching", "document_only", "absent", "other_revision"] as const)("reports a restore failure with %s saved-plan evidence", (evidence) => { if (evidence !== "absent") planState.data = planDocument(); - render( {}} issueStatus="blocked" - interactions={evidence === "absent" || evidence === "document_only" ? [] : [planReviewInteraction("accepted", evidence === "matching" ? "revision-3" : "revision-2", "restore-run")]} - onRetryFailedRun={vi.fn()} linkedRuns={[{ + renderThread({ + issueId: "issue-1", + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + interactions: evidence === "absent" || evidence === "document_only" ? [] : [planReviewInteraction("accepted", evidence === "matching" ? "revision-3" : "revision-2", "restore-run")], + onRetryFailedRun: vi.fn(), + linkedRuns: [{ runId: "restore-run", runtimeMode: "legacy", status: "failed", errorCode: "workspace_restore_failed", agentId: "agent-1", agentName: "Runner", adapterType: "grok_local", createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", finishedAt: "2026-08-25T18:00:02.000Z", resultJson: { workspaceRestoreFailure: "restore_unsafe_archive", workspaceRestorePath: ".claude/skills/paperclip", finalResponseRecorded: false, ...(evidence === "document_only" ? { savedPlanRevisionId: "revision-3" } : {}), }, - }]} />); + }], + }); const marker = container.querySelector('[data-testid="task-chat-collapsible-marker"]'); expect(marker?.textContent).toContain("Workspace restore failed"); flushSync(() => marker!.querySelector('button[aria-expanded]')!.click()); @@ -1001,23 +1012,33 @@ describe("TaskChatThread runtime transcript selection", () => { }); it("uses neutral wording for an unclassified historical legacy failure", () => { - render( {}} linkedRuns={[{ - runId: "old-run", runtimeMode: "legacy", status: "failed", errorCode: "adapter_failed", - agentId: "agent-1", agentName: "Runner", adapterType: "grok_local", - createdAt: "2026-08-25T18:00:00.000Z", startedAt: null, finishedAt: "2026-08-25T18:00:02.000Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [{ + runId: "old-run", runtimeMode: "legacy", status: "failed", errorCode: "adapter_failed", + agentId: "agent-1", agentName: "Runner", adapterType: "grok_local", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: null, finishedAt: "2026-08-25T18:00:02.000Z", + }], + }); expect(container.textContent).toContain("The run failed"); expect(container.textContent).not.toContain("before returning an answer"); expect(container.textContent).not.toContain("Workspace restore failed"); }); it("directs a missing starting branch to repair instead of offering an unchanged retry", () => { - render( {}} issueStatus="blocked" onRetryFailedRun={vi.fn()} linkedRuns={[{ - runId: "missing-branch-run", runtimeMode: "legacy", status: "failed", errorCode: "configuration_incomplete", - agentId: "agent-1", agentName: "Worker", adapterType: "claude_local", - createdAt: "2026-08-25T18:00:00.000Z", startedAt: null, finishedAt: "2026-08-25T18:00:02.000Z", - resultJson: { configurationIncomplete: { reason: "workspace_base_ref_unresolved", requestedRef: "main" } }, - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + onRetryFailedRun: vi.fn(), + linkedRuns: [{ + runId: "missing-branch-run", runtimeMode: "legacy", status: "failed", errorCode: "configuration_incomplete", + agentId: "agent-1", agentName: "Worker", adapterType: "claude_local", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: null, finishedAt: "2026-08-25T18:00:02.000Z", + resultJson: { configurationIncomplete: { reason: "workspace_base_ref_unresolved", requestedRef: "main" } }, + }], + }); expect(container.textContent).toContain("Starting branch unavailable"); expect(container.textContent).toContain("Repair the starting branch below before retrying."); expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull(); @@ -1044,15 +1065,21 @@ describe("TaskChatThread runtime transcript selection", () => { }); it("directs a missing personal AI credential to its card without offering a premature retry", () => { - render( {}} issueStatus="blocked" - onRetryFailedRun={vi.fn()} interactions={[{ + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + onRetryFailedRun: vi.fn(), + interactions: [{ ...pendingConnectionIntentInteraction, sourceRunId: "missing-ai-run", payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" }, - }]} linkedRuns={[{ + }], + linkedRuns: [{ runId: "missing-ai-run", runtimeMode: "legacy", status: "failed", errorCode: "configuration_incomplete", agentId: "agent-1", agentName: "Chief of Staff", adapterType: "claude_local", createdAt: "2026-08-25T18:00:00.000Z", startedAt: null, finishedAt: "2026-08-25T18:00:02.000Z", - }]} />); + }], + }); expect(container.textContent).toContain("AI connection needed"); expect(container.textContent).toContain("Use the connection card below to continue."); expect(container.textContent).not.toContain("The selected AI account is unavailable"); @@ -1064,12 +1091,7 @@ describe("TaskChatThread runtime transcript selection", () => { updatedAt: new Date("2026-08-25T18:00:02.000Z"), }); nativeTranscriptState.transcriptByRun.set("native-plan", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Preparing the Plan.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the Plan.", "progress"), { kind: "tool_call", ts: "2026-08-25T18:00:02.000Z", @@ -1084,34 +1106,15 @@ describe("TaskChatThread runtime transcript selection", () => { toolName: "write_document", content: "revision-3", }, - { - kind: "assistant", - ts: "2026-08-25T18:00:03.000Z", - text: "Plan revision is ready for review.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:03.000Z", "Plan revision is ready for review.", "progress"), ]); - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: "native-plan", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - runtimeMode: "native", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: nativePlanRun("native-plan"), + }); const preview = container.querySelector( '[data-testid="task-chat-plan-preview"]', @@ -1179,17 +1182,22 @@ describe("TaskChatThread runtime transcript selection", () => { // Exercise persisted ACP display events, without native raw arguments or // a fabricated semantic-tool receipt, through the actual transcript parser. nativeTranscriptState.transcriptByRun.set(runId, nativeRunEventsToTranscript(events)); - render( {}} issueStatus="blocked" linkedRuns={[{ - runId, - runtimeMode: "native", - status: "succeeded", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:03.000Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + linkedRuns: [{ + runId, + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:03.000Z", + }], + }); const preview = container.querySelector('[data-testid="task-chat-plan-preview"]'); const fallback = container.querySelector('[data-testid="task-chat-plan-preview-fallback"]'); expect(Boolean(preview)).toBe(anchored); @@ -1203,34 +1211,15 @@ describe("TaskChatThread runtime transcript selection", () => { updatedAt: new Date("2026-08-25T18:00:02.000Z"), }); nativeTranscriptState.transcriptByRun.set("native-plan-fallback", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Recovering the Plan transcript.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Recovering the Plan transcript.", "progress"), ]); - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: "native-plan-fallback", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - runtimeMode: "native", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: nativePlanRun("native-plan-fallback"), + }); const fallback = container.querySelector( '[data-testid="task-chat-plan-preview-fallback"]', @@ -1246,37 +1235,18 @@ describe("TaskChatThread runtime transcript selection", () => { it("suppresses early provider wait prose when its native interaction is pending", () => { nativeTranscriptState.transcriptByRun.set("native-wait", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Waiting for Plan approval.", - channel: "final", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Waiting for Plan approval.", "final"), ]); - render( - {}} - issueStatus="in_progress" - interactions={[ - planReviewInteraction("pending", "revision-3", "native-wait"), - ]} - activeRun={{ - id: "native-wait", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - runtimeMode: "native", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + interactions: [ + planReviewInteraction("pending", "revision-3", "native-wait"), + ], + activeRun: nativePlanRun("native-wait"), + }); expect(container.textContent).toContain("Review the Plan"); expect(container.textContent).not.toContain("Waiting for Plan approval."); @@ -1287,34 +1257,15 @@ describe("TaskChatThread runtime transcript selection", () => { it("uses the live log when a native run has no persisted event transcript", () => { transcriptState.transcriptByRun.set("native-run", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Visible from the runner log fallback.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Visible from the runner log fallback.", "progress"), ]); - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: "native-run", - runtimeMode: "native", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: nativeRunningRun(), + }); expect(container.textContent).toContain( "Visible from the runner log fallback.", @@ -1323,46 +1274,22 @@ describe("TaskChatThread runtime transcript selection", () => { it("uses a fresher live log when native event polling fails after earlier events", () => { nativeTranscriptState.transcriptByRun.set("native-run", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Stale native activity.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Stale native activity.", "progress"), ]); nativeTranscriptState.errorsByRun.set("native-run", { message: "event endpoint unavailable", failedAt: "2026-08-25T18:00:02.000Z", }); transcriptState.transcriptByRun.set("native-run", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:03.000Z", - text: "Fresh activity from the runner log.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:03.000Z", "Fresh activity from the runner log.", "progress"), ]); - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: "native-run", - runtimeMode: "native", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: nativeRunningRun(), + }); expect(container.textContent).toContain( "Fresh activity from the runner log.", @@ -1373,36 +1300,17 @@ describe("TaskChatThread runtime transcript selection", () => { it("keeps legacy channel-less native messages readable across settlement", () => { nativeTranscriptState.transcriptByRun.set("native-run", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Persisted before message channels existed.", - channel: "unknown", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Persisted before message channels existed.", "unknown"), ]); - const run = { - id: "native-run", - runtimeMode: "native" as const, - status: "running" as const, - invocationSource: "issue" as const, - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - }; + const run = nativeRunningRun(); - render( - {}} - issueStatus="in_progress" - activeRun={run} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: run, + }); expect( container.querySelector('[data-testid="task-chat-phase-interstitial"]') @@ -1412,18 +1320,16 @@ describe("TaskChatThread runtime transcript selection", () => { container.querySelector('[data-testid="task-chat-final-response"]'), ).toBeNull(); - render( - {}} - issueStatus="done" - activeRun={{ - ...run, - status: "succeeded", - finishedAt: "2026-08-25T18:00:02.000Z", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "done", + activeRun: { + ...run, + status: "succeeded", + finishedAt: "2026-08-25T18:00:02.000Z", + }, + }); expect( container.querySelector('[data-testid="task-chat-phase-interstitial"]'), @@ -1435,36 +1341,17 @@ describe("TaskChatThread runtime transcript selection", () => { }); it("recomputes a runner turn when only the message channel changes", () => { - const run = { - id: "native-run", - runtimeMode: "native" as const, - status: "running" as const, - invocationSource: "issue" as const, - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - }; + const run = nativeRunningRun(); const renderRun = () => - render( - {}} - issueStatus="in_progress" - activeRun={run} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: run, + }); nativeTranscriptState.transcriptByRun.set("native-run", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Same text.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Same text.", "progress"), ]); renderRun(); expect( @@ -1476,12 +1363,7 @@ describe("TaskChatThread runtime transcript selection", () => { ).toBeNull(); nativeTranscriptState.transcriptByRun.set("native-run", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Same text.", - channel: "final", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Same text.", "final"), ]); renderRun(); expect( @@ -1494,19 +1376,7 @@ describe("TaskChatThread runtime transcript selection", () => { }); it("recomputes a runner turn when only usage totals change", () => { - const run = { - id: "native-run", - runtimeMode: "native" as const, - status: "running" as const, - invocationSource: "issue" as const, - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - }; + const run = nativeRunningRun(); const usageEntry = (inputTokens: number) => ({ kind: "result" as const, ts: "2026-08-25T18:00:01.000Z", @@ -1520,14 +1390,12 @@ describe("TaskChatThread runtime transcript selection", () => { errors: [], }); const renderRun = () => - render( - {}} - issueStatus="in_progress" - activeRun={run} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: run, + }); const revealUsage = () => { const summary = container.querySelector( '[data-testid="task-chat-activity-phase-toggle"]', @@ -1553,36 +1421,29 @@ describe("TaskChatThread runtime transcript selection", () => { it("keeps a failed native run actionable after progress was rendered", async () => { const onRetryFailedRun = vi.fn(); nativeTranscriptState.transcriptByRun.set("native-failed", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Started the provider request.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Started the provider request.", "progress"), ]); - render( - {}} - issueStatus="blocked" - onRetryFailedRun={onRetryFailedRun} - linkedRuns={[ - { - runId: "native-failed", - runtimeMode: "native", - status: "failed", - errorCode: "provider_transport_failed", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z", - }, - ]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + onRetryFailedRun: onRetryFailedRun, + linkedRuns: [ + { + runId: "native-failed", + runtimeMode: "native", + status: "failed", + errorCode: "provider_transport_failed", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + }, + ], + }); expect(container.textContent).toContain("Started the provider request."); const marker = container.querySelector( @@ -1600,8 +1461,12 @@ describe("TaskChatThread runtime transcript selection", () => { it.each([false, true])("keeps a later bootstrap failure actionable only after the old recovery has a successor: %s", async (continued) => { const onRetryFailedRun = vi.fn(); - render( {}} issueStatus="blocked" - onRetryFailedRun={onRetryFailedRun} linkedRuns={[ + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + onRetryFailedRun: onRetryFailedRun, + linkedRuns: [ { runId: "old-native", runtimeMode: "native", status: "failed", errorCode: "adapter_failed", agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", @@ -1620,7 +1485,8 @@ describe("TaskChatThread runtime transcript selection", () => { createdAt: "2026-08-25T18:01:00.000Z", startedAt: "2026-08-25T18:01:00.000Z", finishedAt: "2026-08-25T18:01:02.000Z", }, - ]} />); + ], + }); const retryButtons = Array.from(container.querySelectorAll('[data-testid="task-chat-run-failed-try-again"]')); if (!continued) { expect(retryButtons).toHaveLength(0); @@ -1633,43 +1499,46 @@ describe("TaskChatThread runtime transcript selection", () => { }); it.each([false, true])("never offers the old quarantined run as Try again after continuation: %s", continued => { - render( {}} issueStatus="in_progress" - onRetryFailedRun={vi.fn()} linkedRuns={[{ + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + onRetryFailedRun: vi.fn(), + linkedRuns: [{ runId: "quarantined", runtimeMode: "native", status: "failed", errorCode: "native_session_cleanup_quarantined", agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", finishedAt: "2026-08-25T18:00:02.000Z", ...(continued ? { execution: { phase: "completed" as const, label: "Continued in another run", cause: "native_session_cleanup_quarantined", lastConfirmedActivityAt: null, retryAt: null, attempt: 2, maxAttempts: 3, recoveryOwner: null, nextAction: null, permittedActions: ["inspect_run" as const], predecessorRunId: null, successorRunId: "fresh-run" } } : {}), - }]} />); + }], + }); expect(container.textContent?.includes("Run failed")).toBe(!continued); expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull(); }); it("explains a native provider usage limit without exposing its error code", async () => { const onRetryFailedRun = vi.fn(); - render( - {}} - issueStatus="blocked" - onRetryFailedRun={onRetryFailedRun} - linkedRuns={[ - { - runId: "native-usage-limit", - runtimeMode: "native", - status: "failed", - errorCode: "native_provider_usage_limit", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z", - }, - ]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + onRetryFailedRun: onRetryFailedRun, + linkedRuns: [ + { + runId: "native-usage-limit", + runtimeMode: "native", + status: "failed", + errorCode: "native_provider_usage_limit", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + }, + ], + }); const marker = container.querySelector( '[data-testid="task-chat-collapsible-marker"]', @@ -1698,12 +1567,16 @@ describe("TaskChatThread runtime transcript selection", () => { }); it("explains a legacy run prevented from starting by a reconciliation hold", () => { - render( {}} linkedRuns={[{ - runId: "blocked-legacy", runtimeMode: "legacy", status: "cancelled", - errorCode: "execution_reconciliation_required", agentId: "agent-1", agentName: "Runner", - adapterType: "claude_local", createdAt: "2026-08-25T18:00:00.000Z", - startedAt: null, finishedAt: "2026-08-25T18:00:00.012Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [{ + runId: "blocked-legacy", runtimeMode: "legacy", status: "cancelled", + errorCode: "execution_reconciliation_required", agentId: "agent-1", agentName: "Runner", + adapterType: "claude_local", createdAt: "2026-08-25T18:00:00.000Z", + startedAt: null, finishedAt: "2026-08-25T18:00:00.012Z", + }], + }); expect(container.textContent).toContain("Waiting to resume"); expect(container.textContent).not.toContain("No user-facing response"); expect(container.textContent).not.toContain("Run completed"); @@ -1717,12 +1590,16 @@ describe("TaskChatThread runtime transcript selection", () => { createdAt: "2026-09-10T18:00:00.000Z", finishedAt: "2026-09-10T18:00:01.000Z", startedAt, resultJson: { executionWait: { recoveryActionId } }, }); - render( {}} linkedRuns={[ + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [ ...Array.from({ length: 100 }, (_, i) => heldRun(`wait-${i}`, "hold-1")), heldRun("ran", "hold-1", "2026-09-10T18:00:00.500Z"), heldRun("new-hold", "hold-2"), heldRun("same-new-hold", "hold-2"), - ]} />); + ], + }); expect(container.textContent?.match(/Waiting to resume/g)).toHaveLength(2); expect(container.textContent).not.toContain("Couldn't start"); expect(container.textContent).toContain(runtimeMode === "native" ? "Run cancelled" : "Stopped"); @@ -1730,33 +1607,26 @@ describe("TaskChatThread runtime transcript selection", () => { it("shows cancellation after native progress without offering a retry", () => { nativeTranscriptState.transcriptByRun.set("native-cancelled", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Work was in progress.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Work was in progress.", "progress"), ]); - render( - {}} - linkedRuns={[ - { - runId: "native-cancelled", - runtimeMode: "native", - status: "cancelled", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z", - }, - ]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [ + { + runId: "native-cancelled", + runtimeMode: "native", + status: "cancelled", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + }, + ], + }); expect(container.textContent).toContain("Work was in progress."); expect(container.querySelector('[data-testid="task-chat-collapsible-marker"] button')?.classList).toContain("text-muted-foreground"); @@ -1787,11 +1657,10 @@ describe("TaskChatThread runtime transcript selection", () => { }, ]); - render( - {}} - linkedRuns={[ + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [ { runId: `native-${status}`, runtimeMode: "native", @@ -1804,9 +1673,8 @@ describe("TaskChatThread runtime transcript selection", () => { startedAt: "2026-08-25T18:00:00.000Z", finishedAt: "2026-08-25T18:00:02.000Z", }, - ]} - />, - ); + ], + }); const finalResponses = container.querySelectorAll( '[data-testid="task-chat-final-response"]', @@ -1838,13 +1706,17 @@ describe("TaskChatThread runtime transcript selection", () => { ); it("shows an actionable approval-required reason for a stopped native run", () => { - render( {}} linkedRuns={[{ - runId: "approval-required", runtimeMode: "native", status: "failed", - errorCode: "native_provider_approval_required", - agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", - startedAt: "2026-09-14T15:00:00.000Z", createdAt: "2026-09-14T15:00:00.000Z", - finishedAt: "2026-09-14T15:00:02.000Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [{ + runId: "approval-required", runtimeMode: "native", status: "failed", + errorCode: "native_provider_approval_required", + agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", + startedAt: "2026-09-14T15:00:00.000Z", createdAt: "2026-09-14T15:00:00.000Z", + finishedAt: "2026-09-14T15:00:02.000Z", + }], + }); const marker = container.querySelector('[data-testid="task-chat-collapsible-marker"]'); expect(marker?.textContent).toContain("Approval required"); flushSync(() => marker!.querySelector('button[aria-expanded="false"]')!.click()); @@ -1862,14 +1734,18 @@ describe("TaskChatThread runtime transcript selection", () => { disposition: "needs_review", objectiveSatisfied: false, verification: [], remainingWork: [], blocker: null, artifacts: [], }]); - render( {}} linkedRuns={[{ - runId: "model-rejected", runtimeMode: "native", status: "failed", - errorCode: "native_provider_model_rejected", - error, - agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [{ + runId: "model-rejected", runtimeMode: "native", status: "failed", + errorCode: "native_provider_model_rejected", + error, + agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + }], + }); const marker = container.querySelector('[data-testid="task-chat-collapsible-marker"]'); expect(marker?.textContent).toContain("Model unavailable"); flushSync(() => marker!.querySelector('button[aria-expanded="false"]')!.click()); @@ -1879,22 +1755,30 @@ describe("TaskChatThread runtime transcript selection", () => { }); it("keeps workspace contention out of the conversation's cancellation markers", () => { - render( {}} linkedRuns={[{ - runId: "workspace-wait", runtimeMode: "native", status: "cancelled", errorCode: "workspace_busy", - agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", startedAt: null, - createdAt: "2026-09-12T18:00:00.000Z", finishedAt: "2026-09-12T18:00:01.000Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [{ + runId: "workspace-wait", runtimeMode: "native", status: "cancelled", errorCode: "workspace_busy", + agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", startedAt: null, + createdAt: "2026-09-12T18:00:00.000Z", finishedAt: "2026-09-12T18:00:01.000Z", + }], + }); expect(container.textContent).not.toContain("Run cancelled"); expect(container.textContent).not.toContain("Run failed"); expect(container.textContent).not.toContain("before returning an answer"); }); it("does not render an empty response notice for a conversation reset", () => { - render( {}} linkedRuns={[{ - runId: "chat-reset", status: "succeeded", startedAt: null, resultJson: { conversationReset: true }, - agentId: "agent-1", agentName: "Claude", adapterType: "claude_local", - createdAt: "2026-09-11T18:00:00.000Z", finishedAt: "2026-09-11T18:00:01.000Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [{ + runId: "chat-reset", status: "succeeded", startedAt: null, resultJson: { conversationReset: true }, + agentId: "agent-1", agentName: "Claude", adapterType: "claude_local", + createdAt: "2026-09-11T18:00:00.000Z", finishedAt: "2026-09-11T18:00:01.000Z", + }], + }); expect(container.textContent).not.toContain("The runner returned no user-facing response."); expect(container.textContent).not.toContain("Run completed"); }); @@ -1907,26 +1791,24 @@ describe("TaskChatThread runtime transcript selection", () => { ] as const)( "hides a redundant cancelled continuation (%s, %s)", (runtimeMode, errorCode) => { - render( - {}} - linkedRuns={[ - { - runId: "connection-continuation-skipped", - runtimeMode, - status: "cancelled", - errorCode, - startedAt: null, - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-09-07T18:00:00.000Z", - finishedAt: "2026-09-07T18:00:01.000Z", - }, - ]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [ + { + runId: "connection-continuation-skipped", + runtimeMode, + status: "cancelled", + errorCode, + startedAt: null, + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-09-07T18:00:00.000Z", + finishedAt: "2026-09-07T18:00:01.000Z", + }, + ], + }); expect(container.textContent).not.toContain( "The runner returned no user-facing response.", ); @@ -1940,26 +1822,24 @@ describe("TaskChatThread runtime transcript selection", () => { it.each(["legacy", "native"] as const)( "keeps a cancellation visible when the %s run had already started", (runtimeMode) => { - render( - {}} - linkedRuns={[ - { - runId: "started-cancellation", - runtimeMode, - status: "cancelled", - errorCode: "issue_terminal_status", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-09-07T18:00:00.000Z", - startedAt: "2026-09-07T18:00:00.500Z", - finishedAt: "2026-09-07T18:00:01.000Z", - }, - ]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [ + { + runId: "started-cancellation", + runtimeMode, + status: "cancelled", + errorCode: "issue_terminal_status", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-09-07T18:00:00.000Z", + startedAt: "2026-09-07T18:00:00.500Z", + finishedAt: "2026-09-07T18:00:01.000Z", + }, + ], + }); expect(container.textContent).toContain( runtimeMode === "native" ? "Run cancelled" : "Stopped", ); @@ -1968,56 +1848,49 @@ describe("TaskChatThread runtime transcript selection", () => { it("does not treat a progress comment as the final response of a failed native run", () => { nativeTranscriptState.transcriptByRun.set("native-progress-failed", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Still working through the provider request.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Still working through the provider request.", "progress"), ]); - render( - {}} - linkedRuns={[ - { - runId: "native-progress-failed", - runtimeMode: "native", - status: "failed", - errorCode: "provider_transport_failed", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z", - resultJson: { - presentationDecision: { - schema: "paperclip.run_presentation_decision.v1", - chosenSource: "none", - commentId: null, - }, + renderThread({ + comments: [ + { + id: "progress-comment", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + body: "Progress checkpoint only.", + presentation: null, + metadata: null, + runId: "native-progress-failed", + createdAt: new Date("2026-08-25T18:00:01.500Z"), + updatedAt: new Date("2026-08-25T18:00:01.500Z"), + }, + ], + onAdd: async () => {}, + linkedRuns: [ + { + runId: "native-progress-failed", + runtimeMode: "native", + status: "failed", + errorCode: "provider_transport_failed", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + resultJson: { + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "none", + commentId: null, }, }, - ]} - />, - ); + }, + ], + }); const marker = container.querySelector( '[data-testid="task-chat-collapsible-marker"]', @@ -2040,61 +1913,49 @@ describe("TaskChatThread runtime transcript selection", () => { it("does not repeat progress when the persisted completion comment owns the same text", () => { const repeated = "BASELINE-DONE"; nativeTranscriptState.transcriptByRun.set("native-repeated-final", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: repeated, - channel: "progress", - }, - { - kind: "assistant", - ts: "2026-08-25T18:00:02.000Z", - text: repeated, - channel: "final", - }, + assistantEntry("2026-08-25T18:00:01.000Z", repeated, "progress"), + assistantEntry("2026-08-25T18:00:02.000Z", repeated, "final"), ]); - render( - {}} - linkedRuns={[ - { - runId: "native-repeated-final", - runtimeMode: "native", - status: "succeeded", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:03.000Z", - resultJson: { - presentationDecision: { - schema: "paperclip.run_presentation_decision.v1", - chosenSource: "final_agent_message", - commentId: "native-repeated-final-comment", - }, + renderThread({ + comments: [ + { + id: "native-repeated-final-comment", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + body: repeated, + presentation: null, + metadata: null, + runId: "native-repeated-final", + createdAt: new Date("2026-08-25T18:00:03.000Z"), + updatedAt: new Date("2026-08-25T18:00:03.000Z"), + }, + ], + onAdd: async () => {}, + linkedRuns: [ + { + runId: "native-repeated-final", + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:03.000Z", + resultJson: { + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "final_agent_message", + commentId: "native-repeated-final-comment", }, }, - ]} - />, - ); + }, + ], + }); expect(container.textContent?.split(repeated)).toHaveLength(2); expect( @@ -2113,85 +1974,68 @@ describe("TaskChatThread runtime transcript selection", () => { it("removes only the final repeated progress across steering segments", () => { const repeated = "SAME-BEFORE-AND-AFTER"; nativeTranscriptState.transcriptByRun.set("native-steered-repetition", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: repeated, - channel: "progress", - }, - { - kind: "assistant", - ts: "2026-08-25T18:00:03.000Z", - text: repeated, - channel: "progress", - }, - { - kind: "assistant", - ts: "2026-08-25T18:00:04.000Z", - text: repeated, - channel: "final", - }, + assistantEntry("2026-08-25T18:00:01.000Z", repeated, "progress"), + assistantEntry("2026-08-25T18:00:03.000Z", repeated, "progress"), + assistantEntry("2026-08-25T18:00:04.000Z", repeated, "final"), ]); - render( - {}} - linkedRuns={[ - { - runId: "native-steered-repetition", - runtimeMode: "native", - status: "succeeded", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:05.000Z", - resultJson: { - presentationDecision: { - schema: "paperclip.run_presentation_decision.v1", - chosenSource: "final_agent_message", - commentId: "native-steered-repetition-final", - }, + renderThread({ + comments: [ + { + id: "steering-comment", + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body: "Steer this run.", + presentation: null, + metadata: null, + runId: null, + followUpRequested: true, + consumedByRunId: "native-steered-repetition", + steeredIntoRunId: "native-steered-repetition", + conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"), + createdAt: new Date("2026-08-25T17:59:32.000Z"), + updatedAt: new Date("2026-08-25T18:00:02.000Z"), + }, + { + id: "native-steered-repetition-final", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + body: repeated, + presentation: null, + metadata: null, + runId: "native-steered-repetition", + createdAt: new Date("2026-08-25T18:00:05.000Z"), + updatedAt: new Date("2026-08-25T18:00:05.000Z"), + }, + ], + onAdd: async () => {}, + linkedRuns: [ + { + runId: "native-steered-repetition", + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:05.000Z", + resultJson: { + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "final_agent_message", + commentId: "native-steered-repetition-final", }, }, - ]} - />, - ); + }, + ], + }); const matchingPhases = Array.from( container.querySelectorAll( @@ -2224,20 +2068,25 @@ describe("TaskChatThread runtime transcript selection", () => { { kind: "runtime_request", ts: "2026-08-25T18:00:01.000Z", requestId: "permission-1", requestKind: "permission_approval", turnId: "provider-turn-1", requestType: "permission", status: "pending", prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] }, - { kind: "assistant", ts: "2026-08-25T18:00:03.000Z", text: "Steering acknowledged.", channel: "progress" }, + assistantEntry("2026-08-25T18:00:03.000Z", "Steering acknowledged.", "progress"), ]); const resolve = vi.spyOn(heartbeatsApi, "resolveRuntimeRequest").mockResolvedValue({} as never); - render( {}} issueStatus="in_progress" activeRun={{ - id: runId, runtimeMode: "native", status: "running", invocationSource: "issue", triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", finishedAt: null, createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", - }} />); + renderThread({ + comments: [{ + id: "pending-steering-comment", companyId: "company-1", issueId: "issue-1", authorType: "user", + authorAgentId: null, authorUserId: "user-1", body: "Change course.", presentation: null, metadata: null, + runId: null, consumedByRunId: runId, steeredIntoRunId: runId, + conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"), + createdAt: new Date("2026-08-25T18:00:02.000Z"), updatedAt: new Date("2026-08-25T18:00:02.000Z"), + }], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { + id: runId, runtimeMode: "native", status: "running", invocationSource: "issue", triggerDetail: null, + startedAt: "2026-08-25T18:00:00.000Z", finishedAt: null, createdAt: "2026-08-25T18:00:00.000Z", + agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", + }, + }); const deny = Array.from(container.querySelectorAll("button")).filter(button => button.textContent === "Deny"); expect(deny).toHaveLength(1); expect(deny[0].disabled).toBe(false); @@ -2249,58 +2098,46 @@ describe("TaskChatThread runtime transcript selection", () => { it("labels the live tail as a continuation after the steering bubble", () => { nativeTranscriptState.transcriptByRun.set("native-live-steered", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Work before steering.", - channel: "progress", - }, - { - kind: "assistant", - ts: "2026-08-25T18:00:03.000Z", - text: "Work after steering.", - channel: "progress", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Work before steering.", "progress"), + assistantEntry("2026-08-25T18:00:03.000Z", "Work after steering.", "progress"), ]); - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: "native-live-steered", - runtimeMode: "native", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - }} - />, - ); + renderThread({ + comments: [ + { + id: "live-steering-comment", + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body: "Change course now.", + presentation: null, + metadata: null, + runId: null, + consumedByRunId: "native-live-steered", + steeredIntoRunId: "native-live-steered", + conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"), + createdAt: new Date("2026-08-25T17:59:32.000Z"), + updatedAt: new Date("2026-08-25T18:00:02.000Z"), + }, + ], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { + id: "native-live-steered", + runtimeMode: "native", + status: "running", + invocationSource: "issue", + triggerDetail: null, + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: null, + createdAt: "2026-08-25T18:00:00.000Z", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + }, + }); expect( container.querySelector('[data-testid="task-chat-turn-summary"]') @@ -2416,108 +2253,100 @@ describe("TaskChatThread runtime transcript selection", () => { }), ]), ); - render( - {}} - issueStatus="in_progress" - interactions={ - mode === "pending_attention" - ? [planReviewInteraction("pending", "revision-3", runId)] - : [] - } - activeRun={ - mode === "live" - ? { - id: runId, - runtimeMode: "native", - status: "running", - invocationSource: "issue", - triggerDetail: null, - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00Z", - startedAt: "2026-08-25T18:00:00Z", - finishedAt: null, - } - : undefined - } - linkedRuns={[ + renderThread({ + comments: mode === "steered_missing_result_metadata" + ? [ { - runId, - runtimeMode: "native", - status: - mode === "live" - ? "running" - : mode === "failed" - ? "failed" - : "succeeded", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00Z", - startedAt: "2026-08-25T18:00:00Z", - finishedAt: mode === "live" ? null : "2026-08-25T18:00:30Z", - resultJson: mode.endsWith("missing_result_metadata") - ? { recoveredExecutionFailure: { errorCode: "adapter_failed" } } - : { - nativeResult: result, - ...(mode === "persisted_response_comment" - ? { - presentationDecision: { - schema: "paperclip.run_presentation_decision.v1", - chosenSource: "existing_issue_comment", - commentId: "private-board-response", - }, - } - : {}), - }, + id: "response-wake-steering", + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body: "Keep the task open for follow-up.", + presentation: null, + metadata: null, + runId: null, + followUpRequested: true, + consumedByRunId: runId, + steeredIntoRunId: runId, + conversationAnchorAt: new Date("2026-08-25T18:00:03.500Z"), + createdAt: new Date("2026-08-25T18:00:03.000Z"), + updatedAt: new Date("2026-08-25T18:00:03.500Z"), }, - ]} - />, - ); + ] + : mode === "persisted_response_comment" + ? [ + { + id: "private-board-response", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + body: summary, + presentation: null, + metadata: null, + createdByRunId: runId, + runId, + createdAt: new Date("2026-08-25T18:00:29.000Z"), + updatedAt: new Date("2026-08-25T18:00:29.000Z"), + }, + ] + : [], + onAdd: async () => {}, + issueStatus: "in_progress", + interactions: mode === "pending_attention" + ? [planReviewInteraction("pending", "revision-3", runId)] + : [], + activeRun: mode === "live" + ? { + id: runId, + runtimeMode: "native", + status: "running", + invocationSource: "issue", + triggerDetail: null, + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00Z", + startedAt: "2026-08-25T18:00:00Z", + finishedAt: null, + } + : undefined, + linkedRuns: [ + { + runId, + runtimeMode: "native", + status: + mode === "live" + ? "running" + : mode === "failed" + ? "failed" + : "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00Z", + startedAt: "2026-08-25T18:00:00Z", + finishedAt: mode === "live" ? null : "2026-08-25T18:00:30Z", + resultJson: mode.endsWith("missing_result_metadata") + ? { recoveredExecutionFailure: { errorCode: "adapter_failed" } } + : { + nativeResult: result, + ...(mode === "persisted_response_comment" + ? { + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "existing_issue_comment", + commentId: "private-board-response", + }, + } + : {}), + }, + }, + ], + }); const responses = container.querySelectorAll( '[data-testid="task-chat-final-response"]', ); @@ -2549,97 +2378,80 @@ describe("TaskChatThread runtime transcript selection", () => { it("keeps a yielded question turn out of the final-response slot", () => { nativeTranscriptState.transcriptByRun.set("native-question", [ - { - kind: "assistant", - ts: "2026-08-25T18:00:01.000Z", - text: "Preparing the verification question.", - channel: "progress", - }, - { - kind: "assistant", - ts: "2026-08-25T18:00:02.000Z", - text: "Waiting for Choose the verification word.", - channel: "final", - }, + assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the verification question.", "progress"), + assistantEntry("2026-08-25T18:00:02.000Z", "Waiting for Choose the verification word.", "final"), ]); nativeTranscriptState.transcriptByRun.set("native-continuation", [ - { - kind: "assistant", - ts: "2026-08-25T18:01:01.000Z", - text: "Cobalt was selected and the task is complete.", - channel: "final", - }, + assistantEntry("2026-08-25T18:01:01.000Z", "Cobalt was selected and the task is complete.", "final"), ]); - render( - {}} - linkedRuns={[ - { - runId: "native-question", - runtimeMode: "native", - status: "succeeded", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:03.000Z", - resultJson: { - nativeResult: { - schema: "paperclip.run_result.v1", - reportedWorkDisposition: "yielded", - summary: "Waiting for Choose the verification word.", - }, - presentationDecision: { - schema: "paperclip.run_presentation_decision.v1", - chosenSource: "none", - commentId: null, - }, + renderThread({ + comments: [ + { + id: "native-question-placeholder", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + body: "Run completed. Agent did not post a summary comment this run (transcript withheld — see run log).", + presentation: null, + metadata: null, + runId: "native-question", + createdAt: new Date("2026-08-25T18:00:03.000Z"), + updatedAt: new Date("2026-08-25T18:00:03.000Z"), + }, + ], + onAdd: async () => {}, + linkedRuns: [ + { + runId: "native-question", + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:03.000Z", + resultJson: { + nativeResult: { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "yielded", + summary: "Waiting for Choose the verification word.", + }, + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "none", + commentId: null, }, }, - { - runId: "native-continuation", - runtimeMode: "native", - status: "succeeded", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - createdAt: "2026-08-25T18:01:00.000Z", - startedAt: "2026-08-25T18:01:00.000Z", - finishedAt: "2026-08-25T18:01:02.000Z", - resultJson: { - nativeResult: { - schema: "paperclip.run_result.v1", - reportedWorkDisposition: "done", - summary: "Cobalt was selected and the task is complete.", - }, - presentationDecision: { - schema: "paperclip.run_presentation_decision.v1", - chosenSource: "none", - commentId: null, - }, + }, + { + runId: "native-continuation", + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:01:00.000Z", + startedAt: "2026-08-25T18:01:00.000Z", + finishedAt: "2026-08-25T18:01:02.000Z", + resultJson: { + nativeResult: { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "done", + summary: "Cobalt was selected and the task is complete.", + }, + presentationDecision: { + schema: "paperclip.run_presentation_decision.v1", + chosenSource: "none", + commentId: null, }, }, - ]} - />, - ); + }, + ], + }); const finalResponses = container.querySelectorAll( '[data-testid="task-chat-final-response"]', @@ -2661,28 +2473,26 @@ describe("TaskChatThread runtime transcript selection", () => { "keeps an empty legacy %s actionable without turning Stop into Retry", async (status) => { const onRetryFailedRun = vi.fn(); - render( - {}} - issueStatus="blocked" - onRetryFailedRun={onRetryFailedRun} - linkedRuns={[ - { - runId: "legacy-failed", - runtimeMode: "legacy", - status, - errorCode: "legacy_process_exited", - agentId: "agent-1", - agentName: "Direct Codex", - adapterType: "codex_local", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z", - }, - ]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + onRetryFailedRun: onRetryFailedRun, + linkedRuns: [ + { + runId: "legacy-failed", + runtimeMode: "legacy", + status, + errorCode: "legacy_process_exited", + agentId: "agent-1", + agentName: "Direct Codex", + adapterType: "codex_local", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + }, + ], + }); expect(container.textContent).toContain(status === "cancelled" ? "Stopped" : "Run failed"); if (status !== "cancelled") { @@ -2722,39 +2532,37 @@ describe("TaskChatThread runtime transcript selection", () => { agentName: "Direct agent", startedAt: null, }; - render( - {}} - issueStatus="todo" - onRetryFailedRun={onRetryFailedRun} - linkedRuns={[ - { ...run, runId: "original-failure", status: "failed", - createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z" }, - { ...run, runId: "latest-attempt", status, errorCode: "acpx_handshake_timeout", - createdAt: "2026-08-25T18:01:00.000Z", startedAt: "2026-08-25T18:01:00.000Z", - finishedAt: "2026-08-25T18:01:02.000Z", - resultJson: { presentationDecision: { commentId: "failure-comment" } } }, - { ...run, runId: "cancelled-automatic-retry", status: "cancelled", - errorCode: "execution_reconciliation_required", - createdAt: "2026-08-25T18:02:00.000Z", finishedAt: "2026-08-25T18:02:02.000Z" }, - ]} - />, - ); + renderThread({ + comments: [{ + id: "failure-comment", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + authorType: "agent", + presentation: null, + metadata: null, + body: "The startup handshake timed out.", + runId: "latest-attempt", + createdAt: new Date("2026-08-25T18:01:02.000Z"), + updatedAt: new Date("2026-08-25T18:01:02.000Z"), + }], + onAdd: async () => {}, + issueStatus: "todo", + onRetryFailedRun: onRetryFailedRun, + linkedRuns: [ + { ...run, runId: "original-failure", status: "failed", + createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z" }, + { ...run, runId: "latest-attempt", status, errorCode: "acpx_handshake_timeout", + createdAt: "2026-08-25T18:01:00.000Z", startedAt: "2026-08-25T18:01:00.000Z", + finishedAt: "2026-08-25T18:01:02.000Z", + resultJson: { presentationDecision: { commentId: "failure-comment" } } }, + { ...run, runId: "cancelled-automatic-retry", status: "cancelled", + errorCode: "execution_reconciliation_required", + createdAt: "2026-08-25T18:02:00.000Z", finishedAt: "2026-08-25T18:02:02.000Z" }, + ], + }); expect(container.textContent).toContain("The startup handshake timed out."); const buttons = container.querySelectorAll('[data-testid="task-chat-run-failed-try-again"]'); @@ -2842,31 +2650,29 @@ describe("TaskChatThread runtime transcript selection", () => { { adapterType: "claude_local", status: "cancelled", runtimeMode: "legacy" as const, cause: "legacy_execution_requires_reconciliation", canRetry: false }, ])("offers only explicit conversation retries: $runtimeMode/$adapterType/$status/$cause", (testCase) => { - render( - {}} - issueStatus="blocked" - onRetryFailedRun={vi.fn()} - linkedRuns={[{ - runId: "stopped-run", - runtimeMode: testCase.runtimeMode, - adapterType: testCase.adapterType, - status: testCase.status, - agentId: "agent-1", - agentName: "Stopped agent", - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:02.000Z", - execution: { - phase: "recovery_needed", label: "Stopped", cause: testCase.cause, - lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3, - recoveryOwner: null, nextAction: null, permittedActions: [], - predecessorRunId: null, successorRunId: null, - }, - }]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "blocked", + onRetryFailedRun: vi.fn(), + linkedRuns: [{ + runId: "stopped-run", + runtimeMode: testCase.runtimeMode, + adapterType: testCase.adapterType, + status: testCase.status, + agentId: "agent-1", + agentName: "Stopped agent", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + execution: { + phase: "recovery_needed", label: "Stopped", cause: testCase.cause, + lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3, + recoveryOwner: null, nextAction: null, permittedActions: [], + predecessorRunId: null, successorRunId: null, + }, + }], + }); expect(Boolean(container.querySelector('[data-testid="task-chat-run-failed-try-again"]'))) .toBe(testCase.canRetry); if (testCase.canRetry) expect(container.textContent).toContain("You can retry this message now."); @@ -2885,26 +2691,24 @@ describe("TaskChatThread runtime transcript selection", () => { }, ]); - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: runId, - runtimeMode: "legacy", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-25T18:00:00.000Z", - agentId: "agent-1", - agentName: "Direct agent", - adapterType, - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { + id: runId, + runtimeMode: "legacy", + status: "running", + invocationSource: "issue", + triggerDetail: null, + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: null, + createdAt: "2026-08-25T18:00:00.000Z", + agentId: "agent-1", + agentName: "Direct agent", + adapterType, + }, + }); expect( container.querySelector('[data-testid="task-chat-live-run-pill"]'), @@ -2944,25 +2748,23 @@ describe("TaskChatThread runtime transcript selection", () => { }, ]); - render( - {}} - linkedRuns={[ - { - runId, - runtimeMode: "legacy", - status: "succeeded", - agentId: "agent-1", - agentName: "Direct agent", - adapterType, - createdAt: "2026-08-25T18:00:00.000Z", - startedAt: "2026-08-25T18:00:00.000Z", - finishedAt: "2026-08-25T18:00:03.000Z", - }, - ]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [ + { + runId, + runtimeMode: "legacy", + status: "succeeded", + agentId: "agent-1", + agentName: "Direct agent", + adapterType, + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:03.000Z", + }, + ], + }); expect( container.querySelector('[data-testid="task-chat-turn"]'), @@ -2983,12 +2785,11 @@ describe("TaskChatThread runtime transcript selection", () => { it.each(DIRECT_ADAPTER_TYPES)( "keeps an empty active %s run on the legacy status surface", (adapterType) => { - render( - {}} - issueStatus="in_progress" - activeRun={{ + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { id: `empty-${adapterType}`, runtimeMode: "legacy", status: "running", @@ -3001,9 +2802,8 @@ describe("TaskChatThread runtime transcript selection", () => { agentId: "agent-1", agentName: "Direct agent", adapterType, - }} - />, - ); + }, + }); const tail = container.querySelector( '[data-testid="task-chat-live-transcript"]', @@ -3021,20 +2821,19 @@ describe("TaskChatThread runtime transcript selection", () => { it.each(DIRECT_ADAPTER_TYPES)( "keeps durable questions usable for an active %s run without runner controls", (adapterType) => { - render( - {}} - onSubmitInteractionAnswers={async () => {}} - issueStatus="in_progress" - activeRun={{ + ], + onAdd: async () => {}, + onSubmitInteractionAnswers: async () => {}, + issueStatus: "in_progress", + activeRun: { id: `question-run-${adapterType}`, runtimeMode: "legacy", status: "running", @@ -3046,9 +2845,8 @@ describe("TaskChatThread runtime transcript selection", () => { agentId: "agent-1", agentName: "Direct agent", adapterType, - }} - />, - ); + }, + }); expect( container.querySelector('[data-testid="task-chat-composer-takeover"]') @@ -3339,36 +3137,34 @@ describe("TaskChatThread blocker links", () => { terminalBlockers: [terminalBlocker], }; - render( - {}} - issueStatus="blocked" - blockedBy={[ - { - id: "direct-1", - identifier: "PAP-500", - title: "Different dependency", - status: "todo", - priority: "low", - assigneeAgentId: null, - assigneeUserId: null, - }, - directBlocker, - ]} - blockerAttention={{ - state: "needs_attention", - reason: "attention_required", - unresolvedBlockerCount: 2, - coveredBlockerCount: 0, - stalledBlockerCount: 0, - attentionBlockerCount: 1, - sampleBlockerIdentifier: "PAP-777", - sampleStalledBlockerIdentifier: null, - terminalBlockerIssueId: terminalBlocker.id, - }} - />, - ); + renderThread({ + comments: createLongThreadComments(), + onAdd: async () => {}, + issueStatus: "blocked", + blockedBy: [ + { + id: "direct-1", + identifier: "PAP-500", + title: "Different dependency", + status: "todo", + priority: "low", + assigneeAgentId: null, + assigneeUserId: null, + }, + directBlocker, + ], + blockerAttention: { + state: "needs_attention", + reason: "attention_required", + unresolvedBlockerCount: 2, + coveredBlockerCount: 0, + stalledBlockerCount: 0, + attentionBlockerCount: 1, + sampleBlockerIdentifier: "PAP-777", + sampleStalledBlockerIdentifier: null, + terminalBlockerIssueId: terminalBlocker.id, + }, + }); const notices = container.querySelectorAll( '[data-testid="task-chat-blocker-links"]', @@ -3407,58 +3203,56 @@ describe("TaskChatThread blocker links", () => { assigneeUserId: null, }; - render( - {}} - issueStatus="blocked" - liveIssueIds={new Set(["direct-running", "terminal-running"])} - blockerAttention={{ - state: "covered", - reason: "active_dependency", - unresolvedBlockerCount: 2, - coveredBlockerCount: 2, - stalledBlockerCount: 0, - attentionBlockerCount: 0, - sampleBlockerIdentifier: "PAP-17426", - sampleStalledBlockerIdentifier: null, - blockingTreeLive: true, - directBlockerIssueId: "direct-running", - terminalBlockerIssueId: terminalBlocker.id, - terminalBlocker, - }} - blockedBy={[ - { - id: "direct-queued", - identifier: "PAP-17427", - title: "Verify the completed projection", - status: "todo", - priority: "medium", - assigneeAgentId: "agent-4", - assigneeUserId: null, - }, - { - id: "direct-running", - identifier: "PAP-17425", - title: "Verify the live projection", - status: "in_progress", - priority: "medium", - assigneeAgentId: "agent-2", - assigneeUserId: null, - terminalBlockers: [terminalBlocker], - }, - { - id: "direct-done", - identifier: "PAP-17424", - title: "Run the guarded cutover", - status: "done", - priority: "medium", - assigneeAgentId: "agent-1", - assigneeUserId: null, - }, - ]} - />, - ); + renderThread({ + comments: createLongThreadComments(), + onAdd: async () => {}, + issueStatus: "blocked", + liveIssueIds: new Set(["direct-running", "terminal-running"]), + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 2, + coveredBlockerCount: 2, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-17426", + sampleStalledBlockerIdentifier: null, + blockingTreeLive: true, + directBlockerIssueId: "direct-running", + terminalBlockerIssueId: terminalBlocker.id, + terminalBlocker, + }, + blockedBy: [ + { + id: "direct-queued", + identifier: "PAP-17427", + title: "Verify the completed projection", + status: "todo", + priority: "medium", + assigneeAgentId: "agent-4", + assigneeUserId: null, + }, + { + id: "direct-running", + identifier: "PAP-17425", + title: "Verify the live projection", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-2", + assigneeUserId: null, + terminalBlockers: [terminalBlocker], + }, + { + id: "direct-done", + identifier: "PAP-17424", + title: "Run the guarded cutover", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); const notices = container.querySelectorAll( '[data-testid="task-chat-live-work-links"]', @@ -3498,36 +3292,34 @@ describe("TaskChatThread blocker links", () => { }); it("keeps the compact blocker rows when covered work is no longer live", () => { - render( - {}} - issueStatus="blocked" - liveIssueIds={new Set()} - blockerAttention={{ - state: "covered", - reason: "active_dependency", - unresolvedBlockerCount: 1, - coveredBlockerCount: 1, - stalledBlockerCount: 0, - attentionBlockerCount: 0, - sampleBlockerIdentifier: "PAP-500", - sampleStalledBlockerIdentifier: null, - blockingTreeLive: false, - }} - blockedBy={[ - { - id: "direct-1", - identifier: "PAP-500", - title: "Direct dependency", - status: "todo", - priority: "medium", - assigneeAgentId: "agent-1", - assigneeUserId: null, - }, - ]} - />, - ); + renderThread({ + comments: createLongThreadComments(), + onAdd: async () => {}, + issueStatus: "blocked", + liveIssueIds: new Set(), + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 1, + coveredBlockerCount: 1, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-500", + sampleStalledBlockerIdentifier: null, + blockingTreeLive: false, + }, + blockedBy: [ + { + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); expect( container.querySelector('[data-testid="task-chat-live-work-links"]'), @@ -3538,24 +3330,22 @@ describe("TaskChatThread blocker links", () => { }); it("shows only the direct row when the blocker has no deeper unresolved leaf", () => { - render( - {}} - issueStatus="blocked" - blockedBy={[ - { - id: "direct-1", - identifier: "PAP-500", - title: "Direct dependency", - status: "in_progress", - priority: "medium", - assigneeAgentId: "agent-1", - assigneeUserId: null, - }, - ]} - />, - ); + renderThread({ + comments: createLongThreadComments(), + onAdd: async () => {}, + issueStatus: "blocked", + blockedBy: [ + { + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); expect( container.querySelectorAll('[data-testid="task-chat-blocker-links"]'), @@ -3594,38 +3384,36 @@ describe("TaskChatThread blocker links", () => { ], }; - render( - {}} - issueStatus="blocked" - blockedBy={[ - { - id: "direct-1", - identifier: "PAP-500", - title: "Unrelated dependency", - status: "todo", - priority: "low", - assigneeAgentId: null, - assigneeUserId: null, - }, - selectedDirect, - ]} - blockerAttention={{ - state: "stalled", - reason: "stalled_review", - unresolvedBlockerCount: 2, - coveredBlockerCount: 0, - stalledBlockerCount: 1, - attentionBlockerCount: 1, - sampleBlockerIdentifier: "PAP-650", - sampleStalledBlockerIdentifier: "PAP-650", - directBlockerIssueId: selectedDirect.id, - terminalBlockerIssueId: selectedIntermediate.id, - terminalBlocker: selectedIntermediate, - }} - />, - ); + renderThread({ + comments: createLongThreadComments(), + onAdd: async () => {}, + issueStatus: "blocked", + blockedBy: [ + { + id: "direct-1", + identifier: "PAP-500", + title: "Unrelated dependency", + status: "todo", + priority: "low", + assigneeAgentId: null, + assigneeUserId: null, + }, + selectedDirect, + ], + blockerAttention: { + state: "stalled", + reason: "stalled_review", + unresolvedBlockerCount: 2, + coveredBlockerCount: 0, + stalledBlockerCount: 1, + attentionBlockerCount: 1, + sampleBlockerIdentifier: "PAP-650", + sampleStalledBlockerIdentifier: "PAP-650", + directBlockerIssueId: selectedDirect.id, + terminalBlockerIssueId: selectedIntermediate.id, + terminalBlocker: selectedIntermediate, + }, + }); const notices = container.querySelectorAll( '[data-testid="task-chat-blocker-links"]', @@ -3674,24 +3462,22 @@ describe("TaskChatThread blocker links", () => { }); it("keeps a short blocked thread to one top affordance", () => { - render( - {}} - issueStatus="blocked" - blockedBy={[ - { - id: "direct-1", - identifier: "PAP-500", - title: "Direct dependency", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - ]} - />, - ); + renderThread({ + comments: createLongThreadComments().slice(0, 1), + onAdd: async () => {}, + issueStatus: "blocked", + blockedBy: [ + { + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + }); const notices = container.querySelectorAll( '[data-testid="task-chat-blocker-links"]', @@ -3701,24 +3487,22 @@ describe("TaskChatThread blocker links", () => { }); it("does not show blocker rows outside the blocked state", () => { - render( - {}} - issueStatus="in_progress" - blockedBy={[ - { - id: "direct-1", - identifier: "PAP-500", - title: "Direct dependency", - status: "in_progress", - priority: "medium", - assigneeAgentId: "agent-1", - assigneeUserId: null, - }, - ]} - />, - ); + renderThread({ + comments: createLongThreadComments(), + onAdd: async () => {}, + issueStatus: "in_progress", + blockedBy: [ + { + id: "direct-1", + identifier: "PAP-500", + title: "Direct dependency", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); expect( container.querySelector('[data-testid="task-chat-blocker-links"]'), @@ -3745,13 +3529,11 @@ describe("TaskChatThread queued message actions", () => { updatedAt: new Date("2026-08-14T12:00:00.000Z"), }; - render( - {}} - onInterruptQueued={onInterruptQueued} - />, - ); + renderThread({ + comments: [queuedComment], + onAdd: async () => {}, + onInterruptQueued: onInterruptQueued, + }); const interrupt = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Interrupt", @@ -3765,30 +3547,28 @@ describe("TaskChatThread queued message actions", () => { }); it("disables the action while the queued run is being interrupted", () => { - render( - {}} - onInterruptQueued={async () => {}} - interruptingQueuedRunId="run-active" - />, - ); + renderThread({ + comments: [ + { + id: "comment-queued", + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body: "Use the latest requirements instead.", + presentation: null, + metadata: null, + clientStatus: "queued", + queueTargetRunId: "run-active", + createdAt: new Date("2026-08-14T12:00:00.000Z"), + updatedAt: new Date("2026-08-14T12:00:00.000Z"), + }, + ], + onAdd: async () => {}, + onInterruptQueued: async () => {}, + interruptingQueuedRunId: "run-active", + }); const interrupting = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Interrupting…", @@ -3854,12 +3634,12 @@ describe("TaskChatThread Paperclip Runner queue", () => { }); it("hides legacy transcript interrupt actions while paused", () => { - render( {}} - onInterruptQueued={async () => {}} - composerPause={{ scope: "leaf", onResume: () => {} }} - />); + renderThread({ + comments: [{ ...queuedComment, queueState: "queued", queueTargetRunId: "run-1" }], + onAdd: async () => {}, + onInterruptQueued: async () => {}, + composerPause: { scope: "leaf", onResume: () => {} }, + }); expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull(); expect([...container.querySelectorAll("button")].some((button) => button.textContent === "Interrupt")).toBe(false); }); @@ -3933,29 +3713,27 @@ describe("TaskChatThread Paperclip Runner queue", () => { it.each(["run-1", null])("keeps legacy queued delivery available with target %s", (targetRunId) => { const onInterruptQueued = vi.fn(async () => {}); - render( - {}} - onInterruptQueued={onInterruptQueued} - queuedCommentQueue={{ - ...queue, - targetRunId, - protocol: "legacy", - steeringDisposition: "unsupported", - }} - onEditQueuedComment={async () => {}} - onReorderQueuedComments={async () => {}} - onSteerQueuedComment={async () => {}} - onDiscardQueuedComment={async () => {}} - />, - ); + renderThread({ + comments: [ + { + ...queuedComment, + clientStatus: "queued", + queueTargetRunId: "run-1", + }, + ], + onAdd: async () => {}, + onInterruptQueued: onInterruptQueued, + queuedCommentQueue: { + ...queue, + targetRunId, + protocol: "legacy", + steeringDisposition: "unsupported", + }, + onEditQueuedComment: async () => {}, + onReorderQueuedComments: async () => {}, + onSteerQueuedComment: async () => {}, + onDiscardQueuedComment: async () => {}, + }); expect( container.querySelector( @@ -3975,32 +3753,30 @@ describe("TaskChatThread Paperclip Runner queue", () => { it("cancels an optimistic queued row locally before server acknowledgement", async () => { const onCancelQueued = vi.fn(); - render( - {}} - onCancelQueued={onCancelQueued} - queuedCommentQueue={{ - ...queue, - queueId: null, - entries: [ - { - ...queue.entries[0], - comment: { - ...queuedComment, - id: "optimistic-local-1", - body: "Delete before acknowledgement", - }, - canEdit: false, + renderThread({ + comments: [], + onAdd: async () => {}, + onCancelQueued: onCancelQueued, + queuedCommentQueue: { + ...queue, + queueId: null, + entries: [ + { + ...queue.entries[0], + comment: { + ...queuedComment, + id: "optimistic-local-1", + body: "Delete before acknowledgement", }, - ], - }} - onEditQueuedComment={async () => {}} - onReorderQueuedComments={async () => {}} - onSteerQueuedComment={async () => {}} - onDiscardQueuedComment={async () => {}} - />, - ); + canEdit: false, + }, + ], + }, + onEditQueuedComment: async () => {}, + onReorderQueuedComments: async () => {}, + onSteerQueuedComment: async () => {}, + onDiscardQueuedComment: async () => {}, + }); await act(async () => { container @@ -4023,13 +3799,11 @@ describe("TaskChatThread mobile composer dock (PAP-495)", () => { it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => { sidebarState.isMobile = true; - render( - {}} - draftKey="task-chat-draft:issue-mobile" - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + draftKey: "task-chat-draft:issue-mobile", + }); const dock = container .querySelector('[data-testid="mock-editor"]') @@ -4098,15 +3872,13 @@ describe("TaskChatThread live transcript", () => { runtimeMode: "native" as const, }; - render( - {}} - issueStatus="in_progress" - activeRun={activeRun} - composerAccessory={
Monitor
} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: activeRun, + composerAccessory:
Monitor
, + }); const dock = container.querySelector( '[data-testid="task-chat-composer-dock"]', @@ -4130,18 +3902,16 @@ describe("TaskChatThread live transcript", () => { ); expect(dock.contains(island)).toBe(true); - render( - {}} - issueStatus="in_progress" - activeRun={{ - ...activeRun, - status: "succeeded", - finishedAt: "2026-08-24T12:01:00.000Z", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { + ...activeRun, + status: "succeeded", + finishedAt: "2026-08-24T12:01:00.000Z", + }, + }); expect( container.querySelector('[data-testid="task-chat-turn-status-island"]'), ).toBeNull(); @@ -4172,26 +3942,24 @@ describe("TaskChatThread live transcript", () => { updatedAt: new Date("2026-08-21T15:44:22.000Z"), }; - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: "run-runner", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-21T15:44:00.000Z", - finishedAt: null, - createdAt: "2026-08-21T15:44:00.000Z", - agentId: "agent-1", - agentName: "Runner", - adapterType: "paperclip_runner", - runtimeMode: "native", - }} - />, - ); + renderThread({ + comments: [comment], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { + id: "run-runner", + status: "running", + invocationSource: "issue", + triggerDetail: null, + startedAt: "2026-08-21T15:44:00.000Z", + finishedAt: null, + createdAt: "2026-08-21T15:44:00.000Z", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + runtimeMode: "native", + }, + }); expect( container.textContent?.match(/Completed the requested streaming test\./g), @@ -4221,17 +3989,15 @@ describe("TaskChatThread live transcript", () => { adapterType: "claude_local", }; - render( - {}} - issueStatus="in_progress" - activeRun={{ - ...baseRun, - currentStatusMessage: "Syncing workspace to environment", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { + ...baseRun, + currentStatusMessage: "Syncing workspace to environment", + }, + }); const tail = container.querySelector( '[data-testid="task-chat-live-transcript"]', @@ -4241,14 +4007,12 @@ describe("TaskChatThread live transcript", () => { expect(tail!.textContent).not.toContain("Waiting for transcript..."); // Without a runtime status, the generic wait message still shows. - render( - {}} - issueStatus="in_progress" - activeRun={{ ...baseRun, id: "run-prep-2" }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { ...baseRun, id: "run-prep-2" }, + }); const tail2 = container.querySelector( '[data-testid="task-chat-live-transcript"]', ); @@ -4307,25 +4071,23 @@ describe("TaskChatThread live transcript", () => { }, ]); - render( - {}} - issueStatus="in_progress" - activeRun={{ - id: "run-1", - status: "running", - invocationSource: "issue", - triggerDetail: null, - startedAt: "2026-08-07T00:00:00.000Z", - finishedAt: null, - createdAt: "2026-08-07T00:00:00.000Z", - agentId: "agent-1", - agentName: "Coder", - adapterType: "codex_local", - }} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: { + id: "run-1", + status: "running", + invocationSource: "issue", + triggerDetail: null, + startedAt: "2026-08-07T00:00:00.000Z", + finishedAt: null, + createdAt: "2026-08-07T00:00:00.000Z", + agentId: "agent-1", + agentName: "Coder", + adapterType: "codex_local", + }, + }); const tail = container.querySelector( '[data-testid="task-chat-live-transcript"]', @@ -4450,18 +4212,8 @@ describe("TaskChatThread live transcript", () => { const runId = "native-delayed-comment"; const summary = "DELAYED-CANONICAL-REPLY"; nativeTranscriptState.transcriptByRun.set(runId, [ - { - kind: "assistant", - ts: "2026-08-07T00:00:01Z", - text: "UNIQUE-PROGRESS-LINE", - channel: "progress", - }, - { - kind: "assistant", - ts: "2026-08-07T00:00:02Z", - text: summary, - channel: "final", - }, + assistantEntry("2026-08-07T00:00:01Z", "UNIQUE-PROGRESS-LINE", "progress"), + assistantEntry("2026-08-07T00:00:02Z", summary, "final"), { kind: "run_result", ts: "2026-08-07T00:00:03Z", @@ -4520,24 +4272,20 @@ describe("TaskChatThread live transcript", () => { }, }, }; - render( - {}} - issueStatus="in_progress" - activeRun={activeRun} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + activeRun: activeRun, + }); // The terminal run/event query wins the race, but the canonical comment // request is still pending. The settled answer already owns the turn. - render( - {}} - issueStatus="in_progress" - linkedRuns={[linkedRun]} - />, - ); + renderThread({ + comments: [], + onAdd: async () => {}, + issueStatus: "in_progress", + linkedRuns: [linkedRun], + }); expect(container.textContent?.split("UNIQUE-PROGRESS-LINE")).toHaveLength( 2, ); @@ -4545,30 +4293,28 @@ describe("TaskChatThread live transcript", () => { expect( container.querySelector('[data-testid="task-chat-live-transcript"]'), ).toBeNull(); - render( - {}} - issueStatus="in_progress" - linkedRuns={[linkedRun]} - />, - ); + renderThread({ + comments: [ + { + id: "delayed-comment", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + createdByRunId: runId, + runId, + body: summary, + presentation: null, + metadata: null, + createdAt: new Date("2026-08-07T00:00:05Z"), + updatedAt: new Date("2026-08-07T00:00:05Z"), + }, + ], + onAdd: async () => {}, + issueStatus: "in_progress", + linkedRuns: [linkedRun], + }); expect(container.textContent?.split("UNIQUE-PROGRESS-LINE")).toHaveLength( 2, ); @@ -4660,11 +4406,15 @@ describe("TaskChatThread composer execution controls", () => { }); it("explains a missing provider runtime with an installation repair", () => { - render( {}} linkedRuns={[{ - runId: "missing-runtime", runtimeMode: "native", status: "failed", errorCode: "native_provider_runtime_missing", - agentId: "agent-1", agentName: "Content Strategist", adapterType: "paperclip_runner", - createdAt: "2026-10-11T11:19:08Z", startedAt: "2026-10-11T11:19:08Z", finishedAt: "2026-10-11T11:19:14Z", - }]} />); + renderThread({ + comments: [], + onAdd: async () => {}, + linkedRuns: [{ + runId: "missing-runtime", runtimeMode: "native", status: "failed", errorCode: "native_provider_runtime_missing", + agentId: "agent-1", agentName: "Content Strategist", adapterType: "paperclip_runner", + createdAt: "2026-10-11T11:19:08Z", startedAt: "2026-10-11T11:19:08Z", finishedAt: "2026-10-11T11:19:14Z", + }], + }); expect(container.textContent).toContain("Runtime missing"); const toggle = container.querySelector('[data-testid="task-chat-collapsible-marker"] button[aria-expanded]')!; flushSync(() => toggle.click()); diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index be889270e03..4310bd4e545 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, StrictMode, useState, type ReactElement } from "react"; +import { act, StrictMode, useState, type ComponentProps, type ReactElement } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -296,6 +296,10 @@ function autocompleteOption(matchText: string) { return option!; } +function renderComposer(props: ComponentProps) { + render(); +} + describe("TaskChatComposer", () => { it("keeps creation images inline and prevents submission during their upload", async () => { let resolveUpload!: (url: string) => void; @@ -503,14 +507,12 @@ describe("TaskChatComposer", () => { .mockRejectedValueOnce(new Error("Refresh unavailable")) .mockResolvedValue(undefined); try { - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + draftKey: "storage-disabled", + onReviewConversation: review, + }); typeText("Do not replay this unknown save"); pressKey("Enter", { metaKey: true }); await flushAsync(); @@ -589,13 +591,11 @@ describe("TaskChatComposer", () => { contentPath: `/api/attachments/${id}/content`, originalFilename: "notes.txt", }); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + }); typeText("Inspect the new file."); pasteFiles([new File(["new bytes"], "notes.txt", { type: "text/plain" })]); await flushAsync(); @@ -617,13 +617,11 @@ describe("TaskChatComposer", () => { resolveUpload = resolve; }), ); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + }); typeText("Inspect the picture."); const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!; const upload = handler( @@ -673,13 +671,11 @@ describe("TaskChatComposer", () => { originalFilename: file.name, }; }); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + }); pasteFiles([ new File(["one"], "one.txt", { type: "text/plain" }), new File(["two"], "two.txt", { type: "text/plain" }), @@ -755,14 +751,12 @@ describe("TaskChatComposer", () => { resolveUpload = resolve; }), ); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + draftKey: "removed-pending", + }); typeText("Keep this text"); const upload = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!( new File(["png"], "pending.png", { type: "image/png" }), @@ -803,13 +797,11 @@ describe("TaskChatComposer", () => { }); it("leaves an 8px token gap between the editor and action row", () => { - render( - {}} - workMode="planning" - onWorkModeChange={async () => {}} - />, - ); + renderComposer({ + onAdd: async () => {}, + workMode: "planning", + onWorkModeChange: async () => {}, + }); const actions = container.querySelector( '[data-testid="task-chat-composer-actions"]', @@ -820,16 +812,14 @@ describe("TaskChatComposer", () => { }); it("renders a light card shell while preserving the borderless dark treatment", () => { - render( - {}} - workMode="planning" - onWorkModeChange={async () => {}} - enableReassign - currentAssigneeValue="agent:runner" - reassignOptions={[{ id: "agent:runner", label: "Runner" }]} - />, - ); + renderComposer({ + onAdd: async () => {}, + workMode: "planning", + onWorkModeChange: async () => {}, + enableReassign: true, + currentAssigneeValue: "agent:runner", + reassignOptions: [{ id: "agent:runner", label: "Runner" }], + }); const composer = container.querySelector(".paperclip-task-chat-composer")!; const mode = container.querySelector( @@ -912,13 +902,11 @@ describe("TaskChatComposer", () => { it("cycles the pending mode with Shift+Tab and applies it on submit", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onWorkModeChange = vi.fn().mockResolvedValue(undefined); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onWorkModeChange: onWorkModeChange, + }); expect(container.querySelector('[data-testid="task-chat-composer-add"]')).not.toBeNull(); expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull(); @@ -940,13 +928,11 @@ describe("TaskChatComposer", () => { it("cycles Auto, Plan, and Ask modes with Cmd+Period while focused", () => { const onWorkModeChange = vi.fn().mockResolvedValue(undefined); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + onWorkModeChange: onWorkModeChange, + }); editable().focus(); @@ -1049,16 +1035,14 @@ describe("TaskChatComposer", () => { }); it("uses the borderless Paper controls and inverse circular send button", () => { - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + onWorkModeChange: vi.fn(), + enableReassign: true, + reassignOptions: [{ id: "agent:a1", label: "Chief of Staff" }], + currentAssigneeValue: "agent:a1", + }); const add = container.querySelector( '[data-testid="task-chat-composer-add"]', @@ -1081,14 +1065,12 @@ describe("TaskChatComposer", () => { it("passes reopen=true when the issue resumes-to-todo and the assignee is an agent", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + issueStatus: "done", + currentAssigneeValue: "agent:a1", + }); typeText("wake up"); pressKey("Enter", { metaKey: true }); @@ -1103,13 +1085,11 @@ describe("TaskChatComposer", () => { container.querySelector('[data-testid="task-chat-composer-add"]'), ).toBeNull(); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + onAttachImage: vi.fn().mockResolvedValue(undefined), + }); expect( container.querySelector('[data-testid="task-chat-composer-add"]'), ).not.toBeNull(); @@ -1130,13 +1110,11 @@ describe("TaskChatComposer", () => { contentPath: "/attachments/shot.png", originalFilename: "shot.png", }); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + onAttachImage: onAttachImage, + }); const handler = mdxEditorMockState.imagePluginOptions?.imageUploadHandler; expect(handler).toBeTypeOf("function"); @@ -1161,13 +1139,11 @@ describe("TaskChatComposer", () => { contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + }); const file = new File(["plain"], "notes.txt", { type: "text/plain" }); const paste = pasteFiles([file]); @@ -1210,13 +1186,11 @@ describe("TaskChatComposer", () => { contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + }); typeText("Please review this."); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); @@ -1243,13 +1217,11 @@ describe("TaskChatComposer", () => { }, ), ); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + }); typeText("Here is the file."); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); @@ -1276,13 +1248,11 @@ describe("TaskChatComposer", () => { it("blocks send while a failed attachment chip remains, then sends after it is removed", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large")); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + onAttachImage: onAttachImage, + }); typeText("Here is the file."); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); @@ -1310,13 +1280,11 @@ describe("TaskChatComposer", () => { contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + onAttachImage: onAttachImage, + }); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); await flushAsync(); @@ -1333,13 +1301,11 @@ describe("TaskChatComposer", () => { it("shows an error-state chip when a non-image upload fails", async () => { const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large")); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + onAttachImage: onAttachImage, + }); pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]); await flushAsync(); @@ -1360,13 +1326,11 @@ describe("TaskChatComposer", () => { contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", }); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + onAttachImage: onAttachImage, + }); const image = new File(["png-bytes"], "shot.png", { type: "image/png" }); const text = new File(["plain"], "notes.txt", { type: "text/plain" }); @@ -1386,15 +1350,13 @@ describe("TaskChatComposer", () => { it("inserts an @-mention from the autocomplete menu and posts it", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + mentions: [ + { id: "agent:a1", kind: "agent", name: "Clippy", agentId: "a1" }, + ], + }); typeText("@Cl"); await placeCaretAtEnd(); @@ -1505,14 +1467,12 @@ describe("TaskChatComposer", () => { it("dispatches a goal action without posting a comment", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onRunnerGoalCommand = vi.fn().mockResolvedValue(undefined); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + runnerGoalCapability: capability, + onRunnerGoalCommand: onRunnerGoalCommand, + }); typeText("/goal Ship the feature"); pressKey("Enter", { metaKey: true }); @@ -1557,19 +1517,17 @@ describe("TaskChatComposer", () => { const onRunnerGoalCommand = vi.fn().mockImplementation(async () => { order.push("goal"); }); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + enableReassign: true, + reassignOptions: [{ id: "agent:a1", label: "Clippy" }], + currentAssigneeValue: "", + onPendingAssigneeChange: onPendingAssigneeChange, + runnerGoalCapability: capability, + onRunnerGoalReassign: onRunnerGoalReassign, + onRunnerGoalCommand: onRunnerGoalCommand, + }); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', @@ -1606,19 +1564,17 @@ describe("TaskChatComposer", () => { it("preserves unsupported goal text and shows the provider reason", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + runnerGoalCapability: { + ...capability, + availability: "unsupported", + actions: [], + reason: "Unsupported by OpenCode.", + }, + onRunnerGoalCommand: vi.fn(), + }); typeText("/goal Ship the feature"); pressKey("Enter", { metaKey: true }); @@ -1638,18 +1594,16 @@ describe("TaskChatComposer", () => { container.querySelector('[data-testid="task-chat-composer-assignee"]'), ).toBeNull(); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + enableReassign: true, + reassignOptions: [ + { id: "agent:a1", label: "Clippy" }, + { id: "user:u1", label: "Sam" }, + ], + currentAssigneeValue: "user:u1", + }); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', ); @@ -1657,24 +1611,20 @@ describe("TaskChatComposer", () => { }); it("shows configured agent icons in the assignee trigger and dropdown options", async () => { - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + enableReassign: true, + reassignOptions: [ + { id: "agent:a1", label: "Clippy" }, + { id: "agent:a2", label: "Plain Agent" }, + ], + agentMap: new Map([ + ["a1", { icon: "rocket" }], + ["a2", { icon: null }], + ]), + currentAssigneeValue: "agent:a1", + }); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', @@ -1721,22 +1671,18 @@ describe("TaskChatComposer", () => { }); it("shows human avatars in assignee options and after selection", async () => { - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + enableReassign: true, + reassignOptions: [ + { id: "agent:a1", label: "Clippy" }, + { id: "user:u1", label: "Sam Rivera" }, + ], + agentMap: new Map([["a1", { icon: "rocket" }]]), + userProfileMap: new Map([["u1", { label: "Sam Rivera", image: "/sam-avatar.png" }]]), + currentAssigneeValue: "agent:a1", + }); const trigger = container.querySelector( '[data-testid="task-chat-composer-assignee"]', @@ -1768,13 +1714,11 @@ describe("TaskChatComposer", () => { it("restores a saved draft on mount", () => { localStorage.setItem(draftKey, "unsent draft"); - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + draftKey: draftKey, + }); expect(editable().textContent).toBe("unsent draft"); expect(sendButton().disabled).toBe(false); @@ -1800,13 +1744,11 @@ describe("TaskChatComposer", () => { it("saves after the debounce and flushes a pending value on unmount", () => { vi.useFakeTimers(); try { - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + draftKey: draftKey, + }); typeText("work in progress"); expect(localStorage.getItem(draftKey)).toBeNull(); @@ -1825,13 +1767,11 @@ describe("TaskChatComposer", () => { it("flushes a pending value before page unload", () => { vi.useFakeTimers(); try { - render( - , - ); + renderComposer({ + onAdd: vi.fn(), + workMode: "standard", + draftKey: draftKey, + }); typeText("save before reload"); window.dispatchEvent(new Event("beforeunload")); @@ -1845,13 +1785,11 @@ describe("TaskChatComposer", () => { it("clears the visible composer but retains an uncertain reload draft while sending", async () => { localStorage.setItem(draftKey, "queued message"); const onAdd = vi.fn().mockReturnValue(new Promise(() => {})); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + draftKey: draftKey, + }); pressKey("Enter", { metaKey: true }); await flushAsync(); @@ -1871,13 +1809,11 @@ describe("TaskChatComposer", () => { resolveSend = resolve; }), ); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + draftKey: draftKey, + }); typeText("first message"); pressKey("Enter", { metaKey: true }); @@ -1903,14 +1839,12 @@ describe("TaskChatComposer", () => { contentPath: "/attachments/next.txt", originalFilename: "next.txt", }); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + draftKey: draftKey, + onAttachImage: onAttachImage, + }); typeText("first message"); pressKey("Enter", { metaKey: true }); @@ -1938,13 +1872,11 @@ describe("TaskChatComposer", () => { rejectSend = reject; }), ); - render( - , - ); + renderComposer({ + onAdd: onAdd, + workMode: "standard", + draftKey: draftKey, + }); typeText("do not lose this"); vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); vi.useRealTimers(); @@ -2138,21 +2070,19 @@ describe("TaskChatComposer", () => { describe("composer takeovers", () => { it.each([false, true])("keeps the interaction card visible while sending a normal message (mobile=%s)", async (mobile) => { const onAdd = vi.fn().mockResolvedValue(undefined); - render( - Which environment?

, onDismiss: vi.fn(), onSkip: vi.fn(), - }} - />, - ); + }, + }); typeText("Continue investigating while I decide."); expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')?.textContent).toContain("Which environment?"); @@ -2163,21 +2093,19 @@ describe("TaskChatComposer", () => { }); it("keeps the composer mode shortcut available beneath an open question", () => { - render( - Which environment?

, onDismiss: vi.fn(), onSkip: vi.fn(), - }} - />, - ); + }, + }); pressKey(".", { metaKey: true }); expect(container.querySelector('[data-testid="task-chat-composer-mode"]')?.textContent).toContain("Plan"); expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')).not.toBeNull(); @@ -2185,11 +2113,10 @@ describe("TaskChatComposer", () => { it("shows a separate card above a usable editor and exposes Skip", async () => { const onSkip = vi.fn().mockResolvedValue(undefined); - render( - { onDismiss: vi.fn(), onSkip, onShowNext: vi.fn(), - }} - />, - ); + }, + }); expect( container.querySelector('[data-testid="task-chat-composer-takeover"]') @@ -2259,11 +2185,10 @@ describe("TaskChatComposer", () => { }); it("keeps title, pending count, pagination, and dismiss on one header row", () => { - render( - { onDismiss: vi.fn(), onSkip: vi.fn(), onShowNext: vi.fn(), - }} - />, - ); + }, + }); const header = container.querySelector( '[data-testid="task-chat-composer-takeover-header"]', @@ -2321,11 +2245,10 @@ describe("TaskChatComposer", () => { it("waits for the submit button on a single-select question", async () => { const onSubmit = vi.fn(); - render( - { ), onDismiss: vi.fn(), onSkip: vi.fn(), - }} - />, - ); + }, + }); const buttons = () => Array.from(container.querySelectorAll("button")); @@ -2392,11 +2314,10 @@ describe("TaskChatComposer", () => { it("advances on Next, preserves answers when going back, and skips optional answers", async () => { const onSubmit = vi.fn(); - render( - { ), onDismiss: vi.fn(), onSkip: vi.fn(), - }} - />, - ); + }, + }); const buttons = () => Array.from(container.querySelectorAll("button")); @@ -2673,11 +2593,10 @@ describe("TaskChatComposer", () => { it("Skip on the last question submits the other answers", async () => { const onSubmit = vi.fn(); - render( - { ), onDismiss: vi.fn(), onSkip: vi.fn(), - }} - />, - ); + }, + }); const byLabel = (label: string) => Array.from( @@ -2731,11 +2649,10 @@ describe("TaskChatComposer", () => { it("Skip on the last question returns to a required question the arrows walked past", async () => { const onSubmit = vi.fn(); - render( - { ), onDismiss: vi.fn(), onSkip: vi.fn(), - }} - />, - ); + }, + }); const byLabel = (label: string) => Array.from( @@ -2796,11 +2712,10 @@ describe("TaskChatComposer", () => { const onSubmit = vi.fn(); const onDismiss = vi.fn(); const onSkip = vi.fn(); - render( - { ), onDismiss, onSkip, - }} - />, - ); + }, + }); const cancel = Array.from( container.querySelectorAll("button"), @@ -2841,11 +2755,10 @@ describe("TaskChatComposer", () => { }); it("places Skip beside Submit answers for structured questions", () => { - render( - { ), onDismiss: vi.fn(), onSkip: vi.fn(), - }} - />, - ); + }, + }); const buttons = Array.from( container.querySelectorAll("button"), @@ -2921,11 +2833,10 @@ describe("TaskChatComposer", () => { }); it("hides Skip when the takeover already provides a request-changes path", () => { - render( - { onDismiss: vi.fn(), onSkip: vi.fn(), hideSkip: true, - }} - />, - ); + }, + }); expect(container.textContent).not.toContain("Skip"); expect( @@ -2953,20 +2863,18 @@ describe("TaskChatComposer", () => { it("dismisses every takeover without invoking Skip", async () => { const onDismiss = vi.fn(); const onSkip = vi.fn(); - render( - Which release should ship?

, onDismiss, onSkip, - }} - />, - ); + }, + }); flushSync(() => container diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 10b6a08f3e4..779dfe5c6ab 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -283,6 +283,65 @@ async function gotoLinkFrame(container: HTMLDivElement, url: string) { await passAccessStep(); } +function notionConnection(id: string, credentialPolicy: string, status: string) { + return { + id: id, + applicationId: "app-notion", + authKind: "oauth", + credentialPolicy: credentialPolicy, + status: status, + config: { sourceTemplateKey: "notion" }, + transportConfig: {}, + }; +} + +function notionApplication(status: string) { + return { + id: "app-notion", + status: status, + metadata: { sourceTemplateKey: "notion" }, + }; +} + +function oauthStartResponse(connectionId: string, provider: string, authorizationUrl: string) { + return { + connectionId: connectionId, + provider: provider, + authorizationUrl: authorizationUrl, + expiresAt: "2099-01-01T00:00:00.000Z", + }; +} + +function activeConnectorEnrollment() { + return { + configured: true, + status: "active", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "inst-test", + environment: "staging", + origins: ["https://paperclip.example.test"], + }; +} + +function unconfiguredConnectorEnrollment() { + return { + configured: false, + status: "not_configured", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: null, + environment: "staging", + origins: [], + }; +} + +function customerOwnedApp(app: typeof GMAIL) { + return { + ...app, + methods: app.methods.filter((method) => !method.oauthStrategy), + ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, + }; +} + describe("AppsConnect — Connect with a link (M4 frame)", () => { let container: HTMLDivElement; let mountedRoot: Root | null; @@ -315,22 +374,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { listApplicationsMock.mockResolvedValue({ applications: [] }); listConnectionsMock.mockResolvedValue({ connections: [] }); getConnectionInstallsMock.mockResolvedValue({ installs: [{ targetType: "company", targetId: "company-1" }] }); - startOAuthMock.mockResolvedValue({ - connectionId: "conn-notion", - provider: "notion", - authorizationUrl: "https://mcp.notion.com/authorize?state=resumed", - expiresAt: "2099-01-01T00:00:00.000Z", - }); + startOAuthMock.mockResolvedValue(oauthStartResponse("conn-notion", "notion", "https://mcp.notion.com/authorize?state=resumed")); finishAppMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); - getCloudConnectorEnrollmentMock.mockResolvedValue({ - configured: true, - status: "active", - brokerBaseUrl: "https://my-staging.paperclip.app", - instanceId: "inst-test", - environment: "staging", - origins: ["https://paperclip.example.test"], - }); + getCloudConnectorEnrollmentMock.mockResolvedValue(activeConnectorEnrollment()); startCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, status: "pending", @@ -1406,20 +1453,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("prompts for one-time Cloud registration inside Gmail when managed methods are not yet advertised", async () => { mockParams.appKey = "gmail"; listGalleryMock.mockResolvedValue({ - apps: [{ - ...GMAIL, - methods: GMAIL.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }], - }); - getCloudConnectorEnrollmentMock.mockResolvedValueOnce({ - configured: false, - status: "not_configured", - brokerBaseUrl: "https://my-staging.paperclip.app", - instanceId: null, - environment: "staging", - origins: [], + apps: [customerOwnedApp(GMAIL)], }); + getCloudConnectorEnrollmentMock.mockResolvedValueOnce(unconfiguredConnectorEnrollment()); await render(); await passAccessStep(); @@ -1451,10 +1487,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; const open = vi.spyOn(window, "open").mockReturnValue(popupBlocked ? null : popup as unknown as Window); - listGalleryMock.mockResolvedValue({ apps: [{ - ...GMAIL, methods: GMAIL.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }] }); + listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GMAIL)] }); getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" }); await render(client, false, ); await passAccessStep(); @@ -1491,10 +1524,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it.each(["request", "missing-url", "invalid-url"])("closes the reserved enrollment popup after %s failure", async (failure) => { const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window); - listGalleryMock.mockResolvedValue({ apps: [{ - ...GMAIL, methods: GMAIL.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }] }); + listGalleryMock.mockResolvedValue({ apps: [customerOwnedApp(GMAIL)] }); getCloudConnectorEnrollmentMock.mockResolvedValue({ status: "not_configured" }); if (failure === "request") { startCloudConnectorEnrollmentMock.mockRejectedValue(new Error("Enrollment unavailable")); @@ -1530,11 +1560,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { async (expiresAt) => { mockSearch.value = "source=github&stage=setup"; listGalleryMock.mockResolvedValue({ - apps: [{ - ...GITHUB, - methods: GITHUB.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }], + apps: [customerOwnedApp(GITHUB)], }); getCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, @@ -1598,20 +1624,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({ - apps: [{ - ...GITHUB, - methods: GITHUB.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }], - }); - getCloudConnectorEnrollmentMock.mockResolvedValueOnce({ - configured: false, - status: "not_configured", - brokerBaseUrl: "https://my-staging.paperclip.app", - instanceId: null, - environment: "staging", - origins: [], + apps: [customerOwnedApp(GITHUB)], }); + getCloudConnectorEnrollmentMock.mockResolvedValueOnce(unconfiguredConnectorEnrollment()); await render(); @@ -1644,11 +1659,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("explains unavailable GitHub sign-in without silently switching to a PAT", async () => { mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled"; listGalleryMock.mockResolvedValueOnce({ - apps: [{ - ...GITHUB, - methods: GITHUB.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }], + apps: [customerOwnedApp(GITHUB)], }); await render(); @@ -1670,22 +1681,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("keeps GitHub sign-in intent when enrollment recovery reveals an unavailable profile", async () => { mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled"; listGalleryMock.mockResolvedValueOnce({ - apps: [{ - ...GITHUB, - methods: GITHUB.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }], + apps: [customerOwnedApp(GITHUB)], }); getCloudConnectorEnrollmentMock .mockRejectedValueOnce(new Error("Cloud unavailable")) - .mockResolvedValueOnce({ - configured: true, - status: "active", - brokerBaseUrl: "https://my-staging.paperclip.app", - instanceId: "inst-test", - environment: "staging", - origins: ["https://paperclip.example.test"], - }); + .mockResolvedValueOnce(activeConnectorEnrollment()); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await render(queryClient); @@ -1706,11 +1706,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("preserves a dedicated agent identity across the full-page enrollment callback", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({ - apps: [{ - ...GITHUB, - methods: GITHUB.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }], + apps: [customerOwnedApp(GITHUB)], capabilities: { canCreateOrganizationGrant: true, organizationGrantReason: null, @@ -1718,14 +1714,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { companyInstallReason: null, }, }); - getCloudConnectorEnrollmentMock.mockResolvedValue({ - configured: false, - status: "not_configured", - brokerBaseUrl: "https://my-staging.paperclip.app", - instanceId: null, - environment: "staging", - origins: [], - }); + getCloudConnectorEnrollmentMock.mockResolvedValue(unconfiguredConnectorEnrollment()); await render(); await openAccessAdvanced(); @@ -1763,14 +1752,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled"; mockCompany.value = { selectedCompanyId: undefined, selectedCompany: null }; listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); - getCloudConnectorEnrollmentMock.mockResolvedValue({ - configured: true, - status: "active", - brokerBaseUrl: "https://my-staging.paperclip.app", - instanceId: "inst-test", - environment: "staging", - origins: ["https://paperclip.example.test"], - }); + getCloudConnectorEnrollmentMock.mockResolvedValue(activeConnectorEnrollment()); const coldLoadClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await render(coldLoadClient); @@ -1877,11 +1859,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("never renders self-host enrollment when the connector identity is already active", async () => { mockSearch.value = "source=gmail&stage=setup"; listGalleryMock.mockResolvedValueOnce({ - apps: [{ - ...GMAIL, - methods: GMAIL.methods.filter((method) => !method.oauthStrategy), - ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, - }], + apps: [customerOwnedApp(GMAIL)], }); await render(); @@ -2307,17 +2285,34 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it("renders the same PostHog method choices in page and task-dialog hosts", async () => { - const methodState = () => ({ - oauth: buttonByText("Sign in with PostHog")?.getAttribute("aria-pressed"), - apiKey: buttonByText("Use a personal API key")?.getAttribute("aria-pressed"), - connectDisabled: buttonByText("Connect")?.disabled, - }); + const checkMethodChoices = async () => { + await openAccessAdvanced(); + expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("true"); + expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("false"); + expect(buttonByText("Continue to sign in")?.disabled).toBe(false); + await act(async () => { + radioContaining("Use a personal API key")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(radioContaining("Sign in with PostHog")?.getAttribute("aria-checked")).toBe("false"); + expect(radioContaining("Use a personal API key")?.getAttribute("aria-checked")).toBe("true"); + expect(buttonByText("Connect")?.disabled).toBe(true); + const keyInput = container.querySelector('input[type="password"]'); + expect(keyInput).toBeTruthy(); + await act(async () => setInputValue(keyInput!, "phx_host-test-key")); + await flushReact(); + expect(buttonByText("Connect")?.disabled).toBe(false); + await act(async () => { + radioContaining("Sign in with PostHog")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(buttonByText("Continue to sign in")?.disabled).toBe(false); + }; mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValue({ apps: [POSTHOG] }); const pageRoot = await render(); - await passAccessStep(); - const pageMethods = methodState(); + await checkMethodChoices(); await act(async () => pageRoot.unmount()); container.innerHTML = ""; @@ -2335,12 +2330,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { ); expect(continueButton).toBeTruthy(); expect(continueButton?.disabled).toBe(false); + await checkMethodChoices(); await act(async () => { - continueButton?.click(); + buttonByText("Continue to sign in")!.click(); }); await flushReact(); - - expect(methodState()).toEqual(pageMethods); + expect(connectAppMock).toHaveBeenCalledTimes(1); await act(async () => dialogRoot.unmount()); }); @@ -2512,21 +2507,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { ); }); - it("shows an in-flight state while Paperclip prepares Notion sign-in", async () => { - mockSearch.value = "source=notion"; - listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); - connectAppMock.mockReturnValueOnce(new Promise(() => {})); - - await render(); - await passAccessStep(); - await submitCuratedOAuthSetup(); - - expect(container.textContent).toContain("Connect Notion"); - expect(container.textContent).toContain("Preparing secure sign-in"); - expect(container.textContent).toContain("Preparing…"); - expect(connectAppMock).toHaveBeenCalledTimes(1); - }); - it("keeps the same steps when Notion sign-in takes over the wizard", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); @@ -2543,6 +2523,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await submitCuratedOAuthSetup(); expect(container.textContent).toContain("Preparing secure sign-in"); + expect(container.textContent).toContain("Connect Notion"); + expect(container.textContent).toContain("Preparing…"); + expect(connectAppMock).toHaveBeenCalledTimes(1); expect(stepLabelsOnScreen()).toEqual([]); expect(stepDotCount()).toBe(0); }); @@ -2569,29 +2552,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = `source=notion&intent=${interactionId}`; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "draft", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("draft")], }); listConnectionsMock.mockResolvedValueOnce({ - connections: [{ - id: "conn-existing", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "per_user", - status: "draft", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }], - }); - startOAuthMock.mockResolvedValueOnce({ - connectionId: "conn-existing", - provider: "notion", - authorizationUrl: "https://mcp.notion.com/authorize?state=existing", - expiresAt: "2099-01-01T00:00:00.000Z", + connections: [notionConnection("conn-existing", "per_user", "draft")], }); + startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-existing", "notion", "https://mcp.notion.com/authorize?state=existing")); await render(); expect(container.textContent).not.toContain("Reconnect keeps this identity type"); @@ -2614,32 +2580,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "active", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("active")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [ - { - id: "11111111-1111-4111-8111-111111111111", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "shared", - status: "draft", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }, - { - id: "22222222-2222-4222-8222-222222222222", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "per_user", - status: "draft", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }, + notionConnection("11111111-1111-4111-8111-111111111111", "shared", "draft"), + notionConnection("22222222-2222-4222-8222-222222222222", "per_user", "draft"), ], }); @@ -2688,12 +2634,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { transportConfig: {}, }], }); - startOAuthMock.mockResolvedValueOnce({ - connectionId, - provider: "clickup", - authorizationUrl: "https://app.clickup.com/api?state=resumed", - expiresAt: "2099-01-01T00:00:00.000Z", - }); + startOAuthMock.mockResolvedValueOnce(oauthStartResponse(connectionId, "clickup", "https://app.clickup.com/api?state=resumed")); await render(); await vi.waitFor(() => { @@ -2971,11 +2912,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=notion&resume=22222222-2222-4222-8222-222222222222&oauth=denied&code=oauth_authorization_denied"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "draft", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("draft")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ @@ -3016,29 +2953,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "active", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("active")], }); listConnectionsMock.mockResolvedValueOnce({ - connections: [{ - id: "conn-existing", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy, - status: "active", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }], - }); - startOAuthMock.mockResolvedValueOnce({ - connectionId: "conn-existing", - provider: "notion", - authorizationUrl: "https://mcp.notion.com/authorize?state=existing", - expiresAt: "2099-01-01T00:00:00.000Z", + connections: [notionConnection("conn-existing", credentialPolicy, "active")], }); + startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-existing", "notion", "https://mcp.notion.com/authorize?state=existing")); await render(); expect(container.textContent).toContain( @@ -3057,22 +2977,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "archived", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("archived")], }); listConnectionsMock.mockResolvedValueOnce({ - connections: [{ - id: "conn-archived", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "shared", - status: "archived", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }], + connections: [notionConnection("conn-archived", "shared", "archived")], }); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-archived", @@ -3087,12 +2995,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { suggestedDefaults: {}, auth: { kind: "oauth", startUrl: null }, }); - startOAuthMock.mockResolvedValueOnce({ - connectionId: "conn-archived", - provider: "notion", - authorizationUrl: "https://mcp.notion.com/authorize?state=revived", - expiresAt: "2099-01-01T00:00:00.000Z", - }); + startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-archived", "notion", "https://mcp.notion.com/authorize?state=revived")); await render(); expect(container.textContent).toContain("Connects for everyone in your organization"); @@ -3113,22 +3016,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization"; listGalleryMock.mockRejectedValueOnce(new Error("Gallery unavailable")); listApplicationsMock.mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "archived", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("archived")], }); listConnectionsMock.mockResolvedValueOnce({ - connections: [{ - id: "conn-archived", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "shared", - status: "archived", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }], + connections: [notionConnection("conn-archived", "shared", "archived")], }); await render(); @@ -3152,22 +3043,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=notion&applicationId=app-notion&new=1&reconnect=conn-archived&identity=organization"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); const applications = { - applications: [{ - id: "app-notion", - status: "archived", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("archived")], }; const connections = { - connections: [{ - id: "conn-archived", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "shared", - status: "archived", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }], + connections: [notionConnection("conn-archived", "shared", "archived")], }; if (failedLookup === "connection") { listConnectionsMock @@ -3204,11 +3083,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "source=notion&applicationId=app-notion&new=1"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); listApplicationsMock.mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "active", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("active")], }); listConnectionsMock.mockResolvedValueOnce({ connections: [{ @@ -3279,22 +3154,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(startOAuthMock).not.toHaveBeenCalled(); resolveApplications({ - applications: [{ - id: "app-notion", - status: "draft", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("draft")], }); resolveConnections({ - connections: [{ - id: "conn-refreshed", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "per_user", - status: "draft", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }], + connections: [notionConnection("conn-refreshed", "per_user", "draft")], }); await flushReact(); await flushReact(); @@ -3316,24 +3179,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { listApplicationsMock .mockRejectedValueOnce(new Error("Lookup unavailable")) .mockResolvedValueOnce({ - applications: [{ - id: "app-notion", - status: "draft", - metadata: { sourceTemplateKey: "notion" }, - }], + applications: [notionApplication("draft")], }); listConnectionsMock .mockResolvedValueOnce({ connections: [] }) .mockResolvedValueOnce({ - connections: [{ - id: "conn-after-retry", - applicationId: "app-notion", - authKind: "oauth", - credentialPolicy: "per_user", - status: "draft", - config: { sourceTemplateKey: "notion" }, - transportConfig: {}, - }], + connections: [notionConnection("conn-after-retry", "per_user", "draft")], }); await render(); @@ -3392,12 +3243,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); startOAuthMock .mockRejectedValueOnce(new Error("Provider unavailable")) - .mockResolvedValueOnce({ - connectionId: "conn-prepared", - provider: "notion", - authorizationUrl: "https://mcp.notion.com/authorize?state=retry", - expiresAt: "2099-01-01T00:00:00.000Z", - }); + .mockResolvedValueOnce(oauthStartResponse("conn-prepared", "notion", "https://mcp.notion.com/authorize?state=retry")); await render(); await passAccessStep(); @@ -3445,12 +3291,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }], }); connectAppMock.mockRejectedValueOnce(new Error("Response lost")); - startOAuthMock.mockResolvedValueOnce({ - connectionId: "conn-response-lost", - provider: "notion", - authorizationUrl: "https://mcp.notion.com/authorize?state=recovered", - expiresAt: "2099-01-01T00:00:00.000Z", - }); + startOAuthMock.mockResolvedValueOnce(oauthStartResponse("conn-response-lost", "notion", "https://mcp.notion.com/authorize?state=recovered")); await render(); await passAccessStep(); @@ -3921,31 +3762,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); }); - it("uses the gallery app name automatically", async () => { - await render(); - - await act(async () => { - buttonContaining("GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - await passAccessStep(); - - const keyField = container.querySelector("input[type=password]"); - await act(async () => setInputValue(keyField!, "secret-key")); - await flushReact(); - await act(async () => { - buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushReact(); - - expect(connectAppMock).toHaveBeenCalledTimes(1); - const [, input] = connectAppMock.mock.calls[0]; - expect(input).toMatchObject({ - galleryKey: "github", - name: "GitHub for the organization", - }); - }); - it("uses the Google Sheets app name automatically", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ @@ -4045,12 +3861,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { listAgentsMock.mockResolvedValue([]); finishAppMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); - startOAuthMock.mockResolvedValue({ - connectionId: "conn-1", - provider: "mcp_example_test", - authorizationUrl: "https://auth.example.test/authorize?state=abc", - expiresAt: "2099-01-01T00:00:00.000Z", - }); + startOAuthMock.mockResolvedValue(oauthStartResponse("conn-1", "mcp_example_test", "https://auth.example.test/authorize?state=abc")); connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "mcp.example.test" }, @@ -4410,12 +4221,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { suggestedDefaults: {}, auth: { kind: "oauth", startUrl: null }, }); - startOAuthMock.mockResolvedValue({ - connectionId: "conn-1", - provider: "mcp_example_test", - authorizationUrl, - expiresAt: "2099-01-01T00:00:00.000Z", - }); + startOAuthMock.mockResolvedValue(oauthStartResponse("conn-1", "mcp_example_test", authorizationUrl)); await render(); await gotoLinkFrame(container, "https://mcp.example.test/mcp"); await act(async () => {