@gajae-code/coding-agent 0.18.7 → 0.18.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. package/CHANGELOG.md +377 -1
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/job-manager.d.ts +1 -1
  4. package/dist/types/async/support.d.ts +0 -14
  5. package/dist/types/capability/mcp.d.ts +2 -0
  6. package/dist/types/capability/skill.d.ts +2 -0
  7. package/dist/types/capability/types.d.ts +2 -0
  8. package/dist/types/cli/public-command-errors.d.ts +3 -1
  9. package/dist/types/cli/setup-cli.d.ts +1 -1
  10. package/dist/types/commands/deep-interview.d.ts +3 -0
  11. package/dist/types/commands/mcp-serve.d.ts +4 -0
  12. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  13. package/dist/types/config/file-lock.d.ts +1 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  15. package/dist/types/config/model-profile-activation.d.ts +11 -3
  16. package/dist/types/config/model-registry.d.ts +4 -9
  17. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  18. package/dist/types/cursor-delete-path.d.ts +1 -0
  19. package/dist/types/cursor-mcp-name.d.ts +8 -0
  20. package/dist/types/customization/inventory.d.ts +2 -0
  21. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  24. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  25. package/dist/types/discovery/helpers.d.ts +6 -1
  26. package/dist/types/discovery/mcp-json.d.ts +1 -0
  27. package/dist/types/eval/py/kernel.d.ts +18 -0
  28. package/dist/types/export/html/css-color.d.ts +2 -0
  29. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  30. package/dist/types/export/html/template.generated.d.ts +1 -1
  31. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  32. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  33. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  34. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  35. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  36. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  37. package/dist/types/extensibility/skill-management.d.ts +4 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  40. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  41. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  42. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  44. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  45. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  46. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  47. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  48. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  49. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  50. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  51. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  52. package/dist/types/lsp/config.d.ts +8 -5
  53. package/dist/types/lsp/workspace-path.d.ts +1 -0
  54. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  55. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  56. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  57. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  58. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  59. package/dist/types/modes/interactive-mode.d.ts +1 -0
  60. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  61. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  62. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  63. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  64. package/dist/types/progress/progress-contract.d.ts +154 -0
  65. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  66. package/dist/types/progress/project-progress.d.ts +141 -0
  67. package/dist/types/progress/render-progress.d.ts +28 -0
  68. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  69. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  70. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -0
  71. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  72. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  73. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  74. package/dist/types/runtime-mcp/types.d.ts +6 -0
  75. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  76. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  77. package/dist/types/sdk/broker/broker.d.ts +24 -2
  78. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  79. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  80. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  81. package/dist/types/sdk/bus/index.d.ts +12 -1
  82. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  83. package/dist/types/sdk/client/client.d.ts +2 -0
  84. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  85. package/dist/types/sdk/index.d.ts +1 -0
  86. package/dist/types/sdk/router/session-router.d.ts +2 -0
  87. package/dist/types/sdk/session-list.d.ts +1 -1
  88. package/dist/types/sdk/session.d.ts +8 -0
  89. package/dist/types/session/agent-session.d.ts +38 -9
  90. package/dist/types/session/artifacts.d.ts +2 -2
  91. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  92. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  93. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  94. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  95. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  96. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  97. package/dist/types/session/session-manager.d.ts +31 -5
  98. package/dist/types/session/session-retirement.d.ts +31 -7
  99. package/dist/types/session/session-storage.d.ts +69 -2
  100. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  101. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  102. package/dist/types/setup/credential-import.d.ts +6 -4
  103. package/dist/types/skill-state/active-state.d.ts +38 -0
  104. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  105. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  106. package/dist/types/task/executor.d.ts +3 -0
  107. package/dist/types/task/isolation-dir.d.ts +26 -0
  108. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  109. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  110. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  111. package/dist/types/tools/github-side-effect.d.ts +22 -0
  112. package/dist/types/tools/index.d.ts +3 -0
  113. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  114. package/dist/types/tools/python.d.ts +9 -1
  115. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  116. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  117. package/dist/types/tools/todo-write.d.ts +6 -0
  118. package/dist/types/utils/tools-checksum.d.ts +7 -0
  119. package/package.json +7 -7
  120. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  121. package/scripts/verify-managed-empty-stop.ts +28 -0
  122. package/src/async/endpoint-id.ts +26 -0
  123. package/src/async/job-manager.ts +247 -132
  124. package/src/async/support.ts +0 -25
  125. package/src/capability/index.ts +15 -0
  126. package/src/capability/mcp.ts +2 -0
  127. package/src/capability/skill.ts +2 -0
  128. package/src/capability/types.ts +2 -0
  129. package/src/cli/auth-broker-cli.ts +1 -1
  130. package/src/cli/customize-doctor.ts +15 -2
  131. package/src/cli/doctor/repairs.ts +4 -1
  132. package/src/cli/doctor-supervisor.ts +3 -0
  133. package/src/cli/public-command-errors.ts +21 -1
  134. package/src/cli/public-command-registry.ts +28 -6
  135. package/src/cli/setup-cli.ts +4 -4
  136. package/src/cli/skills-cli.ts +5 -0
  137. package/src/commands/deep-interview.ts +47 -8
  138. package/src/commands/mcp-serve.ts +7 -0
  139. package/src/commands/sdk.ts +455 -397
  140. package/src/config/autorouting-tier-map.ts +117 -7
  141. package/src/config/file-lock.ts +316 -79
  142. package/src/config/mcp-schema.json +4 -0
  143. package/src/config/model-bindings-applier.ts +20 -0
  144. package/src/config/model-profile-activation.ts +240 -46
  145. package/src/config/model-registry.ts +47 -153
  146. package/src/config/skill-settings-defaults.ts +7 -0
  147. package/src/coordinator-mcp/policy.ts +3 -2
  148. package/src/coordinator-mcp/server.ts +2 -0
  149. package/src/cursor-delete-path.ts +10 -0
  150. package/src/cursor-mcp-name.ts +15 -0
  151. package/src/cursor.ts +13 -2
  152. package/src/customization/inventory.ts +4 -13
  153. package/src/dap/config.ts +2 -2
  154. package/src/debug/report-bundle-parent.ts +22 -0
  155. package/src/debug/report-bundle.ts +3 -0
  156. package/src/debug/system-info.ts +13 -1
  157. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  158. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  159. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  160. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  161. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  162. package/src/defaults/gjc-defaults.test.ts +18 -1
  163. package/src/defaults/gjc-defaults.ts +3 -0
  164. package/src/defaults/gjc-skills.generated.ts +58 -36
  165. package/src/discovery/agents.ts +6 -1
  166. package/src/discovery/builtin.ts +13 -1
  167. package/src/discovery/helpers.ts +171 -29
  168. package/src/discovery/mcp-json.ts +26 -12
  169. package/src/discovery/ssh.ts +1 -1
  170. package/src/discovery/substitute-plugin-root.ts +3 -1
  171. package/src/edit/file-read-cache.ts +1 -1
  172. package/src/eval/py/executor.ts +796 -154
  173. package/src/eval/py/kernel.ts +79 -13
  174. package/src/eval/py/tool-bridge.ts +4 -0
  175. package/src/export/html/css-color.ts +9 -0
  176. package/src/export/html/html-export-destination.ts +16 -0
  177. package/src/export/html/index.ts +7 -10
  178. package/src/export/html/template.generated.ts +1 -1
  179. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  180. package/src/extensibility/extensions/runner.ts +15 -1
  181. package/src/extensibility/extensions/types.ts +7 -0
  182. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  183. package/src/extensibility/gjc-plugins/runtime-adapters.ts +85 -8
  184. package/src/extensibility/plugins/manager.ts +3 -2
  185. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  186. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  187. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  188. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  189. package/src/extensibility/skill-management.ts +9 -2
  190. package/src/extensibility/skills.ts +6 -1
  191. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  192. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  193. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  194. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  195. package/src/gjc-runtime/gc-runtime.ts +206 -9
  196. package/src/gjc-runtime/launch-worktree.ts +70 -9
  197. package/src/gjc-runtime/psmux-detect.ts +28 -4
  198. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  199. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  200. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  201. package/src/gjc-runtime/state-argv.ts +2 -0
  202. package/src/gjc-runtime/state-migrations.ts +91 -29
  203. package/src/gjc-runtime/state-runtime.ts +3716 -135
  204. package/src/gjc-runtime/state-writer.ts +105 -16
  205. package/src/gjc-runtime/tmux-common.ts +8 -5
  206. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  207. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  208. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  209. package/src/harness-control-plane/operate.ts +12 -4
  210. package/src/harness-control-plane/owner.ts +5 -1
  211. package/src/hashline/parser.ts +48 -9
  212. package/src/hashline/recovery.ts +13 -4
  213. package/src/internal-urls/docs-index.generated.ts +20 -19
  214. package/src/internal-urls/skill-path.ts +19 -0
  215. package/src/internal-urls/skill-protocol.ts +2 -0
  216. package/src/lsp/config.ts +12 -63
  217. package/src/lsp/edits.ts +6 -0
  218. package/src/lsp/workspace-path.ts +51 -0
  219. package/src/main.ts +195 -31
  220. package/src/memories/index.ts +88 -7
  221. package/src/modes/acp/acp-agent.ts +88 -4
  222. package/src/modes/components/custom-editor.ts +14 -0
  223. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  224. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  225. package/src/modes/components/extensions/state-manager.ts +15 -5
  226. package/src/modes/components/hook-editor.ts +1 -1
  227. package/src/modes/components/hook-selector.ts +1 -1
  228. package/src/modes/components/model-selector.ts +1 -4
  229. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  230. package/src/modes/components/status-line/segments.ts +4 -0
  231. package/src/modes/controllers/event-controller.ts +11 -4
  232. package/src/modes/controllers/input-controller.ts +76 -31
  233. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -16
  234. package/src/modes/controllers/selector-controller.ts +24 -15
  235. package/src/modes/controllers/todo-command-controller.ts +1 -1
  236. package/src/modes/interactive-mode.ts +5 -1
  237. package/src/modes/runtime-init.ts +4 -0
  238. package/src/modes/utils/abort-message.ts +3 -1
  239. package/src/modes/utils/interrupt-ownership.ts +12 -0
  240. package/src/modes/utils/keybinding-matchers.ts +4 -0
  241. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  242. package/src/progress/collect-project-progress.ts +153 -0
  243. package/src/progress/progress-contract.ts +172 -0
  244. package/src/progress/progress-snapshot.ts +140 -0
  245. package/src/progress/project-progress.ts +446 -0
  246. package/src/progress/render-progress.ts +209 -0
  247. package/src/prompts/system/master-mode.md +1 -0
  248. package/src/prompts/tools/skill-discovery.md +1 -1
  249. package/src/runtime-mcp/client.ts +4 -2
  250. package/src/runtime-mcp/config-writer.ts +10 -6
  251. package/src/runtime-mcp/config.ts +1 -0
  252. package/src/runtime-mcp/json-rpc.ts +2 -1
  253. package/src/runtime-mcp/manager.ts +29 -11
  254. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  255. package/src/runtime-mcp/oauth-flow.ts +14 -2
  256. package/src/runtime-mcp/oauth-public-url.ts +14 -0
  257. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  258. package/src/runtime-mcp/pool.ts +6 -0
  259. package/src/runtime-mcp/project-host-values.ts +46 -0
  260. package/src/runtime-mcp/smithery-registry.ts +22 -3
  261. package/src/runtime-mcp/transports/http.ts +4 -3
  262. package/src/runtime-mcp/types.ts +6 -0
  263. package/src/sdk/acp/adapter.ts +95 -8
  264. package/src/sdk/broker/broker-exit.ts +52 -23
  265. package/src/sdk/broker/broker.ts +81 -6
  266. package/src/sdk/broker/doctor-restart.ts +4 -0
  267. package/src/sdk/broker/ensure.ts +31 -2
  268. package/src/sdk/broker/lifecycle.ts +711 -66
  269. package/src/sdk/broker/session-index.ts +191 -81
  270. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  271. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  272. package/src/sdk/bus/index.ts +238 -43
  273. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  274. package/src/sdk/cli/session-cli.ts +17 -3
  275. package/src/sdk/client/client.ts +28 -8
  276. package/src/sdk/host/query/handlers.ts +9 -1
  277. package/src/sdk/host/session-runtime.test.ts +2705 -303
  278. package/src/sdk/host/session-runtime.ts +490 -143
  279. package/src/sdk/host/surface-policy.ts +1 -0
  280. package/src/sdk/index.ts +8 -0
  281. package/src/sdk/lifecycle-session.ts +3 -1
  282. package/src/sdk/prompt-deadline-manager.ts +15 -0
  283. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  284. package/src/sdk/protocol/operation-registry.ts +9 -2
  285. package/src/sdk/router/session-router.ts +82 -18
  286. package/src/sdk/session-list.ts +1 -0
  287. package/src/sdk/session.ts +69 -45
  288. package/src/session/agent-session.ts +679 -238
  289. package/src/session/artifacts.ts +176 -71
  290. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  291. package/src/session/internal/managed-session-scope.ts +3779 -462
  292. package/src/session/internal/managed-session-storage.ts +501 -78
  293. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  294. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  295. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  296. package/src/session/session-manager.ts +951 -133
  297. package/src/session/session-retirement.ts +573 -14
  298. package/src/session/session-storage.ts +447 -15
  299. package/src/session/task-artifact-owner-codec.ts +71 -8
  300. package/src/session/task-artifact-owner-retirement.ts +21 -19
  301. package/src/setup/credential-auto-import.ts +12 -3
  302. package/src/setup/credential-import.ts +176 -5
  303. package/src/setup/paseo/announce.ts +6 -1
  304. package/src/skill-state/active-state.ts +145 -2
  305. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  306. package/src/slash-commands/builtin-registry.ts +39 -9
  307. package/src/slash-commands/helpers/mcp.ts +8 -4
  308. package/src/slash-commands/helpers/progress-report.ts +23 -0
  309. package/src/stt/setup.ts +3 -5
  310. package/src/task/commands.ts +3 -1
  311. package/src/task/executor.ts +4 -0
  312. package/src/task/index.ts +6 -0
  313. package/src/task/isolation-dir.ts +143 -0
  314. package/src/task/worktree.ts +16 -7
  315. package/src/tools/ask.ts +205 -6
  316. package/src/tools/ast-edit-write-path.ts +68 -0
  317. package/src/tools/ast-edit.ts +10 -1
  318. package/src/tools/bash-allowed-prefixes.ts +15 -0
  319. package/src/tools/bash-skill-urls.ts +56 -3
  320. package/src/tools/bash.ts +48 -79
  321. package/src/tools/browser/cdp-url.ts +18 -0
  322. package/src/tools/browser.ts +8 -1
  323. package/src/tools/descriptors.ts +5 -1
  324. package/src/tools/eval.ts +56 -36
  325. package/src/tools/gh.ts +10 -0
  326. package/src/tools/github-side-effect.ts +112 -0
  327. package/src/tools/image-gen.ts +1 -1
  328. package/src/tools/index.ts +3 -0
  329. package/src/tools/monitor-mutation.ts +27 -0
  330. package/src/tools/monitor.ts +7 -0
  331. package/src/tools/python.ts +188 -66
  332. package/src/tools/recipe/index.ts +9 -0
  333. package/src/tools/recipe/recipe-guard.ts +19 -0
  334. package/src/tools/restricted-cwd.ts +10 -0
  335. package/src/tools/skill-discovery.ts +5 -0
  336. package/src/tools/skill.ts +28 -1
  337. package/src/tools/todo-write.ts +10 -1
  338. package/src/tools/tool-catalog.generated.ts +1 -1
  339. package/src/tools/write.ts +1 -0
  340. package/src/utils/external-editor.ts +3 -2
  341. package/src/utils/tools-checksum.ts +34 -0
  342. package/src/utils/tools-manager.ts +24 -6
  343. package/src/web/insane/url-guard.ts +2 -1
  344. package/src/web/search/providers/codex.ts +3 -2
@@ -1,14 +1,25 @@
1
1
  import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test";
2
2
  import { createHash } from "node:crypto";
3
+ import * as fsPromises from "node:fs/promises";
3
4
  import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
6
- import { markNonDispatchedToolEvent, ThinkingLevel } from "@gajae-code/agent-core";
7
+ import { Agent, type AgentOptions, markNonDispatchedToolEvent, ThinkingLevel } from "@gajae-code/agent-core";
8
+ import { createAttemptMinter } from "@gajae-code/agent-core/attempt-scope";
9
+ import { CompactionCancelledError } from "@gajae-code/agent-core/compaction";
10
+ import { type AssistantMessage, getBundledModel, type Model, type UserMessage } from "@gajae-code/ai/core";
11
+ import { createMockModel } from "@gajae-code/ai/providers/mock";
12
+ import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream";
7
13
  import { logger } from "@gajae-code/utils";
8
14
  import { createTestSession } from "../../../test/utilities";
9
15
  import { AsyncJobManager } from "../../async";
10
- import type { Settings } from "../../config/settings";
16
+ import { ModelRegistry } from "../../config/model-registry";
17
+ import { Settings } from "../../config/settings";
11
18
  import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
19
+ import { ExtensionRunner } from "../../extensibility/extensions/runner";
20
+ import { AgentSession } from "../../session/agent-session";
21
+ import { AuthStorage } from "../../session/auth-storage";
22
+ import { SessionManager } from "../../session/session-manager";
12
23
  import {
13
24
  registerOwnedRegistration,
14
25
  resetTerminalAbortRegistriesForTests,
@@ -16,14 +27,21 @@ import {
16
27
  unregisterOwnedRegistration,
17
28
  } from "../../session/terminal-abort";
18
29
  import { Broker } from "../broker/broker";
30
+ import type { BrokerDiscovery } from "../broker/discovery";
31
+ import { SessionIndex, type SessionIndexEvent } from "../broker/session-index";
19
32
  import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
20
33
  import { createPromptReconciliation } from "../bus/prompt-reconciliation";
21
- import { createReconciliationStore } from "../bus/reconciliation-store";
34
+ import {
35
+ createReconciliationStore,
36
+ type ReconciliationStoreDocument,
37
+ reconciliationStorePath,
38
+ } from "../bus/reconciliation-store";
22
39
  import { PromptDeadlineManager } from "../prompt-deadline-manager";
23
40
  import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
24
41
  import { SESSION_HOST_OBSERVER_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
25
42
  import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
26
43
  import {
44
+ type CreateSdkSessionRuntimeOptions,
27
45
  createInvocationReconciliation,
28
46
  createSdkSessionRuntimeExtension,
29
47
  createSdkSurfaceFactory,
@@ -1693,7 +1711,7 @@ describe("SessionSdkSessionRuntime", () => {
1693
1711
  sendUserMessage: async () => {},
1694
1712
  } as unknown as ExtensionAPI;
1695
1713
  const transport = memoryTransport();
1696
- createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1714
+ createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1697
1715
  const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
1698
1716
  const ctx = {
1699
1717
  ...extensionContext(transport.sessionId, cwd),
@@ -1749,7 +1767,7 @@ describe("SessionSdkSessionRuntime", () => {
1749
1767
  }
1750
1768
  broadcastFrame?.(frame);
1751
1769
  };
1752
- createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1770
+ createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1753
1771
  const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
1754
1772
  const ctx = {
1755
1773
  ...extensionContext(transport.sessionId, cwd),
@@ -1810,7 +1828,7 @@ describe("SessionSdkSessionRuntime", () => {
1810
1828
  let activeHandle: string | undefined = "exact-run-handle";
1811
1829
  let activeEpoch: number | undefined = 7;
1812
1830
  let captureCalls = 0;
1813
- createSdkSessionRuntimeExtension(api, {
1831
+ createTestRuntimeExtension(api, {
1814
1832
  agentDir: cwd,
1815
1833
  createTransport: async () => transport,
1816
1834
  onSdkRequest: undefined,
@@ -2009,7 +2027,7 @@ describe("SessionSdkSessionRuntime", () => {
2009
2027
  });
2010
2028
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2011
2029
  let captureCalls = 0;
2012
- createSdkSessionRuntimeExtension(api, {
2030
+ createTestRuntimeExtension(api, {
2013
2031
  agentDir: cwd,
2014
2032
  createTransport: async () => transport,
2015
2033
  terminalAbortSeams: {
@@ -2106,7 +2124,7 @@ describe("SessionSdkSessionRuntime", () => {
2106
2124
  // exactly the window where writeNoEffect awaits the store.
2107
2125
  let promptReads = 0;
2108
2126
  let captureCalls = 0;
2109
- createSdkSessionRuntimeExtension(api, {
2127
+ createTestRuntimeExtension(api, {
2110
2128
  agentDir: cwd,
2111
2129
  createTransport: async () => transport,
2112
2130
  terminalAbortSeams: {
@@ -2191,7 +2209,7 @@ describe("SessionSdkSessionRuntime", () => {
2191
2209
  });
2192
2210
  const seamCalls: string[] = [];
2193
2211
  let promptReads = 0;
2194
- createSdkSessionRuntimeExtension(api, {
2212
+ createTestRuntimeExtension(api, {
2195
2213
  agentDir: cwd,
2196
2214
  createTransport: async () => transport,
2197
2215
  terminalAbortSeams: {
@@ -2261,7 +2279,7 @@ describe("SessionSdkSessionRuntime", () => {
2261
2279
  // (review thread P1).
2262
2280
  let reads = 0;
2263
2281
  let captureCalls = 0;
2264
- createSdkSessionRuntimeExtension(api, {
2282
+ createTestRuntimeExtension(api, {
2265
2283
  agentDir: cwd,
2266
2284
  createTransport: async () => transport,
2267
2285
  terminalAbortSeams: {
@@ -2354,7 +2372,7 @@ describe("SessionSdkSessionRuntime", () => {
2354
2372
  sessionId: transport.sessionId,
2355
2373
  });
2356
2374
  let captureCalls = 0;
2357
- createSdkSessionRuntimeExtension(api, {
2375
+ createTestRuntimeExtension(api, {
2358
2376
  agentDir: cwd,
2359
2377
  createTransport: async () => transport,
2360
2378
  terminalAbortSeams: {
@@ -2421,7 +2439,7 @@ describe("SessionSdkSessionRuntime", () => {
2421
2439
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2422
2440
  let seamCount = 0;
2423
2441
  let captureCalls = 0;
2424
- createSdkSessionRuntimeExtension(api, {
2442
+ createTestRuntimeExtension(api, {
2425
2443
  agentDir: cwd,
2426
2444
  createTransport: async () => transport,
2427
2445
  terminalAbortSeams: {
@@ -2530,7 +2548,7 @@ describe("SessionSdkSessionRuntime", () => {
2530
2548
  let activeHandle = "predecessor";
2531
2549
  let abortCalls = 0;
2532
2550
  const abortRelease = Promise.withResolvers<void>();
2533
- createSdkSessionRuntimeExtension(api, {
2551
+ createTestRuntimeExtension(api, {
2534
2552
  agentDir: cwd,
2535
2553
  createTransport: async () => transport,
2536
2554
  terminalAbortSeams: {
@@ -2618,7 +2636,7 @@ describe("SessionSdkSessionRuntime", () => {
2618
2636
  sessionFile: path.join(cwd, "session.json"),
2619
2637
  sessionId: transport.sessionId,
2620
2638
  });
2621
- createSdkSessionRuntimeExtension(api, {
2639
+ createTestRuntimeExtension(api, {
2622
2640
  agentDir: cwd,
2623
2641
  createTransport: async () => transport,
2624
2642
  terminalAbortSeams: {
@@ -2665,24 +2683,38 @@ describe("SessionSdkSessionRuntime", () => {
2665
2683
  }
2666
2684
  });
2667
2685
 
2668
- test("SDK-only host cancels only the preflights admitted at abort time, never a pipelined successor", async () => {
2686
+ test.each([
2687
+ "ordinary",
2688
+ "terminal",
2689
+ ] as const)("SDK-only %s abort cancels only its admitted preflight snapshot", async mode => {
2669
2690
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-preflight-snapshot-"));
2691
+ const signals = new Map<string, AbortSignal>();
2692
+ const acceptCallbacks = new Map<string, () => void | Promise<void>>();
2693
+ let rootAbortCalls = 0;
2670
2694
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2671
2695
  const api = {
2672
2696
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2673
2697
  handlers.set(event, handler);
2674
2698
  },
2675
- // Hold every prompt preflight open: acceptance is never signalled, so
2676
- // each submission stays pending until the abort (or nothing) settles it.
2677
- // Deferred via Promise.withResolvers per the repository contract.
2678
- sendUserMessage: () => Promise.withResolvers<void>().promise,
2699
+ // Keep actual admission capabilities pending, rather than inventing
2700
+ // an acceptance or terminal. Record before the runtime can reject.
2701
+ sendUserMessage: (
2702
+ content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
2703
+ options: PreflightHooks | undefined,
2704
+ ) => {
2705
+ const text = typeof content === "string" ? content : content.find(block => block.type === "text")?.text;
2706
+ if (text !== undefined && options?.preflightSignal) signals.set(text, options.preflightSignal);
2707
+ if (text !== undefined && options?.onPreflightAcceptCommit)
2708
+ acceptCallbacks.set(text, options.onPreflightAcceptCommit);
2709
+ return Promise.withResolvers<void>().promise;
2710
+ },
2679
2711
  } as unknown as ExtensionAPI;
2680
2712
  const transport = memoryTransport();
2681
2713
  const reconciliationStore = createReconciliationStore({
2682
2714
  sessionFile: path.join(cwd, "session.json"),
2683
2715
  sessionId: transport.sessionId,
2684
2716
  });
2685
- createSdkSessionRuntimeExtension(api, {
2717
+ createTestRuntimeExtension(api, {
2686
2718
  agentDir: cwd,
2687
2719
  createTransport: async () => transport,
2688
2720
  terminalAbortSeams: {
@@ -2697,7 +2729,12 @@ describe("SessionSdkSessionRuntime", () => {
2697
2729
  },
2698
2730
  },
2699
2731
  });
2700
- const ctx = extensionContext(transport.sessionId, cwd);
2732
+ const ctx = {
2733
+ ...extensionContext(transport.sessionId, cwd),
2734
+ abort: async () => {
2735
+ rootAbortCalls++;
2736
+ },
2737
+ };
2701
2738
  try {
2702
2739
  await handlers.get("session_start")?.({}, ctx);
2703
2740
  const prompt = (id: string) =>
@@ -2708,14 +2745,22 @@ describe("SessionSdkSessionRuntime", () => {
2708
2745
  input: { text: `hold-${id}`, images: [] },
2709
2746
  } as SdkFrame);
2710
2747
  prompt("pre-1");
2711
- // Let the serialized (ordered) turn.prompt work register its preflight
2712
- // callback, so the abort's admission snapshot below sees it.
2713
- await Bun.sleep(0);
2748
+ transport.feed("conn-b", {
2749
+ type: "control_request",
2750
+ id: "foreign-preflight",
2751
+ operation: "turn.prompt",
2752
+ input: { text: "foreign-pending", images: [] },
2753
+ } as SdkFrame);
2754
+ const readyDeadline = Date.now() + 2_000;
2755
+ while (!signals.has("hold-pre-1")) {
2756
+ if (Date.now() >= readyDeadline) throw new Error("Owned preflight was not admitted.");
2757
+ await Bun.sleep(10);
2758
+ }
2714
2759
  transport.feed("conn-a", {
2715
2760
  type: "control_request",
2716
2761
  id: "pre-abort",
2717
2762
  operation: "turn.abort",
2718
- input: { mode: "terminal" },
2763
+ input: mode === "terminal" ? { mode: "terminal" } : {},
2719
2764
  idempotencyKey: "pre-snapshot-key",
2720
2765
  } as SdkFrame);
2721
2766
  // A successor prompt pipelined by the SAME connection while the abort
@@ -2725,12 +2770,30 @@ describe("SessionSdkSessionRuntime", () => {
2725
2770
  const deadline = Date.now() + 15_000;
2726
2771
  while (
2727
2772
  !transport.sent.some(frame => frame.id === "pre-abort" && frame.type === "control_response") ||
2728
- !transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response")
2773
+ !transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response") ||
2774
+ !signals.has("foreign-pending")
2729
2775
  ) {
2730
2776
  if (Date.now() > deadline)
2731
2777
  throw new Error("Timed out waiting for the abort and admitted-preflight responses");
2732
2778
  await Bun.sleep(20);
2733
2779
  }
2780
+ expect(rootAbortCalls).toBe(0);
2781
+ expect(signals.get("hold-pre-1")?.aborted).toBe(true);
2782
+ expect(signals.get("foreign-pending")?.aborted).toBe(false);
2783
+ // Prompt controls are serialized. Commit the untouched foreign
2784
+ // admission through its real callback so the later request can enter.
2785
+ const acceptForeign = acceptCallbacks.get("foreign-pending");
2786
+ expect(acceptForeign).toBeDefined();
2787
+ await acceptForeign!();
2788
+ while (!signals.has("hold-pre-2")) {
2789
+ if (Date.now() > deadline) throw new Error("Later admission did not enter after foreign acceptance.");
2790
+ await Bun.sleep(10);
2791
+ }
2792
+ expect(signals.get("hold-pre-2")?.aborted).toBe(false);
2793
+ expect(transport.sent.find(frame => frame.id === "foreign-preflight")).toMatchObject({
2794
+ ok: true,
2795
+ result: { accepted: true },
2796
+ });
2734
2797
  // The admitted preflight is cancelled by the abort.
2735
2798
  expect(transport.sent.find(frame => frame.id === "pre-1")).toMatchObject({
2736
2799
  ok: false,
@@ -2738,7 +2801,7 @@ describe("SessionSdkSessionRuntime", () => {
2738
2801
  });
2739
2802
  expect(transport.sent.find(frame => frame.id === "pre-abort")).toMatchObject({
2740
2803
  ok: true,
2741
- result: expect.objectContaining({ turn: "no_active_turn" }),
2804
+ result: expect.objectContaining(mode === "terminal" ? { turn: "no_active_turn" } : { aborted: true }),
2742
2805
  });
2743
2806
  // The successor preflight was NOT part of the abort's snapshot: it is
2744
2807
  // neither cancelled nor failed, so no control response is emitted for it.
@@ -2763,7 +2826,7 @@ describe("SessionSdkSessionRuntime", () => {
2763
2826
  sessionId: transport.sessionId,
2764
2827
  });
2765
2828
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2766
- createSdkSessionRuntimeExtension(api, {
2829
+ createTestRuntimeExtension(api, {
2767
2830
  agentDir: cwd,
2768
2831
  createTransport: async () => transport,
2769
2832
  terminalAbortSeams: {
@@ -2881,7 +2944,7 @@ describe("SessionSdkSessionRuntime", () => {
2881
2944
  sessionId: transport.sessionId,
2882
2945
  });
2883
2946
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2884
- createSdkSessionRuntimeExtension(api, {
2947
+ createTestRuntimeExtension(api, {
2885
2948
  agentDir: cwd,
2886
2949
  createTransport: async () => transport,
2887
2950
  terminalAbortSeams: {
@@ -2972,12 +3035,10 @@ describe("SessionSdkSessionRuntime", () => {
2972
3035
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2973
3036
  handlers.set(event, handler);
2974
3037
  },
2975
- sendUserMessage: (
2976
- _content: string,
2977
- options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
2978
- ) =>
3038
+ sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
2979
3039
  Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
2980
3040
  options?.onPreflightAccepted?.();
3041
+ if (!idle) options?.onQueuedPromoted?.({ startsOwnRun: false });
2981
3042
  return {};
2982
3043
  }),
2983
3044
  } as unknown as ExtensionAPI;
@@ -2988,7 +3049,7 @@ describe("SessionSdkSessionRuntime", () => {
2988
3049
  });
2989
3050
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2990
3051
  let idle = true;
2991
- createSdkSessionRuntimeExtension(api, {
3052
+ createTestRuntimeExtension(api, {
2992
3053
  agentDir: cwd,
2993
3054
  createTransport: async () => transport,
2994
3055
  terminalAbortSeams: {
@@ -3067,11 +3128,18 @@ describe("SessionSdkSessionRuntime", () => {
3067
3128
  handlers.set(event, handler);
3068
3129
  },
3069
3130
  sendUserMessage: (
3070
- _content: string,
3071
- options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
3131
+ content: string,
3132
+ options:
3133
+ | {
3134
+ onPreflightAccepted?: () => void;
3135
+ onPreflightAcceptCommit?: () => void;
3136
+ onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
3137
+ }
3138
+ | undefined,
3072
3139
  ) =>
3073
3140
  Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
3074
3141
  options?.onPreflightAccepted?.();
3142
+ if (content === "follow up b") options?.onQueuedPromoted?.({ startsOwnRun: false });
3075
3143
  return {};
3076
3144
  }),
3077
3145
  } as unknown as ExtensionAPI;
@@ -3082,7 +3150,7 @@ describe("SessionSdkSessionRuntime", () => {
3082
3150
  });
3083
3151
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3084
3152
  let idle = true;
3085
- createSdkSessionRuntimeExtension(api, {
3153
+ createTestRuntimeExtension(api, {
3086
3154
  agentDir: cwd,
3087
3155
  createTransport: async () => transport,
3088
3156
  terminalAbortSeams: {
@@ -3241,7 +3309,7 @@ describe("SessionSdkSessionRuntime", () => {
3241
3309
  sessionId: transport.sessionId,
3242
3310
  });
3243
3311
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3244
- createSdkSessionRuntimeExtension(api, {
3312
+ createTestRuntimeExtension(api, {
3245
3313
  agentDir: cwd,
3246
3314
  createTransport: async () => transport,
3247
3315
  terminalAbortSeams: {
@@ -3361,7 +3429,7 @@ describe("SessionSdkSessionRuntime", () => {
3361
3429
  sessionId: transport.sessionId,
3362
3430
  });
3363
3431
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3364
- createSdkSessionRuntimeExtension(api, {
3432
+ createTestRuntimeExtension(api, {
3365
3433
  agentDir: cwd,
3366
3434
  createTransport: async () => transport,
3367
3435
  terminalAbortSeams: {
@@ -3423,7 +3491,7 @@ describe("SessionSdkSessionRuntime", () => {
3423
3491
  sessionFile: path.join(cwd, "session.json"),
3424
3492
  sessionId: transport.sessionId,
3425
3493
  });
3426
- createSdkSessionRuntimeExtension(api, {
3494
+ createTestRuntimeExtension(api, {
3427
3495
  agentDir: cwd,
3428
3496
  createTransport: async () => transport,
3429
3497
  terminalAbortSeams: {
@@ -3514,7 +3582,7 @@ describe("SessionSdkSessionRuntime", () => {
3514
3582
  sessionFile: path.join(cwd, "session.json"),
3515
3583
  sessionId: transport.sessionId,
3516
3584
  });
3517
- createSdkSessionRuntimeExtension(api, {
3585
+ createTestRuntimeExtension(api, {
3518
3586
  agentDir: cwd,
3519
3587
  createTransport: async () => transport,
3520
3588
  terminalAbortSeams: {
@@ -3606,7 +3674,7 @@ describe("SessionSdkSessionRuntime", () => {
3606
3674
  sessionId: transport.sessionId,
3607
3675
  });
3608
3676
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3609
- createSdkSessionRuntimeExtension(api, {
3677
+ createTestRuntimeExtension(api, {
3610
3678
  agentDir: cwd,
3611
3679
  createTransport: async () => transport,
3612
3680
  onFrameAdmitted: admissions.onFrameAdmitted,
@@ -3697,7 +3765,7 @@ describe("SessionSdkSessionRuntime", () => {
3697
3765
  sessionId: transport.sessionId,
3698
3766
  });
3699
3767
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3700
- createSdkSessionRuntimeExtension(api, {
3768
+ createTestRuntimeExtension(api, {
3701
3769
  agentDir: cwd,
3702
3770
  createTransport: async () => transport,
3703
3771
  terminalAbortSeams: {
@@ -3793,7 +3861,7 @@ describe("SessionSdkSessionRuntime", () => {
3793
3861
  };
3794
3862
  registerOwnedRegistration(registration as never, { isJobTerminal: () => false });
3795
3863
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3796
- createSdkSessionRuntimeExtension(api, {
3864
+ createTestRuntimeExtension(api, {
3797
3865
  agentDir: cwd,
3798
3866
  createTransport: async () => transport,
3799
3867
  terminalAbortSeams: {
@@ -3868,7 +3936,7 @@ describe("SessionSdkSessionRuntime", () => {
3868
3936
  sessionFile: path.join(cwd, "session.json"),
3869
3937
  sessionId: transport.sessionId,
3870
3938
  });
3871
- createSdkSessionRuntimeExtension(api, {
3939
+ createTestRuntimeExtension(api, {
3872
3940
  agentDir: cwd,
3873
3941
  createTransport: async () => transport,
3874
3942
  onFrameAdmitted: admissions.onFrameAdmitted,
@@ -3929,7 +3997,7 @@ describe("SessionSdkSessionRuntime", () => {
3929
3997
  sessionFile: path.join(cwd, "session.json"),
3930
3998
  sessionId: transport.sessionId,
3931
3999
  });
3932
- createSdkSessionRuntimeExtension(api, {
4000
+ createTestRuntimeExtension(api, {
3933
4001
  agentDir: cwd,
3934
4002
  createTransport: async () => transport,
3935
4003
  onFrameAdmitted: admissions.onFrameAdmitted,
@@ -4019,7 +4087,7 @@ describe("SessionSdkSessionRuntime", () => {
4019
4087
  },
4020
4088
  } as unknown as ExtensionAPI;
4021
4089
  const transports: Array<{ starts: number; stops: number }> = [];
4022
- createSdkSessionRuntimeExtension(api, {
4090
+ createTestRuntimeExtension(api, {
4023
4091
  agentDir: path.join(cwd, ".gjc", "agent"),
4024
4092
  createTransport: async ({ sessionId, stateRoot, token }) => {
4025
4093
  const stats = { starts: 0, stops: 0 };
@@ -4090,8 +4158,12 @@ describe("SessionSdkSessionRuntime", () => {
4090
4158
  } as any;
4091
4159
  const sessionId = "broker-recovery";
4092
4160
  const endpointUrl = "ws://127.0.0.1:1";
4093
- createSdkSessionRuntimeExtension(api, {
4161
+ createTestRuntimeExtension(api, {
4094
4162
  agentDir,
4163
+ ensureBrokerImpl: async () => {
4164
+ if (!broker) throw Object.assign(new Error("injected unavailable broker"), { code: "acquire_timeout" });
4165
+ return testBrokerDiscovery;
4166
+ },
4095
4167
  createTransport: async ({ stateRoot, token }) => ({
4096
4168
  sessionId,
4097
4169
  stateRoot,
@@ -4116,13 +4188,19 @@ describe("SessionSdkSessionRuntime", () => {
4116
4188
  await chmod(agentDir, 0o700);
4117
4189
  broker = new Broker({ agentDir });
4118
4190
  await broker.start();
4191
+ // Optional registration now completes independently of the event handler.
4192
+ const deadline = Date.now() + 15_000;
4119
4193
  await handlers.get("turn_start")?.({}, context);
4120
- expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 })).toMatchObject(
4121
- {
4122
- ok: true,
4123
- result: { sessionId, token: expect.any(String) },
4124
- },
4125
- );
4194
+ let endpoint = await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 });
4195
+ while (!endpoint.ok && Date.now() < deadline) {
4196
+ await Bun.sleep(20);
4197
+ await handlers.get("turn_start")?.({}, context);
4198
+ endpoint = await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 });
4199
+ }
4200
+ expect(endpoint).toMatchObject({
4201
+ ok: true,
4202
+ result: { sessionId, token: expect.any(String) },
4203
+ });
4126
4204
  await handlers.get("session_shutdown")?.({}, context);
4127
4205
  // DR-1 keeps the unregistered row listed, so the two refusals stay distinct:
4128
4206
  // a matching generation on a terminal row is terminally gone (no endpoint will
@@ -4146,6 +4224,185 @@ describe("SessionSdkSessionRuntime", () => {
4146
4224
  }
4147
4225
  });
4148
4226
 
4227
+ test("optional blocked broker registration leaves startup and turns responsive and fences shutdown", async () => {
4228
+ const gate = Promise.withResolvers<BrokerDiscovery>();
4229
+ let attempts = 0;
4230
+ const harness = await brokerRegistrationHarness({
4231
+ ensureBrokerImpl: () => {
4232
+ attempts += 1;
4233
+ return gate.promise;
4234
+ },
4235
+ });
4236
+ const publication = spyOn(SessionSdkSessionRuntime.prototype, "registerWithBroker");
4237
+ try {
4238
+ await harness.emit("session_start");
4239
+ await Promise.all([harness.emit("turn_start"), harness.emit("turn_start")]);
4240
+ expect(attempts).toBe(1);
4241
+ expect(harness.turnStarts()).toBe(2);
4242
+ // Recovery arms only once the startup attempt settles.
4243
+ expect(harness.recoveryActive()).toBe(false);
4244
+ await harness.emit("session_shutdown");
4245
+ expect(harness.stops()).toBe(1);
4246
+ gate.resolve(testBrokerDiscovery);
4247
+ await Bun.sleep(0);
4248
+ await harness.emit("turn_start");
4249
+ expect(attempts).toBe(1);
4250
+ expect(publication).not.toHaveBeenCalled();
4251
+ // An attempt that settles after shutdown must not arm recovery either.
4252
+ expect(harness.recoveryActive()).toBe(false);
4253
+ } finally {
4254
+ gate.resolve(testBrokerDiscovery);
4255
+ await harness.dispose();
4256
+ publication.mockRestore();
4257
+ }
4258
+ });
4259
+
4260
+ test("optional rejected broker registration reports diagnostics and retries without blocking turns", async () => {
4261
+ const first = Promise.withResolvers<BrokerDiscovery>();
4262
+ const retry = Promise.withResolvers<BrokerDiscovery>();
4263
+ let attempts = 0;
4264
+ const warning = spyOn(logger, "warn").mockImplementation(() => {});
4265
+ const harness = await brokerRegistrationHarness({
4266
+ ensureBrokerImpl: () => (++attempts === 1 ? first.promise : retry.promise),
4267
+ });
4268
+ try {
4269
+ await harness.emit("session_start");
4270
+ first.reject(Object.assign(new Error("injected broker contention"), { code: "acquire_timeout" }));
4271
+ await Bun.sleep(0);
4272
+ expect(warning).toHaveBeenCalledWith("sdk broker registration unavailable", { code: "acquire_timeout" });
4273
+ // The settled startup attempt armed recovery, and its failure holds the next tick.
4274
+ expect(harness.recoveryActive()).toBe(true);
4275
+ harness.recover();
4276
+ await harness.emit("turn_start");
4277
+ harness.recover();
4278
+ expect(attempts).toBe(2);
4279
+ expect(harness.turnStarts()).toBe(1);
4280
+ expect(harness.stops()).toBe(0);
4281
+ retry.reject(Object.assign(new Error("injected retry failure"), { code: "acquire_timeout" }));
4282
+ await Bun.sleep(0);
4283
+ expect(
4284
+ warning.mock.calls.filter(([message]) => message === "sdk broker registration unavailable"),
4285
+ ).toHaveLength(2);
4286
+ } finally {
4287
+ first.resolve(testBrokerDiscovery);
4288
+ retry.resolve(testBrokerDiscovery);
4289
+ await harness.dispose();
4290
+ warning.mockRestore();
4291
+ }
4292
+ });
4293
+
4294
+ test("optional broker publication completing after shutdown is unregistered with its ownership proof", async () => {
4295
+ const published = Promise.withResolvers<SessionIndexEvent>();
4296
+ const release = Promise.withResolvers<void>();
4297
+ const unregistered = Promise.withResolvers<void>();
4298
+ const append = SessionIndex.prototype.append;
4299
+ const unregister = SessionIndex.prototype.unregisterIfCurrent;
4300
+ const publication = spyOn(SessionIndex.prototype, "append").mockImplementation(async function (
4301
+ this: SessionIndex,
4302
+ input,
4303
+ ) {
4304
+ const event = await append.call(this, input);
4305
+ if (input.type === "host_registered") {
4306
+ published.resolve(event);
4307
+ await release.promise;
4308
+ }
4309
+ return event;
4310
+ });
4311
+ const cleanup = spyOn(SessionIndex.prototype, "unregisterIfCurrent").mockImplementation(async function (
4312
+ this: SessionIndex,
4313
+ ...args
4314
+ ) {
4315
+ const result = await unregister.apply(this, args);
4316
+ unregistered.resolve();
4317
+ return result;
4318
+ });
4319
+ const harness = await brokerRegistrationHarness({ ensureBrokerImpl: async () => testBrokerDiscovery });
4320
+ try {
4321
+ await harness.emit("session_start");
4322
+ const ownership = await published.promise;
4323
+ await harness.emit("session_shutdown");
4324
+ expect(cleanup).not.toHaveBeenCalled();
4325
+ release.resolve();
4326
+ await unregistered.promise;
4327
+ expect(cleanup).toHaveBeenCalledWith(ownership);
4328
+ expect(harness.recoveryActive()).toBe(false);
4329
+ } finally {
4330
+ release.resolve();
4331
+ await harness.dispose();
4332
+ publication.mockRestore();
4333
+ cleanup.mockRestore();
4334
+ }
4335
+ });
4336
+
4337
+ for (const outcome of ["success", "failure"] as const) {
4338
+ test(`required broker registration blocks startup and turns until ${outcome}`, async () => {
4339
+ const gate = Promise.withResolvers<BrokerDiscovery>();
4340
+ const entered = Promise.withResolvers<void>();
4341
+ let attempts = 0;
4342
+ const harness = await brokerRegistrationHarness({
4343
+ brokerRegistrationRequired: true,
4344
+ lifecycleRequestId: "required-registration-marker",
4345
+ ensureBrokerImpl: () => {
4346
+ attempts += 1;
4347
+ entered.resolve();
4348
+ return gate.promise;
4349
+ },
4350
+ });
4351
+ let startupSettled = false;
4352
+ let turnSettled = false;
4353
+ const startup = harness.emit("session_start").then(
4354
+ () => {
4355
+ startupSettled = true;
4356
+ return undefined;
4357
+ },
4358
+ error => {
4359
+ startupSettled = true;
4360
+ return error;
4361
+ },
4362
+ );
4363
+ await entered.promise;
4364
+ const turn = harness.emit("turn_start").then(
4365
+ () => {
4366
+ turnSettled = true;
4367
+ return undefined;
4368
+ },
4369
+ error => {
4370
+ turnSettled = true;
4371
+ return error;
4372
+ },
4373
+ );
4374
+ try {
4375
+ await Bun.sleep(0);
4376
+ expect(startupSettled).toBe(false);
4377
+ expect(turnSettled).toBe(false);
4378
+ expect(harness.turnStarts()).toBe(0);
4379
+ expect(attempts).toBe(1);
4380
+ expect(harness.recoveryActive()).toBe(false);
4381
+ if (outcome === "failure") {
4382
+ const error = Object.assign(new Error("required broker unavailable"), { code: "acquire_timeout" });
4383
+ gate.reject(error);
4384
+ expect(await startup).toBe(error);
4385
+ expect(await turn).toBe(error);
4386
+ expect(harness.stops()).toBe(1);
4387
+ expect(harness.turnStarts()).toBe(0);
4388
+ expect(harness.recoveryActive()).toBe(false);
4389
+ } else {
4390
+ gate.resolve(testBrokerDiscovery);
4391
+ expect(await startup).toBeUndefined();
4392
+ expect(await turn).toBeUndefined();
4393
+ expect(harness.turnStarts()).toBe(1);
4394
+ expect(harness.recoveryActive()).toBe(true);
4395
+ await harness.emit("turn_start");
4396
+ expect(attempts).toBe(1);
4397
+ }
4398
+ } finally {
4399
+ gate.resolve(testBrokerDiscovery);
4400
+ await Promise.all([startup, turn]);
4401
+ await harness.dispose();
4402
+ }
4403
+ });
4404
+ }
4405
+
4149
4406
  test("rejects lifecycle-required SDK-only startup when broker registration fails", async () => {
4150
4407
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-broker-required-"));
4151
4408
  const agentDir = path.join(cwd, ".gjc", "agent");
@@ -4157,7 +4414,7 @@ describe("SessionSdkSessionRuntime", () => {
4157
4414
  handlers.set(event, handler);
4158
4415
  },
4159
4416
  } as any;
4160
- createSdkSessionRuntimeExtension(api, {
4417
+ createTestRuntimeExtension(api, {
4161
4418
  agentDir,
4162
4419
  brokerRegistrationRequired: true,
4163
4420
  lifecycleRequestId: "broker-required-marker",
@@ -4180,10 +4437,112 @@ describe("SessionSdkSessionRuntime", () => {
4180
4437
  }
4181
4438
  });
4182
4439
  });
4440
+
4441
+ // Production discovery deliberately launches a daemon that outlives its caller.
4442
+ // Unit fixtures must never launch it: shutting down a host does not own that daemon.
4443
+ function createTestRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {
4444
+ createSdkSessionRuntimeExtension(api, {
4445
+ ...options,
4446
+ ensureBrokerImpl: options.ensureBrokerImpl ?? (async () => testBrokerDiscovery),
4447
+ });
4448
+ }
4449
+
4450
+ const testBrokerDiscovery: BrokerDiscovery = {
4451
+ version: 1,
4452
+ protocolVersion: 3,
4453
+ packageGeneration: "test",
4454
+ ownerId: "test-broker",
4455
+ pid: process.pid,
4456
+ incarnation: "test-incarnation",
4457
+ host: "127.0.0.1",
4458
+ port: 1,
4459
+ url: "ws://127.0.0.1:1",
4460
+ token: "test-token",
4461
+ startedAt: 0,
4462
+ heartbeatAt: 0,
4463
+ };
4464
+
4465
+ async function brokerRegistrationHarness(
4466
+ options: Pick<
4467
+ CreateSdkSessionRuntimeOptions,
4468
+ "ensureBrokerImpl" | "brokerRegistrationRequired" | "lifecycleRequestId"
4469
+ >,
4470
+ ) {
4471
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-registration-"));
4472
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
4473
+ const broadcasts: SdkFrame[] = [];
4474
+ let stops = 0;
4475
+ let recovery: (() => void) | undefined;
4476
+ let recoveryActive = false;
4477
+ const timer = { unref() {} } as NodeJS.Timeout;
4478
+ const api = {
4479
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
4480
+ handlers.set(event, handler);
4481
+ },
4482
+ } as unknown as ExtensionAPI;
4483
+ createTestRuntimeExtension(api, {
4484
+ ...options,
4485
+ agentDir: path.join(cwd, "agent"),
4486
+ setIntervalImpl: ((callback: () => void) => {
4487
+ recovery = callback;
4488
+ recoveryActive = true;
4489
+ return timer;
4490
+ }) as typeof setInterval,
4491
+ clearIntervalImpl: (() => {
4492
+ recoveryActive = false;
4493
+ }) as typeof clearInterval,
4494
+ createTransport: async ({ sessionId, stateRoot, token }) => ({
4495
+ sessionId,
4496
+ stateRoot,
4497
+ token,
4498
+ onFrame: () => undefined,
4499
+ sendFrame: () => {},
4500
+ broadcastFrame: frame => {
4501
+ broadcasts.push(frame);
4502
+ },
4503
+ start: async () => {
4504
+ const url = "ws://127.0.0.1:1";
4505
+ await Bun.write(
4506
+ path.join(stateRoot, "sdk", `${sessionId}.json`),
4507
+ JSON.stringify({
4508
+ sessionId,
4509
+ token,
4510
+ pid: process.pid,
4511
+ url,
4512
+ }),
4513
+ );
4514
+ return { url };
4515
+ },
4516
+ stop: async () => {
4517
+ stops += 1;
4518
+ },
4519
+ }),
4520
+ });
4521
+ const context = extensionContext("broker-registration", cwd);
4522
+ const emit = async (event: string): Promise<void> => {
4523
+ await handlers.get(event)?.({}, context);
4524
+ };
4525
+ return {
4526
+ emit,
4527
+ recover: () => recovery?.(),
4528
+ recoveryActive: () => recoveryActive,
4529
+ stops: () => stops,
4530
+ turnStarts: () => broadcasts.filter(frame => frame.type === "event" && frame.kind === "turn_start").length,
4531
+ dispose: async () => {
4532
+ await emit("session_shutdown");
4533
+ await Bun.sleep(0);
4534
+ await rm(cwd, { recursive: true, force: true });
4535
+ },
4536
+ };
4537
+ }
4538
+
4183
4539
  interface PreflightHooks {
4540
+ onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
4184
4541
  onPreflightAccepted?: () => void;
4185
4542
  onPreflightAcceptCommit?: () => void | Promise<void>;
4186
4543
  expectedSdkRunToken?: string;
4544
+ preflightSignal?: AbortSignal;
4545
+ onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
4187
4546
  }
4188
4547
 
4189
4548
  interface ResponseFrame {
@@ -4191,11 +4550,22 @@ interface ResponseFrame {
4191
4550
  ok?: boolean;
4192
4551
  code?: string;
4193
4552
  error?: { code: string; message: string };
4194
- result?: { status?: string; commandId?: string; turnId?: string; error?: { code: string; message: string } };
4553
+ result?: {
4554
+ status?: string;
4555
+ commandId?: string;
4556
+ turnId?: string;
4557
+ error?: { code: string; message: string };
4558
+ };
4195
4559
  }
4196
4560
 
4197
4561
  interface InvocationHarness {
4198
4562
  control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
4563
+ controlAs(
4564
+ connectionId: string,
4565
+ operation: string,
4566
+ input: Record<string, unknown>,
4567
+ idempotencyKey?: string,
4568
+ ): Promise<ResponseFrame>;
4199
4569
  query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
4200
4570
  emit(event: string, payload?: unknown): Promise<void>;
4201
4571
  switchSession(sessionId: string): Promise<void>;
@@ -4267,7 +4637,7 @@ async function invocationHarness(
4267
4637
  hooks.onDurableAttempt,
4268
4638
  )
4269
4639
  : undefined;
4270
- createSdkSessionRuntimeExtension(api, {
4640
+ createTestRuntimeExtension(api, {
4271
4641
  agentDir: cwd,
4272
4642
  ...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
4273
4643
  ...(interceptorStore || hooks.terminalAbortSeams
@@ -4333,16 +4703,26 @@ async function invocationHarness(
4333
4703
  },
4334
4704
  };
4335
4705
  await handlers.get("session_start")?.({}, ctx);
4336
- const request = (frame: Record<string, unknown>): Promise<ResponseFrame> => {
4706
+ const request = (frame: Record<string, unknown>, connectionId = "client"): Promise<ResponseFrame> => {
4337
4707
  const id = `frame-${nextId}`;
4338
4708
  nextId += 1;
4339
4709
  const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
4340
4710
  waiters.set(id, resolve);
4341
- deliver?.("client", { ...frame, id } as SdkFrame);
4711
+ deliver?.(connectionId, { ...frame, id } as SdkFrame);
4342
4712
  return promise;
4343
4713
  };
4344
4714
  return {
4345
4715
  control: (operation, input) => request({ type: "control_request", operation, input }),
4716
+ controlAs: (connectionId, operation, input, idempotencyKey) =>
4717
+ request(
4718
+ {
4719
+ type: "control_request",
4720
+ operation,
4721
+ input,
4722
+ ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
4723
+ },
4724
+ connectionId,
4725
+ ),
4346
4726
  query: (name, input) => request({ type: "query_request", query: name, input }),
4347
4727
  broadcasts,
4348
4728
  emit: async (event, payload) => {
@@ -4392,6 +4772,500 @@ async function invocationHarness(
4392
4772
  };
4393
4773
  }
4394
4774
 
4775
+ test.each(["natural", "removed"] as const)("SDK-only text follow-up has a durable terminal (%s)", async mode => {
4776
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-only-text-followup-${mode}-`));
4777
+ let harness: InvocationHarness | undefined;
4778
+ let promotion: PreflightHooks["onQueuedPromoted"];
4779
+ try {
4780
+ harness = await invocationHarness(`sdk-only-text-followup-${mode}`, cwd, {
4781
+ sendUserMessage: async (content, options) => {
4782
+ await options?.onPreflightAcceptCommit?.();
4783
+ if (options?.deliverAs === "followUp") {
4784
+ expect(content).toBe("queued generic follow-up");
4785
+ options.onDispatchDisposition?.({ startsOwnRun: false });
4786
+ promotion = options.onQueuedPromoted;
4787
+ return;
4788
+ }
4789
+ await neverSettlingPromise();
4790
+ },
4791
+ });
4792
+ const root = await harness.controlAs("connection-root", "turn.prompt", { text: "root text" });
4793
+ expect(root).toMatchObject({ ok: true, result: { accepted: true } });
4794
+ await harness.emit("agent_start");
4795
+ const followUp = await harness.controlAs("connection-followup", "turn.follow_up", {
4796
+ text: "queued generic follow-up",
4797
+ });
4798
+ expect(followUp).toMatchObject({ ok: true, result: { accepted: true } });
4799
+ const correlation = { commandId: followUp.result?.commandId, turnId: followUp.result?.turnId };
4800
+ expect(await harness.query("turn.result", { kind: "prompt", ...correlation })).toMatchObject({
4801
+ result: { status: "accepted" },
4802
+ });
4803
+ expect(promotion).toBeDefined();
4804
+ promotion?.({ startsOwnRun: false, ...(mode === "removed" ? { removed: true } : {}) });
4805
+ if (mode === "natural")
4806
+ await harness.emit("agent_end", {
4807
+ messages: [{ role: "assistant", stopReason: "stop", content: "root complete" }],
4808
+ });
4809
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject(
4810
+ mode === "natural" ? { status: "terminal_ok" } : { status: "failed", error: { code: "cancelled" } },
4811
+ );
4812
+ await harness.emit("agent_start");
4813
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "unrelated" }] });
4814
+ expect(
4815
+ harness.broadcasts.filter(
4816
+ frame =>
4817
+ frame.type === "event" &&
4818
+ frame.kind === "agent_end" &&
4819
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId ===
4820
+ correlation.commandId &&
4821
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === correlation.turnId,
4822
+ ),
4823
+ ).toHaveLength(1);
4824
+ } finally {
4825
+ await harness?.stop();
4826
+ await rm(cwd, { recursive: true, force: true });
4827
+ }
4828
+ });
4829
+
4830
+ test.each([
4831
+ "accepting",
4832
+ "accepted",
4833
+ ] as const)("SDK-only ordinary abort fences a real %s prompt before agent_start", async phase => {
4834
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-owned-preflight-${phase}-`));
4835
+ const entered = Promise.withResolvers<void>();
4836
+ const release = Promise.withResolvers<void>();
4837
+ const clientRef = `owned-before-start-${phase}`;
4838
+ const manager = SessionManager.inMemory(cwd);
4839
+ const sessionId = manager.getSessionId();
4840
+ const store = createReconciliationStore({ sessionFile: path.join(cwd, "session.jsonl"), sessionId });
4841
+ const originalRename = fsPromises.rename;
4842
+ let held = false;
4843
+ let modelCalls = 0;
4844
+ let rootAbortCalls = 0;
4845
+ let ownedSignal: AbortSignal | undefined;
4846
+ let harness: InvocationHarness | undefined;
4847
+ let session: AgentSession | undefined;
4848
+ let authStorage: AuthStorage | undefined;
4849
+ const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
4850
+ if (phase === "accepting" && !held && String(to) === store.path) {
4851
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
4852
+ if (document.records.some(record => record.clientRef === clientRef && record.status === "accepted")) {
4853
+ held = true;
4854
+ entered.resolve();
4855
+ await release.promise;
4856
+ }
4857
+ }
4858
+ await originalRename(from, to);
4859
+ });
4860
+ try {
4861
+ authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
4862
+ const mock = createMockModel({
4863
+ responses: [
4864
+ () => {
4865
+ modelCalls++;
4866
+ return { content: ["unexpected model execution"] };
4867
+ },
4868
+ ],
4869
+ });
4870
+ authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
4871
+ session = new AgentSession({
4872
+ agent: new Agent({
4873
+ getApiKey: () => "test-key",
4874
+ initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
4875
+ streamFn: mock.stream,
4876
+ }),
4877
+ sessionManager: manager,
4878
+ settings: Settings.isolated({ "compaction.enabled": false }),
4879
+ modelRegistry: new ModelRegistry(authStorage),
4880
+ });
4881
+ harness = await invocationHarness(sessionId, cwd, {
4882
+ isIdle: () => !session!.isStreaming,
4883
+ abort: () => {
4884
+ rootAbortCalls++;
4885
+ },
4886
+ terminalAbortSeams: { getReconciliationStore: () => store },
4887
+ sendUserMessage: (content, options) => {
4888
+ if (typeof content !== "string") throw new Error("Expected a text-only admission.");
4889
+ const { deliverAs, ...admissionOptions } = options ?? {};
4890
+ if (deliverAs !== undefined) throw new Error("Expected an ordinary prompt admission.");
4891
+ ownedSignal = admissionOptions.preflightSignal;
4892
+ return session!.sendUserMessage(content, {
4893
+ ...admissionOptions,
4894
+ onPreflightAcceptCommit: async () => {
4895
+ await admissionOptions.onPreflightAcceptCommit?.();
4896
+ if (phase === "accepted") {
4897
+ entered.resolve();
4898
+ await release.promise;
4899
+ }
4900
+ },
4901
+ });
4902
+ },
4903
+ });
4904
+ session.subscribe(async event => {
4905
+ await harness?.emit(event.type, event);
4906
+ });
4907
+ const prompt = harness.controlAs("owner", "turn.prompt", { text: "cancel before execution", clientRef });
4908
+ await entered.promise;
4909
+ if (phase === "accepted") expect(await prompt).toMatchObject({ ok: true, result: { accepted: true } });
4910
+ expect(ownedSignal?.aborted).toBe(false);
4911
+ expect(modelCalls).toBe(0);
4912
+ expect(await harness.controlAs("owner", "turn.abort", {})).toMatchObject({ ok: true, result: { aborted: true } });
4913
+ expect(ownedSignal?.aborted).toBe(true);
4914
+ expect(rootAbortCalls).toBe(0);
4915
+ release.resolve();
4916
+ if (phase === "accepting") expect(await prompt).toMatchObject({ ok: false, error: { code: "busy" } });
4917
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef })).toMatchObject({
4918
+ status: "failed",
4919
+ });
4920
+ await session.waitForIdle();
4921
+ expect(modelCalls).toBe(0);
4922
+ expect(rootAbortCalls).toBe(0);
4923
+ const document = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
4924
+ const durable = document.records.find(record => record.clientRef === clientRef);
4925
+ expect(durable?.status).toBe("failed");
4926
+ expect(durable?.terminalAt).toBeNumber();
4927
+ } finally {
4928
+ release.resolve();
4929
+ await session?.waitForIdle().catch(() => undefined);
4930
+ await harness?.stop();
4931
+ await session?.dispose();
4932
+ authStorage?.close();
4933
+ renameSpy.mockRestore();
4934
+ await rm(cwd, { recursive: true, force: true });
4935
+ }
4936
+ }, 30_000);
4937
+
4938
+ test("SDK-only ordinary abort preserves real foreign and later admissions behind a cancelled durable preflight", async () => {
4939
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-real-admission-snapshot-"));
4940
+ const entered = Promise.withResolvers<void>();
4941
+ const release = Promise.withResolvers<void>();
4942
+ const foreignStarted = Promise.withResolvers<void>();
4943
+ const releaseForeign = Promise.withResolvers<void>();
4944
+ const manager = SessionManager.inMemory(cwd);
4945
+ const sessionId = manager.getSessionId();
4946
+ const store = createReconciliationStore({ sessionFile: path.join(cwd, "session.jsonl"), sessionId });
4947
+ const originalRename = fsPromises.rename;
4948
+ const signals = new Map<string, AbortSignal | undefined>();
4949
+ const modelCalls: string[] = [];
4950
+ let held = false;
4951
+ let rootAbortCalls = 0;
4952
+ let harness: InvocationHarness | undefined;
4953
+ let session: AgentSession | undefined;
4954
+ let authStorage: AuthStorage | undefined;
4955
+ const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
4956
+ if (!held && String(to) === store.path) {
4957
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
4958
+ if (
4959
+ document.records.some(record => record.clientRef === "cancel-owned-real" && record.status === "accepted")
4960
+ ) {
4961
+ held = true;
4962
+ entered.resolve();
4963
+ await release.promise;
4964
+ }
4965
+ }
4966
+ await originalRename(from, to);
4967
+ });
4968
+ try {
4969
+ authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
4970
+ const mock = createMockModel({
4971
+ responses: [
4972
+ () => {
4973
+ modelCalls.push("seed");
4974
+ return { content: ["natural seed completed"] };
4975
+ },
4976
+ async () => {
4977
+ modelCalls.push("foreign");
4978
+ foreignStarted.resolve();
4979
+ await releaseForeign.promise;
4980
+ return { content: ["foreign admission completed"] };
4981
+ },
4982
+ () => {
4983
+ modelCalls.push("later-owner");
4984
+ return { content: ["later owner admission completed"] };
4985
+ },
4986
+ ],
4987
+ });
4988
+ authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
4989
+ session = new AgentSession({
4990
+ agent: new Agent({
4991
+ getApiKey: () => "test-key",
4992
+ initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
4993
+ streamFn: mock.stream,
4994
+ }),
4995
+ sessionManager: manager,
4996
+ settings: Settings.isolated({ "compaction.enabled": false }),
4997
+ modelRegistry: new ModelRegistry(authStorage),
4998
+ });
4999
+ harness = await invocationHarness(sessionId, cwd, {
5000
+ isIdle: () => !session!.isStreaming,
5001
+ abort: () => {
5002
+ rootAbortCalls++;
5003
+ },
5004
+ terminalAbortSeams: { getReconciliationStore: () => store },
5005
+ sendUserMessage: (content, options) => {
5006
+ if (typeof content !== "string") throw new Error("Expected text-only real admission.");
5007
+ signals.set(content, options?.preflightSignal);
5008
+ const { deliverAs, ...admissionOptions } = options ?? {};
5009
+ if (deliverAs !== undefined && deliverAs !== "steer" && deliverAs !== "followUp")
5010
+ throw new Error("Unsupported real admission delivery mode.");
5011
+ return session!.sendUserMessage(content, {
5012
+ ...admissionOptions,
5013
+ ...(deliverAs === undefined ? {} : { deliverAs }),
5014
+ });
5015
+ },
5016
+ });
5017
+ session.subscribe(async event => {
5018
+ await harness?.emit(event.type, event);
5019
+ });
5020
+ expect(
5021
+ await harness.controlAs("seed-owner", "turn.prompt", { text: "seed-real", clientRef: "seed-real" }),
5022
+ ).toMatchObject({ ok: true, result: { accepted: true } });
5023
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "seed-real" })).toMatchObject({
5024
+ status: "terminal_ok",
5025
+ });
5026
+ await session.waitForIdle();
5027
+ const owned = harness.controlAs("owner", "turn.prompt", {
5028
+ text: "cancel-owned-real",
5029
+ clientRef: "cancel-owned-real",
5030
+ });
5031
+ await entered.promise;
5032
+ expect(modelCalls).toEqual(["seed"]);
5033
+ expect(signals.get("cancel-owned-real")?.aborted).toBe(false);
5034
+ const foreign = harness.controlAs("foreign", "turn.prompt", { text: "foreign-real", clientRef: "foreign-real" });
5035
+ expect(await harness.controlAs("owner", "turn.abort", {})).toMatchObject({ ok: true, result: { aborted: true } });
5036
+ expect(signals.get("cancel-owned-real")?.aborted).toBe(true);
5037
+ expect(rootAbortCalls).toBe(0);
5038
+ release.resolve();
5039
+ expect(await owned).toMatchObject({ ok: false, error: { code: "busy" } });
5040
+ expect(
5041
+ await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "cancel-owned-real" }),
5042
+ ).toMatchObject({ status: "failed" });
5043
+ expect(await foreign).toMatchObject({ ok: true, result: { accepted: true } });
5044
+ await foreignStarted.promise;
5045
+ const later = harness.controlAs("owner", "turn.prompt", {
5046
+ text: "later-owner-real",
5047
+ clientRef: "later-owner-real",
5048
+ });
5049
+ expect(await later).toMatchObject({ ok: true, result: { accepted: true } });
5050
+ expect(signals.get("foreign-real")?.aborted).toBe(false);
5051
+ expect(signals.get("later-owner-real")?.aborted).toBe(false);
5052
+ expect(modelCalls).toEqual(["seed", "foreign"]);
5053
+ const pendingDocument = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
5054
+ const pendingLater = pendingDocument.records.find(record => record.clientRef === "later-owner-real");
5055
+ expect(pendingLater?.status).toBe("accepted");
5056
+ expect(pendingLater?.startedAt).toBeUndefined();
5057
+ expect(pendingLater?.terminalAt).toBeUndefined();
5058
+ releaseForeign.resolve();
5059
+ await session.waitForIdle();
5060
+ expect(modelCalls).toEqual(["seed", "foreign", "later-owner"]);
5061
+ expect(rootAbortCalls).toBe(0);
5062
+ for (const clientRef of ["foreign-real", "later-owner-real"]) {
5063
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef })).toMatchObject({
5064
+ status: "terminal_ok",
5065
+ });
5066
+ }
5067
+ const document = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
5068
+ for (const [clientRef, status] of [
5069
+ ["cancel-owned-real", "failed"],
5070
+ ["foreign-real", "terminal_ok"],
5071
+ ["later-owner-real", "terminal_ok"],
5072
+ ] as const) {
5073
+ const record = document.records.find(candidate => candidate.clientRef === clientRef);
5074
+ expect(record?.status).toBe(status);
5075
+ expect(record?.terminalAt).toBeNumber();
5076
+ }
5077
+ } finally {
5078
+ release.resolve();
5079
+ releaseForeign.resolve();
5080
+ await session?.waitForIdle().catch(() => undefined);
5081
+ await harness?.stop();
5082
+ await session?.dispose();
5083
+ authStorage?.close();
5084
+ renameSpy.mockRestore();
5085
+ await rm(cwd, { recursive: true, force: true });
5086
+ }
5087
+ }, 30_000);
5088
+
5089
+ test.each([
5090
+ "ordinary",
5091
+ "terminal",
5092
+ ] as const)("SDK-only %s abort cancels only its generic queued owner behind a real active AgentSession run", async mode => {
5093
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-queued-text-${mode}-`));
5094
+ const rootGate = Promise.withResolvers<void>();
5095
+ const rootStarted = Promise.withResolvers<void>();
5096
+ const failedRemovalWrite = Promise.withResolvers<void>();
5097
+ const modelCalls: number[] = [];
5098
+ let rootAbortCalls = 0;
5099
+ let queuedPreflightSignal: AbortSignal | undefined;
5100
+ let harness: InvocationHarness | undefined;
5101
+ let session: AgentSession | undefined;
5102
+ let authStorage: AuthStorage | undefined;
5103
+ try {
5104
+ authStorage = await AuthStorage.create(path.join(cwd, "testauth.db"));
5105
+ const mock = createMockModel({
5106
+ responses: [
5107
+ async () => {
5108
+ modelCalls.push(Date.now());
5109
+ rootStarted.resolve();
5110
+ await rootGate.promise;
5111
+ return { content: ["root completed"] };
5112
+ },
5113
+ () => {
5114
+ modelCalls.push(Date.now());
5115
+ return { content: ["unrelated follow-up completed"] };
5116
+ },
5117
+ ],
5118
+ });
5119
+ authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
5120
+ const modelRegistry = new ModelRegistry(authStorage);
5121
+ const agent = new Agent({
5122
+ getApiKey: () => "test-key",
5123
+ initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
5124
+ streamFn: mock.stream,
5125
+ });
5126
+ session = new AgentSession({
5127
+ agent,
5128
+ sessionManager: SessionManager.inMemory(cwd),
5129
+ settings: Settings.isolated({ "compaction.enabled": false }),
5130
+ modelRegistry,
5131
+ });
5132
+ const hooks: Parameters<typeof invocationHarness>[2] = {
5133
+ isIdle: () => !session!.isStreaming,
5134
+ abort: () => {
5135
+ rootAbortCalls += 1;
5136
+ },
5137
+ sendUserMessage: (content, options) => {
5138
+ if (content === "cancel only this queued text") queuedPreflightSignal = options?.preflightSignal;
5139
+ return session!.sendUserMessage(content as never, options as never);
5140
+ },
5141
+ ...(mode === "ordinary"
5142
+ ? {
5143
+ persistInterceptor: transition => {
5144
+ if (transition.type === "agent_failed") failedRemovalWrite.resolve();
5145
+ },
5146
+ agentFailedWriteFailures: 1,
5147
+ onDurableAttempt: attempt => {
5148
+ if (attempt.type === "agent_failed" && attempt.outcome === "rejected")
5149
+ failedRemovalWrite.resolve();
5150
+ },
5151
+ }
5152
+ : {
5153
+ terminalAbortSeams: {
5154
+ getTerminalTurnEpoch: () => (session?.isStreaming ? 17 : undefined),
5155
+ getActivePromptHandle: () => (session?.isStreaming ? "root-run-handle" : undefined),
5156
+ getActivePromptOwnerConnectionId: () => (session?.isStreaming ? "connection-root" : undefined),
5157
+ cancelPendingPreflightForTerminalAbort: () => {},
5158
+ abortPromptAndWaitWithTerminal: async () => {
5159
+ rootAbortCalls += 1;
5160
+ return { status: "settled", terminalScope: {} };
5161
+ },
5162
+ },
5163
+ }),
5164
+ };
5165
+ harness = await invocationHarness(`queued-text-${mode}`, cwd, hooks);
5166
+ session.subscribe(async event => {
5167
+ await harness?.emit(event.type, event);
5168
+ });
5169
+
5170
+ const root = await harness.controlAs("connection-root", "turn.prompt", { text: "root owned by B" });
5171
+ expect(root).toMatchObject({ ok: true, result: { accepted: true } });
5172
+ await rootStarted.promise;
5173
+ const unrelated = await harness.controlAs("connection-other", "turn.prompt", {
5174
+ text: "keep unrelated queued text",
5175
+ });
5176
+ expect(unrelated).toMatchObject({ ok: true, result: { accepted: true } });
5177
+ const queued = await harness.controlAs("connection-requester", "turn.prompt", {
5178
+ text: "cancel only this queued text",
5179
+ });
5180
+ expect(queued).toMatchObject({ ok: true, result: { accepted: true } });
5181
+ const correlation = { commandId: queued.result?.commandId, turnId: queued.result?.turnId };
5182
+ expect(queuedPreflightSignal).toBeInstanceOf(AbortSignal);
5183
+ expect(queuedPreflightSignal?.aborted).toBe(false);
5184
+ expect(session.getQueuedMessages().steering).toEqual([
5185
+ "keep unrelated queued text",
5186
+ "cancel only this queued text",
5187
+ ]);
5188
+
5189
+ let abortCompleted = false;
5190
+ const abortRequest =
5191
+ mode === "ordinary"
5192
+ ? harness.controlAs("connection-requester", "turn.abort", { mode: "turn" })
5193
+ : harness.controlAs("connection-requester", "turn.abort", { mode: "terminal" }, "queued-text-terminal");
5194
+ const observedAbort = abortRequest.then(response => {
5195
+ abortCompleted = true;
5196
+ return response;
5197
+ });
5198
+ if (mode === "ordinary") {
5199
+ await failedRemovalWrite.promise;
5200
+ await Bun.sleep(0);
5201
+ expect(abortCompleted).toBe(false);
5202
+ }
5203
+ const abortResponse = await observedAbort;
5204
+ if (mode === "ordinary") expect(abortResponse).toMatchObject({ ok: true, result: { aborted: true } });
5205
+ else
5206
+ expect(abortResponse).toMatchObject({
5207
+ ok: true,
5208
+ result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
5209
+ });
5210
+ expect(queuedPreflightSignal?.aborted).toBe(true);
5211
+ expect(rootAbortCalls).toBe(0);
5212
+ expect(session.isStreaming).toBe(true);
5213
+ expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queued text"]);
5214
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
5215
+ status: "failed",
5216
+ error: { code: "cancelled" },
5217
+ });
5218
+ const repeatedAbort = await harness.controlAs(
5219
+ "connection-requester",
5220
+ "turn.abort",
5221
+ mode === "ordinary" ? { mode: "turn" } : { mode: "terminal" },
5222
+ mode === "terminal" ? "queued-text-terminal-retry" : undefined,
5223
+ );
5224
+ if (mode === "ordinary")
5225
+ expect(repeatedAbort).toMatchObject({ ok: true, result: { aborted: false, turn: "no_active_turn" } });
5226
+ else
5227
+ expect(repeatedAbort).toMatchObject({
5228
+ ok: true,
5229
+ result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
5230
+ });
5231
+ expect(rootAbortCalls).toBe(0);
5232
+ expect(session.isStreaming).toBe(true);
5233
+ expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queued text"]);
5234
+
5235
+ rootGate.resolve();
5236
+ await session.waitForIdle();
5237
+ const userMessages = session.agent.state.messages.filter(
5238
+ (message): message is UserMessage => message.role === "user",
5239
+ );
5240
+ const userText = (message: (typeof userMessages)[number]): string =>
5241
+ typeof message.content === "string"
5242
+ ? message.content
5243
+ : message.content
5244
+ .filter(block => block.type === "text")
5245
+ .map(block => block.text)
5246
+ .join("");
5247
+ expect(userMessages.map(userText)).toEqual(["root owned by B", "keep unrelated queued text"]);
5248
+ expect(modelCalls).toHaveLength(2);
5249
+ expect(
5250
+ harness.broadcasts.filter(
5251
+ frame =>
5252
+ frame.type === "event" &&
5253
+ frame.kind === "agent_end" &&
5254
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId ===
5255
+ correlation.commandId &&
5256
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === correlation.turnId,
5257
+ ),
5258
+ ).toHaveLength(1);
5259
+ } finally {
5260
+ rootGate.resolve();
5261
+ await session?.waitForIdle().catch(() => undefined);
5262
+ await harness?.stop();
5263
+ await session?.dispose();
5264
+ authStorage?.close();
5265
+ await rm(cwd, { recursive: true, force: true });
5266
+ }
5267
+ });
5268
+
4395
5269
  /** Reconciliation store used to inject durable-write failures from tests: wraps a
4396
5270
  * session-file-backed store and fails the write whenever the staged records contain
4397
5271
  * the intermediate failed-without-terminal state an agent_failed transition persists. */
@@ -4504,19 +5378,18 @@ async function settledStatus(
4504
5378
  harness: InvocationHarness,
4505
5379
  name: string,
4506
5380
  input: Record<string, unknown>,
5381
+ timeoutMs = 10_000,
4507
5382
  ): Promise<NonNullable<ResponseFrame["result"]>> {
4508
5383
  // Wall-clock budget instead of a fixed poll count: CI runners can starve the
4509
- // event loop long enough to exhaust 200 ~1ms polls before a 25ms deadline
4510
- // timer is dispatched, failing a correct implementation on timing alone.
4511
- // The contract is unchanged — the status must still reach a terminal
4512
- // reconciliation state with the asserted shape within a bounded horizon.
4513
- const budgetEndsAt = Date.now() + 10_000;
5384
+ // event loop and delay deadline timers. Polling every 10ms avoids flooding
5385
+ // reconciliation queries while preserving the asserted terminal contract.
5386
+ const budgetEndsAt = Date.now() + timeoutMs;
4514
5387
  for (;;) {
4515
5388
  const frame = await harness.query(name, input);
4516
5389
  const result = frame.result;
4517
5390
  if (result && (result.status === "failed" || result.status === "terminal_ok")) return result;
4518
5391
  if (Date.now() > budgetEndsAt) throw new Error(`${name} never reported a terminal reconciliation status`);
4519
- await Bun.sleep(1);
5392
+ await Bun.sleep(10);
4520
5393
  }
4521
5394
  }
4522
5395
 
@@ -4524,14 +5397,420 @@ function neverSettlingPromise(): Promise<void> {
4524
5397
  return Promise.withResolvers<void>().promise;
4525
5398
  }
4526
5399
 
4527
- test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
4528
- const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
4529
- const staleToken = "ended-command:ended-turn";
4530
- const clientRef = "stale-steer-ref";
4531
- const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
4532
- let harness: InvocationHarness | undefined;
4533
- try {
4534
- harness = await invocationHarness("steer-run-token", cwd, {
5400
+ function typedContextOverflowStream(model: Model, onEmitted?: () => void): AssistantMessageEventStream {
5401
+ const stream = new AssistantMessageEventStream();
5402
+ queueMicrotask(() => {
5403
+ const message: AssistantMessage & {
5404
+ transportFailure: { kind: "transport"; status: number; openaiErrorCode: string };
5405
+ } = {
5406
+ role: "assistant",
5407
+ content: [],
5408
+ api: model.api,
5409
+ provider: model.provider,
5410
+ model: model.id,
5411
+ usage: {
5412
+ input: 0,
5413
+ output: 0,
5414
+ cacheRead: 0,
5415
+ cacheWrite: 0,
5416
+ totalTokens: 0,
5417
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
5418
+ },
5419
+ stopReason: "error",
5420
+ errorMessage: "",
5421
+ errorStatus: 400,
5422
+ timestamp: Date.now(),
5423
+ transportFailure: { kind: "transport", status: 400, openaiErrorCode: "context_length_exceeded" },
5424
+ };
5425
+ stream.push({ type: "start", partial: message });
5426
+ stream.push({ type: "error", reason: "error", error: message });
5427
+ onEmitted?.();
5428
+ });
5429
+ return stream;
5430
+ }
5431
+
5432
+ function selector(model: Model): string {
5433
+ return `${model.provider}/${model.id}`;
5434
+ }
5435
+
5436
+ async function createTerminalizationSession(
5437
+ cwd: string,
5438
+ streamFn: AgentOptions["streamFn"],
5439
+ settingsOverrides: Record<string, unknown> = {},
5440
+ withExtensions = false,
5441
+ ): Promise<{ session: AgentSession; authStorage: AuthStorage; model: Model }> {
5442
+ const authStorage = await AuthStorage.create(path.join(cwd, "testauth.db"));
5443
+ const model = getBundledModel("anthropic", "claude-sonnet-4-5");
5444
+ const fallback = getBundledModel("openai", "gpt-4o-mini");
5445
+ if (!model || !fallback) throw new Error("Expected bundled test models");
5446
+ authStorage.setRuntimeApiKey(model.provider, "test-key");
5447
+ authStorage.setRuntimeApiKey(fallback.provider, "test-key");
5448
+ const settings = Settings.isolated({
5449
+ "compaction.enabled": false,
5450
+ "contextPromotion.enabled": false,
5451
+ "fallback.maxAttempts": 3,
5452
+ "retry.baseDelayMs": 1,
5453
+ ...settingsOverrides,
5454
+ });
5455
+ settings.setModelRole("default", selector(model));
5456
+ const agent = new Agent({
5457
+ getApiKey: provider => `${provider}-key`,
5458
+ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
5459
+ streamFn,
5460
+ });
5461
+ const sessionManager = SessionManager.inMemory(cwd);
5462
+ const modelRegistry = new ModelRegistry(authStorage);
5463
+ const session = new AgentSession({
5464
+ agent,
5465
+ sessionManager,
5466
+ settings,
5467
+ modelRegistry,
5468
+ extensionRunner: withExtensions
5469
+ ? new ExtensionRunner(
5470
+ [],
5471
+ { flagValues: new Map(), pendingProviderRegistrations: [] } as never,
5472
+ cwd,
5473
+ sessionManager,
5474
+ modelRegistry,
5475
+ undefined,
5476
+ settings,
5477
+ )
5478
+ : undefined,
5479
+ });
5480
+ session.setConfiguredModelChain("default", [selector(model), selector(fallback)], "test");
5481
+ return { session, authStorage, model };
5482
+ }
5483
+
5484
+ function deferredRealSendUserMessage(session: AgentSession) {
5485
+ return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
5486
+ await options?.onPreflightAcceptCommit?.();
5487
+ const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
5488
+ void session.sendUserMessage(content as string, dispatchOptions as never);
5489
+ await neverSettlingPromise();
5490
+ };
5491
+ }
5492
+
5493
+ function realSendUserMessage(session: AgentSession) {
5494
+ return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
5495
+ await options?.onPreflightAcceptCommit?.();
5496
+ const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
5497
+ await session.sendUserMessage(content as string, dispatchOptions as never);
5498
+ };
5499
+ }
5500
+
5501
+ test.each([
5502
+ "clearContext",
5503
+ "newSession",
5504
+ "compact",
5505
+ ] as const)("SDK %s disconnect cancels only its accepted publication owner before disposal", async operation => {
5506
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-${operation}-publication-`));
5507
+ const entered = Promise.withResolvers<void>();
5508
+ const freshEntered = Promise.withResolvers<void>();
5509
+ const aborted = Promise.withResolvers<void>();
5510
+ const release = Promise.withResolvers<void>();
5511
+ let session: AgentSession | undefined;
5512
+ let authStorage: AuthStorage | undefined;
5513
+ let harness: InvocationHarness | undefined;
5514
+ let submission: Promise<void> | undefined;
5515
+ let providerCalls = 0;
5516
+ try {
5517
+ const real = await createTerminalizationSession(cwd, (model, context, options) => {
5518
+ providerCalls++;
5519
+ if (providerCalls === 2) freshEntered.resolve();
5520
+ if (providerCalls === 1)
5521
+ options?.signal?.addEventListener(
5522
+ "abort",
5523
+ () => {
5524
+ if (operation === "compact") session?.abortCompaction();
5525
+ aborted.resolve();
5526
+ },
5527
+ { once: true },
5528
+ );
5529
+ return createMockModel({
5530
+ responses: [
5531
+ async () => {
5532
+ if (providerCalls === 1) {
5533
+ entered.resolve();
5534
+ await release.promise;
5535
+ }
5536
+ return { content: ["fresh independent completion"] };
5537
+ },
5538
+ ],
5539
+ }).stream(model, context, options);
5540
+ });
5541
+ session = real.session;
5542
+ authStorage = real.authStorage;
5543
+ const send = realSendUserMessage(session);
5544
+ harness = await invocationHarness(`disconnect-publication-${operation}`, cwd, {
5545
+ isIdle: () => !session?.isStreaming,
5546
+ sendUserMessage: (content, options) => {
5547
+ submission = send(content, options);
5548
+ void submission.catch(() => undefined);
5549
+ return submission;
5550
+ },
5551
+ });
5552
+ session.subscribe(event => harness?.emit(event.type, event));
5553
+ expect((await harness.control("turn.prompt", { text: "cancel this disconnected owner" })).ok).toBe(true);
5554
+ await entered.promise;
5555
+ const transition = operation === "compact" ? session.compact() : session[operation]();
5556
+ void transition.catch(() => undefined);
5557
+ await aborted.promise;
5558
+ release.resolve();
5559
+ if (operation === "compact") await expect(transition).rejects.toBeInstanceOf(CompactionCancelledError);
5560
+ else expect(await transition).toBe(true);
5561
+ if (!submission) throw new Error("Expected the accepted actual SDK submission promise.");
5562
+ await expect(submission).rejects.toMatchObject({ code: "cancelled" });
5563
+ const fresh = await harness.control("turn.prompt", { text: "run a fresh independent prompt" });
5564
+ expect(fresh.ok).toBe(true);
5565
+ await freshEntered.promise;
5566
+ await expect(submission).resolves.toBeUndefined();
5567
+ expect(
5568
+ await settledStatus(harness, "turn.result", {
5569
+ kind: "prompt",
5570
+ commandId: fresh.result?.commandId,
5571
+ turnId: fresh.result?.turnId,
5572
+ }),
5573
+ ).toMatchObject({ status: "terminal_ok" });
5574
+ expect(session.agent.state.messages.at(-1)).toMatchObject({
5575
+ role: "assistant",
5576
+ content: [{ type: "text", text: "fresh independent completion" }],
5577
+ });
5578
+ expect(providerCalls).toBe(2);
5579
+ } finally {
5580
+ release.resolve();
5581
+ await session?.dispose();
5582
+ authStorage?.close();
5583
+ await harness?.stop();
5584
+ await rm(cwd, { recursive: true, force: true });
5585
+ }
5586
+ });
5587
+
5588
+ test("SDK disconnect preserves an already published projected terminal's actual submission", async () => {
5589
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-publishing-disconnect-"));
5590
+ const delivered = Promise.withResolvers<void>();
5591
+ const release = Promise.withResolvers<void>();
5592
+ let session: AgentSession | undefined;
5593
+ let authStorage: AuthStorage | undefined;
5594
+ let harness: InvocationHarness | undefined;
5595
+ let submission: Promise<void> | undefined;
5596
+ let restoreEmission: (() => void) | undefined;
5597
+ let terminalCount = 0;
5598
+ try {
5599
+ const real = await createTerminalizationSession(
5600
+ cwd,
5601
+ (model, context, options) =>
5602
+ createMockModel({ responses: [{ content: ["projected completion"] }] }).stream(model, context, options),
5603
+ {},
5604
+ true,
5605
+ );
5606
+ session = real.session;
5607
+ authStorage = real.authStorage;
5608
+ const send = realSendUserMessage(session);
5609
+ harness = await invocationHarness("publishing-disconnect", cwd, {
5610
+ isIdle: () => !session?.isStreaming,
5611
+ sendUserMessage: (content, options) => {
5612
+ submission = send(content, options);
5613
+ void submission.catch(() => undefined);
5614
+ return submission;
5615
+ },
5616
+ });
5617
+ session.subscribe(event => (event.type === "agent_end" ? undefined : harness?.emit(event.type, event)));
5618
+ const runner = session.extensionRunner;
5619
+ if (!runner) throw new Error("Expected the real projected terminal extension bridge.");
5620
+ const emit = runner.emit.bind(runner);
5621
+ const emission = spyOn(runner, "emit").mockImplementation(async event => {
5622
+ const result = await emit(event);
5623
+ if (event.type === "agent_end") {
5624
+ terminalCount++;
5625
+ await harness?.emit(event.type, event);
5626
+ delivered.resolve();
5627
+ await release.promise;
5628
+ }
5629
+ return result;
5630
+ });
5631
+ restoreEmission = () => emission.mockRestore();
5632
+ const accepted = await harness.control("turn.prompt", { text: "publish then disconnect this owner" });
5633
+ expect(accepted.ok).toBe(true);
5634
+ await delivered.promise;
5635
+ expect(
5636
+ await settledStatus(harness, "turn.result", {
5637
+ kind: "prompt",
5638
+ commandId: accepted.result?.commandId,
5639
+ turnId: accepted.result?.turnId,
5640
+ }),
5641
+ ).toMatchObject({ status: "terminal_ok", content: { text: "projected completion" } });
5642
+ const transition = session.clearContext();
5643
+ release.resolve();
5644
+ expect(await transition).toBe(true);
5645
+ if (!submission) throw new Error("Expected the actual publishing SDK submission promise.");
5646
+ await expect(submission).resolves.toBeUndefined();
5647
+ expect(terminalCount).toBe(1);
5648
+ } finally {
5649
+ release.resolve();
5650
+ restoreEmission?.();
5651
+ await session?.dispose();
5652
+ authStorage?.close();
5653
+ await harness?.stop();
5654
+ await rm(cwd, { recursive: true, force: true });
5655
+ }
5656
+ });
5657
+
5658
+ test("SDK disposal rejects an accepted real submission whose terminal bridge is disconnected", async () => {
5659
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-dispose-publication-"));
5660
+ const entered = Promise.withResolvers<void>();
5661
+ const release = Promise.withResolvers<void>();
5662
+ let session: AgentSession | undefined;
5663
+ let authStorage: AuthStorage | undefined;
5664
+ let harness: InvocationHarness | undefined;
5665
+ let submission: Promise<void> | undefined;
5666
+ try {
5667
+ const real = await createTerminalizationSession(cwd, (model, context, options) =>
5668
+ createMockModel({
5669
+ responses: [
5670
+ async () => {
5671
+ entered.resolve();
5672
+ await release.promise;
5673
+ return { content: ["completed"] };
5674
+ },
5675
+ ],
5676
+ }).stream(model, context, options),
5677
+ );
5678
+ session = real.session;
5679
+ authStorage = real.authStorage;
5680
+ const send = realSendUserMessage(session);
5681
+ harness = await invocationHarness("dispose-publication", cwd, {
5682
+ isIdle: () => !session?.isStreaming,
5683
+ sendUserMessage: (content, options) => {
5684
+ submission = send(content, options);
5685
+ void submission.catch(() => undefined);
5686
+ return submission;
5687
+ },
5688
+ });
5689
+ session.subscribe(event => harness?.emit(event.type, event));
5690
+ expect((await harness.control("turn.prompt", { text: "dispose this accepted prompt" })).ok).toBe(true);
5691
+ await entered.promise;
5692
+ const disposal = session.dispose();
5693
+ release.resolve();
5694
+ if (!submission) throw new Error("Expected the actual accepted submission promise.");
5695
+ await expect(submission).rejects.toMatchObject({ code: "cancelled" });
5696
+ await disposal;
5697
+ } finally {
5698
+ release.resolve();
5699
+ await session?.dispose();
5700
+ authStorage?.close();
5701
+ await harness?.stop();
5702
+ await rm(cwd, { recursive: true, force: true });
5703
+ }
5704
+ });
5705
+
5706
+ test.each([
5707
+ false,
5708
+ true,
5709
+ ])("SDK terminal publication rejection is observed independently of its cohort waiter (queued=%s)", async queued => {
5710
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-publication-rejection-"));
5711
+ const entered = Promise.withResolvers<void>();
5712
+ const release = Promise.withResolvers<void>();
5713
+ const rejected = Promise.withResolvers<void>();
5714
+ const failure = new Error("Injected terminal extension delivery failure.");
5715
+ const unhandled: unknown[] = [];
5716
+ const onUnhandled = (reason: unknown) => {
5717
+ unhandled.push(reason);
5718
+ };
5719
+ let session: AgentSession | undefined;
5720
+ let authStorage: AuthStorage | undefined;
5721
+ let harness: InvocationHarness | undefined;
5722
+ let rootSubmission: Promise<void> | undefined;
5723
+ let providerCalls = 0;
5724
+ let targetToken: string | undefined;
5725
+ let injected = false;
5726
+ const projectedTerminals: Array<{ calls: number; token: string | undefined }> = [];
5727
+ let restoreFault: (() => void) | undefined;
5728
+ process.on("unhandledRejection", onUnhandled);
5729
+ try {
5730
+ const real = await createTerminalizationSession(
5731
+ cwd,
5732
+ (model, context, options) => {
5733
+ providerCalls++;
5734
+ return createMockModel({
5735
+ responses: [
5736
+ async () => {
5737
+ if (providerCalls === 1) {
5738
+ entered.resolve();
5739
+ await release.promise;
5740
+ }
5741
+ return { content: ["completed"] };
5742
+ },
5743
+ ],
5744
+ }).stream(model, context, options);
5745
+ },
5746
+ {},
5747
+ true,
5748
+ );
5749
+ session = real.session;
5750
+ authStorage = real.authStorage;
5751
+ const runner = session.extensionRunner;
5752
+ if (!runner) throw new Error("Expected the actual extension runner.");
5753
+ const emit = runner.emit.bind(runner);
5754
+ const fault = spyOn(runner, "emit").mockImplementation(async (event, ...args) => {
5755
+ if (event.type === "agent_end") projectedTerminals.push({ calls: providerCalls, token: event.sdkRunToken });
5756
+ if (!injected && event.type === "agent_end" && event.sdkRunToken === targetToken) {
5757
+ injected = true;
5758
+ rejected.resolve();
5759
+ throw failure;
5760
+ }
5761
+ return emit(event, ...args);
5762
+ });
5763
+ restoreFault = () => fault.mockRestore();
5764
+ const send = realSendUserMessage(session);
5765
+ harness = await invocationHarness("publication-rejection", cwd, {
5766
+ isIdle: () => !session?.isStreaming,
5767
+ sendUserMessage: (content, options) => {
5768
+ const submission = send(content, options);
5769
+ if (!rootSubmission) rootSubmission = submission;
5770
+ void submission.catch(() => undefined);
5771
+ return submission;
5772
+ },
5773
+ });
5774
+ session.subscribe(event => harness?.emit(event.type, event));
5775
+ const active = await harness.control("turn.prompt", { text: "active prompt" });
5776
+ expect(active.ok).toBe(true);
5777
+ await entered.promise;
5778
+ const target = queued ? await harness.control("turn.follow_up", { text: "queued prompt" }) : active;
5779
+ expect(target.ok).toBe(true);
5780
+ targetToken = `${target.result?.commandId}:${target.result?.turnId}`;
5781
+ release.resolve();
5782
+ await rejected.promise;
5783
+ if (!rootSubmission) throw new Error("Expected the actual root submission promise.");
5784
+ if (queued) await rootSubmission;
5785
+ else await expect(rootSubmission).rejects.toBe(failure);
5786
+ await Bun.sleep(0);
5787
+ await Bun.sleep(0);
5788
+ expect(injected).toBe(true);
5789
+ expect(providerCalls).toBe(queued ? 2 : 1);
5790
+ expect(projectedTerminals).toHaveLength(queued ? 2 : 1);
5791
+ expect(unhandled).toEqual([]);
5792
+ } finally {
5793
+ process.off("unhandledRejection", onUnhandled);
5794
+ restoreFault?.();
5795
+ release.resolve();
5796
+ await session?.dispose().catch(error => {
5797
+ expect(error).toBeInstanceOf(AggregateError);
5798
+ expect((error as AggregateError).errors).toEqual([failure]);
5799
+ });
5800
+ authStorage?.close();
5801
+ await harness?.stop();
5802
+ await rm(cwd, { recursive: true, force: true });
5803
+ }
5804
+ });
5805
+
5806
+ test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
5807
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
5808
+ const staleToken = "ended-command:ended-turn";
5809
+ const clientRef = "stale-steer-ref";
5810
+ const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
5811
+ let harness: InvocationHarness | undefined;
5812
+ try {
5813
+ harness = await invocationHarness("steer-run-token", cwd, {
4535
5814
  sendUserMessage: async (content, options) => {
4536
5815
  calls.push({
4537
5816
  content,
@@ -4562,6 +5841,575 @@ test("SDK turn.steer preserves its expected run token and propagates a stale-run
4562
5841
  });
4563
5842
 
4564
5843
  describe("post-acceptance invocation terminalization", () => {
5844
+ test("completion fallback and a held lifecycle end publish one terminal boundary", async () => {
5845
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-completion-lifecycle-race-"));
5846
+ const completion = Promise.withResolvers<string>();
5847
+ const terminalEntered = Promise.withResolvers<void>();
5848
+ const releaseTerminal = Promise.withResolvers<void>();
5849
+ const completionReconciled = Promise.withResolvers<void>();
5850
+ let harness: InvocationHarness | undefined;
5851
+ try {
5852
+ harness = await invocationHarness("completion-lifecycle-race", cwd, {
5853
+ sendUserMessage: async (_content, options) => {
5854
+ await options?.onPreflightAcceptCommit?.();
5855
+ return completion.promise;
5856
+ },
5857
+ persistInterceptor: () => {},
5858
+ agentFailedWriteFailures: 0,
5859
+ persistHold: { type: "agent_end", onEntered: terminalEntered.resolve, release: releaseTerminal.promise },
5860
+ onInvocationCompletionReconciled: () => completionReconciled.resolve(),
5861
+ });
5862
+ const accepted = await harness.control("turn.prompt", { text: "complete once" });
5863
+ expect(accepted.ok).toBe(true);
5864
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
5865
+ await harness.emit("agent_start");
5866
+ completion.resolve("completed");
5867
+ await terminalEntered.promise;
5868
+ const end = harness.emit("agent_end", {
5869
+ messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
5870
+ });
5871
+ await Bun.sleep(0);
5872
+ releaseTerminal.resolve();
5873
+ await Promise.all([end, completionReconciled.promise]);
5874
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
5875
+ status: "terminal_ok",
5876
+ content: { text: "completed" },
5877
+ });
5878
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
5879
+ } finally {
5880
+ completion.resolve("completed");
5881
+ releaseTerminal.resolve();
5882
+ await harness?.stop();
5883
+ await rm(cwd, { recursive: true, force: true });
5884
+ }
5885
+ });
5886
+ test.each([
5887
+ false,
5888
+ true,
5889
+ ])("a returning SDK bridge waits for its real overflow continuation terminal (managed=%s)", async managed => {
5890
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-returning-bridge-"));
5891
+ let harness: InvocationHarness | undefined;
5892
+ let session: AgentSession | undefined;
5893
+ let authStorage: AuthStorage | undefined;
5894
+ let submission: Promise<void> | undefined;
5895
+ let submissionSettled = false;
5896
+ let providerCalls = 0;
5897
+ const recoveryEntered = Promise.withResolvers<void>();
5898
+ const releaseRecovery = Promise.withResolvers<void>();
5899
+ try {
5900
+ const real = await createTerminalizationSession(cwd, (model, context, options) => {
5901
+ providerCalls++;
5902
+ if (providerCalls === 1) return typedContextOverflowStream(model);
5903
+ return createMockModel({
5904
+ responses: [
5905
+ async () => {
5906
+ recoveryEntered.resolve();
5907
+ await releaseRecovery.promise;
5908
+ return { content: ["FINAL_OVERFLOW_RECOVERY"] };
5909
+ },
5910
+ ],
5911
+ }).stream(model, context, options);
5912
+ });
5913
+ session = real.session;
5914
+ authStorage = real.authStorage;
5915
+ if (!managed) session.setConfiguredModelChain("default", [selector(real.model)], "returning-overflow-test");
5916
+ const send = realSendUserMessage(session);
5917
+ harness = await invocationHarness("overflow-returning-bridge", cwd, {
5918
+ isIdle: () => !session?.isStreaming,
5919
+ sendUserMessage: (content, options) => {
5920
+ submission = send(content, options);
5921
+ void submission.then(
5922
+ () => {
5923
+ submissionSettled = true;
5924
+ },
5925
+ () => {
5926
+ submissionSettled = true;
5927
+ },
5928
+ );
5929
+ return submission;
5930
+ },
5931
+ });
5932
+ session.subscribe(event => harness?.emit(event.type, event));
5933
+ const accepted = await harness.control("turn.prompt", { text: "finish after the context overflow" });
5934
+ expect(accepted.ok).toBe(true);
5935
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
5936
+ expect(correlation.commandId).toBeDefined();
5937
+ expect(correlation.turnId).toBeDefined();
5938
+ await recoveryEntered.promise;
5939
+ expect(submissionSettled).toBe(false);
5940
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
5941
+ expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
5942
+ status: expect.stringMatching(/^(accepted|in_flight)$/),
5943
+ });
5944
+ releaseRecovery.resolve();
5945
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
5946
+ expect(terminal).toMatchObject({
5947
+ ...correlation,
5948
+ status: "terminal_ok",
5949
+ content: { text: "FINAL_OVERFLOW_RECOVERY" },
5950
+ outcome: { kind: "stopped", reason: "end_turn" },
5951
+ });
5952
+ expect(terminal.error).toBeUndefined();
5953
+ if (!submission) throw new Error("Expected the actual accepted overflow submission promise.");
5954
+ await submission;
5955
+ expect(submissionSettled).toBe(true);
5956
+ await session.waitForIdle();
5957
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
5958
+ expect(ends).toHaveLength(1);
5959
+ expect(ends[0]).toMatchObject({
5960
+ payload: { ...correlation, outcome: { kind: "stopped", reason: "end_turn" } },
5961
+ });
5962
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(managed ? 1 : 2);
5963
+ expect(providerCalls).toBe(2);
5964
+ } finally {
5965
+ releaseRecovery.resolve();
5966
+ await session?.dispose();
5967
+ authStorage?.close();
5968
+ await harness?.stop();
5969
+ await rm(cwd, { recursive: true, force: true });
5970
+ }
5971
+ });
5972
+
5973
+ test("preserves prompt correlation across a real overflow retry", async () => {
5974
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-correlation-"));
5975
+ let harness: InvocationHarness | undefined;
5976
+ let session: AgentSession | undefined;
5977
+ let authStorage: AuthStorage | undefined;
5978
+ let providerCalls = 0;
5979
+ try {
5980
+ const real = await createTerminalizationSession(cwd, async (model, context, options) => {
5981
+ providerCalls++;
5982
+ if (providerCalls === 1) return typedContextOverflowStream(model);
5983
+ return createMockModel({ responses: [{ content: ["recovered"] }] }).stream(model, context, options);
5984
+ });
5985
+ session = real.session;
5986
+ authStorage = real.authStorage;
5987
+
5988
+ harness = await invocationHarness("overflow-retry-correlation", cwd, {
5989
+ isIdle: () => !session?.isStreaming,
5990
+ sendUserMessage: deferredRealSendUserMessage(session),
5991
+ });
5992
+ session.subscribe(async event => {
5993
+ await harness?.emit(event.type, event);
5994
+ });
5995
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
5996
+ expect(accepted.ok).toBe(true);
5997
+ const correlation = {
5998
+ commandId: accepted.result?.commandId,
5999
+ turnId: accepted.result?.turnId,
6000
+ };
6001
+
6002
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6003
+ expect(terminal).toMatchObject({
6004
+ status: "terminal_ok",
6005
+ commandId: correlation.commandId,
6006
+ turnId: correlation.turnId,
6007
+ });
6008
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
6009
+ expect(ends).toHaveLength(1);
6010
+ expect(ends[0]).toMatchObject({
6011
+ payload: {
6012
+ commandId: correlation.commandId,
6013
+ turnId: correlation.turnId,
6014
+ },
6015
+ });
6016
+ expect(providerCalls).toBe(2);
6017
+ } finally {
6018
+ await session?.dispose();
6019
+ authStorage?.close();
6020
+ await harness?.stop();
6021
+ await rm(cwd, { recursive: true, force: true });
6022
+ }
6023
+ });
6024
+
6025
+ test("preserves prompt correlation across a todo-reminder continuation", async () => {
6026
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-continuation-"));
6027
+ let harness: InvocationHarness | undefined;
6028
+ let session: AgentSession | undefined;
6029
+ let authStorage: AuthStorage | undefined;
6030
+ let providerCalls = 0;
6031
+ const secondEntered = Promise.withResolvers<void>();
6032
+ const releaseSecond = Promise.withResolvers<void>();
6033
+ try {
6034
+ const real = await createTerminalizationSession(
6035
+ cwd,
6036
+ async (model, context, options) => {
6037
+ providerCalls++;
6038
+ return createMockModel({
6039
+ responses: [
6040
+ providerCalls === 2
6041
+ ? async () => {
6042
+ secondEntered.resolve();
6043
+ await releaseSecond.promise;
6044
+ return { content: ["FINAL"] };
6045
+ }
6046
+ : { content: ["FIRST"] },
6047
+ ],
6048
+ }).stream(model, context, options);
6049
+ },
6050
+ { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 },
6051
+ );
6052
+ session = real.session;
6053
+ authStorage = real.authStorage;
6054
+
6055
+ session.setTodoPhases([
6056
+ { name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
6057
+ ]);
6058
+ harness = await invocationHarness("todo-reminder-continuation", cwd, {
6059
+ isIdle: () => !session?.isStreaming,
6060
+ sendUserMessage: realSendUserMessage(session),
6061
+ });
6062
+ session.subscribe(async event => {
6063
+ await harness?.emit(event.type, event);
6064
+ });
6065
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
6066
+ expect(accepted.ok).toBe(true);
6067
+ const correlation = {
6068
+ commandId: accepted.result?.commandId,
6069
+ turnId: accepted.result?.turnId,
6070
+ };
6071
+ expect(correlation.commandId).toBeDefined();
6072
+ expect(correlation.turnId).toBeDefined();
6073
+ await secondEntered.promise;
6074
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
6075
+ expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
6076
+ status: expect.stringMatching(/^(accepted|in_flight)$/),
6077
+ });
6078
+ releaseSecond.resolve();
6079
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6080
+ expect(terminal).toMatchObject({
6081
+ status: "terminal_ok",
6082
+ commandId: correlation.commandId,
6083
+ turnId: correlation.turnId,
6084
+ content: { text: "FINAL" },
6085
+ });
6086
+ expect(terminal.error).toBeUndefined();
6087
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(2);
6088
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
6089
+ expect(ends).toHaveLength(1);
6090
+ expect(ends[0]).toMatchObject({
6091
+ payload: {
6092
+ commandId: correlation.commandId,
6093
+ turnId: correlation.turnId,
6094
+ },
6095
+ });
6096
+ expect(providerCalls).toBe(2);
6097
+ } finally {
6098
+ releaseSecond.resolve();
6099
+ await session?.dispose();
6100
+ authStorage?.close();
6101
+ await harness?.stop();
6102
+ await rm(cwd, { recursive: true, force: true });
6103
+ }
6104
+ });
6105
+
6106
+ test("terminalizes cancellation during a todo-reminder continuation", async () => {
6107
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-cancel-"));
6108
+ let harness: InvocationHarness | undefined;
6109
+ let session: AgentSession | undefined;
6110
+ let authStorage: AuthStorage | undefined;
6111
+ let providerCalls = 0;
6112
+ let secondSignal: AbortSignal | undefined;
6113
+ const secondEntered = Promise.withResolvers<void>();
6114
+ const releaseSecond = Promise.withResolvers<void>();
6115
+ try {
6116
+ const real = await createTerminalizationSession(
6117
+ cwd,
6118
+ async (model, context, options) => {
6119
+ providerCalls++;
6120
+ if (providerCalls === 2) {
6121
+ secondSignal = options?.signal;
6122
+ secondEntered.resolve();
6123
+ }
6124
+ const response =
6125
+ providerCalls === 2
6126
+ ? async () => {
6127
+ await releaseSecond.promise;
6128
+ return { content: ["completed"] };
6129
+ }
6130
+ : { content: ["started"] };
6131
+ return createMockModel({ responses: [response] }).stream(model, context, options);
6132
+ },
6133
+ { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 },
6134
+ );
6135
+ session = real.session;
6136
+ authStorage = real.authStorage;
6137
+ session.setTodoPhases([
6138
+ { name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
6139
+ ]);
6140
+ harness = await invocationHarness("todo-reminder-cancel", cwd, {
6141
+ isIdle: () => !session?.isStreaming,
6142
+ sendUserMessage: realSendUserMessage(session),
6143
+ });
6144
+ session.subscribe(async event => {
6145
+ await harness?.emit(event.type, event);
6146
+ });
6147
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
6148
+ expect(accepted.ok).toBe(true);
6149
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6150
+ expect(correlation.commandId).toBeDefined();
6151
+ expect(correlation.turnId).toBeDefined();
6152
+ await secondEntered.promise;
6153
+ expect(secondSignal?.aborted).toBe(false);
6154
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
6155
+ await session.abort();
6156
+ expect(secondSignal?.aborted).toBe(true);
6157
+ releaseSecond.resolve();
6158
+ await session.waitForIdle();
6159
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6160
+ expect(terminal).toMatchObject(correlation);
6161
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
6162
+ expect(ends).toHaveLength(1);
6163
+ expect(ends[0]).toMatchObject({ payload: correlation });
6164
+ expect(providerCalls).toBe(2);
6165
+ } finally {
6166
+ releaseSecond.resolve();
6167
+ await session?.dispose();
6168
+ authStorage?.close();
6169
+ await harness?.stop();
6170
+ await rm(cwd, { recursive: true, force: true });
6171
+ }
6172
+ });
6173
+
6174
+ test("terminalizes a synchronous throw during a todo-reminder continuation", async () => {
6175
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-throw-"));
6176
+ let harness: InvocationHarness | undefined;
6177
+ let session: AgentSession | undefined;
6178
+ let authStorage: AuthStorage | undefined;
6179
+ let providerCalls = 0;
6180
+ try {
6181
+ const real = await createTerminalizationSession(
6182
+ cwd,
6183
+ (model, context, options) => {
6184
+ providerCalls++;
6185
+ if (providerCalls === 2) throw new Error("todo continuation stream failed synchronously");
6186
+ return createMockModel({ responses: [{ content: ["started"] }] }).stream(model, context, options);
6187
+ },
6188
+ { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1, "retry.enabled": false },
6189
+ );
6190
+ session = real.session;
6191
+ session.setConfiguredModelChain("default", [selector(real.model)], "terminal-throw-test");
6192
+ authStorage = real.authStorage;
6193
+ session.setTodoPhases([
6194
+ { name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
6195
+ ]);
6196
+ harness = await invocationHarness("todo-reminder-throw", cwd, {
6197
+ isIdle: () => !session?.isStreaming,
6198
+ sendUserMessage: realSendUserMessage(session),
6199
+ });
6200
+ session.subscribe(async event => {
6201
+ await harness?.emit(event.type, event);
6202
+ });
6203
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
6204
+ expect(accepted.ok).toBe(true);
6205
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6206
+ expect(correlation.commandId).toBeDefined();
6207
+ expect(correlation.turnId).toBeDefined();
6208
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6209
+ expect(terminal).toMatchObject({
6210
+ ...correlation,
6211
+ status: "failed",
6212
+ outcome: { kind: "failed" },
6213
+ error: { code: "provider_rejected" },
6214
+ });
6215
+ // Wait for session to settle and ensure no further continuations are queued
6216
+ await session?.waitForIdle();
6217
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(2);
6218
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
6219
+ expect(ends).toHaveLength(1);
6220
+ expect(ends[0]).toMatchObject({ payload: { ...correlation, outcome: { kind: "failed" } } });
6221
+ expect(providerCalls).toBe(2);
6222
+ await session.waitForIdle();
6223
+ expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
6224
+ ...correlation,
6225
+ status: "failed",
6226
+ outcome: { kind: "failed" },
6227
+ });
6228
+ } finally {
6229
+ await session?.dispose();
6230
+ authStorage?.close();
6231
+ await harness?.stop();
6232
+ await rm(cwd, { recursive: true, force: true });
6233
+ }
6234
+ });
6235
+
6236
+ test("terminalizes a second overflow during overflow maintenance", async () => {
6237
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-maintenance-failure-"));
6238
+ let harness: InvocationHarness | undefined;
6239
+ let session: AgentSession | undefined;
6240
+ let authStorage: AuthStorage | undefined;
6241
+ try {
6242
+ const real = await createTerminalizationSession(cwd, model => {
6243
+ return typedContextOverflowStream(model);
6244
+ });
6245
+ session = real.session;
6246
+ authStorage = real.authStorage;
6247
+ harness = await invocationHarness("overflow-maintenance-failure", cwd, {
6248
+ isIdle: () => !session?.isStreaming,
6249
+ sendUserMessage: deferredRealSendUserMessage(session),
6250
+ });
6251
+ session.subscribe(async event => {
6252
+ await harness?.emit(event.type, event);
6253
+ });
6254
+ const accepted = await harness.control("turn.prompt", { text: "retry the outstanding work" });
6255
+ expect(accepted.ok).toBe(true);
6256
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6257
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6258
+ expect(terminal).toMatchObject({
6259
+ status: "failed",
6260
+ commandId: correlation.commandId,
6261
+ turnId: correlation.turnId,
6262
+ });
6263
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
6264
+ } finally {
6265
+ await session?.dispose();
6266
+ authStorage?.close();
6267
+ await harness?.stop();
6268
+ await rm(cwd, { recursive: true, force: true });
6269
+ }
6270
+ });
6271
+
6272
+ test("terminalizes cancellation during a scheduled overflow retry", async () => {
6273
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-cancel-"));
6274
+ let harness: InvocationHarness | undefined;
6275
+ let session: AgentSession | undefined;
6276
+ let authStorage: AuthStorage | undefined;
6277
+ let providerCalls = 0;
6278
+ const overflowEmitted = Promise.withResolvers<void>();
6279
+ try {
6280
+ const real = await createTerminalizationSession(
6281
+ cwd,
6282
+ model => {
6283
+ providerCalls++;
6284
+ return typedContextOverflowStream(
6285
+ model,
6286
+ providerCalls === 1 ? () => overflowEmitted.resolve() : undefined,
6287
+ );
6288
+ },
6289
+ { "retry.baseDelayMs": 1_000 },
6290
+ );
6291
+ session = real.session;
6292
+ authStorage = real.authStorage;
6293
+ session.setClientBridge({ capabilities: {}, deferAgentInitiatedTurns: true });
6294
+ harness = await invocationHarness("overflow-retry-cancel", cwd, {
6295
+ isIdle: () => !session?.isStreaming,
6296
+ sendUserMessage: deferredRealSendUserMessage(session),
6297
+ });
6298
+ session.subscribe(async event => {
6299
+ await harness?.emit(event.type, event);
6300
+ });
6301
+ const accepted = await harness.control("turn.prompt", { text: "cancel the retry" });
6302
+ expect(accepted.ok).toBe(true);
6303
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6304
+ await overflowEmitted.promise;
6305
+ await harness.control("turn.cancel", correlation);
6306
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6307
+ expect(terminal.status).toBe("failed");
6308
+ expect(terminal).toMatchObject(correlation);
6309
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
6310
+ await session.waitForIdle();
6311
+ expect(
6312
+ harness.broadcasts.filter(
6313
+ frame =>
6314
+ frame.kind === "agent_end" &&
6315
+ (frame.payload as { commandId?: string }).commandId === correlation.commandId,
6316
+ ),
6317
+ ).toHaveLength(1);
6318
+ } finally {
6319
+ await session?.dispose();
6320
+ authStorage?.close();
6321
+ await harness?.stop();
6322
+ await rm(cwd, { recursive: true, force: true });
6323
+ }
6324
+ });
6325
+
6326
+ test("terminalizes a synchronous throw from the overflow retry stream", async () => {
6327
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-throw-"));
6328
+ let harness: InvocationHarness | undefined;
6329
+ let session: AgentSession | undefined;
6330
+ let authStorage: AuthStorage | undefined;
6331
+ let providerCalls = 0;
6332
+ try {
6333
+ const real = await createTerminalizationSession(cwd, async model => {
6334
+ providerCalls++;
6335
+ if (providerCalls === 2) throw new Error("retry stream failed synchronously");
6336
+ return typedContextOverflowStream(model);
6337
+ });
6338
+ session = real.session;
6339
+ authStorage = real.authStorage;
6340
+ harness = await invocationHarness("overflow-retry-throw", cwd, {
6341
+ isIdle: () => !session?.isStreaming,
6342
+ sendUserMessage: deferredRealSendUserMessage(session),
6343
+ });
6344
+ session.subscribe(async event => {
6345
+ await harness?.emit(event.type, event);
6346
+ });
6347
+ const accepted = await harness.control("turn.prompt", { text: "retry with a throwing provider" });
6348
+ expect(accepted.ok).toBe(true);
6349
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6350
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6351
+ expect(terminal).toMatchObject({
6352
+ status: "failed",
6353
+ commandId: correlation.commandId,
6354
+ turnId: correlation.turnId,
6355
+ });
6356
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
6357
+ } finally {
6358
+ await session?.dispose();
6359
+ authStorage?.close();
6360
+ await harness?.stop();
6361
+ await rm(cwd, { recursive: true, force: true });
6362
+ }
6363
+ });
6364
+
6365
+ test("preserves external prompt correlation across a retryable provider error", async () => {
6366
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-retryable-provider-correlation-"));
6367
+ let harness: InvocationHarness | undefined;
6368
+ try {
6369
+ harness = await invocationHarness("retryable-provider-correlation", cwd, {
6370
+ sendUserMessage: async (_content, options) => {
6371
+ await options?.onPreflightAcceptCommit?.();
6372
+ await neverSettlingPromise();
6373
+ },
6374
+ });
6375
+ const accepted = await harness.control("turn.prompt", {
6376
+ text: "recover after a provider overload",
6377
+ clientRef: "retryable-provider-correlation",
6378
+ });
6379
+ expect(accepted.ok).toBe(true);
6380
+ const correlation = {
6381
+ commandId: accepted.result?.commandId,
6382
+ turnId: accepted.result?.turnId,
6383
+ };
6384
+ const retryScope = {};
6385
+
6386
+ for (let attempt = 0; attempt < 3; attempt++) {
6387
+ await harness.emit("agent_start", { lifecycleScope: retryScope });
6388
+ }
6389
+ await harness.emit("agent_end", {
6390
+ messages: [{ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "recovered" }] }],
6391
+ });
6392
+
6393
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6394
+ expect(terminal).toMatchObject({
6395
+ status: "terminal_ok",
6396
+ commandId: correlation.commandId,
6397
+ turnId: correlation.turnId,
6398
+ outcome: { kind: "stopped", reason: "end_turn" },
6399
+ });
6400
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end").at(-1)).toMatchObject({
6401
+ payload: {
6402
+ commandId: correlation.commandId,
6403
+ turnId: correlation.turnId,
6404
+ outcome: { kind: "stopped", reason: "end_turn" },
6405
+ },
6406
+ });
6407
+ } finally {
6408
+ await harness?.stop();
6409
+ await rm(cwd, { recursive: true, force: true });
6410
+ }
6411
+ });
6412
+
4565
6413
  test.each([
4566
6414
  { status: 402, code: "provider_http_402" },
4567
6415
  { status: 429, code: "provider_http_429" },
@@ -5209,6 +7057,85 @@ describe("post-acceptance invocation terminalization", () => {
5209
7057
  }
5210
7058
  });
5211
7059
 
7060
+ test("tokenless retry boundaries release replacement terminal publication and replay", async () => {
7061
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-tokenless-retry-reopen-"));
7062
+ const sessionId = "tokenless-retry-reopen";
7063
+ const harness = await invocationHarness(sessionId, cwd, {});
7064
+ const scopes = createAttemptMinter();
7065
+ const activity = spyOn(SessionSdkSessionRuntime.prototype, "reportActivity");
7066
+ try {
7067
+ // Accepted ordinary retries suppress predecessor ends. Three attempts
7068
+ // therefore share one public lifecycle boundary, not three unmatched ends.
7069
+ const lifecycleScope = scopes.mint("main");
7070
+ for (let attempt = 0; attempt < 3; attempt++) {
7071
+ await harness.emit("agent_start", { lifecycleScope });
7072
+ }
7073
+ await harness.emit("agent_end");
7074
+ for (let turn = 0; turn < 2; turn++) {
7075
+ await harness.emit("agent_start", { lifecycleScope: scopes.mint("main") });
7076
+ await harness.emit("agent_end");
7077
+ }
7078
+ await harness.switchSession("different-session");
7079
+ await harness.switchSession(sessionId);
7080
+ const priorEnds = harness.broadcasts.filter(frame => frame.kind === "agent_end").length;
7081
+ await harness.emit("agent_start", { lifecycleScope: scopes.mint("main") });
7082
+ await harness.emit("agent_end");
7083
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(priorEnds + 1);
7084
+ expect(activity.mock.calls.at(-1)?.[0]).toBe("idle");
7085
+ expect(await harness.requestOnSession(sessionId, { type: "event_replay", sinceSeq: 0 })).toMatchObject({
7086
+ type: "event_replay_result",
7087
+ events: expect.arrayContaining([expect.objectContaining({ kind: "agent_end" })]),
7088
+ });
7089
+ } finally {
7090
+ activity.mockRestore();
7091
+ await harness.stop();
7092
+ await rm(cwd, { recursive: true, force: true });
7093
+ }
7094
+ });
7095
+
7096
+ test("tokenless retry preserves attached owners alongside newly promoted SDK work", async () => {
7097
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-tokenless-retry-owners-"));
7098
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7099
+ const harness = await invocationHarness("tokenless-retry-owners", cwd, {
7100
+ sendUserMessage: async (_content, options) => {
7101
+ await options?.onPreflightAcceptCommit?.();
7102
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7103
+ options?.onPreflightAccepted?.();
7104
+ },
7105
+ });
7106
+ try {
7107
+ const lifecycleScope = createAttemptMinter().mint("main");
7108
+ await harness.emit("agent_start", { lifecycleScope });
7109
+ const attached = await harness.control("turn.follow_up", { text: "attached work" });
7110
+ const queued = await harness.control("turn.follow_up", { text: "promoted work" });
7111
+ expect(attached.ok).toBe(true);
7112
+ expect(queued.ok).toBe(true);
7113
+ expect(promotions).toHaveLength(2);
7114
+ promotions[0]!({ startsOwnRun: false });
7115
+ promotions[1]!({ startsOwnRun: true });
7116
+ await harness.emit("agent_start", { lifecycleScope });
7117
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "shared retry final" }] });
7118
+ for (const accepted of [attached, queued]) {
7119
+ expect(
7120
+ await harness.query("turn.result", {
7121
+ kind: "prompt",
7122
+ commandId: accepted.result?.commandId,
7123
+ turnId: accepted.result?.turnId,
7124
+ }),
7125
+ ).toMatchObject({
7126
+ result: {
7127
+ status: "terminal_ok",
7128
+ receiptState: "present",
7129
+ content: { text: "shared retry final" },
7130
+ },
7131
+ });
7132
+ }
7133
+ } finally {
7134
+ await harness.stop();
7135
+ await rm(cwd, { recursive: true, force: true });
7136
+ }
7137
+ });
7138
+
5212
7139
  test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
5213
7140
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
5214
7141
  try {
@@ -5274,6 +7201,129 @@ describe("post-acceptance invocation terminalization", () => {
5274
7201
  await rm(cwd, { recursive: true, force: true });
5275
7202
  }
5276
7203
  });
7204
+ test("unowned predecessor end cannot publish a queued successor receipt", async () => {
7205
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-predecessor-"));
7206
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7207
+ const harness = await invocationHarness("unowned-predecessor", cwd, {
7208
+ sendUserMessage: async (_content, options) => {
7209
+ await options?.onPreflightAcceptCommit?.();
7210
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7211
+ options?.onPreflightAccepted?.();
7212
+ },
7213
+ });
7214
+ try {
7215
+ await harness.emit("agent_start");
7216
+ const accepted = await harness.control("turn.follow_up", { text: "queued verification" });
7217
+ expect(accepted.ok).toBe(true);
7218
+ expect(promotions).toHaveLength(1);
7219
+ promotions[0]!({ startsOwnRun: true });
7220
+ await harness.emit("agent_start");
7221
+ const selector = {
7222
+ kind: "prompt",
7223
+ commandId: accepted.result?.commandId,
7224
+ turnId: accepted.result?.turnId,
7225
+ };
7226
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "predecessor final" }] });
7227
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7228
+ result: { status: "in_flight", receiptState: "absent" },
7229
+ });
7230
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "verification final" }] });
7231
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7232
+ result: {
7233
+ status: "terminal_ok",
7234
+ turnId: accepted.result?.turnId,
7235
+ receiptState: "present",
7236
+ content: { text: "verification final" },
7237
+ },
7238
+ });
7239
+ } finally {
7240
+ await harness.stop();
7241
+ await rm(cwd, { recursive: true, force: true });
7242
+ }
7243
+ });
7244
+
7245
+ test("unowned run stays paired after predecessor SDK references retire", async () => {
7246
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-retirement-"));
7247
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7248
+ const harness = await invocationHarness("unowned-retirement", cwd, {
7249
+ sendUserMessage: async (_content, options) => {
7250
+ await options?.onPreflightAcceptCommit?.();
7251
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7252
+ options?.onPreflightAccepted?.();
7253
+ },
7254
+ });
7255
+ try {
7256
+ const first = await harness.control("turn.follow_up", { text: "first" });
7257
+ expect(promotions).toHaveLength(1);
7258
+ promotions[0]!({ startsOwnRun: true });
7259
+ await harness.emit("agent_start");
7260
+ await harness.emit("agent_start");
7261
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "first final" }] });
7262
+ expect(
7263
+ await harness.query("turn.result", {
7264
+ kind: "prompt",
7265
+ commandId: first.result?.commandId,
7266
+ turnId: first.result?.turnId,
7267
+ }),
7268
+ ).toMatchObject({ result: { status: "terminal_ok", content: { text: "first final" } } });
7269
+ const next = await harness.control("turn.follow_up", { text: "next" });
7270
+ expect(promotions).toHaveLength(2);
7271
+ promotions[1]!({ startsOwnRun: true });
7272
+ await harness.emit("agent_start");
7273
+ const selector = { kind: "prompt", commandId: next.result?.commandId, turnId: next.result?.turnId };
7274
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "unowned final" }] });
7275
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7276
+ result: { status: "in_flight", receiptState: "absent" },
7277
+ });
7278
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "next final" }] });
7279
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7280
+ result: { status: "terminal_ok", content: { text: "next final" } },
7281
+ });
7282
+ } finally {
7283
+ await harness.stop();
7284
+ await rm(cwd, { recursive: true, force: true });
7285
+ }
7286
+ });
7287
+
7288
+ test("unowned run retains attached invocation across predecessor completion", async () => {
7289
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-attached-"));
7290
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7291
+ const harness = await invocationHarness("unowned-attached", cwd, {
7292
+ sendUserMessage: async (_content, options) => {
7293
+ await options?.onPreflightAcceptCommit?.();
7294
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7295
+ options?.onPreflightAccepted?.();
7296
+ },
7297
+ });
7298
+ try {
7299
+ await harness.control("turn.follow_up", { text: "predecessor" });
7300
+ expect(promotions).toHaveLength(1);
7301
+ promotions[0]!({ startsOwnRun: true });
7302
+ await harness.emit("agent_start");
7303
+ await harness.emit("agent_start");
7304
+ const attached = await harness.control("turn.follow_up", { text: "attach to current unowned run" });
7305
+ expect(attached.ok).toBe(true);
7306
+ expect(promotions).toHaveLength(2);
7307
+ promotions[1]!({ startsOwnRun: false });
7308
+ const selector = {
7309
+ kind: "prompt",
7310
+ commandId: attached.result?.commandId,
7311
+ turnId: attached.result?.turnId,
7312
+ };
7313
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "predecessor final" }] });
7314
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7315
+ result: { status: expect.stringMatching(/accepted|in_flight/), receiptState: "absent" },
7316
+ });
7317
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "attached final" }] });
7318
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7319
+ result: { status: "terminal_ok", content: { text: "attached final" } },
7320
+ });
7321
+ } finally {
7322
+ await harness.stop();
7323
+ await rm(cwd, { recursive: true, force: true });
7324
+ }
7325
+ });
7326
+
5277
7327
  test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
5278
7328
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
5279
7329
  try {
@@ -5737,104 +7787,101 @@ describe("post-acceptance invocation terminalization", () => {
5737
7787
  await rm(cwd, { recursive: true, force: true });
5738
7788
  }
5739
7789
  });
5740
- test("fails closed without independent terminal evidence", async () => {
5741
- const cases = [
5742
- { name: "usage-omitted", content: [{ type: "thinking", thinking: "" }], omitUsage: true },
5743
- { name: "usage-null", content: [{ type: "thinking", thinking: "" }], usage: null },
5744
- { name: "primitive-usage", content: [{ type: "thinking", thinking: "" }], usage: "bad" },
5745
- { name: "array-usage", content: [{ type: "thinking", thinking: "" }], usage: [] },
5746
- { name: "missing-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { input: 0 } },
5747
- {
5748
- name: "undefined-total-tokens",
5749
- content: [{ type: "thinking", thinking: "" }],
5750
- usage: { totalTokens: undefined },
5751
- },
5752
- { name: "negative-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { totalTokens: -1 } },
5753
- {
5754
- name: "nan-total-tokens",
5755
- content: [{ type: "thinking", thinking: "" }],
5756
- usage: { totalTokens: Number.NaN },
5757
- },
5758
- {
5759
- name: "infinite-total-tokens",
5760
- content: [{ type: "thinking", thinking: "" }],
5761
- usage: { totalTokens: Number.POSITIVE_INFINITY },
5762
- },
5763
- {
5764
- name: "incomplete-tool-call",
5765
- content: [
5766
- {
5767
- type: "toolCall",
5768
- id: "call-1",
5769
- name: "read",
5770
- arguments: {},
5771
- incompleteArguments: true,
5772
- incompleteArgumentsReason: "truncated",
5773
- },
5774
- ],
5775
- usage: { totalTokens: 0 },
5776
- },
5777
- {
5778
- name: "malformed-tool-arguments",
5779
- content: [{ type: "toolCall", id: "call-1", name: "read", arguments: null }],
5780
- usage: { totalTokens: 0 },
5781
- },
5782
- {
5783
- name: "orphaned-incomplete-reason",
5784
- content: [
5785
- {
5786
- type: "toolCall",
5787
- id: "call-1",
5788
- name: "read",
5789
- arguments: {},
5790
- incompleteArgumentsReason: "malformed",
5791
- },
5792
- ],
5793
- usage: { totalTokens: 0 },
5794
- },
5795
- ] as const;
5796
- for (const testCase of cases) {
5797
- const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-malformed-${testCase.name}-`));
5798
- try {
5799
- const harness = await invocationHarness(`terminal-malformed-${testCase.name}`, cwd, {
5800
- sendUserMessage: async (_content, options) => {
5801
- await options?.onPreflightAcceptCommit?.();
5802
- await Promise.withResolvers<void>().promise;
5803
- },
5804
- });
5805
- const accepted = await harness.control("turn.prompt", { text: "hello" });
5806
- await harness.emit("agent_start");
5807
- await harness.emit("agent_end", {
5808
- messages: [
5809
- {
5810
- role: "assistant",
5811
- content: testCase.content,
5812
- ...("omitUsage" in testCase ? {} : { usage: testCase.usage }),
5813
- },
5814
- ],
5815
- });
5816
- expect(
5817
- await settledStatus(harness, "turn.result", {
5818
- kind: "prompt",
5819
- commandId: accepted.result?.commandId,
5820
- turnId: accepted.result?.turnId,
5821
- }),
5822
- ).toMatchObject({
5823
- status: "failed",
5824
- error: { code: "prompt_failed", message: "Agent run failed after execution started." },
5825
- outcome: {
5826
- kind: "failed",
5827
- code: "prompt_failed",
5828
- message: "Agent run failed after execution started.",
5829
- provenance: "agent_failed",
5830
- phase: "post_start",
5831
- category: "agent_runtime",
7790
+ test.each([
7791
+ { name: "usage-omitted", content: [{ type: "thinking", thinking: "" }], omitUsage: true },
7792
+ { name: "usage-null", content: [{ type: "thinking", thinking: "" }], usage: null },
7793
+ { name: "primitive-usage", content: [{ type: "thinking", thinking: "" }], usage: "bad" },
7794
+ { name: "array-usage", content: [{ type: "thinking", thinking: "" }], usage: [] },
7795
+ { name: "missing-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { input: 0 } },
7796
+ {
7797
+ name: "undefined-total-tokens",
7798
+ content: [{ type: "thinking", thinking: "" }],
7799
+ usage: { totalTokens: undefined },
7800
+ },
7801
+ { name: "negative-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { totalTokens: -1 } },
7802
+ {
7803
+ name: "nan-total-tokens",
7804
+ content: [{ type: "thinking", thinking: "" }],
7805
+ usage: { totalTokens: Number.NaN },
7806
+ },
7807
+ {
7808
+ name: "infinite-total-tokens",
7809
+ content: [{ type: "thinking", thinking: "" }],
7810
+ usage: { totalTokens: Number.POSITIVE_INFINITY },
7811
+ },
7812
+ {
7813
+ name: "incomplete-tool-call",
7814
+ content: [
7815
+ {
7816
+ type: "toolCall",
7817
+ id: "call-1",
7818
+ name: "read",
7819
+ arguments: {},
7820
+ incompleteArguments: true,
7821
+ incompleteArgumentsReason: "truncated",
7822
+ },
7823
+ ],
7824
+ usage: { totalTokens: 0 },
7825
+ },
7826
+ {
7827
+ name: "malformed-tool-arguments",
7828
+ content: [{ type: "toolCall", id: "call-1", name: "read", arguments: null }],
7829
+ usage: { totalTokens: 0 },
7830
+ },
7831
+ {
7832
+ name: "orphaned-incomplete-reason",
7833
+ content: [
7834
+ {
7835
+ type: "toolCall",
7836
+ id: "call-1",
7837
+ name: "read",
7838
+ arguments: {},
7839
+ incompleteArgumentsReason: "malformed",
7840
+ },
7841
+ ],
7842
+ usage: { totalTokens: 0 },
7843
+ },
7844
+ ] as const)("fails closed without independent terminal evidence ($name)", async testCase => {
7845
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-malformed-${testCase.name}-`));
7846
+ try {
7847
+ const harness = await invocationHarness(`terminal-malformed-${testCase.name}`, cwd, {
7848
+ sendUserMessage: async (_content, options) => {
7849
+ await options?.onPreflightAcceptCommit?.();
7850
+ await Promise.withResolvers<void>().promise;
7851
+ },
7852
+ });
7853
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
7854
+ await harness.emit("agent_start");
7855
+ await harness.emit("agent_end", {
7856
+ messages: [
7857
+ {
7858
+ role: "assistant",
7859
+ content: testCase.content,
7860
+ ...("omitUsage" in testCase ? {} : { usage: testCase.usage }),
5832
7861
  },
5833
- });
5834
- await harness.stop();
5835
- } finally {
5836
- await rm(cwd, { recursive: true, force: true });
5837
- }
7862
+ ],
7863
+ });
7864
+ expect(
7865
+ await settledStatus(harness, "turn.result", {
7866
+ kind: "prompt",
7867
+ commandId: accepted.result?.commandId,
7868
+ turnId: accepted.result?.turnId,
7869
+ }),
7870
+ ).toMatchObject({
7871
+ status: "failed",
7872
+ error: { code: "prompt_failed", message: "Agent run failed after execution started." },
7873
+ outcome: {
7874
+ kind: "failed",
7875
+ code: "prompt_failed",
7876
+ message: "Agent run failed after execution started.",
7877
+ provenance: "agent_failed",
7878
+ phase: "post_start",
7879
+ category: "agent_runtime",
7880
+ },
7881
+ });
7882
+ await harness.stop();
7883
+ } finally {
7884
+ await rm(cwd, { recursive: true, force: true });
5838
7885
  }
5839
7886
  });
5840
7887
  test("preserves explicit cancellation for an empty zero-token turn", async () => {
@@ -5898,114 +7945,111 @@ describe("post-acceptance invocation terminalization", () => {
5898
7945
  await rm(cwd, { recursive: true, force: true });
5899
7946
  }
5900
7947
  });
5901
- test("reconciles contract-valid skill completion and agent_end ordering", async () => {
5902
- const cases = [
5903
- {
5904
- name: "completion-real-agent-real",
5905
- order: "completion-first",
5906
- completion: "completion",
5907
- agent: "agent",
5908
- expected: "completion",
5909
- },
5910
- {
5911
- name: "completion-blank-agent-real",
5912
- order: "completion-first",
5913
- completion: " ",
5914
- agent: "agent",
5915
- expected: "agent",
5916
- },
5917
- {
5918
- name: "completion-none-agent-real",
5919
- order: "completion-first",
5920
- completion: null,
5921
- agent: "agent",
5922
- expected: "agent",
5923
- },
5924
- {
5925
- name: "agent-real-completion-real",
5926
- order: "agent-first",
5927
- completion: "completion",
5928
- agent: "agent",
5929
- expected: "agent",
5930
- },
5931
- {
5932
- name: "agent-real-completion-blank",
5933
- order: "agent-first",
5934
- completion: " ",
5935
- agent: "agent",
5936
- expected: "agent",
5937
- },
5938
- {
5939
- name: "agent-blank-completion-real",
5940
- order: "agent-first",
5941
- completion: "completion",
5942
- agent: " ",
5943
- expected: "completion",
5944
- },
5945
- {
5946
- name: "agent-blank-completion-none",
5947
- order: "agent-first",
5948
- completion: null,
5949
- agent: " ",
5950
- expected: undefined,
5951
- },
5952
- {
5953
- name: "agent-none-completion-real",
5954
- order: "agent-first",
5955
- completion: "completion",
5956
- agent: null,
5957
- expected: "completion",
5958
- },
5959
- {
5960
- name: "agent-none-completion-blank",
5961
- order: "agent-first",
5962
- completion: " ",
5963
- agent: null,
5964
- expected: undefined,
5965
- },
5966
- ] as const;
5967
- for (const testCase of cases) {
5968
- const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`));
5969
- const completion = Promise.withResolvers<unknown>();
5970
- const completionReconciled = Promise.withResolvers<void>();
5971
- try {
5972
- const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, {
5973
- onInvocationCompletionReconciled: kind => {
5974
- if (kind === "skill") completionReconciled.resolve();
5975
- },
5976
- invokeSkill: async (_name, _args, options) => {
5977
- await options?.onPreflightAcceptCommit?.();
5978
- return await completion.promise;
5979
- },
7948
+ test.each([
7949
+ {
7950
+ name: "completion-real-agent-real",
7951
+ order: "completion-first",
7952
+ completion: "completion",
7953
+ agent: "agent",
7954
+ expected: "completion",
7955
+ },
7956
+ {
7957
+ name: "completion-blank-agent-real",
7958
+ order: "completion-first",
7959
+ completion: " ",
7960
+ agent: "agent",
7961
+ expected: "agent",
7962
+ },
7963
+ {
7964
+ name: "completion-none-agent-real",
7965
+ order: "completion-first",
7966
+ completion: null,
7967
+ agent: "agent",
7968
+ expected: "agent",
7969
+ },
7970
+ {
7971
+ name: "agent-real-completion-real",
7972
+ order: "agent-first",
7973
+ completion: "completion",
7974
+ agent: "agent",
7975
+ expected: "agent",
7976
+ },
7977
+ {
7978
+ name: "agent-real-completion-blank",
7979
+ order: "agent-first",
7980
+ completion: " ",
7981
+ agent: "agent",
7982
+ expected: "agent",
7983
+ },
7984
+ {
7985
+ name: "agent-blank-completion-real",
7986
+ order: "agent-first",
7987
+ completion: "completion",
7988
+ agent: " ",
7989
+ expected: "completion",
7990
+ },
7991
+ {
7992
+ name: "agent-blank-completion-none",
7993
+ order: "agent-first",
7994
+ completion: null,
7995
+ agent: " ",
7996
+ expected: undefined,
7997
+ },
7998
+ {
7999
+ name: "agent-none-completion-real",
8000
+ order: "agent-first",
8001
+ completion: "completion",
8002
+ agent: null,
8003
+ expected: "completion",
8004
+ },
8005
+ {
8006
+ name: "agent-none-completion-blank",
8007
+ order: "agent-first",
8008
+ completion: " ",
8009
+ agent: null,
8010
+ expected: undefined,
8011
+ },
8012
+ ] as const)("reconciles contract-valid skill completion and agent_end ordering ($name)", async testCase => {
8013
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`));
8014
+ const completion = Promise.withResolvers<unknown>();
8015
+ const completionReconciled = Promise.withResolvers<void>();
8016
+ try {
8017
+ const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, {
8018
+ onInvocationCompletionReconciled: kind => {
8019
+ if (kind === "skill") completionReconciled.resolve();
8020
+ },
8021
+ invokeSkill: async (_name, _args, options) => {
8022
+ await options?.onPreflightAcceptCommit?.();
8023
+ return await completion.promise;
8024
+ },
8025
+ });
8026
+ const accepted = await harness.control("skill.invoke", { name: "ralplan" });
8027
+ const selector = {
8028
+ kind: "skill" as const,
8029
+ commandId: accepted.result?.commandId,
8030
+ turnId: accepted.result?.turnId,
8031
+ };
8032
+ await harness.emit("agent_start");
8033
+ const emitAgentEnd = () =>
8034
+ harness.emit("agent_end", {
8035
+ messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
5980
8036
  });
5981
- const accepted = await harness.control("skill.invoke", { name: "ralplan" });
5982
- const selector = {
5983
- kind: "skill" as const,
5984
- commandId: accepted.result?.commandId,
5985
- turnId: accepted.result?.turnId,
5986
- };
5987
- await harness.emit("agent_start");
5988
- const emitAgentEnd = () =>
5989
- harness.emit("agent_end", {
5990
- messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
5991
- });
5992
- if (testCase.order === "completion-first") {
5993
- completion.resolve(testCase.completion);
5994
- await completionReconciled.promise;
5995
- await emitAgentEnd();
5996
- } else {
5997
- await emitAgentEnd();
5998
- completion.resolve(testCase.completion);
5999
- await completionReconciled.promise;
6000
- }
6001
- const result = await harness.query("turn.result", selector);
6002
- const content = (result.result as { content?: { text?: string } } | undefined)?.content;
6003
- expect(content?.text).toBe(testCase.expected);
6004
- await harness.stop();
6005
- } finally {
6006
- completion.resolve(undefined);
6007
- await rm(cwd, { recursive: true, force: true });
8037
+ if (testCase.order === "completion-first") {
8038
+ completion.resolve(testCase.completion);
8039
+ await completionReconciled.promise;
8040
+ await emitAgentEnd();
8041
+ } else {
8042
+ await emitAgentEnd();
8043
+ completion.resolve(testCase.completion);
8044
+ await completionReconciled.promise;
6008
8045
  }
8046
+ const result = await harness.query("turn.result", selector);
8047
+ const content = (result.result as { content?: { text?: string } } | undefined)?.content;
8048
+ expect(content?.text).toBe(testCase.expected);
8049
+ await harness.stop();
8050
+ } finally {
8051
+ completion.resolve(undefined);
8052
+ await rm(cwd, { recursive: true, force: true });
6009
8053
  }
6010
8054
  }, 30_000);
6011
8055
  test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
@@ -6016,6 +8060,11 @@ describe("post-acceptance invocation terminalization", () => {
6016
8060
  sendUserMessage: async (_content, options) => {
6017
8061
  if (options?.deliverAs === "followUp") {
6018
8062
  await options?.onPreflightAcceptCommit?.();
8063
+ options.preflightSignal?.addEventListener(
8064
+ "abort",
8065
+ () => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
8066
+ { once: true },
8067
+ );
6019
8068
  // #queueFollowUp resolves immediately; the turn has not run yet.
6020
8069
  return;
6021
8070
  }
@@ -6078,6 +8127,11 @@ describe("post-acceptance invocation terminalization", () => {
6078
8127
  sendUserMessage: async (_content, options) => {
6079
8128
  // Session is streaming: sendUserMessage diverts to #queueSteer and resolves.
6080
8129
  await options?.onPreflightAcceptCommit?.();
8130
+ options?.preflightSignal?.addEventListener(
8131
+ "abort",
8132
+ () => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
8133
+ { once: true },
8134
+ );
6081
8135
  },
6082
8136
  isIdle: () => false,
6083
8137
  });
@@ -6102,6 +8156,11 @@ describe("post-acceptance invocation terminalization", () => {
6102
8156
  // The session is streaming when the submission starts (divert to steer).
6103
8157
  // During accept()->persist(), the prior turn unwinds and isIdle flips to true.
6104
8158
  await options?.onPreflightAcceptCommit?.();
8159
+ options?.preflightSignal?.addEventListener(
8160
+ "abort",
8161
+ () => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
8162
+ { once: true },
8163
+ );
6105
8164
  idle = true;
6106
8165
  },
6107
8166
  isIdle: () => idle,
@@ -6470,8 +8529,15 @@ describe("accepted-control zero-execution bound (#4668)", () => {
6470
8529
  try {
6471
8530
  harness = await invocationHarness(sessionId, cwd, {
6472
8531
  settings: {
8532
+ // This test covers deferred terminal recovery, not deadline-triggered worktree persistence.
6473
8533
  get: (key: string) =>
6474
- key === "sdk.promptDeadlineMs" ? 150 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
8534
+ key === "sdk.promptDeadlineMs"
8535
+ ? 150
8536
+ : key === "sdk.promptMaxRuntimeMs"
8537
+ ? 60_000
8538
+ : key === "sdk.flushWorktreeOnDeadline"
8539
+ ? false
8540
+ : undefined,
6475
8541
  } as unknown as Settings,
6476
8542
  sendUserMessage: async (_content, options) => {
6477
8543
  await options?.onPreflightAcceptCommit?.();
@@ -6531,7 +8597,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
6531
8597
  });
6532
8598
 
6533
8599
  activeTools.clear();
6534
- expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
8600
+ // Keep this inside the 60s hard-runtime budget while tolerating a slow CI retry tick.
8601
+ expect(await settledStatus(harness, "turn.prompt_status", correlation, 30_000)).toMatchObject({
6535
8602
  status: "terminal_ok",
6536
8603
  outcome: { kind: "stopped", reason: "cancelled" },
6537
8604
  });
@@ -6541,7 +8608,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
6541
8608
  await Bun.sleep(10);
6542
8609
  await rm(cwd, { recursive: true, force: true });
6543
8610
  }
6544
- });
8611
+ }, 60_000);
6545
8612
 
6546
8613
  test("an unproven real end stays private until exact settlement evidence exists", async () => {
6547
8614
  // A matching lifecycle end still cannot settle a recovered deadline record
@@ -6928,10 +8995,16 @@ describe("accepted-control zero-execution bound (#4668)", () => {
6928
8995
  promptDeadlineMs = 100;
6929
8996
  const queued = await harness.control("turn.follow_up", { text: "queued prompt" });
6930
8997
  expect(queued.ok).toBe(true);
6931
- promoted?.({ startsOwnRun: true });
6932
- promptDeadlineMs = 600_000;
6933
8998
  const activeIds = { commandId: active.result?.commandId, turnId: active.result?.turnId };
6934
8999
  const queuedIds = { commandId: queued.result?.commandId, turnId: queued.result?.turnId };
9000
+ // The short lease belongs to an unconsumed queue entry, not to the root
9001
+ // run: it stays accepted past two lease periods without borrowing root abort.
9002
+ await Bun.sleep(250);
9003
+ expect(await harness.query("turn.prompt_status", queuedIds)).toMatchObject({ result: { status: "accepted" } });
9004
+ expect(abortCalls).toBe(0);
9005
+ expect(correlatedFrames(harness, queuedIds).filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
9006
+ promoted?.({ startsOwnRun: true });
9007
+ promptDeadlineMs = 600_000;
6935
9008
  await Bun.sleep(150);
6936
9009
  expect((await harness.query("turn.prompt_status", queuedIds)).result?.status).toMatch(/accepted|in_flight/);
6937
9010
  expect(await harness.query("turn.prompt_status", activeIds)).toMatchObject({
@@ -7711,7 +9784,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
7711
9784
  sessionId: transport.sessionId,
7712
9785
  });
7713
9786
  const seamCalls: Array<{ handle: string; scope: string }> = [];
7714
- createSdkSessionRuntimeExtension(api, {
9787
+ createTestRuntimeExtension(api, {
7715
9788
  agentDir: cwd,
7716
9789
  createTransport: async () => transport,
7717
9790
  terminalAbortSeams: {
@@ -7774,6 +9847,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
7774
9847
  expect((await statusOf(idsB, "inrun-status-b")).result?.status).toBe("terminal_ok");
7775
9848
  // A separate later turn starts: the consumed correlation must NOT be
7776
9849
  // drained into it, so conn-b owns nothing and its abort is refused.
9850
+ idle = true;
7777
9851
  transport.feed("conn-c", {
7778
9852
  type: "control_request",
7779
9853
  id: "inrun-c",
@@ -8784,7 +10858,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8784
10858
  sessionId: transport.sessionId,
8785
10859
  });
8786
10860
  const seamCalls: Array<{ handle: string; scope: string }> = [];
8787
- createSdkSessionRuntimeExtension(api, {
10861
+ createTestRuntimeExtension(api, {
8788
10862
  agentDir: cwd,
8789
10863
  createTransport: async () => transport,
8790
10864
  terminalAbortSeams: {
@@ -8925,7 +10999,7 @@ test("SDK-only host never advances a finalized uncertain row for a mismatched re
8925
10999
  sessionId: transport.sessionId,
8926
11000
  });
8927
11001
  const seamCalls: Array<{ handle: string; scope: string }> = [];
8928
- createSdkSessionRuntimeExtension(api, {
11002
+ createTestRuntimeExtension(api, {
8929
11003
  agentDir: cwd,
8930
11004
  createTransport: async () => transport,
8931
11005
  terminalAbortSeams: {
@@ -9018,7 +11092,7 @@ test("SDK-only host FIFO-expires tombstones instead of failing the finalization
9018
11092
  sessionFile: path.join(cwd, "session.json"),
9019
11093
  sessionId: transport.sessionId,
9020
11094
  });
9021
- createSdkSessionRuntimeExtension(api, {
11095
+ createTestRuntimeExtension(api, {
9022
11096
  agentDir: cwd,
9023
11097
  createTransport: async () => transport,
9024
11098
  terminalAbortSeams: {
@@ -9094,7 +11168,7 @@ test("SDK-only host cancels only the aborting requester's preflight while anothe
9094
11168
  sessionFile: path.join(cwd, "session.json"),
9095
11169
  sessionId: transport.sessionId,
9096
11170
  });
9097
- createSdkSessionRuntimeExtension(api, {
11171
+ createTestRuntimeExtension(api, {
9098
11172
  agentDir: cwd,
9099
11173
  createTransport: async () => transport,
9100
11174
  terminalAbortSeams: {
@@ -9184,7 +11258,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
9184
11258
  sessionFile: path.join(cwd, "session.json"),
9185
11259
  sessionId: transport.sessionId,
9186
11260
  });
9187
- createSdkSessionRuntimeExtension(api, {
11261
+ createTestRuntimeExtension(api, {
9188
11262
  agentDir: cwd,
9189
11263
  createTransport: async () => transport,
9190
11264
  terminalAbortSeams: {
@@ -9257,7 +11331,7 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
9257
11331
  });
9258
11332
  let captureCalls = 0;
9259
11333
  let discardCalls = 0;
9260
- createSdkSessionRuntimeExtension(api, {
11334
+ createTestRuntimeExtension(api, {
9261
11335
  agentDir: cwd,
9262
11336
  createTransport: async () => transport,
9263
11337
  terminalAbortSeams: {
@@ -9372,6 +11446,318 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
9372
11446
  }
9373
11447
  });
9374
11448
 
11449
+ test.each([
11450
+ "hold",
11451
+ "fail",
11452
+ "removed-before-shutdown",
11453
+ "timeout",
11454
+ "failed-stop",
11455
+ ] as const)("SDK-only shutdown joins queued terminal publication (%s)", async mode => {
11456
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-queued-shutdown-"));
11457
+ let removeQueued: (() => void) | undefined;
11458
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
11459
+ let queued = false;
11460
+ let queueSignal: AbortSignal | undefined;
11461
+ const api = {
11462
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
11463
+ handlers.set(event, handler);
11464
+ },
11465
+ sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
11466
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
11467
+ queued = true;
11468
+ queueSignal = options?.preflightSignal;
11469
+ removeQueued = () => {
11470
+ if (!queued) return;
11471
+ queued = false;
11472
+ options?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
11473
+ };
11474
+ queueSignal?.addEventListener("abort", removeQueued, { once: true });
11475
+ options?.onPreflightAccepted?.();
11476
+ return {};
11477
+ }),
11478
+ } as unknown as ExtensionAPI;
11479
+ const transport = memoryTransport();
11480
+ let transportStops = 0;
11481
+ const originalStop = transport.stop.bind(transport);
11482
+ const stopSpy = spyOn(transport, "stop").mockImplementation(async () => {
11483
+ transportStops++;
11484
+ if (mode === "failed-stop" && transportStops === 1) throw new Error("Injected first transport stop failure");
11485
+ await originalStop();
11486
+ });
11487
+ const sessionFile = path.join(cwd, "session.json");
11488
+ const store = createReconciliationStore({ sessionFile, sessionId: transport.sessionId });
11489
+ let transportCreations = 0;
11490
+ createSdkSessionRuntimeExtension(api, {
11491
+ agentDir: cwd,
11492
+ createTransport: async () => {
11493
+ transportCreations++;
11494
+ return transport;
11495
+ },
11496
+ terminalAbortSeams: {
11497
+ getReconciliationStore: () => store,
11498
+ getTerminalTurnEpoch: () => 7,
11499
+ getActivePromptHandle: () => "unrelated-handle",
11500
+ cancelPendingPreflightForTerminalAbort: () => {},
11501
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
11502
+ },
11503
+ });
11504
+ const ctx = { ...extensionContext(transport.sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext;
11505
+ const started = Promise.withResolvers<void>();
11506
+ const release = Promise.withResolvers<void>();
11507
+ const target = reconciliationStorePath(sessionFile, transport.sessionId);
11508
+ const originalRename = fsPromises.rename.bind(fsPromises);
11509
+ let commandId: string | undefined;
11510
+ let armed = false;
11511
+ let interrupted = false;
11512
+ const fault = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
11513
+ if (armed && String(to) === target) {
11514
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
11515
+ if (document.records.some(record => record.commandId === commandId && record.terminalAt !== undefined)) {
11516
+ interrupted = true;
11517
+ started.resolve();
11518
+ if (mode === "fail" || mode === "failed-stop")
11519
+ throw Object.assign(new Error("Injected queued terminal EIO"), { code: "EIO" });
11520
+ await release.promise;
11521
+ }
11522
+ }
11523
+ return originalRename(from, to);
11524
+ });
11525
+ const waitFor = async (predicate: () => boolean, label: string): Promise<void> => {
11526
+ const deadline = Date.now() + 15_000;
11527
+ while (!predicate()) {
11528
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
11529
+ await Bun.sleep(10);
11530
+ }
11531
+ };
11532
+ let shutdown: Promise<void> | undefined;
11533
+ try {
11534
+ await handlers.get("session_start")?.({}, ctx);
11535
+ transport.feed("requester", {
11536
+ type: "control_request",
11537
+ id: "followup",
11538
+ operation: "turn.follow_up",
11539
+ input: { text: "unconsumed followup" },
11540
+ } as SdkFrame);
11541
+ await waitFor(() => transport.sent.some(frame => frame.id === "followup"), "follow-up admission");
11542
+ const accepted = transport.sent.find(frame => frame.id === "followup") as ResponseFrame;
11543
+ expect(accepted.ok).toBe(true);
11544
+ commandId = accepted.result?.commandId;
11545
+ expect(commandId).toBeDefined();
11546
+ await waitFor(() => queued && queueSignal !== undefined, "queued follow-up");
11547
+ armed = true;
11548
+ if (mode === "removed-before-shutdown") {
11549
+ removeQueued?.();
11550
+ await started.promise;
11551
+ }
11552
+ let settled = false;
11553
+ shutdown = Promise.resolve(handlers.get("session_shutdown")?.({}, ctx));
11554
+ void shutdown.then(
11555
+ () => {
11556
+ settled = true;
11557
+ },
11558
+ () => {
11559
+ settled = true;
11560
+ },
11561
+ );
11562
+ await started.promise;
11563
+ expect(interrupted).toBe(true);
11564
+ expect(queueSignal?.aborted).toBe(true);
11565
+ expect(queued).toBe(false);
11566
+ if (mode === "fail" || mode === "timeout" || mode === "failed-stop") {
11567
+ await expect(shutdown).rejects.toMatchObject({ code: "sdk_reconciliation_teardown_failed" });
11568
+ expect(transportStops).toBe(1);
11569
+ if (mode === "fail" || mode === "timeout") {
11570
+ await expect(Promise.resolve(handlers.get("session_start")?.({}, ctx))).rejects.toMatchObject({
11571
+ code: "sdk_reconciliation_teardown_failed",
11572
+ });
11573
+ expect(transportCreations).toBe(1);
11574
+ if (mode === "timeout") {
11575
+ release.resolve();
11576
+ const deadline = Date.now() + 5_000;
11577
+ while (
11578
+ !((await Bun.file(target).json()) as ReconciliationStoreDocument).records.some(
11579
+ record => record.commandId === commandId && record.terminalAt !== undefined,
11580
+ )
11581
+ ) {
11582
+ if (Date.now() > deadline) throw new Error("Timed out awaiting original publisher after teardown");
11583
+ await Bun.sleep(10);
11584
+ }
11585
+ await handlers.get("session_start")?.({}, ctx);
11586
+ expect(transportCreations).toBe(2);
11587
+ await handlers.get("session_shutdown")?.({}, ctx);
11588
+ }
11589
+ }
11590
+ if (mode === "failed-stop") {
11591
+ await expect(Promise.resolve(handlers.get("session_shutdown")?.({}, ctx))).rejects.toMatchObject({
11592
+ code: "sdk_reconciliation_teardown_failed",
11593
+ });
11594
+ expect(transportStops).toBe(2);
11595
+ }
11596
+ } else {
11597
+ await Bun.sleep(25);
11598
+ expect(settled).toBe(false);
11599
+ expect(transportStops).toBe(0);
11600
+ transport.feed("requester", {
11601
+ type: "control_request",
11602
+ id: "draining-followup",
11603
+ operation: "turn.follow_up",
11604
+ input: { text: "must not enter during drain" },
11605
+ } as SdkFrame);
11606
+ await waitFor(() => transport.sent.some(frame => frame.id === "draining-followup"), "fenced admission");
11607
+ expect(transport.sent.find(frame => frame.id === "draining-followup")).toMatchObject({
11608
+ ok: false,
11609
+ error: { code: "session_quiescing" },
11610
+ });
11611
+ const pending = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11612
+ expect(pending.records.find(record => record.commandId === commandId)?.terminalAt).toBeUndefined();
11613
+ release.resolve();
11614
+ await shutdown;
11615
+ expect(transportStops).toBe(1);
11616
+ const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11617
+ expect(durable.records.find(record => record.commandId === commandId)?.terminalAt).toBeDefined();
11618
+ }
11619
+ } finally {
11620
+ release.resolve();
11621
+ await shutdown?.catch(() => undefined);
11622
+ fault.mockRestore();
11623
+ stopSpy.mockRestore();
11624
+ await rm(cwd, { recursive: true, force: true });
11625
+ }
11626
+ });
11627
+
11628
+ test.each([
11629
+ false,
11630
+ true,
11631
+ ])("SDK-only queued terminal recovery permits replacement after EIO (early shutdown=%s)", async earlyShutdown => {
11632
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-recovery-"));
11633
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
11634
+ let transport = memoryTransport();
11635
+ const sessionId = transport.sessionId;
11636
+ const sessionFile = path.join(cwd, `${sessionId}.json`);
11637
+ const store = createReconciliationStore({ sessionFile, sessionId });
11638
+ let creations = 0;
11639
+ let queued = false;
11640
+ let queueSignal: AbortSignal | undefined;
11641
+ const api = {
11642
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
11643
+ handlers.set(event, handler);
11644
+ },
11645
+ sendUserMessage: (_content: string, hooks?: PreflightHooks) =>
11646
+ Promise.resolve(hooks?.onPreflightAcceptCommit?.()).then(() => {
11647
+ queued = true;
11648
+ queueSignal = hooks?.preflightSignal;
11649
+ queueSignal?.addEventListener(
11650
+ "abort",
11651
+ () => {
11652
+ if (!queued) return;
11653
+ queued = false;
11654
+ hooks?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
11655
+ },
11656
+ { once: true },
11657
+ );
11658
+ hooks?.onPreflightAccepted?.();
11659
+ return {};
11660
+ }),
11661
+ } as unknown as ExtensionAPI;
11662
+ createSdkSessionRuntimeExtension(api, {
11663
+ agentDir: cwd,
11664
+ settings: {
11665
+ get: (key: string) =>
11666
+ key === "sdk.promptDeadlineMs" ? 1_500 : key === "sdk.promptMaxRuntimeMs" ? 15_000 : undefined,
11667
+ } as unknown as Settings,
11668
+ createTransport: async () => {
11669
+ creations++;
11670
+ transport = memoryTransport();
11671
+ return transport;
11672
+ },
11673
+ terminalAbortSeams: {
11674
+ getReconciliationStore: () => store,
11675
+ getTerminalTurnEpoch: () => 7,
11676
+ getActivePromptHandle: () => "unrelated-handle",
11677
+ cancelPendingPreflightForTerminalAbort: () => {},
11678
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
11679
+ },
11680
+ });
11681
+ const ctx = { ...extensionContext(sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext;
11682
+ const target = reconciliationStorePath(sessionFile, sessionId);
11683
+ const waitFor = async (predicate: () => boolean | Promise<boolean>, label: string): Promise<void> => {
11684
+ const deadline = Date.now() + 10_000;
11685
+ while (!(await predicate())) {
11686
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
11687
+ await Bun.sleep(10);
11688
+ }
11689
+ };
11690
+ let requestId = 0;
11691
+ const request = async (frame: Record<string, unknown>): Promise<ResponseFrame> => {
11692
+ const id = `terminal-recovery-${++requestId}`;
11693
+ transport.feed("requester", { ...frame, id } as SdkFrame);
11694
+ await waitFor(() => transport.sent.some(candidate => candidate.id === id), id);
11695
+ return transport.sent.find(candidate => candidate.id === id) as ResponseFrame;
11696
+ };
11697
+ const originalRename = fsPromises.rename.bind(fsPromises);
11698
+ let commandId: string | undefined;
11699
+ let injected = false;
11700
+ const fault = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
11701
+ if (!injected && commandId && String(to) === target) {
11702
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
11703
+ if (document.records.some(record => record.commandId === commandId && record.terminalAt !== undefined)) {
11704
+ injected = true;
11705
+ throw Object.assign(new Error("Injected terminal publication failure"), { code: "EIO" });
11706
+ }
11707
+ }
11708
+ return originalRename(from, to);
11709
+ });
11710
+ try {
11711
+ await handlers.get("session_start")?.({}, ctx);
11712
+ const accepted = await request({
11713
+ type: "control_request",
11714
+ operation: "turn.follow_up",
11715
+ input: { text: "queued input" },
11716
+ });
11717
+ expect(accepted.ok).toBe(true);
11718
+ commandId = accepted.result?.commandId;
11719
+ const turnId = accepted.result?.turnId;
11720
+ expect(commandId).toBeDefined();
11721
+ await waitFor(() => queued && queueSignal !== undefined, "queued admission");
11722
+ expect(await request({ type: "control_request", operation: "turn.abort", input: {} })).toMatchObject({
11723
+ ok: true,
11724
+ result: { aborted: false, reason: "queue_terminal_unconfirmed" },
11725
+ });
11726
+ expect(injected).toBe(true);
11727
+ expect(queueSignal?.aborted).toBe(true);
11728
+ expect(queued).toBe(false);
11729
+ fault.mockRestore();
11730
+ const initial = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11731
+ expect(initial.records.find(record => record.commandId === commandId)?.terminalAt).toBeUndefined();
11732
+ if (earlyShutdown)
11733
+ await expect(Promise.resolve(handlers.get("session_shutdown")?.({}, ctx))).rejects.toMatchObject({
11734
+ code: "sdk_reconciliation_teardown_failed",
11735
+ });
11736
+ await waitFor(async () => {
11737
+ const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11738
+ return durable.records.some(record => record.commandId === commandId && record.terminalAt !== undefined);
11739
+ }, "original-owner terminal recovery");
11740
+ const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11741
+ expect(durable.records.find(record => record.commandId === commandId)).toMatchObject({
11742
+ status: "failed",
11743
+ error: { code: "cancelled" },
11744
+ });
11745
+ if (!earlyShutdown) await handlers.get("session_shutdown")?.({}, ctx);
11746
+ await handlers.get("session_start")?.({}, ctx);
11747
+ expect(creations).toBe(2);
11748
+ expect(
11749
+ await request({ type: "query_request", query: "turn.result", input: { kind: "prompt", commandId, turnId } }),
11750
+ ).toMatchObject({
11751
+ ok: true,
11752
+ result: { status: "failed", error: { code: "cancelled" } },
11753
+ });
11754
+ } finally {
11755
+ fault.mockRestore();
11756
+ await Promise.resolve(handlers.get("session_shutdown")?.({}, ctx)).catch(() => undefined);
11757
+ await rm(cwd, { recursive: true, force: true });
11758
+ }
11759
+ });
11760
+
9375
11761
  test("SDK-only host does not assign a follow-up requester ownership until the follow-up actually starts", async () => {
9376
11762
  // Review thread P1: a turn.follow_up accepted while ctx.isIdle() is true
9377
11763
  // but the follow-up is never promoted (compaction, transcript ending in a
@@ -9382,23 +11768,30 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
9382
11768
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-followup-stale-"));
9383
11769
  const idle = true;
9384
11770
  let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
11771
+ let queueSignal: AbortSignal | undefined;
9385
11772
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
9386
11773
  const api = {
9387
11774
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
9388
11775
  handlers.set(event, handler);
9389
11776
  },
9390
- sendUserMessage: (
9391
- _content: string,
9392
- options:
9393
- | {
9394
- onPreflightAccepted?: () => void;
9395
- onPreflightAcceptCommit?: () => void;
9396
- onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
9397
- }
9398
- | undefined,
9399
- ) =>
11777
+ sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
9400
11778
  Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
9401
- promoted = options?.onQueuedPromoted;
11779
+ let queued = true;
11780
+ queueSignal = options?.preflightSignal;
11781
+ promoted = promotion => {
11782
+ if (!queued) return;
11783
+ queued = false;
11784
+ options?.onQueuedPromoted?.(promotion);
11785
+ };
11786
+ queueSignal?.addEventListener(
11787
+ "abort",
11788
+ () => {
11789
+ if (!queued) return;
11790
+ queued = false;
11791
+ options?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
11792
+ },
11793
+ { once: true },
11794
+ );
9402
11795
  options?.onPreflightAccepted?.();
9403
11796
  return {};
9404
11797
  }),
@@ -9409,7 +11802,7 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
9409
11802
  sessionId: transport.sessionId,
9410
11803
  });
9411
11804
  const seamCalls: Array<{ handle: string; scope: string }> = [];
9412
- createSdkSessionRuntimeExtension(api, {
11805
+ createTestRuntimeExtension(api, {
9413
11806
  agentDir: cwd,
9414
11807
  createTransport: async () => transport,
9415
11808
  terminalAbortSeams: {
@@ -9465,7 +11858,16 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
9465
11858
  result: expect.objectContaining({ turn: "no_active_turn" }),
9466
11859
  });
9467
11860
  expect(seamCalls).toHaveLength(0);
9468
- // When the follow-up IS promoted, B owns its run and can abort it.
11861
+ expect(queueSignal?.aborted).toBe(true);
11862
+ // The cancelled input cannot be promoted. A fresh follow-up receives fresh authority.
11863
+ transport.feed("conn-b", {
11864
+ type: "control_request",
11865
+ id: "followup-b-next",
11866
+ operation: "turn.follow_up",
11867
+ input: { text: "fresh followup" },
11868
+ } as SdkFrame);
11869
+ await waitResponse("followup-b-next");
11870
+ expect(queueSignal?.aborted).toBe(false);
9469
11871
  promoted!({ startsOwnRun: true });
9470
11872
  await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
9471
11873
  transport.feed("conn-b", {
@@ -9522,7 +11924,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
9522
11924
  sessionId: transport.sessionId,
9523
11925
  });
9524
11926
  const seamCalls: Array<{ handle: string; scope: string }> = [];
9525
- createSdkSessionRuntimeExtension(api, {
11927
+ createTestRuntimeExtension(api, {
9526
11928
  agentDir: cwd,
9527
11929
  createTransport: async () => transport,
9528
11930
  terminalAbortSeams: {
@@ -9647,7 +12049,7 @@ test("SDK-only host rebinds the steering snapshot when the requester's turn wins
9647
12049
  let ownerReads = 0;
9648
12050
  let rebindCalls = 0;
9649
12051
  const settledOptions: Array<{ scope?: string; steeringSnapshotToken?: number }> = [];
9650
- createSdkSessionRuntimeExtension(api, {
12052
+ createTestRuntimeExtension(api, {
9651
12053
  agentDir: cwd,
9652
12054
  createTransport: async () => transport,
9653
12055
  terminalAbortSeams: {