@gajae-code/coding-agent 0.13.2 → 0.14.0

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 (610) hide show
  1. package/CHANGELOG.md +215 -8
  2. package/README.md +2 -0
  3. package/bin/gajaessi.js +4 -0
  4. package/dist/types/async/job-manager.d.ts +55 -2
  5. package/dist/types/async/support.d.ts +14 -0
  6. package/dist/types/capability/mcp.d.ts +6 -0
  7. package/dist/types/capability/types.d.ts +6 -0
  8. package/dist/types/cli/accounts-cli.d.ts +19 -0
  9. package/dist/types/cli/args.d.ts +3 -0
  10. package/dist/types/cli/config-cli.d.ts +6 -0
  11. package/dist/types/cli/crash-cli.d.ts +10 -0
  12. package/dist/types/cli/customize-doctor.d.ts +145 -0
  13. package/dist/types/cli/daemon-cli.d.ts +3 -1
  14. package/dist/types/cli/mcp-cli.d.ts +11 -0
  15. package/dist/types/cli/notify-cli.d.ts +1 -1
  16. package/dist/types/cli/root-flags.d.ts +12 -0
  17. package/dist/types/cli/setup-cli.d.ts +3 -1
  18. package/dist/types/cli/skills-cli.d.ts +2 -1
  19. package/dist/types/cli/update-cli.d.ts +19 -1
  20. package/dist/types/cli.d.ts +6 -0
  21. package/dist/types/commands/accounts.d.ts +40 -0
  22. package/dist/types/commands/crash.d.ts +24 -0
  23. package/dist/types/commands/customize.d.ts +22 -0
  24. package/dist/types/commands/daemon.d.ts +1 -1
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/commands/setup.d.ts +6 -0
  27. package/dist/types/commands/skills.d.ts +6 -1
  28. package/dist/types/commands/update.d.ts +7 -0
  29. package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
  30. package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
  31. package/dist/types/config/config-schema-version.d.ts +10 -0
  32. package/dist/types/config/file-lock.d.ts +18 -0
  33. package/dist/types/config/model-profile-activation.d.ts +3 -1
  34. package/dist/types/config/model-registry.d.ts +10 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +73 -9
  37. package/dist/types/config/settings.d.ts +63 -0
  38. package/dist/types/config/skill-settings-defaults.d.ts +26 -0
  39. package/dist/types/config/task-delegation.d.ts +39 -0
  40. package/dist/types/config/update-channel.d.ts +10 -0
  41. package/dist/types/coordinator-mcp/server.d.ts +5 -1
  42. package/dist/types/crash/index-store.d.ts +87 -0
  43. package/dist/types/crash/nudge.d.ts +50 -0
  44. package/dist/types/crash/record-loader.d.ts +17 -0
  45. package/dist/types/crash/report.d.ts +134 -0
  46. package/dist/types/crash/sanitize.d.ts +25 -0
  47. package/dist/types/cursor.d.ts +13 -2
  48. package/dist/types/customization/import.d.ts +37 -0
  49. package/dist/types/customization/index.d.ts +4 -0
  50. package/dist/types/customization/inventory.d.ts +25 -0
  51. package/dist/types/customization/mutations.d.ts +32 -0
  52. package/dist/types/customization/types.d.ts +115 -0
  53. package/dist/types/daemon/control-types.d.ts +8 -1
  54. package/dist/types/daemon/operator-contract.d.ts +4 -4
  55. package/dist/types/discovery/claude.d.ts +39 -1
  56. package/dist/types/discovery/codex.d.ts +39 -1
  57. package/dist/types/discovery/index.d.ts +2 -0
  58. package/dist/types/discovery/mcp-compat.d.ts +30 -0
  59. package/dist/types/edit/renderer.d.ts +23 -0
  60. package/dist/types/extensibility/extensions/runner.d.ts +3 -0
  61. package/dist/types/extensibility/extensions/types.d.ts +21 -4
  62. package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
  63. package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
  64. package/dist/types/extensibility/hooks/loader.d.ts +19 -5
  65. package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
  66. package/dist/types/extensibility/skill-management.d.ts +107 -0
  67. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
  68. package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
  69. package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
  70. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
  71. package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
  72. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
  73. package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
  74. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
  75. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
  76. package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
  77. package/dist/types/hooks/events.d.ts +60 -0
  78. package/dist/types/hooks/normalize.d.ts +83 -0
  79. package/dist/types/hooks/skill-state.d.ts +9 -2
  80. package/dist/types/main.d.ts +2 -1
  81. package/dist/types/modes/acp/abort-scope.d.ts +13 -0
  82. package/dist/types/modes/acp/acp-agent.d.ts +7 -4
  83. package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
  84. package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
  85. package/dist/types/modes/components/customization/index.d.ts +1 -0
  86. package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
  87. package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
  88. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  89. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  90. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  91. package/dist/types/modes/components/pet-capability.d.ts +7 -21
  92. package/dist/types/modes/components/settings-selector.d.ts +5 -0
  93. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  94. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  95. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  96. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  97. package/dist/types/modes/interactive-mode.d.ts +1 -0
  98. package/dist/types/modes/theme/defaults/index.d.ts +118 -0
  99. package/dist/types/modes/types.d.ts +1 -0
  100. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  101. package/dist/types/runtime-mcp/config.d.ts +9 -0
  102. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  103. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  104. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  105. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  106. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  107. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  108. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  109. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  110. package/dist/types/runtime-mcp/types.d.ts +102 -10
  111. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  112. package/dist/types/sdk/broker/broker.d.ts +3 -55
  113. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  114. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  115. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  116. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  117. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  118. package/dist/types/sdk/bus/config.d.ts +11 -0
  119. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  120. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  121. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  122. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  123. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  124. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  125. package/dist/types/sdk/bus/index.d.ts +85 -99
  126. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  127. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  128. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  129. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  130. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  131. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  132. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  133. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  135. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  136. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  137. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  138. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  139. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  140. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  141. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  142. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  143. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  144. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  145. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  146. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  147. package/dist/types/sdk/cli/rows.d.ts +2 -11
  148. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  149. package/dist/types/sdk/client/client.d.ts +97 -1
  150. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  151. package/dist/types/sdk/host/control/index.d.ts +1 -1
  152. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  153. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  154. package/dist/types/sdk/host/host.d.ts +15 -1
  155. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  156. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  157. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  158. package/dist/types/sdk/host/types.d.ts +1 -1
  159. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  160. package/dist/types/sdk/index.d.ts +2 -1
  161. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  162. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  163. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  164. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  165. package/dist/types/sdk/mcp/server.d.ts +9 -6
  166. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  167. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  168. package/dist/types/sdk/prompt-status.d.ts +10 -3
  169. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  170. package/dist/types/sdk/receipt-state.d.ts +17 -0
  171. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  172. package/dist/types/sdk/router/index.d.ts +1 -0
  173. package/dist/types/sdk/router/session-router.d.ts +152 -0
  174. package/dist/types/sdk/session-list.d.ts +22 -0
  175. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  176. package/dist/types/sdk/session.d.ts +34 -1
  177. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  178. package/dist/types/session/account-inventory.d.ts +86 -0
  179. package/dist/types/session/agent-session.d.ts +145 -6
  180. package/dist/types/session/agent-storage.d.ts +10 -0
  181. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  182. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  183. package/dist/types/session/messages.d.ts +1 -1
  184. package/dist/types/session/secure-token-file.d.ts +3 -0
  185. package/dist/types/session/session-manager.d.ts +47 -1
  186. package/dist/types/session/startup-auth-config.d.ts +44 -0
  187. package/dist/types/session/terminal-abort.d.ts +378 -0
  188. package/dist/types/session/yield-queue.d.ts +7 -0
  189. package/dist/types/session-import/claude.d.ts +26 -0
  190. package/dist/types/session-import/codex.d.ts +88 -0
  191. package/dist/types/session-import/command.d.ts +35 -0
  192. package/dist/types/session-import/detect.d.ts +22 -0
  193. package/dist/types/session-import/index.d.ts +7 -0
  194. package/dist/types/session-import/provider-codex.d.ts +22 -0
  195. package/dist/types/session-import/provider-service.d.ts +38 -0
  196. package/dist/types/session-import/redact.d.ts +23 -0
  197. package/dist/types/session-import/service.d.ts +47 -0
  198. package/dist/types/session-import/types.d.ts +120 -0
  199. package/dist/types/setup/paseo/check.d.ts +16 -0
  200. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  201. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  202. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  203. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  204. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  205. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  206. package/dist/types/setup/paseo/remove.d.ts +14 -0
  207. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  208. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  209. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  210. package/dist/types/skill-state/active-state.d.ts +5 -1
  211. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  212. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  213. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  214. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  215. package/dist/types/slash-commands/types.d.ts +7 -1
  216. package/dist/types/task/executor.d.ts +10 -0
  217. package/dist/types/task/index.d.ts +3 -1
  218. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  219. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  220. package/dist/types/tools/bash.d.ts +2 -6
  221. package/dist/types/tools/browser/attach.d.ts +11 -0
  222. package/dist/types/tools/browser/launch.d.ts +20 -0
  223. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  224. package/dist/types/tools/browser/render.d.ts +1 -0
  225. package/dist/types/tools/browser.d.ts +3 -1
  226. package/dist/types/tools/image-gen.d.ts +10 -2
  227. package/dist/types/tools/index.d.ts +8 -0
  228. package/dist/types/tools/monitor.d.ts +1 -1
  229. package/dist/types/tools/skill-discovery.d.ts +8 -1
  230. package/dist/types/tools/subagent.d.ts +2 -0
  231. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  232. package/dist/types/utils/herdr-pane.d.ts +76 -0
  233. package/package.json +19 -9
  234. package/scripts/build-binary.ts +2 -1
  235. package/scripts/build-sdk-package-smoke.ts +2 -13
  236. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  237. package/scripts/compile-args.ts +2 -0
  238. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  239. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  240. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  241. package/scripts/generate-tool-catalog.ts +6 -4
  242. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  243. package/src/async/job-manager.ts +352 -17
  244. package/src/async/support.ts +25 -0
  245. package/src/autoresearch/dashboard.ts +28 -13
  246. package/src/capability/index.ts +3 -1
  247. package/src/capability/mcp.ts +6 -0
  248. package/src/capability/types.ts +6 -0
  249. package/src/cli/accounts-cli.ts +395 -0
  250. package/src/cli/args.ts +23 -0
  251. package/src/cli/auth-broker-cli.ts +136 -66
  252. package/src/cli/auth-gateway-cli.ts +94 -70
  253. package/src/cli/config-cli.ts +27 -2
  254. package/src/cli/crash-cli.ts +78 -0
  255. package/src/cli/customize-doctor.ts +1605 -0
  256. package/src/cli/daemon-cli.ts +12 -3
  257. package/src/cli/fast-help.ts +1 -0
  258. package/src/cli/mcp-cli.ts +128 -60
  259. package/src/cli/notify-cli.ts +8 -14
  260. package/src/cli/root-flags.ts +8 -0
  261. package/src/cli/setup-cli.ts +145 -0
  262. package/src/cli/skills-cli.ts +38 -2
  263. package/src/cli/update-cli.ts +167 -12
  264. package/src/cli.ts +4 -0
  265. package/src/commands/accounts.ts +71 -0
  266. package/src/commands/crash.ts +35 -0
  267. package/src/commands/customize.ts +84 -0
  268. package/src/commands/daemon.ts +2 -2
  269. package/src/commands/deep-interview.ts +2 -0
  270. package/src/commands/launch.ts +1 -0
  271. package/src/commands/mcp.ts +12 -7
  272. package/src/commands/ralplan.ts +2 -0
  273. package/src/commands/sdk.ts +65 -32
  274. package/src/commands/setup.ts +5 -0
  275. package/src/commands/skills.ts +14 -4
  276. package/src/commands/ultragoal.ts +10 -1
  277. package/src/commands/update.ts +26 -15
  278. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  279. package/src/config/atomic-yaml-patch.ts +769 -29
  280. package/src/config/config-schema-version.ts +10 -0
  281. package/src/config/file-lock.ts +70 -1
  282. package/src/config/mcp-schema.json +52 -14
  283. package/src/config/model-profile-activation.ts +23 -2
  284. package/src/config/model-profiles.ts +88 -21
  285. package/src/config/model-registry.ts +147 -21
  286. package/src/config/models-config-schema.ts +1 -1
  287. package/src/config/settings-schema.ts +77 -7
  288. package/src/config/settings.ts +4038 -199
  289. package/src/config/skill-settings-defaults.ts +41 -3
  290. package/src/config/task-delegation.ts +87 -0
  291. package/src/config/update-channel.ts +23 -0
  292. package/src/coordinator-mcp/server.ts +377 -308
  293. package/src/crash/index-store.ts +723 -0
  294. package/src/crash/nudge.ts +89 -0
  295. package/src/crash/record-loader.ts +149 -0
  296. package/src/crash/report.ts +551 -0
  297. package/src/crash/sanitize.ts +129 -0
  298. package/src/cursor.ts +36 -10
  299. package/src/customization/import.ts +946 -0
  300. package/src/customization/index.ts +4 -0
  301. package/src/customization/inventory.ts +335 -0
  302. package/src/customization/mutations.ts +141 -0
  303. package/src/customization/types.ts +223 -0
  304. package/src/daemon/control-types.ts +8 -1
  305. package/src/daemon/operator-contract.ts +6 -6
  306. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
  307. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  308. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  309. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  310. package/src/discovery/claude-hooks.ts +38 -0
  311. package/src/discovery/claude.ts +142 -209
  312. package/src/discovery/codex-hooks.ts +38 -0
  313. package/src/discovery/codex.ts +132 -198
  314. package/src/discovery/helpers.ts +8 -1
  315. package/src/discovery/index.ts +2 -0
  316. package/src/discovery/mcp-compat.ts +234 -0
  317. package/src/discovery/mcp-json.ts +6 -0
  318. package/src/edit/renderer.ts +40 -0
  319. package/src/edit/streaming.ts +4 -2
  320. package/src/exec/bash-executor.ts +24 -20
  321. package/src/export/html/index.ts +51 -2
  322. package/src/extensibility/extensions/loader.ts +9 -1
  323. package/src/extensibility/extensions/runner.ts +17 -4
  324. package/src/extensibility/extensions/types.ts +31 -6
  325. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  326. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  327. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  328. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  329. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  330. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  331. package/src/extensibility/hooks/loader.ts +154 -10
  332. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  333. package/src/extensibility/skill-management.ts +368 -0
  334. package/src/extensibility/skills.ts +52 -16
  335. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  336. package/src/gjc-runtime/launch-worktree.ts +56 -2
  337. package/src/gjc-runtime/linux-proc.ts +7 -1
  338. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  339. package/src/gjc-runtime/session-state-lock.ts +992 -0
  340. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  341. package/src/gjc-runtime/state-writer.ts +39 -14
  342. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  343. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  344. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  345. package/src/gjc-runtime/workflow-settings.ts +596 -0
  346. package/src/goals/tools/goal-tool.ts +6 -1
  347. package/src/hooks/events.ts +291 -0
  348. package/src/hooks/native-skill-hook.ts +4 -0
  349. package/src/hooks/normalize.ts +369 -0
  350. package/src/hooks/skill-state.ts +165 -46
  351. package/src/internal-urls/docs-index.generated.ts +41 -32
  352. package/src/main.ts +36 -5
  353. package/src/migrate/mcp-mapper.ts +25 -2
  354. package/src/modes/acp/abort-scope.ts +29 -0
  355. package/src/modes/acp/acp-agent.ts +423 -98
  356. package/src/modes/components/assistant-message.ts +6 -1
  357. package/src/modes/components/chrome.ts +4 -3
  358. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  359. package/src/modes/components/customization/import-wizard.ts +372 -0
  360. package/src/modes/components/customization/index.ts +1 -0
  361. package/src/modes/components/dynamic-theme-text.ts +22 -0
  362. package/src/modes/components/gajae-pet-widget.ts +607 -193
  363. package/src/modes/components/irc-sidebar.ts +11 -0
  364. package/src/modes/components/iterm-pet-transport.ts +585 -0
  365. package/src/modes/components/model-selector.ts +1 -6
  366. package/src/modes/components/oauth-selector.ts +277 -86
  367. package/src/modes/components/pet-capability.ts +35 -31
  368. package/src/modes/components/settings-selector.ts +29 -5
  369. package/src/modes/components/status-line/gh.ts +45 -1
  370. package/src/modes/components/status-line/segments.ts +19 -4
  371. package/src/modes/components/tool-status-header.ts +13 -3
  372. package/src/modes/controllers/command-controller.ts +20 -8
  373. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  374. package/src/modes/controllers/input-controller.ts +10 -0
  375. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  376. package/src/modes/controllers/selector-controller.ts +238 -77
  377. package/src/modes/controllers/stt-controller.ts +3 -3
  378. package/src/modes/interactive-mode.ts +88 -16
  379. package/src/modes/print-mode.ts +35 -6
  380. package/src/modes/runtime-init.ts +21 -68
  381. package/src/modes/theme/defaults/index.ts +2 -0
  382. package/src/modes/theme/defaults/ouroboros.json +118 -0
  383. package/src/modes/types.ts +1 -0
  384. package/src/modes/utils/injected-user-submission.ts +25 -0
  385. package/src/modes/utils/terminal-bell.ts +15 -1
  386. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  387. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  388. package/src/prompts/agents/critic.md +1 -1
  389. package/src/prompts/system/rlm-research.md +1 -1
  390. package/src/prompts/system/system-prompt.md +0 -2
  391. package/src/prompts/tools/bash.md +7 -1
  392. package/src/prompts/tools/browser.md +1 -1
  393. package/src/prompts/tools/cron.md +6 -6
  394. package/src/prompts/tools/read.md +0 -2
  395. package/src/prompts/tools/skill-discovery.md +4 -2
  396. package/src/runtime-mcp/client.ts +427 -23
  397. package/src/runtime-mcp/config.ts +37 -5
  398. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  399. package/src/runtime-mcp/manager.ts +52 -2
  400. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  401. package/src/runtime-mcp/oauth-flow.ts +46 -1
  402. package/src/runtime-mcp/pool-key.ts +5 -1
  403. package/src/runtime-mcp/pool.test.ts +9 -0
  404. package/src/runtime-mcp/protocol.test.ts +353 -0
  405. package/src/runtime-mcp/protocol.ts +624 -0
  406. package/src/runtime-mcp/scope-config.ts +78 -0
  407. package/src/runtime-mcp/tool-bridge.ts +44 -10
  408. package/src/runtime-mcp/transports/http.ts +84 -13
  409. package/src/runtime-mcp/types.ts +123 -12
  410. package/src/sdk/acp/adapter.ts +328 -97
  411. package/src/sdk/broker/broker.ts +160 -741
  412. package/src/sdk/broker/ensure.ts +9 -1
  413. package/src/sdk/broker/lifecycle.ts +510 -116
  414. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  415. package/src/sdk/broker/process-incarnation.ts +19 -0
  416. package/src/sdk/broker/session-index.ts +516 -117
  417. package/src/sdk/broker/startup-failure.ts +28 -5
  418. package/src/sdk/broker/transport.ts +1 -13
  419. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  420. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  421. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  422. package/src/sdk/bus/config.ts +13 -0
  423. package/src/sdk/bus/conversation-store.ts +59 -171
  424. package/src/sdk/bus/daemon-paths.ts +20 -0
  425. package/src/sdk/bus/discord-conversation.ts +2 -0
  426. package/src/sdk/bus/discord-daemon.ts +524 -126
  427. package/src/sdk/bus/discord-live-provider.ts +206 -79
  428. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  429. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  430. package/src/sdk/bus/index.ts +2480 -456
  431. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  432. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  433. package/src/sdk/bus/managed-daemon.ts +2 -145
  434. package/src/sdk/bus/notification-service.ts +196 -0
  435. package/src/sdk/bus/operator-runtime.ts +1 -1
  436. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  437. package/src/sdk/bus/reconciliation-store.ts +553 -24
  438. package/src/sdk/bus/session-control.ts +49 -3
  439. package/src/sdk/bus/slack-conversation.ts +4 -0
  440. package/src/sdk/bus/slack-daemon.ts +733 -134
  441. package/src/sdk/bus/slack-live-provider.ts +65 -15
  442. package/src/sdk/bus/slack-provider.ts +4 -0
  443. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  444. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  445. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  446. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  447. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  448. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  449. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  450. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  451. package/src/sdk/bus/telegram-reference.ts +1 -226
  452. package/src/sdk/bus/telegram-setup.ts +2 -2
  453. package/src/sdk/bus/threaded-render.ts +1 -0
  454. package/src/sdk/bus/topic-registry.ts +279 -146
  455. package/src/sdk/cli/rows.ts +2 -35
  456. package/src/sdk/cli/session-cli.ts +776 -700
  457. package/src/sdk/client/client.ts +892 -1
  458. package/src/sdk/host/control/dispatch.ts +93 -58
  459. package/src/sdk/host/control/index.ts +1 -1
  460. package/src/sdk/host/control/operations.ts +18 -10
  461. package/src/sdk/host/control/runtime-gate.ts +40 -0
  462. package/src/sdk/host/host.ts +63 -12
  463. package/src/sdk/host/query/handlers.ts +66 -7
  464. package/src/sdk/host/reverse-leases.ts +2 -2
  465. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  466. package/src/sdk/host/session-runtime.test.ts +2787 -34
  467. package/src/sdk/host/session-runtime.ts +1489 -101
  468. package/src/sdk/host/surface-policy.ts +12 -8
  469. package/src/sdk/host/types.ts +1 -1
  470. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  471. package/src/sdk/host/websocket-transport.ts +85 -50
  472. package/src/sdk/index.ts +2 -1
  473. package/src/sdk/lifecycle/broker-client.ts +53 -0
  474. package/src/sdk/lifecycle/client.ts +234 -0
  475. package/src/sdk/lifecycle/index.ts +3 -0
  476. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  477. package/src/sdk/lifecycle/service.ts +720 -0
  478. package/src/sdk/mcp/server.ts +226 -188
  479. package/src/sdk/prompt-deadline-lease.ts +55 -0
  480. package/src/sdk/prompt-deadline-manager.ts +148 -0
  481. package/src/sdk/prompt-status.ts +11 -3
  482. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  483. package/src/sdk/protocol/operation-registry.ts +71 -8
  484. package/src/sdk/receipt-state.ts +43 -0
  485. package/src/sdk/reconciliation-extensions.ts +14 -0
  486. package/src/sdk/router/index.ts +1 -0
  487. package/src/sdk/router/session-router.ts +1658 -0
  488. package/src/sdk/session-list.ts +93 -0
  489. package/src/sdk/session-reconnect.ts +45 -1
  490. package/src/sdk/session.ts +716 -120
  491. package/src/sdk/transport/serve-cli.ts +18 -34
  492. package/src/session/account-inventory.ts +603 -0
  493. package/src/session/agent-session.ts +2873 -559
  494. package/src/session/agent-storage.ts +35 -0
  495. package/src/session/blob-store.ts +32 -1
  496. package/src/session/coordinator-tool-label.ts +40 -0
  497. package/src/session/internal/managed-session-scope.ts +4 -0
  498. package/src/session/internal/managed-session-storage.ts +199 -31
  499. package/src/session/messages.ts +1 -1
  500. package/src/session/secure-token-file.ts +80 -0
  501. package/src/session/session-manager.ts +757 -57
  502. package/src/session/session-storage.ts +25 -16
  503. package/src/session/startup-auth-config.ts +297 -0
  504. package/src/session/terminal-abort.ts +1253 -0
  505. package/src/session/yield-queue.ts +57 -15
  506. package/src/session-import/claude.ts +334 -0
  507. package/src/session-import/codex.ts +1033 -0
  508. package/src/session-import/command.ts +77 -0
  509. package/src/session-import/detect.ts +133 -0
  510. package/src/session-import/index.ts +7 -0
  511. package/src/session-import/provider-codex.ts +327 -0
  512. package/src/session-import/provider-service.ts +457 -0
  513. package/src/session-import/redact.ts +128 -0
  514. package/src/session-import/service.ts +1264 -0
  515. package/src/session-import/types.ts +148 -0
  516. package/src/setup/paseo/check.ts +213 -0
  517. package/src/setup/paseo/install-saga.ts +293 -0
  518. package/src/setup/paseo/json-publisher.ts +263 -0
  519. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  520. package/src/setup/paseo/paseo-ownership.ts +224 -0
  521. package/src/setup/paseo/paseo-setup.ts +253 -0
  522. package/src/setup/paseo/provider-config.ts +139 -0
  523. package/src/setup/paseo/remove.ts +183 -0
  524. package/src/setup/paseo/result-types.ts +79 -0
  525. package/src/setup/paseo/setup-deps.ts +175 -0
  526. package/src/setup/paseo/skills-bridge.ts +260 -0
  527. package/src/skill-state/active-state.ts +21 -11
  528. package/src/slash-commands/acp-builtins.ts +9 -1
  529. package/src/slash-commands/builtin-registry.ts +117 -19
  530. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  531. package/src/slash-commands/helpers/mcp.ts +9 -3
  532. package/src/slash-commands/helpers/usage-report.ts +61 -56
  533. package/src/slash-commands/types.ts +7 -2
  534. package/src/task/executor.ts +30 -11
  535. package/src/task/index.ts +66 -7
  536. package/src/task/render.ts +23 -9
  537. package/src/tools/ask-answer-registry.ts +23 -7
  538. package/src/tools/ask.ts +8 -4
  539. package/src/tools/bash-sleep-advisory.ts +115 -0
  540. package/src/tools/bash.ts +98 -18
  541. package/src/tools/browser/attach.ts +71 -3
  542. package/src/tools/browser/launch.ts +86 -13
  543. package/src/tools/browser/profile-discovery.ts +57 -7
  544. package/src/tools/browser/render.ts +8 -1
  545. package/src/tools/browser.ts +130 -21
  546. package/src/tools/computer.enforcement.test.ts +1 -1
  547. package/src/tools/computer.ts +3 -2
  548. package/src/tools/cron.ts +12 -1
  549. package/src/tools/image-gen.ts +349 -142
  550. package/src/tools/index.ts +8 -0
  551. package/src/tools/job.ts +33 -6
  552. package/src/tools/monitor.ts +41 -2
  553. package/src/tools/skill-discovery.ts +21 -29
  554. package/src/tools/subagent.ts +142 -24
  555. package/src/tools/todo-contract.ts +55 -3
  556. package/src/tools/todo-write.ts +4 -3
  557. package/src/tools/tool-catalog.generated.ts +6 -6
  558. package/src/tools/ultragoal-ask-guard.ts +10 -3
  559. package/src/utils/herdr-pane.ts +345 -0
  560. package/src/utils/title-generator.ts +2 -0
  561. package/src/web/scrapers/index.ts +6 -4
  562. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  563. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  564. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  565. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  566. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  567. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  568. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  569. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  570. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  571. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  572. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  573. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  574. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  575. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  576. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  577. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  578. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  579. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  580. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  581. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  582. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  583. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  584. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  585. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  586. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  587. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  588. package/dist/types/session/auth-broker-config.d.ts +0 -13
  589. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  590. package/dist/types/skills/skills.test.d.ts +0 -1
  591. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  592. package/dist/types/task/token-log.test.d.ts +0 -1
  593. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  594. package/dist/types/tools/descriptors.test.d.ts +0 -1
  595. package/scripts/g005-daemon-smoke.ts +0 -180
  596. package/scripts/g011-daemon-path-smoke.ts +0 -153
  597. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  598. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  599. package/src/sdk/bus/telegram-cli.ts +0 -212
  600. package/src/sdk/elevation/allowlist.ts +0 -54
  601. package/src/sdk/elevation/audit.ts +0 -208
  602. package/src/sdk/elevation/capability.ts +0 -41
  603. package/src/sdk/elevation/digest.ts +0 -129
  604. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  605. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  606. package/src/sdk/elevation/owner.ts +0 -109
  607. package/src/session/auth-broker-config.ts +0 -109
  608. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  609. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  610. /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
@@ -1,9 +1,18 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import * as os from "node:os";
4
5
  import * as path from "node:path";
5
- import type { ExtensionAPI } from "../../extensibility/extensions";
6
+ import { AsyncJobManager } from "../../async";
7
+ import type { ExtensionAPI, ExtensionContext } from "../../extensibility/extensions";
8
+ import {
9
+ registerOwnedRegistration,
10
+ resetTerminalAbortRegistriesForTests,
11
+ type TurnRegistrationKey,
12
+ unregisterOwnedRegistration,
13
+ } from "../../session/terminal-abort";
6
14
  import { Broker } from "../broker/broker";
15
+ import { createReconciliationStore } from "../bus/reconciliation-store";
7
16
  import {
8
17
  createInvocationReconciliation,
9
18
  createSdkSessionRuntimeExtension,
@@ -55,16 +64,29 @@ function memoryTransport(): SessionSdkTransport & {
55
64
  };
56
65
  }
57
66
 
58
- function extensionContext(sessionId: string, cwd: string): any {
67
+ function admissionBarrier(target: number) {
68
+ const ready = Promise.withResolvers<void>();
69
+ let admitted = 0;
70
+ return {
71
+ onFrameAdmitted() {
72
+ admitted += 1;
73
+ if (admitted === target) ready.resolve();
74
+ },
75
+ ready: ready.promise,
76
+ };
77
+ }
78
+
79
+ function extensionContext(sessionId: string, cwd: string): ExtensionContext {
59
80
  return {
60
81
  cwd,
61
82
  workflowGate: undefined,
62
83
  sdkBindings: () => [],
63
84
  sessionManager: {
64
85
  getSessionId: () => sessionId,
86
+ getSessionFile: () => path.join(cwd, `${sessionId}.json`),
65
87
  getSessionName: () => undefined,
66
88
  },
67
- };
89
+ } as unknown as ExtensionContext;
68
90
  }
69
91
 
70
92
  test("preserves an agent failure code in host prompt reconciliation", async () => {
@@ -171,6 +193,62 @@ test("redacts a persisted host failure during hydration", async () => {
171
193
  await rm(stateRoot, { recursive: true, force: true });
172
194
  }
173
195
  });
196
+ test("retains terminal host reconciliation records past the 15-minute window", async () => {
197
+ // #4547: terminal records must stay canonical until per-kind capacity
198
+ // eviction; age must never expire them on the SDK-only host path.
199
+ const realNow = Date.now;
200
+ let clock = 1_000_000;
201
+ Date.now = () => clock;
202
+ try {
203
+ const reconciliation = createInvocationReconciliation();
204
+ const promptCorrelation = { commandId: "aged-command", turnId: "aged-turn" };
205
+ const skillCorrelation = { commandId: "aged-skill-command", turnId: "aged-skill-turn" };
206
+ reconciliation.admit("prompt", "aged-ref");
207
+ reconciliation.admit("skill", "aged-skill-ref");
208
+ await reconciliation.noteAccepted("prompt", promptCorrelation, "aged-ref");
209
+ await reconciliation.noteAccepted("skill", skillCorrelation, "aged-skill-ref");
210
+ await reconciliation.noteTransition("prompt", promptCorrelation, { type: "agent_end" });
211
+ await reconciliation.noteTransition("skill", skillCorrelation, { type: "agent_end" });
212
+
213
+ clock += 15 * 60_000 + 1;
214
+ expect(reconciliation.lookup("prompt", { clientRef: "aged-ref" })).toMatchObject({
215
+ status: "terminal_ok",
216
+ });
217
+ expect(reconciliation.lookup("skill", { clientRef: "aged-skill-ref" })).toMatchObject({
218
+ status: "terminal_ok",
219
+ });
220
+ expect(() => reconciliation.admit("prompt", "aged-ref")).toThrowError(/never reuse a clientRef/);
221
+ } finally {
222
+ Date.now = realNow;
223
+ }
224
+ });
225
+
226
+ test("capacity eviction still releases the clientRef and reports honest unknown", async () => {
227
+ // The removal of age eviction must not weaken the oldest-terminal-first
228
+ // capacity trim: at the cap the oldest terminal is evicted and its
229
+ // clientRef becomes admissible again with the prior outcome unknown.
230
+ const reconciliation = createInvocationReconciliation();
231
+ const first = { commandId: "capacity-command-1", turnId: "capacity-turn-1" };
232
+ reconciliation.admit("prompt", "capacity-ref-1");
233
+ await reconciliation.noteAccepted("prompt", first, "capacity-ref-1");
234
+ await reconciliation.noteTransition("prompt", first, { type: "agent_end" });
235
+ const TERMINAL_CAPACITY = 512;
236
+ for (let index = 2; index <= TERMINAL_CAPACITY + 1; index++) {
237
+ const correlation = { commandId: `capacity-command-${index}`, turnId: `capacity-turn-${index}` };
238
+ reconciliation.admit("prompt", `capacity-ref-${index}`);
239
+ await reconciliation.noteAccepted("prompt", correlation, `capacity-ref-${index}`);
240
+ await reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
241
+ }
242
+ expect(reconciliation.lookup("prompt", { clientRef: "capacity-ref-1" })).toEqual({
243
+ status: "unknown",
244
+ });
245
+ expect(() => reconciliation.admit("prompt", "capacity-ref-1")).not.toThrow();
246
+ const retained = {
247
+ commandId: `capacity-command-${TERMINAL_CAPACITY + 1}`,
248
+ turnId: `capacity-turn-${TERMINAL_CAPACITY + 1}`,
249
+ };
250
+ expect(reconciliation.lookup("prompt", retained)).toMatchObject({ status: "terminal_ok" });
251
+ });
174
252
 
175
253
  describe("SessionSdkSessionRuntime", () => {
176
254
  test("has no notification adapter or native notification import edge", async () => {
@@ -180,39 +258,1912 @@ describe("SessionSdkSessionRuntime", () => {
180
258
  expect(source).not.toContain("NotificationServer");
181
259
  });
182
260
 
183
- test("hosts control, replay, and reverse frames with notifications disabled", async () => {
261
+ test("hosts control, replay, and reverse frames with notifications disabled", async () => {
262
+ const transport = memoryTransport();
263
+ const runtime = new SessionSdkSessionRuntime({
264
+ transport,
265
+ control: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { operation: frame.operation } }),
266
+ query: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { query: frame.query } }),
267
+ });
268
+ await runtime.start();
269
+ runtime.emitEvent({ kind: "session_ready", sessionId: transport.sessionId });
270
+ transport.feed("client", {
271
+ type: "event_replay",
272
+ id: "replay",
273
+ sinceGeneration: runtime.generation,
274
+ sinceSeq: 0,
275
+ });
276
+ transport.feed("client", {
277
+ type: "control_request",
278
+ id: "control",
279
+ operation: "runtime.capabilities",
280
+ input: {},
281
+ });
282
+ transport.feed("client", { type: "query_request", id: "query", query: "Q18", input: {} });
283
+ await Bun.sleep(0);
284
+ expect(transport.broadcasts.some(frame => frame.kind === "session_ready")).toBe(true);
285
+ expect(transport.sent).toEqual(
286
+ expect.arrayContaining([
287
+ expect.objectContaining({ type: "event_replay_result", id: "replay", ok: true }),
288
+ expect.objectContaining({ type: "control_response", id: "control", ok: true }),
289
+ expect.objectContaining({ type: "query_response", id: "query", ok: true }),
290
+ ]),
291
+ );
292
+ await runtime.stop();
293
+ });
294
+ test("SDK-only host admits, replays, and conflicts terminal abort requests durably", async () => {
295
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-abort-"));
296
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
297
+ const api = {
298
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
299
+ handlers.set(event, handler);
300
+ },
301
+ } as unknown as ExtensionAPI;
302
+ const transport = memoryTransport();
303
+ const reconciliationStore = createReconciliationStore({
304
+ sessionFile: path.join(cwd, "session.json"),
305
+ sessionId: transport.sessionId,
306
+ });
307
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
308
+ let activeHandle: string | undefined = "exact-run-handle";
309
+ let activeEpoch: number | undefined = 7;
310
+ let captureCalls = 0;
311
+ createSdkSessionRuntimeExtension(api, {
312
+ agentDir: cwd,
313
+ createTransport: async () => transport,
314
+ onSdkRequest: undefined,
315
+ terminalAbortSeams: {
316
+ getReconciliationStore: () => reconciliationStore,
317
+ getTerminalTurnEpoch: () => activeEpoch,
318
+ getActivePromptHandle: () => activeHandle,
319
+ getActivePromptOwnerConnectionId: () => "client",
320
+ cancelPendingPreflightForTerminalAbort: () => {},
321
+ captureTerminalAbortSteeringSnapshot: () => {
322
+ captureCalls += 1;
323
+ return captureCalls;
324
+ },
325
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
326
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
327
+ return { status: "settled", terminalScope: {} };
328
+ },
329
+ },
330
+ });
331
+ const ctx = extensionContext(transport.sessionId, cwd);
332
+ try {
333
+ await handlers.get("session_start")?.({}, ctx);
334
+ const waitForFrame = async (id: string) => {
335
+ const deadline = Date.now() + 15_000;
336
+ while (!transport.sent.some(frame => frame.id === id)) {
337
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for control frame ${id}`);
338
+ await Bun.sleep(20);
339
+ }
340
+ };
341
+ const request = {
342
+ type: "control_request",
343
+ id: "terminal-abort-1",
344
+ operation: "turn.abort",
345
+ input: { mode: "terminal" },
346
+ idempotencyKey: "terminal-key-1",
347
+ } as SdkFrame;
348
+ transport.feed("client", request);
349
+ await waitForFrame("terminal-abort-1");
350
+ // The steering snapshot seam is captured at ADMISSION (before the
351
+ // durable transaction and settlement) — mirrors the production
352
+ // wiring in session.ts (review thread P1).
353
+ expect(captureCalls).toBeGreaterThanOrEqual(1);
354
+ expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
355
+ expect(transport.sent).toEqual(
356
+ expect.arrayContaining([
357
+ expect.objectContaining({
358
+ type: "control_response",
359
+ id: "terminal-abort-1",
360
+ ok: true,
361
+ result: expect.objectContaining({ turn: "stopped" }),
362
+ }),
363
+ ]),
364
+ );
365
+
366
+ transport.feed("client", { ...request, id: "terminal-abort-replay" });
367
+ await waitForFrame("terminal-abort-replay");
368
+ // An in-memory dispatch-cache replay short-circuits before the
369
+ // surface: terminalAbort never runs, so no snapshot is captured and
370
+ // nothing to discard (the durable-replay discard is covered by the
371
+ // seeded-row test below).
372
+ expect(seamCalls).toHaveLength(1);
373
+ expect(transport.sent).toEqual(
374
+ expect.arrayContaining([
375
+ expect.objectContaining({
376
+ type: "control_response",
377
+ id: "terminal-abort-replay",
378
+ ok: true,
379
+ }),
380
+ ]),
381
+ );
382
+
383
+ transport.feed("client", {
384
+ ...request,
385
+ id: "terminal-abort-conflict",
386
+ input: { mode: "terminal", scope: "owned" },
387
+ });
388
+ await waitForFrame("terminal-abort-conflict");
389
+ expect(seamCalls).toHaveLength(1);
390
+ expect(transport.sent).toEqual(
391
+ expect.arrayContaining([
392
+ expect.objectContaining({
393
+ type: "control_response",
394
+ id: "terminal-abort-conflict",
395
+ ok: false,
396
+ error: expect.objectContaining({ code: "idempotency_conflict" }),
397
+ }),
398
+ ]),
399
+ );
400
+
401
+ activeHandle = undefined;
402
+ activeEpoch = undefined;
403
+ const idleRequest = { ...request, id: "terminal-abort-idle", idempotencyKey: "terminal-idle-key" };
404
+ transport.feed("client", idleRequest);
405
+ await waitForFrame("terminal-abort-idle");
406
+ expect(seamCalls).toHaveLength(1);
407
+ activeHandle = "later-run-handle";
408
+ activeEpoch = 8;
409
+ transport.feed("client", { ...idleRequest, id: "terminal-abort-idle-replay" });
410
+ await waitForFrame("terminal-abort-idle-replay");
411
+ expect(seamCalls).toHaveLength(1);
412
+ expect(transport.sent).toEqual(
413
+ expect.arrayContaining([
414
+ expect.objectContaining({
415
+ type: "control_response",
416
+ id: "terminal-abort-idle-replay",
417
+ ok: true,
418
+ result: expect.objectContaining({ turn: "no_active_turn" }),
419
+ }),
420
+ ]),
421
+ );
422
+ } finally {
423
+ await handlers.get("session_shutdown")?.({}, ctx);
424
+ await rm(cwd, { recursive: true, force: true });
425
+ }
426
+ });
427
+
428
+ test("SDK-only host correlates a steering-queued prompt when the unwind promotes it to a run", async () => {
429
+ // Review thread P2: a prompt accepted while the session reports busy
430
+ // (finished prompt unwinding) is queued as steering with NO pending entry
431
+ // at accept; when the unwind continuation actually promotes it to its own
432
+ // run, the promotion hook must create the ownership correlation so the
433
+ // submitting connection can terminal-abort that turn.
434
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-unwind-promoted-"));
435
+ let idle = true;
436
+ let promoted: (() => void) | undefined;
437
+ const queuedDispositions: boolean[] = [];
438
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
439
+ const api = {
440
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
441
+ handlers.set(event, handler);
442
+ },
443
+ sendUserMessage: (
444
+ _content: string,
445
+ options:
446
+ | {
447
+ onPreflightAccepted?: () => void;
448
+ onPreflightAcceptCommit?: () => void;
449
+ onQueuedPromoted?: () => void;
450
+ queuedAtDispatch?: boolean;
451
+ }
452
+ | undefined,
453
+ ) =>
454
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
455
+ queuedDispositions.push(options?.queuedAtDispatch === true);
456
+ promoted = options?.onQueuedPromoted;
457
+ options?.onPreflightAccepted?.();
458
+ return {};
459
+ }),
460
+ } as unknown as ExtensionAPI;
461
+ const transport = memoryTransport();
462
+ const reconciliationStore = createReconciliationStore({
463
+ sessionFile: path.join(cwd, "session.json"),
464
+ sessionId: transport.sessionId,
465
+ });
466
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
467
+ let captureCalls = 0;
468
+ createSdkSessionRuntimeExtension(api, {
469
+ agentDir: cwd,
470
+ createTransport: async () => transport,
471
+ terminalAbortSeams: {
472
+ getReconciliationStore: () => reconciliationStore,
473
+ getTerminalTurnEpoch: () => 7,
474
+ getActivePromptHandle: () => "exact-run-handle",
475
+ cancelPendingPreflightForTerminalAbort: () => {},
476
+ captureTerminalAbortSteeringSnapshot: () => {
477
+ captureCalls += 1;
478
+ return captureCalls;
479
+ },
480
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
481
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
482
+ return { status: "settled", terminalScope: {} };
483
+ },
484
+ },
485
+ });
486
+ const ctx = {
487
+ ...extensionContext(transport.sessionId, cwd),
488
+ isIdle: () => idle,
489
+ } as unknown as ExtensionContext;
490
+ try {
491
+ await handlers.get("session_start")?.({}, ctx);
492
+ const prompt = (connectionId: string, id: string) =>
493
+ transport.feed(connectionId, {
494
+ type: "control_request",
495
+ id,
496
+ operation: "turn.prompt",
497
+ input: { text: id, images: [] },
498
+ } as SdkFrame);
499
+ const waitResponse = async (id: string) => {
500
+ const deadline = Date.now() + 15_000;
501
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
502
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
503
+ await Bun.sleep(20);
504
+ }
505
+ };
506
+ // A submits while idle and STARTS its run: owner becomes conn-a.
507
+ prompt("conn-a", "unwind-a");
508
+ await waitResponse("unwind-a");
509
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
510
+ // B submits while the session still reports busy (unwinding): the prompt
511
+ // is queued as steering and NO pending entry is created at accept.
512
+ idle = false;
513
+ prompt("conn-b", "unwind-b");
514
+ await waitResponse("unwind-b");
515
+ expect(queuedDispositions).toEqual([false, true]);
516
+ expect(promoted).toBeDefined();
517
+ // The unwind continuation promotes the queued steer to its own run: the
518
+ // correlation hook fires before agent_start...
519
+ promoted!();
520
+ // ...and B's run starts: ownership transfers to conn-b.
521
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
522
+ // B's own terminal abort now stops its turn (previously no_active_turn).
523
+ transport.feed("conn-b", {
524
+ type: "control_request",
525
+ id: "unwind-abort",
526
+ operation: "turn.abort",
527
+ input: { mode: "terminal" },
528
+ idempotencyKey: "unwind-abort-key",
529
+ } as SdkFrame);
530
+ await waitResponse("unwind-abort");
531
+ expect(transport.sent.find(frame => frame.id === "unwind-abort")).toMatchObject({
532
+ ok: true,
533
+ result: expect.objectContaining({ turn: "stopped" }),
534
+ });
535
+ expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
536
+ } finally {
537
+ await handlers.get("session_shutdown")?.({}, ctx);
538
+ await rm(cwd, { recursive: true, force: true });
539
+ }
540
+ });
541
+
542
+ test("SDK-only host re-reads the active prompt after an idle reservation wins the race", async () => {
543
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-race-active-"));
544
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
545
+ const api = {
546
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
547
+ handlers.set(event, handler);
548
+ },
549
+ } as unknown as ExtensionAPI;
550
+ const transport = memoryTransport();
551
+ const reconciliationStore = createReconciliationStore({
552
+ sessionFile: path.join(cwd, "session.json"),
553
+ sessionId: transport.sessionId,
554
+ });
555
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
556
+ // The FIRST active-prompt read sees no turn (idle abort request); a
557
+ // prompt then wins the race and every later read sees it active —
558
+ // exactly the window where writeNoEffect awaits the store.
559
+ let promptReads = 0;
560
+ let captureCalls = 0;
561
+ createSdkSessionRuntimeExtension(api, {
562
+ agentDir: cwd,
563
+ createTransport: async () => transport,
564
+ terminalAbortSeams: {
565
+ getReconciliationStore: () => reconciliationStore,
566
+ getTerminalTurnEpoch: () => (promptReads++ === 0 ? undefined : 7),
567
+ getActivePromptHandle: () => (promptReads === 0 ? undefined : "won-race-handle"),
568
+ getActivePromptOwnerConnectionId: () => "client",
569
+ cancelPendingPreflightForTerminalAbort: () => {},
570
+ captureTerminalAbortSteeringSnapshot: () => {
571
+ captureCalls += 1;
572
+ return captureCalls;
573
+ },
574
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
575
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
576
+ return {
577
+ status: "settled",
578
+ terminalScope: { scopeId: "scope-race", abortedAttemptEpoch: 7, lineageIdHash: "race-lineage" },
579
+ };
580
+ },
581
+ },
582
+ });
583
+ const ctx = extensionContext(transport.sessionId, cwd);
584
+ try {
585
+ await handlers.get("session_start")?.({}, ctx);
586
+ transport.feed("client", {
587
+ type: "control_request",
588
+ id: "race-abort",
589
+ operation: "turn.abort",
590
+ input: { mode: "terminal" },
591
+ idempotencyKey: "race-key",
592
+ } as SdkFrame);
593
+ const deadline = Date.now() + 15_000;
594
+ while (!transport.sent.some(frame => frame.id === "race-abort")) {
595
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the race abort response");
596
+ await Bun.sleep(20);
597
+ }
598
+ // The prompt that won the race is ACTIVE-terminalized, never reported
599
+ // as no_active_turn, and the durable no-effect reservation was replaced
600
+ // by the stopped marker so a same-key retry replays stopped.
601
+ expect(seamCalls).toEqual([{ handle: "won-race-handle", scope: "turn" }]);
602
+ expect(transport.sent.find(frame => frame.id === "race-abort")).toMatchObject({
603
+ ok: true,
604
+ result: expect.objectContaining({ turn: "stopped" }),
605
+ });
606
+ transport.feed("client", {
607
+ type: "control_request",
608
+ id: "race-replay",
609
+ operation: "turn.abort",
610
+ input: { mode: "terminal" },
611
+ idempotencyKey: "race-key",
612
+ } as SdkFrame);
613
+ const replayDeadline = Date.now() + 15_000;
614
+ while (!transport.sent.some(frame => frame.id === "race-replay")) {
615
+ if (Date.now() > replayDeadline) throw new Error("Timed out waiting for the race replay");
616
+ await Bun.sleep(20);
617
+ }
618
+ expect(transport.sent.find(frame => frame.id === "race-replay")).toMatchObject({
619
+ ok: true,
620
+ result: expect.objectContaining({ turn: "stopped" }),
621
+ });
622
+ expect(seamCalls).toHaveLength(1);
623
+ } finally {
624
+ await handlers.get("session_shutdown")?.({}, ctx);
625
+ await rm(cwd, { recursive: true, force: true });
626
+ }
627
+ });
628
+
629
+ test("SDK-only host re-reads the active prompt AND its owner after an owner-mismatch reservation wins the race", async () => {
630
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-owner-race-"));
631
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
632
+ const api = {
633
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
634
+ handlers.set(event, handler);
635
+ },
636
+ } as unknown as ExtensionAPI;
637
+ const transport = memoryTransport();
638
+ const reconciliationStore = createReconciliationStore({
639
+ sessionFile: path.join(cwd, "session.json"),
640
+ sessionId: transport.sessionId,
641
+ });
642
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
643
+ // Connection A owns the active prompt when the abort is read; while the
644
+ // no-effect reservation awaits the store, A's prompt finishes and
645
+ // connection B's pending prompt wins the race. The aborting connection
646
+ // (B) must re-read handle, epoch, AND owner and fall through to ACTIVE
647
+ // terminalization instead of returning no_active_turn and leaving a
648
+ // durable reservation that blocks B's own running prompt forever
649
+ // (review thread P1).
650
+ let reads = 0;
651
+ let captureCalls = 0;
652
+ createSdkSessionRuntimeExtension(api, {
653
+ agentDir: cwd,
654
+ createTransport: async () => transport,
655
+ terminalAbortSeams: {
656
+ getReconciliationStore: () => reconciliationStore,
657
+ getActivePromptHandle: () => {
658
+ reads += 1;
659
+ return reads === 1 ? "owner-a-handle" : "winner-b-handle";
660
+ },
661
+ getTerminalTurnEpoch: () => (reads === 1 ? 1 : 2),
662
+ getActivePromptOwnerConnectionId: () => (reads === 1 ? "conn-a" : "conn-b"),
663
+ cancelPendingPreflightForTerminalAbort: () => {},
664
+ captureTerminalAbortSteeringSnapshot: () => {
665
+ captureCalls += 1;
666
+ return captureCalls;
667
+ },
668
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
669
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
670
+ return {
671
+ status: "settled",
672
+ terminalScope: {
673
+ scopeId: "scope-owner-race",
674
+ abortedAttemptEpoch: 2,
675
+ lineageIdHash: "owner-lineage",
676
+ },
677
+ };
678
+ },
679
+ },
680
+ });
681
+ const ctx = extensionContext(transport.sessionId, cwd);
682
+ try {
683
+ await handlers.get("session_start")?.({}, ctx);
684
+ // The aborting connection is "conn-b"; the first owner read is
685
+ // "conn-a", so this enters the owner-mismatch branch.
686
+ transport.feed("conn-b", {
687
+ type: "control_request",
688
+ id: "owner-race-abort",
689
+ operation: "turn.abort",
690
+ input: { mode: "terminal" },
691
+ idempotencyKey: "owner-race-key",
692
+ } as SdkFrame);
693
+ const deadline = Date.now() + 15_000;
694
+ while (!transport.sent.some(frame => frame.id === "owner-race-abort")) {
695
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the owner race abort response");
696
+ await Bun.sleep(20);
697
+ }
698
+ // B's now-owned prompt is ACTIVE-terminalized with B's run handle,
699
+ // never reported no_active_turn.
700
+ expect(seamCalls).toEqual([{ handle: "winner-b-handle", scope: "turn" }]);
701
+ expect(transport.sent.find(frame => frame.id === "owner-race-abort")).toMatchObject({
702
+ ok: true,
703
+ result: expect.objectContaining({ turn: "stopped" }),
704
+ });
705
+ // The durable no-effect reservation was replaced by the stopped
706
+ // marker, so a same-key retry replays stopped instead of
707
+ // no_active_turn.
708
+ transport.feed("conn-b", {
709
+ type: "control_request",
710
+ id: "owner-race-replay",
711
+ operation: "turn.abort",
712
+ input: { mode: "terminal" },
713
+ idempotencyKey: "owner-race-key",
714
+ } as SdkFrame);
715
+ const replayDeadline = Date.now() + 15_000;
716
+ while (!transport.sent.some(frame => frame.id === "owner-race-replay")) {
717
+ if (Date.now() > replayDeadline) throw new Error("Timed out waiting for the owner race replay");
718
+ await Bun.sleep(20);
719
+ }
720
+ expect(transport.sent.find(frame => frame.id === "owner-race-replay")).toMatchObject({
721
+ ok: true,
722
+ result: expect.objectContaining({ turn: "stopped" }),
723
+ });
724
+ expect(seamCalls).toHaveLength(1);
725
+ } finally {
726
+ await handlers.get("session_shutdown")?.({}, ctx);
727
+ await rm(cwd, { recursive: true, force: true });
728
+ }
729
+ });
730
+
731
+ test("SDK-only host records the OBSERVED agent_end publication on the terminal stopped row", async () => {
732
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-published-"));
733
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
734
+ const api = {
735
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
736
+ handlers.set(event, handler);
737
+ },
738
+ } as unknown as ExtensionAPI;
739
+ const transport = memoryTransport();
740
+ const reconciliationStore = createReconciliationStore({
741
+ sessionFile: path.join(cwd, "session.json"),
742
+ sessionId: transport.sessionId,
743
+ });
744
+ let captureCalls = 0;
745
+ createSdkSessionRuntimeExtension(api, {
746
+ agentDir: cwd,
747
+ createTransport: async () => transport,
748
+ terminalAbortSeams: {
749
+ getReconciliationStore: () => reconciliationStore,
750
+ getTerminalTurnEpoch: () => 7,
751
+ getActivePromptHandle: () => "exact-run-handle",
752
+ getActivePromptOwnerConnectionId: () => "client",
753
+ cancelPendingPreflightForTerminalAbort: () => {},
754
+ captureTerminalAbortSteeringSnapshot: () => {
755
+ captureCalls += 1;
756
+ return captureCalls;
757
+ },
758
+ abortPromptAndWaitWithTerminal: async (_handle, _options) => {
759
+ // The aborted run's loop exit publishes the correlated
760
+ // agent_end lifecycle event before settlement returns.
761
+ await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
762
+ return { status: "settled", terminalScope: {} };
763
+ },
764
+ },
765
+ });
766
+ const ctx = extensionContext(transport.sessionId, cwd);
767
+ try {
768
+ await handlers.get("session_start")?.({}, ctx);
769
+ transport.feed("client", {
770
+ type: "control_request",
771
+ id: "published-abort",
772
+ operation: "turn.abort",
773
+ input: { mode: "terminal" },
774
+ idempotencyKey: "published-key",
775
+ } as SdkFrame);
776
+ const deadline = Date.now() + 15_000;
777
+ while (!transport.sent.some(frame => frame.id === "published-abort")) {
778
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the published abort response");
779
+ await Bun.sleep(20);
780
+ }
781
+ expect(transport.sent.find(frame => frame.id === "published-abort")).toMatchObject({
782
+ ok: true,
783
+ result: expect.objectContaining({ turn: "stopped" }),
784
+ });
785
+ // The correlated agent_end was OBSERVED, so the durable row claims
786
+ // terminalPublished (AC 19) — never assumed.
787
+ const scopes = reconciliationStore.snapshotTerminalScopes();
788
+ expect(scopes).toHaveLength(1);
789
+ expect(scopes[0]).toMatchObject({ turnDisposition: "stopped", terminalPublished: true });
790
+ } finally {
791
+ await handlers.get("session_shutdown")?.({}, ctx);
792
+ await rm(cwd, { recursive: true, force: true });
793
+ }
794
+ });
795
+
796
+ test("SDK-only host resolves EVERY concurrent terminal-publication waiter for one aborted turn", async () => {
797
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-concurrent-publish-"));
798
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
799
+ const api = {
800
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
801
+ handlers.set(event, handler);
802
+ },
803
+ } as unknown as ExtensionAPI;
804
+ const transport = memoryTransport();
805
+ const reconciliationStore = createReconciliationStore({
806
+ sessionFile: path.join(cwd, "session.json"),
807
+ sessionId: transport.sessionId,
808
+ });
809
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
810
+ let seamCount = 0;
811
+ let captureCalls = 0;
812
+ createSdkSessionRuntimeExtension(api, {
813
+ agentDir: cwd,
814
+ createTransport: async () => transport,
815
+ terminalAbortSeams: {
816
+ getReconciliationStore: () => reconciliationStore,
817
+ getTerminalTurnEpoch: () => 7,
818
+ getActivePromptHandle: () => "exact-run-handle",
819
+ getActivePromptOwnerConnectionId: () => "client",
820
+ cancelPendingPreflightForTerminalAbort: () => {},
821
+ captureTerminalAbortSteeringSnapshot: () => {
822
+ captureCalls += 1;
823
+ return captureCalls;
824
+ },
825
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
826
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
827
+ // The turn emits exactly ONE correlated agent_end no matter how
828
+ // many concurrent aborts stop it; both waiters are installed just
829
+ // before their seam call, so fire it once BOTH have landed.
830
+ seamCount += 1;
831
+ if (seamCount === 2) {
832
+ await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
833
+ }
834
+ return { status: "settled", terminalScope: {} };
835
+ },
836
+ },
837
+ });
838
+ const ctx = extensionContext(transport.sessionId, cwd);
839
+ try {
840
+ await handlers.get("session_start")?.({}, ctx);
841
+ const abort = (id: string, idempotencyKey: string) =>
842
+ transport.feed("client", {
843
+ type: "control_request",
844
+ id,
845
+ operation: "turn.abort",
846
+ input: { mode: "terminal" },
847
+ idempotencyKey,
848
+ } as SdkFrame);
849
+ // Two concurrent aborts of the SAME active turn with DISTINCT keys:
850
+ // both are admitted and both await the single agent_end.
851
+ abort("conc-abort-1", "conc-key-1");
852
+ abort("conc-abort-2", "conc-key-2");
853
+ const deadline = Date.now() + 15_000;
854
+ while (
855
+ !transport.sent.some(frame => frame.id === "conc-abort-1" && frame.type === "control_response") ||
856
+ !transport.sent.some(frame => frame.id === "conc-abort-2" && frame.type === "control_response")
857
+ ) {
858
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the concurrent abort responses");
859
+ await Bun.sleep(20);
860
+ }
861
+ // The onControlResponseDelivery observer writes responseState asynchronously
862
+ // after the transport send; wait for BOTH durable rows to settle before
863
+ // asserting, so the test is not racy on the observer's transactTerminalState
864
+ // microtask drain.
865
+ while (
866
+ reconciliationStore.snapshotTerminalScopes().length < 2 ||
867
+ reconciliationStore.snapshotTerminalScopes().some(scope => scope.responseState !== "sent")
868
+ ) {
869
+ if (Date.now() > deadline) throw new Error("Timed out waiting for durable responseState to settle");
870
+ await Bun.sleep(20);
871
+ }
872
+ expect(transport.sent.find(frame => frame.id === "conc-abort-1")).toMatchObject({
873
+ ok: true,
874
+ result: expect.objectContaining({ turn: "stopped" }),
875
+ });
876
+ expect(transport.sent.find(frame => frame.id === "conc-abort-2")).toMatchObject({
877
+ ok: true,
878
+ result: expect.objectContaining({ turn: "stopped" }),
879
+ });
880
+ // BOTH durable rows observed the single publication — a latest-wins
881
+ // single slot would leave one of them terminalPublished:false (review
882
+ // thread P2).
883
+ const scopes = reconciliationStore.snapshotTerminalScopes();
884
+ expect(scopes).toHaveLength(2);
885
+ for (const scope of scopes) {
886
+ expect(scope).toMatchObject({ turnDisposition: "stopped", terminalPublished: true });
887
+ // The stopped result was actually written: the delivery observer
888
+ // matched the response payload hash (which must include the public
889
+ // `ok` field) and advanced the durable state — a shape mismatch
890
+ // would leave responseState pending despite the successful write
891
+ // (review thread P2).
892
+ expect(scope.responseState).toBe("sent");
893
+ }
894
+ } finally {
895
+ await handlers.get("session_shutdown")?.({}, ctx);
896
+ await rm(cwd, { recursive: true, force: true });
897
+ }
898
+ });
899
+
900
+ test("SDK-only host never claims terminalPublished without observing the agent_end publication", async () => {
901
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-unpublished-"));
902
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
903
+ const api = {
904
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
905
+ handlers.set(event, handler);
906
+ },
907
+ } as unknown as ExtensionAPI;
908
+ const transport = memoryTransport();
909
+ const reconciliationStore = createReconciliationStore({
910
+ sessionFile: path.join(cwd, "session.json"),
911
+ sessionId: transport.sessionId,
912
+ });
913
+ createSdkSessionRuntimeExtension(api, {
914
+ agentDir: cwd,
915
+ createTransport: async () => transport,
916
+ terminalAbortSeams: {
917
+ getReconciliationStore: () => reconciliationStore,
918
+ getTerminalTurnEpoch: () => 7,
919
+ getActivePromptHandle: () => "exact-run-handle",
920
+ getActivePromptOwnerConnectionId: () => "client",
921
+ cancelPendingPreflightForTerminalAbort: () => {},
922
+ abortPromptAndWaitWithTerminal: async (_handle, _options) => {
923
+ // The worker settles but the lifecycle listener never completes:
924
+ // the durable row must NOT claim the terminal event reached
925
+ // clients (review thread P2).
926
+ return { status: "settled", terminalScope: {} };
927
+ },
928
+ },
929
+ });
930
+ const ctx = extensionContext(transport.sessionId, cwd);
931
+ try {
932
+ await handlers.get("session_start")?.({}, ctx);
933
+ transport.feed("client", {
934
+ type: "control_request",
935
+ id: "unpublished-abort",
936
+ operation: "turn.abort",
937
+ input: { mode: "terminal" },
938
+ idempotencyKey: "unpublished-key",
939
+ } as SdkFrame);
940
+ const deadline = Date.now() + 15_000;
941
+ while (!transport.sent.some(frame => frame.id === "unpublished-abort")) {
942
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the unpublished abort response");
943
+ await Bun.sleep(20);
944
+ }
945
+ expect(transport.sent.find(frame => frame.id === "unpublished-abort")).toMatchObject({
946
+ ok: true,
947
+ result: expect.objectContaining({ turn: "stopped" }),
948
+ });
949
+ // No agent_end was observed: the durable stopped row reports
950
+ // terminalPublished:false, the fail-safe direction.
951
+ const scopes = reconciliationStore.snapshotTerminalScopes();
952
+ expect(scopes).toHaveLength(1);
953
+ expect(scopes[0]).toMatchObject({ turnDisposition: "stopped", terminalPublished: false });
954
+ } finally {
955
+ await handlers.get("session_shutdown")?.({}, ctx);
956
+ await rm(cwd, { recursive: true, force: true });
957
+ }
958
+ });
959
+
960
+ test("SDK-only host cancels only the preflights admitted at abort time, never a pipelined successor", async () => {
961
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-preflight-snapshot-"));
962
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
963
+ const api = {
964
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
965
+ handlers.set(event, handler);
966
+ },
967
+ // Hold every prompt preflight open: acceptance is never signalled, so
968
+ // each submission stays pending until the abort (or nothing) settles it.
969
+ // Deferred via Promise.withResolvers per the repository contract.
970
+ sendUserMessage: () => Promise.withResolvers<void>().promise,
971
+ } as unknown as ExtensionAPI;
972
+ const transport = memoryTransport();
973
+ const reconciliationStore = createReconciliationStore({
974
+ sessionFile: path.join(cwd, "session.json"),
975
+ sessionId: transport.sessionId,
976
+ });
977
+ createSdkSessionRuntimeExtension(api, {
978
+ agentDir: cwd,
979
+ createTransport: async () => transport,
980
+ terminalAbortSeams: {
981
+ getReconciliationStore: () => reconciliationStore,
982
+ // No active turn: the abort takes the idle no-effect path, which is
983
+ // where cancelRequesterPreflights runs after the durable reservation.
984
+ getTerminalTurnEpoch: () => undefined,
985
+ getActivePromptHandle: () => undefined,
986
+ cancelPendingPreflightForTerminalAbort: () => {},
987
+ abortPromptAndWaitWithTerminal: async (_handle, _options) => {
988
+ throw new Error("seam must not be called for an idle abort");
989
+ },
990
+ },
991
+ });
992
+ const ctx = extensionContext(transport.sessionId, cwd);
993
+ try {
994
+ await handlers.get("session_start")?.({}, ctx);
995
+ const prompt = (id: string) =>
996
+ transport.feed("conn-a", {
997
+ type: "control_request",
998
+ id,
999
+ operation: "turn.prompt",
1000
+ input: { text: `hold-${id}`, images: [] },
1001
+ } as SdkFrame);
1002
+ prompt("pre-1");
1003
+ // Let the serialized (ordered) turn.prompt work register its preflight
1004
+ // callback, so the abort's admission snapshot below sees it.
1005
+ await Bun.sleep(0);
1006
+ transport.feed("conn-a", {
1007
+ type: "control_request",
1008
+ id: "pre-abort",
1009
+ operation: "turn.abort",
1010
+ input: { mode: "terminal" },
1011
+ idempotencyKey: "pre-snapshot-key",
1012
+ } as SdkFrame);
1013
+ // A successor prompt pipelined by the SAME connection while the abort
1014
+ // awaits the reconciliation transaction: its preflight lands in the
1015
+ // live bucket AFTER the abort's admission snapshot and must survive.
1016
+ prompt("pre-2");
1017
+ const deadline = Date.now() + 15_000;
1018
+ while (
1019
+ !transport.sent.some(frame => frame.id === "pre-abort" && frame.type === "control_response") ||
1020
+ !transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response")
1021
+ ) {
1022
+ if (Date.now() > deadline)
1023
+ throw new Error("Timed out waiting for the abort and admitted-preflight responses");
1024
+ await Bun.sleep(20);
1025
+ }
1026
+ // The admitted preflight is cancelled by the abort.
1027
+ expect(transport.sent.find(frame => frame.id === "pre-1")).toMatchObject({
1028
+ ok: false,
1029
+ error: expect.objectContaining({ code: "busy" }),
1030
+ });
1031
+ expect(transport.sent.find(frame => frame.id === "pre-abort")).toMatchObject({
1032
+ ok: true,
1033
+ result: expect.objectContaining({ turn: "no_active_turn" }),
1034
+ });
1035
+ // The successor preflight was NOT part of the abort's snapshot: it is
1036
+ // neither cancelled nor failed, so no control response is emitted for it.
1037
+ expect(transport.sent.some(frame => frame.id === "pre-2" && frame.type === "control_response")).toBe(false);
1038
+ } finally {
1039
+ await handlers.get("session_shutdown")?.({}, ctx);
1040
+ await rm(cwd, { recursive: true, force: true });
1041
+ }
1042
+ });
1043
+
1044
+ test("SDK-only host tracks ownership for skill-invoked turns so a foreign abort cannot stop them", async () => {
1045
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-skill-owner-"));
1046
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1047
+ const api = {
1048
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1049
+ handlers.set(event, handler);
1050
+ },
1051
+ } as unknown as ExtensionAPI;
1052
+ const transport = memoryTransport();
1053
+ const reconciliationStore = createReconciliationStore({
1054
+ sessionFile: path.join(cwd, "session.json"),
1055
+ sessionId: transport.sessionId,
1056
+ });
1057
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1058
+ createSdkSessionRuntimeExtension(api, {
1059
+ agentDir: cwd,
1060
+ createTransport: async () => transport,
1061
+ terminalAbortSeams: {
1062
+ getReconciliationStore: () => reconciliationStore,
1063
+ getTerminalTurnEpoch: () => 7,
1064
+ getActivePromptHandle: () => "exact-run-handle",
1065
+ cancelPendingPreflightForTerminalAbort: () => {},
1066
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1067
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1068
+ return { status: "settled", terminalScope: {} };
1069
+ },
1070
+ },
1071
+ });
1072
+ const ctx = {
1073
+ ...extensionContext(transport.sessionId, cwd),
1074
+ // Declare the binding so the surface policy installs skill.invoke, and
1075
+ // accept the preflight so the skill turn is ADMITTED under conn-a.
1076
+ sdkBindings: () => ["invokeSkill"],
1077
+ invokeSkill: async (_name: string, _args: unknown, options: { onPreflightAccepted?: () => void }) => {
1078
+ options.onPreflightAccepted?.();
1079
+ return { accepted: true };
1080
+ },
1081
+ } as unknown as ExtensionContext;
1082
+ try {
1083
+ await handlers.get("session_start")?.({}, ctx);
1084
+ // Client A starts a skill: skill.invoke runs a real prompt through
1085
+ // submit(), so the ACCEPTING connection must own the active turn.
1086
+ transport.feed("conn-a", {
1087
+ type: "control_request",
1088
+ id: "skill-a",
1089
+ operation: "skill.invoke",
1090
+ input: { name: "some-skill", args: "" },
1091
+ } as SdkFrame);
1092
+ const skillDeadline = Date.now() + 15_000;
1093
+ while (!transport.sent.some(frame => frame.id === "skill-a" && frame.type === "control_response")) {
1094
+ if (Date.now() > skillDeadline) throw new Error("Timed out waiting for the skill acceptance");
1095
+ await Bun.sleep(20);
1096
+ }
1097
+ expect(transport.sent.find(frame => frame.id === "skill-a")).toMatchObject({
1098
+ ok: true,
1099
+ result: expect.objectContaining({ accepted: true }),
1100
+ });
1101
+ // Ownership is associated when the accepted submission STARTS its run
1102
+ // (agent_start), not at acceptance: fire the lifecycle event so the
1103
+ // skill run is owned by conn-a (review thread P1).
1104
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1105
+ // Client B's terminal abort must NOT stop A's skill run: owner is A.
1106
+ transport.feed("conn-b", {
1107
+ type: "control_request",
1108
+ id: "skill-abort-b",
1109
+ operation: "turn.abort",
1110
+ input: { mode: "terminal" },
1111
+ idempotencyKey: "skill-abort-b-key",
1112
+ } as SdkFrame);
1113
+ const abortDeadline = Date.now() + 15_000;
1114
+ while (!transport.sent.some(frame => frame.id === "skill-abort-b" && frame.type === "control_response")) {
1115
+ if (Date.now() > abortDeadline) throw new Error("Timed out waiting for the foreign abort response");
1116
+ await Bun.sleep(20);
1117
+ }
1118
+ expect(transport.sent.find(frame => frame.id === "skill-abort-b")).toMatchObject({
1119
+ ok: true,
1120
+ result: expect.objectContaining({ turn: "no_active_turn" }),
1121
+ });
1122
+ expect(seamCalls).toHaveLength(0);
1123
+ // A's own terminal abort still stops its skill run.
1124
+ transport.feed("conn-a", {
1125
+ type: "control_request",
1126
+ id: "skill-abort-a",
1127
+ operation: "turn.abort",
1128
+ input: { mode: "terminal" },
1129
+ idempotencyKey: "skill-abort-a-key",
1130
+ } as SdkFrame);
1131
+ const ownDeadline = Date.now() + 15_000;
1132
+ while (!transport.sent.some(frame => frame.id === "skill-abort-a" && frame.type === "control_response")) {
1133
+ if (Date.now() > ownDeadline) throw new Error("Timed out waiting for the owner abort response");
1134
+ await Bun.sleep(20);
1135
+ }
1136
+ expect(transport.sent.find(frame => frame.id === "skill-abort-a")).toMatchObject({
1137
+ ok: true,
1138
+ result: expect.objectContaining({ turn: "stopped" }),
1139
+ });
1140
+ expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
1141
+ } finally {
1142
+ await handlers.get("session_shutdown")?.({}, ctx);
1143
+ await rm(cwd, { recursive: true, force: true });
1144
+ }
1145
+ });
1146
+
1147
+ test("SDK-only host does not transfer ownership to a queued submission until its run starts", async () => {
1148
+ // While client A's prompt is streaming, client B's prompt is accepted as
1149
+ // queued steering/follow-up: the owner must STAY with A until B's run
1150
+ // actually starts (agent_start), or B could terminal-abort A's running
1151
+ // turn while A is refused (review thread P1).
1152
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-queued-owner-"));
1153
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1154
+ const api = {
1155
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1156
+ handlers.set(event, handler);
1157
+ },
1158
+ sendUserMessage: (
1159
+ _content: string,
1160
+ options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
1161
+ ) =>
1162
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
1163
+ options?.onPreflightAccepted?.();
1164
+ return {};
1165
+ }),
1166
+ } as unknown as ExtensionAPI;
1167
+ const transport = memoryTransport();
1168
+ const reconciliationStore = createReconciliationStore({
1169
+ sessionFile: path.join(cwd, "session.json"),
1170
+ sessionId: transport.sessionId,
1171
+ });
1172
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1173
+ createSdkSessionRuntimeExtension(api, {
1174
+ agentDir: cwd,
1175
+ createTransport: async () => transport,
1176
+ terminalAbortSeams: {
1177
+ getReconciliationStore: () => reconciliationStore,
1178
+ getTerminalTurnEpoch: () => 7,
1179
+ getActivePromptHandle: () => "exact-run-handle",
1180
+ cancelPendingPreflightForTerminalAbort: () => {},
1181
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1182
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1183
+ return { status: "settled", terminalScope: {} };
1184
+ },
1185
+ },
1186
+ });
1187
+ const ctx = extensionContext(transport.sessionId, cwd);
1188
+ try {
1189
+ await handlers.get("session_start")?.({}, ctx);
1190
+ const prompt = (connectionId: string, id: string) =>
1191
+ transport.feed(connectionId, {
1192
+ type: "control_request",
1193
+ id,
1194
+ operation: "turn.prompt",
1195
+ input: { text: id, images: [] },
1196
+ } as SdkFrame);
1197
+ const waitResponse = async (id: string) => {
1198
+ const deadline = Date.now() + 15_000;
1199
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
1200
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
1201
+ await Bun.sleep(20);
1202
+ }
1203
+ };
1204
+ // A submits and STARTS its run: owner becomes conn-a.
1205
+ prompt("conn-a", "prompt-a");
1206
+ await waitResponse("prompt-a");
1207
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1208
+ // B submits and is ACCEPTED, but only queued (agent_start not fired).
1209
+ prompt("conn-b", "prompt-b");
1210
+ await waitResponse("prompt-b");
1211
+ expect(transport.sent.find(frame => frame.id === "prompt-b")).toMatchObject({
1212
+ ok: true,
1213
+ result: expect.objectContaining({ accepted: true }),
1214
+ });
1215
+ // B's abort must NOT stop A's streaming turn: the owner is still A.
1216
+ transport.feed("conn-b", {
1217
+ type: "control_request",
1218
+ id: "queued-abort",
1219
+ operation: "turn.abort",
1220
+ input: { mode: "terminal" },
1221
+ idempotencyKey: "queued-abort-key",
1222
+ } as SdkFrame);
1223
+ await waitResponse("queued-abort");
1224
+ expect(transport.sent.find(frame => frame.id === "queued-abort")).toMatchObject({
1225
+ ok: true,
1226
+ result: expect.objectContaining({ turn: "no_active_turn" }),
1227
+ });
1228
+ expect(seamCalls).toHaveLength(0);
1229
+ // B's run now STARTS: ownership transfers to conn-b, and B's own abort
1230
+ // stops it.
1231
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1232
+ transport.feed("conn-b", {
1233
+ type: "control_request",
1234
+ id: "queued-abort-2",
1235
+ operation: "turn.abort",
1236
+ input: { mode: "terminal" },
1237
+ idempotencyKey: "queued-abort-2-key",
1238
+ } as SdkFrame);
1239
+ await waitResponse("queued-abort-2");
1240
+ expect(transport.sent.find(frame => frame.id === "queued-abort-2")).toMatchObject({
1241
+ ok: true,
1242
+ result: expect.objectContaining({ turn: "stopped" }),
1243
+ });
1244
+ expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
1245
+ } finally {
1246
+ await handlers.get("session_shutdown")?.({}, ctx);
1247
+ await rm(cwd, { recursive: true, force: true });
1248
+ }
1249
+ });
1250
+
1251
+ test("SDK-only host retires steering-queued submissions so a later agent-initiated turn is not mis-owned", async () => {
1252
+ // While client A's prompt streams, client B's plain prompt is accepted as
1253
+ // queued STEERING and consumed inside the current run — it emits no
1254
+ // agent_start, so its pending entry must be retired. Otherwise the next
1255
+ // agent-initiated monitor/cron turn's agent_start would shift the stale
1256
+ // entry and let B terminal-abort a turn it did not submit (review thread
1257
+ // P1).
1258
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stale-owner-"));
1259
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1260
+ const api = {
1261
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1262
+ handlers.set(event, handler);
1263
+ },
1264
+ sendUserMessage: (
1265
+ _content: string,
1266
+ options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
1267
+ ) =>
1268
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
1269
+ options?.onPreflightAccepted?.();
1270
+ return {};
1271
+ }),
1272
+ } as unknown as ExtensionAPI;
1273
+ const transport = memoryTransport();
1274
+ const reconciliationStore = createReconciliationStore({
1275
+ sessionFile: path.join(cwd, "session.json"),
1276
+ sessionId: transport.sessionId,
1277
+ });
1278
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1279
+ let idle = true;
1280
+ createSdkSessionRuntimeExtension(api, {
1281
+ agentDir: cwd,
1282
+ createTransport: async () => transport,
1283
+ terminalAbortSeams: {
1284
+ getReconciliationStore: () => reconciliationStore,
1285
+ getTerminalTurnEpoch: () => 7,
1286
+ getActivePromptHandle: () => "exact-run-handle",
1287
+ cancelPendingPreflightForTerminalAbort: () => {},
1288
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1289
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1290
+ return { status: "settled", terminalScope: {} };
1291
+ },
1292
+ },
1293
+ });
1294
+ const ctx = {
1295
+ ...extensionContext(transport.sessionId, cwd),
1296
+ isIdle: () => idle,
1297
+ } as unknown as ExtensionContext;
1298
+ try {
1299
+ await handlers.get("session_start")?.({}, ctx);
1300
+ const prompt = (connectionId: string, id: string) =>
1301
+ transport.feed(connectionId, {
1302
+ type: "control_request",
1303
+ id,
1304
+ operation: "turn.prompt",
1305
+ input: { text: id, images: [] },
1306
+ } as SdkFrame);
1307
+ const waitResponse = async (id: string) => {
1308
+ const deadline = Date.now() + 15_000;
1309
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
1310
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
1311
+ await Bun.sleep(20);
1312
+ }
1313
+ };
1314
+ // A submits while idle and STARTS its run: owner becomes conn-a.
1315
+ prompt("conn-a", "stale-a");
1316
+ await waitResponse("stale-a");
1317
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1318
+ // A's turn is now streaming: B's plain prompt is queued as steering and
1319
+ // consumed in-run — its pending entry must NOT be created.
1320
+ idle = false;
1321
+ prompt("conn-b", "stale-b");
1322
+ await waitResponse("stale-b");
1323
+ // A later AGENT-INITIATED turn starts: the pending queue is empty, so
1324
+ // the owner stays conn-a (B's stale connection is not associated).
1325
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1326
+ // B's abort must NOT stop the agent-initiated turn.
1327
+ transport.feed("conn-b", {
1328
+ type: "control_request",
1329
+ id: "stale-owner-abort",
1330
+ operation: "turn.abort",
1331
+ input: { mode: "terminal" },
1332
+ idempotencyKey: "stale-owner-abort-key",
1333
+ } as SdkFrame);
1334
+ await waitResponse("stale-owner-abort");
1335
+ expect(transport.sent.find(frame => frame.id === "stale-owner-abort")).toMatchObject({
1336
+ ok: true,
1337
+ result: expect.objectContaining({ turn: "no_active_turn" }),
1338
+ });
1339
+ expect(seamCalls).toHaveLength(0);
1340
+ } finally {
1341
+ await handlers.get("session_shutdown")?.({}, ctx);
1342
+ await rm(cwd, { recursive: true, force: true });
1343
+ }
1344
+ });
1345
+
1346
+ test("SDK-only host retires a follow-up queued while streaming so a later agent-initiated turn is not mis-owned", async () => {
1347
+ // While client A's prompt streams, client B's follow-up is consumed by the
1348
+ // active loop (agent-loop.ts getFollowUpMessages) with NO new agent_start.
1349
+ // Its pending entry must not survive to a later agent-initiated turn, or
1350
+ // B could terminal-abort an unrelated monitor/cron turn (review thread
1351
+ // P1).
1352
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stale-followup-"));
1353
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1354
+ const api = {
1355
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1356
+ handlers.set(event, handler);
1357
+ },
1358
+ sendUserMessage: (
1359
+ _content: string,
1360
+ options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
1361
+ ) =>
1362
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
1363
+ options?.onPreflightAccepted?.();
1364
+ return {};
1365
+ }),
1366
+ } as unknown as ExtensionAPI;
1367
+ const transport = memoryTransport();
1368
+ const reconciliationStore = createReconciliationStore({
1369
+ sessionFile: path.join(cwd, "session.json"),
1370
+ sessionId: transport.sessionId,
1371
+ });
1372
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1373
+ let idle = true;
1374
+ createSdkSessionRuntimeExtension(api, {
1375
+ agentDir: cwd,
1376
+ createTransport: async () => transport,
1377
+ terminalAbortSeams: {
1378
+ getReconciliationStore: () => reconciliationStore,
1379
+ getTerminalTurnEpoch: () => 7,
1380
+ getActivePromptHandle: () => "exact-run-handle",
1381
+ cancelPendingPreflightForTerminalAbort: () => {},
1382
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1383
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1384
+ return { status: "settled", terminalScope: {} };
1385
+ },
1386
+ },
1387
+ });
1388
+ const ctx = {
1389
+ ...extensionContext(transport.sessionId, cwd),
1390
+ isIdle: () => idle,
1391
+ } as unknown as ExtensionContext;
1392
+ try {
1393
+ await handlers.get("session_start")?.({}, ctx);
1394
+ const waitResponse = async (id: string) => {
1395
+ const deadline = Date.now() + 15_000;
1396
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
1397
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
1398
+ await Bun.sleep(20);
1399
+ }
1400
+ };
1401
+ // A starts its run while idle: owner becomes conn-a.
1402
+ transport.feed("conn-a", {
1403
+ type: "control_request",
1404
+ id: "fu-a",
1405
+ operation: "turn.prompt",
1406
+ input: { text: "a", images: [] },
1407
+ } as SdkFrame);
1408
+ await waitResponse("fu-a");
1409
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1410
+ // A streams: B's follow-up is consumed in-run -> NO pending entry.
1411
+ idle = false;
1412
+ transport.feed("conn-b", {
1413
+ type: "control_request",
1414
+ id: "fu-b",
1415
+ operation: "turn.follow_up",
1416
+ input: { text: "follow up b" },
1417
+ } as SdkFrame);
1418
+ await waitResponse("fu-b");
1419
+ // A later agent-initiated turn starts: the pending queue is empty, so
1420
+ // B's stale connection is never associated as owner.
1421
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1422
+ transport.feed("conn-b", {
1423
+ type: "control_request",
1424
+ id: "stale-followup-abort",
1425
+ operation: "turn.abort",
1426
+ input: { mode: "terminal" },
1427
+ idempotencyKey: "stale-followup-abort-key",
1428
+ } as SdkFrame);
1429
+ await waitResponse("stale-followup-abort");
1430
+ expect(transport.sent.find(frame => frame.id === "stale-followup-abort")).toMatchObject({
1431
+ ok: true,
1432
+ result: expect.objectContaining({ turn: "no_active_turn" }),
1433
+ });
1434
+ expect(seamCalls).toHaveLength(0);
1435
+ } finally {
1436
+ await handlers.get("session_shutdown")?.({}, ctx);
1437
+ await rm(cwd, { recursive: true, force: true });
1438
+ }
1439
+ });
1440
+
1441
+ test("SDK-only host retires an accepted-then-failed submission so a later agent-initiated turn is not mis-owned", async () => {
1442
+ // An idle submission is accepted (pending entry pushed) and then REJECTS
1443
+ // before agent_start (e.g. the busy retry expires). The failed entry must
1444
+ // be retired — a later agent-initiated monitor/cron turn must not inherit
1445
+ // the failed submission's connection as owner (review thread P1).
1446
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-failed-owner-"));
1447
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1448
+ const api = {
1449
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1450
+ handlers.set(event, handler);
1451
+ },
1452
+ sendUserMessage: (
1453
+ content: string | { text: string }[],
1454
+ options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
1455
+ ) => {
1456
+ const commit = Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
1457
+ options?.onPreflightAccepted?.();
1458
+ return {};
1459
+ });
1460
+ const text = typeof content === "string" ? content : (content[0]?.text ?? "");
1461
+ // The "fail-b" submission is accepted, then its run REJECTS.
1462
+ if (text === "fail-b")
1463
+ return commit.then(() => {
1464
+ throw new Error("provider failed after acceptance");
1465
+ });
1466
+ return commit;
1467
+ },
1468
+ } as unknown as ExtensionAPI;
1469
+ const transport = memoryTransport();
1470
+ const reconciliationStore = createReconciliationStore({
1471
+ sessionFile: path.join(cwd, "session.json"),
1472
+ sessionId: transport.sessionId,
1473
+ });
1474
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1475
+ createSdkSessionRuntimeExtension(api, {
1476
+ agentDir: cwd,
1477
+ createTransport: async () => transport,
1478
+ terminalAbortSeams: {
1479
+ getReconciliationStore: () => reconciliationStore,
1480
+ getTerminalTurnEpoch: () => 7,
1481
+ getActivePromptHandle: () => "exact-run-handle",
1482
+ cancelPendingPreflightForTerminalAbort: () => {},
1483
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1484
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1485
+ return { status: "settled", terminalScope: {} };
1486
+ },
1487
+ },
1488
+ });
1489
+ const ctx = extensionContext(transport.sessionId, cwd);
1490
+ try {
1491
+ await handlers.get("session_start")?.({}, ctx);
1492
+ const prompt = (connectionId: string, id: string, text: string) =>
1493
+ transport.feed(connectionId, {
1494
+ type: "control_request",
1495
+ id,
1496
+ operation: "turn.prompt",
1497
+ input: { text, images: [] },
1498
+ } as SdkFrame);
1499
+ const waitResponse = async (id: string) => {
1500
+ const deadline = Date.now() + 15_000;
1501
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
1502
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
1503
+ await Bun.sleep(20);
1504
+ }
1505
+ };
1506
+ // A submits while idle and STARTS its run: owner becomes conn-a.
1507
+ prompt("conn-a", "ok-a", "ok-a");
1508
+ await waitResponse("ok-a");
1509
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1510
+ // B submits while idle (entry pushed), then its run REJECTS.
1511
+ prompt("conn-b", "fail-b", "fail-b");
1512
+ await waitResponse("fail-b");
1513
+ expect(transport.sent.find(frame => frame.id === "fail-b")).toMatchObject({
1514
+ ok: true,
1515
+ result: expect.objectContaining({ accepted: true }),
1516
+ });
1517
+ // Let the async rejection settle and retire B's pending entry.
1518
+ await Bun.sleep(50);
1519
+ // A later agent-initiated turn starts: the pending queue is empty, so
1520
+ // B's failed submission is never associated as owner.
1521
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1522
+ transport.feed("conn-b", {
1523
+ type: "control_request",
1524
+ id: "failed-owner-abort",
1525
+ operation: "turn.abort",
1526
+ input: { mode: "terminal" },
1527
+ idempotencyKey: "failed-owner-abort-key",
1528
+ } as SdkFrame);
1529
+ await waitResponse("failed-owner-abort");
1530
+ expect(transport.sent.find(frame => frame.id === "failed-owner-abort")).toMatchObject({
1531
+ ok: true,
1532
+ result: expect.objectContaining({ turn: "no_active_turn" }),
1533
+ });
1534
+ expect(seamCalls).toHaveLength(0);
1535
+ } finally {
1536
+ await handlers.get("session_shutdown")?.({}, ctx);
1537
+ await rm(cwd, { recursive: true, force: true });
1538
+ }
1539
+ });
1540
+
1541
+ test("SDK-only host refuses terminal aborts of UNOWNED agent-initiated turns", async () => {
1542
+ // An agent-initiated turn (monitor/cron follow-up) has NO accepting SDK
1543
+ // connection: the active handle exists but the owner is undefined, so
1544
+ // every client must be refused — never authorized by an absent or stale
1545
+ // owner (review thread P1).
1546
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-unowned-turn-"));
1547
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1548
+ const api = {
1549
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1550
+ handlers.set(event, handler);
1551
+ },
1552
+ } as unknown as ExtensionAPI;
1553
+ const transport = memoryTransport();
1554
+ const reconciliationStore = createReconciliationStore({
1555
+ sessionFile: path.join(cwd, "session.json"),
1556
+ sessionId: transport.sessionId,
1557
+ });
1558
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1559
+ createSdkSessionRuntimeExtension(api, {
1560
+ agentDir: cwd,
1561
+ createTransport: async () => transport,
1562
+ terminalAbortSeams: {
1563
+ getReconciliationStore: () => reconciliationStore,
1564
+ // Active turn exists, but NO owner is recorded (no prompt/skill was
1565
+ // accepted by any SDK connection) — the seam getter is absent so the
1566
+ // runtime-tracked owner (undefined) is consulted.
1567
+ getTerminalTurnEpoch: () => 7,
1568
+ getActivePromptHandle: () => "agent-initiated-handle",
1569
+ cancelPendingPreflightForTerminalAbort: () => {},
1570
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1571
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1572
+ return { status: "settled", terminalScope: {} };
1573
+ },
1574
+ },
1575
+ });
1576
+ const ctx = extensionContext(transport.sessionId, cwd);
1577
+ try {
1578
+ await handlers.get("session_start")?.({}, ctx);
1579
+ transport.feed("client", {
1580
+ type: "control_request",
1581
+ id: "unowned-abort",
1582
+ operation: "turn.abort",
1583
+ input: { mode: "terminal" },
1584
+ idempotencyKey: "unowned-abort-key",
1585
+ } as SdkFrame);
1586
+ const deadline = Date.now() + 15_000;
1587
+ while (!transport.sent.some(frame => frame.id === "unowned-abort" && frame.type === "control_response")) {
1588
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the unowned abort response");
1589
+ await Bun.sleep(20);
1590
+ }
1591
+ // No owner: the abort is refused without touching the seam.
1592
+ expect(transport.sent.find(frame => frame.id === "unowned-abort")).toMatchObject({
1593
+ ok: true,
1594
+ result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
1595
+ });
1596
+ expect(seamCalls).toHaveLength(0);
1597
+ } finally {
1598
+ await handlers.get("session_shutdown")?.({}, ctx);
1599
+ await rm(cwd, { recursive: true, force: true });
1600
+ }
1601
+ });
1602
+
1603
+ test("SDK-only host replays a no_effect_reserved row as uncertainty, never a fabricated no_active_turn", async () => {
1604
+ // A transitional no-effect reservation (the abort may still transition to
1605
+ // active while the reservation is awaited) must never replay as a
1606
+ // definitive no_active_turn: a duplicate in that window would otherwise
1607
+ // get no_active_turn while the original stops the prompt (review thread
1608
+ // P2).
1609
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-reserved-"));
1610
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1611
+ const api = {
1612
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1613
+ handlers.set(event, handler);
1614
+ },
1615
+ } as unknown as ExtensionAPI;
1616
+ const transport = memoryTransport();
1617
+ const reconciliationStore = createReconciliationStore({
1618
+ sessionFile: path.join(cwd, "session.json"),
1619
+ sessionId: transport.sessionId,
1620
+ });
1621
+ createSdkSessionRuntimeExtension(api, {
1622
+ agentDir: cwd,
1623
+ createTransport: async () => transport,
1624
+ terminalAbortSeams: {
1625
+ getReconciliationStore: () => reconciliationStore,
1626
+ getTerminalTurnEpoch: () => undefined,
1627
+ getActivePromptHandle: () => undefined,
1628
+ cancelPendingPreflightForTerminalAbort: () => {},
1629
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled" }),
1630
+ },
1631
+ });
1632
+ const ctx = extensionContext(transport.sessionId, cwd);
1633
+ try {
1634
+ await handlers.get("session_start")?.({}, ctx);
1635
+ const keyHash = createHash("sha256").update("reserved-key").digest("hex");
1636
+ const inputHash = createHash("sha256")
1637
+ .update(JSON.stringify({ mode: "terminal", scope: "turn" }))
1638
+ .digest("hex");
1639
+ // Seed a mid-flight reserved reservation directly (the abort's own
1640
+ // writeNoEffect produces this disposition while the recheck is
1641
+ // pending).
1642
+ await reconciliationStore.transactTerminalState(state => ({
1643
+ scopes: [
1644
+ {
1645
+ selection: "turn",
1646
+ idempotencyKeyHash: keyHash,
1647
+ idempotencyInputHash: inputHash,
1648
+ turnDisposition: "no_effect_reserved",
1649
+ terminalPublished: false,
1650
+ ownedWorkDisposition: "not_requested",
1651
+ automaticDeliveryDisposition: "enabled",
1652
+ resumeOnOwnedCompletion: true,
1653
+ turnContinuationFence: {
1654
+ state: "retained",
1655
+ abortedAttemptEpoch: 0,
1656
+ blockedContinuationIds: [],
1657
+ predecessorTombstones: [],
1658
+ ownedCompletionPolicy: "enabled",
1659
+ },
1660
+ responseState: "pending",
1661
+ responsePayloadHash: inputHash,
1662
+ acceptedAt: Date.now(),
1663
+ },
1664
+ ...state.scopes,
1665
+ ],
1666
+ keys: state.keys,
1667
+ }));
1668
+ transport.feed("client", {
1669
+ type: "control_request",
1670
+ id: "reserved-replay",
1671
+ operation: "turn.abort",
1672
+ input: { mode: "terminal" },
1673
+ idempotencyKey: "reserved-key",
1674
+ } as SdkFrame);
1675
+ const deadline = Date.now() + 15_000;
1676
+ while (!transport.sent.some(frame => frame.id === "reserved-replay" && frame.type === "control_response")) {
1677
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the reserved-row replay");
1678
+ await Bun.sleep(20);
1679
+ }
1680
+ expect(transport.sent.find(frame => frame.id === "reserved-replay")).toMatchObject({
1681
+ ok: true,
1682
+ result: expect.objectContaining({
1683
+ turn: "uncertain",
1684
+ reason: "reservation_in_flight",
1685
+ replay: expect.objectContaining({ responseState: "pending" }),
1686
+ }),
1687
+ });
1688
+ } finally {
1689
+ await handlers.get("session_shutdown")?.({}, ctx);
1690
+ await rm(cwd, { recursive: true, force: true });
1691
+ }
1692
+ });
1693
+
1694
+ test("SDK-only host does not advance a pending marker's response state for a mismatched replayed payload", async () => {
1695
+ // When >256 concurrent requests evict an in-flight abort from the dispatch
1696
+ // cache, a same-key retry replays the PENDING marker as pending_replay. The
1697
+ // delivery observer must NOT mark the original marker sent for the
1698
+ // retry's uncertainty response: it only advances when the written
1699
+ // response's payload matches the row's stored hash (review thread P2).
1700
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-pending-state-"));
1701
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1702
+ const api = {
1703
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1704
+ handlers.set(event, handler);
1705
+ },
1706
+ } as unknown as ExtensionAPI;
1707
+ const transport = memoryTransport();
1708
+ const reconciliationStore = createReconciliationStore({
1709
+ sessionFile: path.join(cwd, "session.json"),
1710
+ sessionId: transport.sessionId,
1711
+ });
1712
+ createSdkSessionRuntimeExtension(api, {
1713
+ agentDir: cwd,
1714
+ createTransport: async () => transport,
1715
+ terminalAbortSeams: {
1716
+ getReconciliationStore: () => reconciliationStore,
1717
+ getTerminalTurnEpoch: () => undefined,
1718
+ getActivePromptHandle: () => undefined,
1719
+ cancelPendingPreflightForTerminalAbort: () => {},
1720
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled" }),
1721
+ },
1722
+ });
1723
+ const ctx = extensionContext(transport.sessionId, cwd);
1724
+ try {
1725
+ await handlers.get("session_start")?.({}, ctx);
1726
+ const keyHash = createHash("sha256").update("pending-key").digest("hex");
1727
+ const inputHash = createHash("sha256")
1728
+ .update(JSON.stringify({ mode: "terminal", scope: "turn" }))
1729
+ .digest("hex");
1730
+ // Seed the ORIGINAL in-flight marker: pending, with the input-hash
1731
+ // placeholder as its payload hash.
1732
+ await reconciliationStore.transactTerminalState(state => ({
1733
+ scopes: [
1734
+ {
1735
+ selection: "turn",
1736
+ idempotencyKeyHash: keyHash,
1737
+ idempotencyInputHash: inputHash,
1738
+ turnDisposition: "pending",
1739
+ terminalPublished: false,
1740
+ ownedWorkDisposition: "not_requested",
1741
+ automaticDeliveryDisposition: "enabled",
1742
+ resumeOnOwnedCompletion: true,
1743
+ turnContinuationFence: {
1744
+ state: "retained",
1745
+ abortedAttemptEpoch: 0,
1746
+ blockedContinuationIds: [],
1747
+ predecessorTombstones: [],
1748
+ ownedCompletionPolicy: "enabled",
1749
+ },
1750
+ responseState: "pending",
1751
+ responsePayloadHash: inputHash,
1752
+ acceptedAt: Date.now(),
1753
+ },
1754
+ ...state.scopes,
1755
+ ],
1756
+ keys: state.keys,
1757
+ }));
1758
+ transport.feed("client", {
1759
+ type: "control_request",
1760
+ id: "pending-replay",
1761
+ operation: "turn.abort",
1762
+ input: { mode: "terminal" },
1763
+ idempotencyKey: "pending-key",
1764
+ } as SdkFrame);
1765
+ const deadline = Date.now() + 15_000;
1766
+ while (!transport.sent.some(frame => frame.id === "pending-replay" && frame.type === "control_response")) {
1767
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the pending-row replay");
1768
+ await Bun.sleep(20);
1769
+ }
1770
+ // The retry replays the pending marker as pending_replay...
1771
+ expect(transport.sent.find(frame => frame.id === "pending-replay")).toMatchObject({
1772
+ ok: true,
1773
+ result: expect.objectContaining({
1774
+ turn: "uncertain",
1775
+ reason: "replay_pending",
1776
+ replay: expect.objectContaining({ responseState: "pending" }),
1777
+ }),
1778
+ });
1779
+ // ...but the ORIGINAL marker's durable state must NOT be advanced by
1780
+ // the retry's mismatched payload.
1781
+ await Bun.sleep(50);
1782
+ expect(reconciliationStore.snapshotTerminalScopes()[0]!.responseState).toBe("pending");
1783
+ } finally {
1784
+ await handlers.get("session_shutdown")?.({}, ctx);
1785
+ await rm(cwd, { recursive: true, force: true });
1786
+ }
1787
+ });
1788
+
1789
+ test("SDK-only host replays an EVICTED no-effect reservation as no_active_turn, not uncertain", async () => {
1790
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-evicted-"));
1791
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1792
+ const api = {
1793
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1794
+ handlers.set(event, handler);
1795
+ },
1796
+ } as unknown as ExtensionAPI;
1797
+ const transport = memoryTransport();
1798
+ const admissions = admissionBarrier(9);
1799
+ const reconciliationStore = createReconciliationStore({
1800
+ sessionFile: path.join(cwd, "session.json"),
1801
+ sessionId: transport.sessionId,
1802
+ });
1803
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1804
+ createSdkSessionRuntimeExtension(api, {
1805
+ agentDir: cwd,
1806
+ createTransport: async () => transport,
1807
+ onFrameAdmitted: admissions.onFrameAdmitted,
1808
+ terminalAbortSeams: {
1809
+ maxDurableTerminalReservationsForTests: 8,
1810
+ getReconciliationStore: () => reconciliationStore,
1811
+ // No active turn: every idle abort reserves a no-effect row.
1812
+ getTerminalTurnEpoch: () => undefined,
1813
+ getActivePromptHandle: () => undefined,
1814
+ cancelPendingPreflightForTerminalAbort: () => {},
1815
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1816
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1817
+ return { status: "settled", terminalScope: {} };
1818
+ },
1819
+ },
1820
+ });
1821
+ const ctx = extensionContext(transport.sessionId, cwd);
1822
+ try {
1823
+ await handlers.get("session_start")?.({}, ctx);
1824
+ const idleAbort = (id: string, idempotencyKey: string) =>
1825
+ transport.feed("client", {
1826
+ type: "control_request",
1827
+ id,
1828
+ operation: "turn.abort",
1829
+ input: { mode: "terminal" },
1830
+ idempotencyKey,
1831
+ } as SdkFrame);
1832
+ // Fill the completed-scope bound (256) and overflow once so the FIRST
1833
+ // no-effect row is evicted into a tombstone that preserves its
1834
+ // turnDisposition (review thread P2).
1835
+ for (let index = 0; index < 9; index++) idleAbort(`idle-${index}`, `evict-key-${index}`);
1836
+ await admissions.ready;
1837
+ await reconciliationStore.drain?.();
1838
+ expect(seamCalls).toHaveLength(0);
1839
+ // The overflowed reservation now exists only as a tombstone; replaying
1840
+ // its key must return the original no_active_turn/terminal_no_effect
1841
+ // result deterministically, never a fabricated uncertainty.
1842
+ transport.feed("client", {
1843
+ type: "control_request",
1844
+ id: "evicted-replay",
1845
+ operation: "turn.abort",
1846
+ input: { mode: "terminal" },
1847
+ idempotencyKey: "evict-key-0",
1848
+ } as SdkFrame);
1849
+ const deadline = Date.now() + 15_000;
1850
+ while (!transport.sent.some(frame => frame.id === "evicted-replay")) {
1851
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the evicted-key replay");
1852
+ await Bun.sleep(20);
1853
+ }
1854
+ expect(transport.sent.find(frame => frame.id === "evicted-replay")).toMatchObject({
1855
+ ok: true,
1856
+ result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
1857
+ });
1858
+ // A truly fresh key still reports no_active_turn with no seam call.
1859
+ transport.feed("client", {
1860
+ type: "control_request",
1861
+ id: "fresh-idle",
1862
+ operation: "turn.abort",
1863
+ input: { mode: "terminal" },
1864
+ idempotencyKey: "fresh-key",
1865
+ } as SdkFrame);
1866
+ while (!transport.sent.some(frame => frame.id === "fresh-idle")) {
1867
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the fresh-key idle abort");
1868
+ await Bun.sleep(20);
1869
+ }
1870
+ expect(transport.sent.find(frame => frame.id === "fresh-idle")).toMatchObject({
1871
+ ok: true,
1872
+ result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
1873
+ });
1874
+ expect(seamCalls).toHaveLength(0);
1875
+ } finally {
1876
+ await handlers.get("session_shutdown")?.({}, ctx);
1877
+ await rm(cwd, { recursive: true, force: true });
1878
+ }
1879
+ });
1880
+
1881
+ test("SDK-only host rejects a same-key different-scope race atomically inside the durable transaction", async () => {
1882
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-race-"));
1883
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1884
+ const api = {
1885
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1886
+ handlers.set(event, handler);
1887
+ },
1888
+ } as unknown as ExtensionAPI;
1889
+ const transport = memoryTransport();
1890
+ const reconciliationStore = createReconciliationStore({
1891
+ sessionFile: path.join(cwd, "session.json"),
1892
+ sessionId: transport.sessionId,
1893
+ });
1894
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1895
+ createSdkSessionRuntimeExtension(api, {
1896
+ agentDir: cwd,
1897
+ createTransport: async () => transport,
1898
+ terminalAbortSeams: {
1899
+ getReconciliationStore: () => reconciliationStore,
1900
+ getTerminalTurnEpoch: () => 7,
1901
+ getActivePromptHandle: () => "exact-run-handle",
1902
+ getActivePromptOwnerConnectionId: () => "client",
1903
+ cancelPendingPreflightForTerminalAbort: () => {},
1904
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
1905
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1906
+ return { status: "settled", terminalScope: {} };
1907
+ },
1908
+ },
1909
+ });
1910
+ const ctx = extensionContext(transport.sessionId, cwd);
1911
+ try {
1912
+ await handlers.get("session_start")?.({}, ctx);
1913
+ const race = {
1914
+ type: "control_request",
1915
+ operation: "turn.abort",
1916
+ input: { mode: "terminal" },
1917
+ idempotencyKey: "race-key",
1918
+ } as SdkFrame;
1919
+ // Both requests pass the earlier snapshot check before either durable
1920
+ // row lands; the serialized transaction must reject the second
1921
+ // (different scope) atomically instead of appending a duplicate-key
1922
+ // row that would make later replay of the first ambiguous (review
1923
+ // thread P2).
1924
+ transport.feed("client", { ...race, id: "race-turn" });
1925
+ transport.feed("client", { ...race, id: "race-owned", input: { mode: "terminal", scope: "owned" } });
1926
+ // The winner's stopped response awaits the bounded agent_end
1927
+ // publication observation, so await both responses instead of a fixed
1928
+ // sleep (review thread P2).
1929
+ const raceDeadline = Date.now() + 15_000;
1930
+ while (
1931
+ !transport.sent.some(frame => frame.id === "race-turn" && frame.type === "control_response") ||
1932
+ !transport.sent.some(frame => frame.id === "race-owned" && frame.type === "control_response")
1933
+ ) {
1934
+ if (Date.now() > raceDeadline)
1935
+ throw new Error("Timed out waiting for the same-key different-scope race responses");
1936
+ await Bun.sleep(20);
1937
+ }
1938
+ const turnResponse = transport.sent.find(frame => frame.id === "race-turn");
1939
+ const ownedResponse = transport.sent.find(frame => frame.id === "race-owned");
1940
+ expect(turnResponse).toMatchObject({ type: "control_response", ok: true });
1941
+ expect(ownedResponse).toMatchObject({
1942
+ type: "control_response",
1943
+ ok: false,
1944
+ error: expect.objectContaining({ code: "idempotency_conflict" }),
1945
+ });
1946
+ // Only the admitted request reached the session seam; the loser never
1947
+ // touched the run.
1948
+ expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
1949
+ // Exactly ONE durable row exists for the key: the winner's.
1950
+ expect(reconciliationStore.snapshotTerminalScopes().filter(s => s.idempotencyKeyHash).length).toBe(1);
1951
+ } finally {
1952
+ await handlers.get("session_shutdown")?.({}, ctx);
1953
+ await rm(cwd, { recursive: true, force: true });
1954
+ }
1955
+ });
1956
+
1957
+ test("SDK-only host cancels exact owned jobs before reporting stopped_owned", async () => {
1958
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-owned-"));
1959
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1960
+ const api = {
1961
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1962
+ handlers.set(event, handler);
1963
+ },
1964
+ } as unknown as ExtensionAPI;
1965
+ const transport = memoryTransport();
1966
+ const reconciliationStore = createReconciliationStore({
1967
+ sessionFile: path.join(cwd, "session.json"),
1968
+ sessionId: transport.sessionId,
1969
+ });
1970
+ const manager = new AsyncJobManager({ onJobComplete: async () => {} });
1971
+ resetTerminalAbortRegistriesForTests();
1972
+ AsyncJobManager.setInstance(manager);
1973
+ AsyncJobManager.registerForEndpoint("owned-ep", manager);
1974
+ const gate = Promise.withResolvers<string>();
1975
+ let jobId: string | undefined;
1976
+ let registration: TurnRegistrationKey | undefined;
1977
+ try {
1978
+ jobId = manager.register("bash", "owned job", () => gate.promise);
1979
+ const generation = manager.getJob(jobId)?.generation;
1980
+ expect(generation).toBeTypeOf("string");
1981
+ registration = {
1982
+ endpointId: "owned-ep",
1983
+ endpointGeneration: 1,
1984
+ lineageIdHash: "sdk-owned-lineage",
1985
+ promptAttemptEpoch: 7,
1986
+ jobId,
1987
+ jobGeneration: generation as string,
1988
+ };
1989
+ registerOwnedRegistration(registration as never, { isJobTerminal: () => false });
1990
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
1991
+ createSdkSessionRuntimeExtension(api, {
1992
+ agentDir: cwd,
1993
+ createTransport: async () => transport,
1994
+ terminalAbortSeams: {
1995
+ getReconciliationStore: () => reconciliationStore,
1996
+ getTerminalTurnEpoch: () => 7,
1997
+ getActivePromptHandle: () => "exact-run-handle",
1998
+ getActivePromptOwnerConnectionId: () => "client",
1999
+ cancelPendingPreflightForTerminalAbort: () => {},
2000
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
2001
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
2002
+ return {
2003
+ status: "settled",
2004
+ terminalScope: {
2005
+ scopeId: "scope-owned",
2006
+ abortedAttemptEpoch: 7,
2007
+ lineageIdHash: "sdk-owned-lineage",
2008
+ },
2009
+ };
2010
+ },
2011
+ },
2012
+ });
2013
+ const ctx = extensionContext(transport.sessionId, cwd);
2014
+ await handlers.get("session_start")?.({}, ctx);
2015
+ transport.feed("client", {
2016
+ type: "control_request",
2017
+ id: "owned-abort",
2018
+ operation: "turn.abort",
2019
+ input: { mode: "terminal", scope: "owned" },
2020
+ idempotencyKey: "owned-key",
2021
+ } as SdkFrame);
2022
+ // Let the background job unwind within the 500ms owned-settlement
2023
+ // grace so quiescence is provable.
2024
+ void Bun.sleep(50).then(() => gate.resolve("done"));
2025
+ const ownedDeadline = Date.now() + 15_000;
2026
+ while (!transport.sent.some(frame => frame.id === "owned-abort" && frame.type === "control_response")) {
2027
+ if (Date.now() > ownedDeadline) throw new Error("Timed out waiting for the owned abort response");
2028
+ await Bun.sleep(20);
2029
+ }
2030
+ const response = transport.sent.find(frame => frame.id === "owned-abort");
2031
+ expect(response).toMatchObject({
2032
+ type: "control_response",
2033
+ ok: true,
2034
+ result: expect.objectContaining({ turn: "stopped", ownedWork: "stopped" }),
2035
+ });
2036
+ expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "owned" }]);
2037
+ // The exact owned job was cancelled by settleOwnedWork before the
2038
+ // stopped disposition was reported.
2039
+ const settledStatus = jobId ? manager.getJob(jobId)?.status : undefined;
2040
+ expect(settledStatus).toBeDefined();
2041
+ expect(["cancelled", "completed", "failed"]).toContain(settledStatus as string);
2042
+ } finally {
2043
+ gate.resolve("done");
2044
+ if (registration) unregisterOwnedRegistration(registration as never);
2045
+ AsyncJobManager.unregisterManager(manager);
2046
+ AsyncJobManager.setInstance(undefined);
2047
+ await manager.dispose({ timeoutMs: 100 }).catch(() => {});
2048
+ await rm(cwd, { recursive: true, force: true });
2049
+ }
2050
+ });
2051
+
2052
+ test("SDK-only host bounds completed terminal rows and retains key tombstones", async () => {
2053
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-bound-"));
2054
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2055
+ const api = {
2056
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2057
+ handlers.set(event, handler);
2058
+ },
2059
+ } as unknown as ExtensionAPI;
184
2060
  const transport = memoryTransport();
185
- const runtime = new SessionSdkSessionRuntime({
186
- transport,
187
- control: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { operation: frame.operation } }),
188
- query: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { query: frame.query } }),
2061
+ const admissions = admissionBarrier(12);
2062
+ const reconciliationStore = createReconciliationStore({
2063
+ sessionFile: path.join(cwd, "session.json"),
2064
+ sessionId: transport.sessionId,
189
2065
  });
190
- await runtime.start();
191
- runtime.emitEvent({ kind: "session_ready", sessionId: transport.sessionId });
192
- transport.feed("client", {
193
- type: "event_replay",
194
- id: "replay",
195
- sinceGeneration: runtime.generation,
196
- sinceSeq: 0,
2066
+ createSdkSessionRuntimeExtension(api, {
2067
+ agentDir: cwd,
2068
+ createTransport: async () => transport,
2069
+ onFrameAdmitted: admissions.onFrameAdmitted,
2070
+ terminalAbortSeams: {
2071
+ maxDurableTerminalReservationsForTests: 8,
2072
+ getReconciliationStore: () => reconciliationStore,
2073
+ getTerminalTurnEpoch: () => undefined,
2074
+ getActivePromptHandle: () => undefined,
2075
+ cancelPendingPreflightForTerminalAbort: () => {},
2076
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled" }),
2077
+ },
197
2078
  });
198
- transport.feed("client", {
199
- type: "control_request",
200
- id: "control",
201
- operation: "runtime.capabilities",
202
- input: {},
2079
+ const ctx = extensionContext(transport.sessionId, cwd);
2080
+ try {
2081
+ await handlers.get("session_start")?.({}, ctx);
2082
+ // Idle terminal aborts with distinct keys must not grow the
2083
+ // reconciliation document without limit: completed rows are bounded
2084
+ // and evicted keys become compact tombstones (review thread P2).
2085
+ for (let index = 0; index < 12; index++) {
2086
+ transport.feed("client", {
2087
+ type: "control_request",
2088
+ id: `bound-${index}`,
2089
+ operation: "turn.abort",
2090
+ input: { mode: "terminal" },
2091
+ idempotencyKey: `bound-key-${index}`,
2092
+ } as SdkFrame);
2093
+ }
2094
+ // Yield once so every fire-and-forget frame handler can admit its first
2095
+ // serialized transaction, then drain the exact durable queue. Polling all
2096
+ // responses under the file's five-second deadline made scheduler load part
2097
+ // of the persistence contract.
2098
+ await admissions.ready;
2099
+ await reconciliationStore.drain?.();
2100
+ expect(transport.sent.length).toBeGreaterThanOrEqual(12);
2101
+ expect(reconciliationStore.snapshotTerminalScopes().length).toBeLessThanOrEqual(8);
2102
+ expect(reconciliationStore.snapshotTerminalKeys().length).toBeGreaterThan(0);
2103
+ } finally {
2104
+ await handlers.get("session_shutdown")?.({}, ctx);
2105
+ await rm(cwd, { recursive: true, force: true });
2106
+ }
2107
+ });
2108
+
2109
+ test("SDK-only host bounds distinct-key terminal markers on UNCERTAIN finalization", async () => {
2110
+ // Many concurrent terminal aborts (distinct keys) of one turn that fails
2111
+ // to settle must not leave an arbitrarily large reconciliation document:
2112
+ // the pending->uncertain finalize applies the same 256-row bound and
2113
+ // retains key tombstones (review thread P2).
2114
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-uncertain-bound-"));
2115
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2116
+ const api = {
2117
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2118
+ handlers.set(event, handler);
2119
+ },
2120
+ } as unknown as ExtensionAPI;
2121
+ const transport = memoryTransport();
2122
+ const admissions = admissionBarrier(12);
2123
+ const reconciliationStore = createReconciliationStore({
2124
+ sessionFile: path.join(cwd, "session.json"),
2125
+ sessionId: transport.sessionId,
203
2126
  });
204
- transport.feed("client", { type: "query_request", id: "query", query: "Q18", input: {} });
205
- await Bun.sleep(0);
206
- expect(transport.broadcasts.some(frame => frame.kind === "session_ready")).toBe(true);
207
- expect(transport.sent).toEqual(
208
- expect.arrayContaining([
209
- expect.objectContaining({ type: "event_replay_result", id: "replay", ok: true }),
210
- expect.objectContaining({ type: "control_response", id: "control", ok: true }),
211
- expect.objectContaining({ type: "query_response", id: "query", ok: true }),
212
- ]),
213
- );
214
- await runtime.stop();
2127
+ createSdkSessionRuntimeExtension(api, {
2128
+ agentDir: cwd,
2129
+ createTransport: async () => transport,
2130
+ onFrameAdmitted: admissions.onFrameAdmitted,
2131
+ terminalAbortSeams: {
2132
+ maxDurableTerminalReservationsForTests: 8,
2133
+ getReconciliationStore: () => reconciliationStore,
2134
+ getTerminalTurnEpoch: () => 7,
2135
+ getActivePromptHandle: () => "exact-run-handle",
2136
+ getActivePromptOwnerConnectionId: () => "client",
2137
+ cancelPendingPreflightForTerminalAbort: () => {},
2138
+ // The turn never settles: every abort finalizes its pending marker
2139
+ // to UNCERTAIN (worker_unsettled).
2140
+ abortPromptAndWaitWithTerminal: async () => ({ status: "unfenced" }),
2141
+ },
2142
+ });
2143
+ const ctx = extensionContext(transport.sessionId, cwd);
2144
+ try {
2145
+ await handlers.get("session_start")?.({}, ctx);
2146
+ for (let index = 0; index < 12; index++) {
2147
+ transport.feed("client", {
2148
+ type: "control_request",
2149
+ id: `uncertain-${index}`,
2150
+ operation: "turn.abort",
2151
+ input: { mode: "terminal" },
2152
+ idempotencyKey: `uncertain-key-${index}`,
2153
+ } as SdkFrame);
2154
+ }
2155
+ await admissions.ready;
2156
+ await reconciliationStore.drain?.();
2157
+ expect(transport.sent.length).toBeGreaterThanOrEqual(12);
2158
+ // The uncertain finalizes evicted the oldest rows into tombstones.
2159
+ expect(reconciliationStore.snapshotTerminalScopes().length).toBeLessThanOrEqual(8);
2160
+ expect(reconciliationStore.snapshotTerminalKeys().length).toBeGreaterThan(0);
2161
+ } finally {
2162
+ await handlers.get("session_shutdown")?.({}, ctx);
2163
+ await rm(cwd, { recursive: true, force: true });
2164
+ }
215
2165
  });
2166
+
216
2167
  test("native-like and loopback transports share the same SDK contract matrix", async () => {
217
2168
  const nativePolicy = createSdkSurfacePolicy({
218
2169
  bindings: ["sdkControl", "cycleModel", "getSkillState"],
@@ -256,12 +2207,12 @@ describe("SessionSdkSessionRuntime", () => {
256
2207
  });
257
2208
  test("failed extension stop retains retry state before replacement start", async () => {
258
2209
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-extension-"));
259
- const handlers = new Map<string, (event: unknown, ctx: any) => Promise<void> | void>();
2210
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
260
2211
  const api = {
261
- on(event: string, handler: (event: unknown, ctx: any) => Promise<void> | void) {
2212
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
262
2213
  handlers.set(event, handler);
263
2214
  },
264
- } as any;
2215
+ } as unknown as ExtensionAPI;
265
2216
  const transports: Array<{ starts: number; stops: number }> = [];
266
2217
  createSdkSessionRuntimeExtension(api, {
267
2218
  agentDir: path.join(cwd, ".gjc", "agent"),
@@ -366,7 +2317,17 @@ describe("SessionSdkSessionRuntime", () => {
366
2317
  },
367
2318
  );
368
2319
  await handlers.get("session_shutdown")?.({}, context);
2320
+ // DR-1 keeps the unregistered row listed, so the two refusals stay distinct:
2321
+ // a matching generation on a terminal row is terminally gone (no endpoint will
2322
+ // ever be issued again, and close takes its signal fallback), while a rotated
2323
+ // generation is still merely stale and worth re-reading.
369
2324
  expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 })).toMatchObject(
2325
+ {
2326
+ ok: false,
2327
+ error: { code: "resource_gone", message: "session endpoint record is gone" },
2328
+ },
2329
+ );
2330
+ expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 2 })).toMatchObject(
370
2331
  {
371
2332
  ok: false,
372
2333
  error: { code: "endpoint_stale", message: "session endpoint is stale" },
@@ -747,3 +2708,795 @@ describe("post-acceptance invocation terminalization", () => {
747
2708
  }
748
2709
  });
749
2710
  });
2711
+
2712
+ test("SDK-only host never advances a finalized uncertain row for a mismatched replayed payload", async () => {
2713
+ // Review thread P2: before the payload-hash fix, an uncertain/no-effect row
2714
+ // kept the input-hash placeholder, and the response-state advance trusted
2715
+ // ANY non-pending placeholder — so a same-key retry whose response differed
2716
+ // from the original (e.g. pending_replay delivered late) could mark the
2717
+ // durable row sent. Finalization now stores the EXACT final payload hash,
2718
+ // so a mismatched delivery must leave the row pending.
2719
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-uncertain-payload-"));
2720
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2721
+ const api = {
2722
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2723
+ handlers.set(event, handler);
2724
+ },
2725
+ } as unknown as ExtensionAPI;
2726
+ const transport = memoryTransport();
2727
+ const reconciliationStore = createReconciliationStore({
2728
+ sessionFile: path.join(cwd, "session.json"),
2729
+ sessionId: transport.sessionId,
2730
+ });
2731
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
2732
+ createSdkSessionRuntimeExtension(api, {
2733
+ agentDir: cwd,
2734
+ createTransport: async () => transport,
2735
+ terminalAbortSeams: {
2736
+ getReconciliationStore: () => reconciliationStore,
2737
+ getTerminalTurnEpoch: () => 7,
2738
+ getActivePromptHandle: () => "exact-run-handle",
2739
+ getActivePromptOwnerConnectionId: () => "client",
2740
+ cancelPendingPreflightForTerminalAbort: () => {},
2741
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
2742
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
2743
+ return { status: "settled", terminalScope: {} };
2744
+ },
2745
+ },
2746
+ });
2747
+ const ctx = extensionContext(transport.sessionId, cwd);
2748
+ try {
2749
+ await handlers.get("session_start")?.({}, ctx);
2750
+ const keyHash = createHash("sha256").update("uncertain-key").digest("hex");
2751
+ const inputHash = createHash("sha256")
2752
+ .update(JSON.stringify({ mode: "terminal", scope: "turn" }))
2753
+ .digest("hex");
2754
+ // Seed a FINALIZED uncertain row with responseState still pending (the
2755
+ // original response was never delivered). Post-fix finalization stores
2756
+ // the exact payload hash; simulating the pre-fix placeholder state must
2757
+ // not let a mismatched retry delivery advance the row.
2758
+ await reconciliationStore.transactTerminalState(state => ({
2759
+ scopes: [
2760
+ {
2761
+ selection: "turn",
2762
+ idempotencyKeyHash: keyHash,
2763
+ idempotencyInputHash: inputHash,
2764
+ turnDisposition: "uncertain",
2765
+ terminalPublished: false,
2766
+ ownedWorkDisposition: "uncertain",
2767
+ automaticDeliveryDisposition: "none",
2768
+ resumeOnOwnedCompletion: false,
2769
+ turnContinuationFence: {
2770
+ state: "retained",
2771
+ abortedAttemptEpoch: 0,
2772
+ blockedContinuationIds: [],
2773
+ predecessorTombstones: [],
2774
+ ownedCompletionPolicy: "disabled",
2775
+ },
2776
+ responseState: "pending",
2777
+ responsePayloadHash: inputHash,
2778
+ acceptedAt: Date.now(),
2779
+ },
2780
+ ...state.scopes,
2781
+ ],
2782
+ keys: state.keys,
2783
+ }));
2784
+ transport.feed("client", {
2785
+ type: "control_request",
2786
+ id: "uncertain-replay",
2787
+ operation: "turn.abort",
2788
+ input: { mode: "terminal" },
2789
+ idempotencyKey: "uncertain-key",
2790
+ } as SdkFrame);
2791
+ const deadline = Date.now() + 15_000;
2792
+ while (!transport.sent.some(frame => frame.id === "uncertain-replay" && frame.type === "control_response")) {
2793
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the uncertain-row replay");
2794
+ await Bun.sleep(20);
2795
+ }
2796
+ // The retry replays the finalized row (its payload differs from the
2797
+ // seeded placeholder), so the delivery must NOT advance the row.
2798
+ await Bun.sleep(50);
2799
+ expect(reconciliationStore.snapshotTerminalScopes()[0]!.responseState).toBe("pending");
2800
+ } finally {
2801
+ await handlers.get("session_shutdown")?.({}, ctx);
2802
+ await rm(cwd, { recursive: true, force: true });
2803
+ }
2804
+ });
2805
+
2806
+ test("SDK-only host FIFO-expires tombstones instead of failing the finalization at the cap", async () => {
2807
+ // Review thread P2: a long-lived session fills the evicted-key tombstone
2808
+ // collection with unique terminal-abort keys. The next finalization must
2809
+ // FIFO-expire the oldest tombstones instead of throwing — the destructive
2810
+ // stop may already have succeeded, and throwing would leave the client
2811
+ // with an error and its durable row pending, with subsequent aborts
2812
+ // repeating the failure.
2813
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-tombstone-cap-"));
2814
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2815
+ const api = {
2816
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2817
+ handlers.set(event, handler);
2818
+ },
2819
+ } as unknown as ExtensionAPI;
2820
+ const transport = memoryTransport();
2821
+ const reconciliationStore = createReconciliationStore({
2822
+ sessionFile: path.join(cwd, "session.json"),
2823
+ sessionId: transport.sessionId,
2824
+ });
2825
+ createSdkSessionRuntimeExtension(api, {
2826
+ agentDir: cwd,
2827
+ createTransport: async () => transport,
2828
+ terminalAbortSeams: {
2829
+ getReconciliationStore: () => reconciliationStore,
2830
+ getTerminalTurnEpoch: () => 7,
2831
+ getActivePromptHandle: () => "exact-run-handle",
2832
+ getActivePromptOwnerConnectionId: () => undefined,
2833
+ cancelPendingPreflightForTerminalAbort: () => {},
2834
+ abortPromptAndWaitWithTerminal: async (_handle, _options) => ({ status: "settled", terminalScope: {} }),
2835
+ },
2836
+ });
2837
+ const ctx = extensionContext(transport.sessionId, cwd);
2838
+ try {
2839
+ await handlers.get("session_start")?.({}, ctx);
2840
+ // Fill the tombstone collection to the 4096 cap.
2841
+ await reconciliationStore.transactTerminalState(state => ({
2842
+ scopes: state.scopes,
2843
+ keys: Array.from({ length: 4096 }, (_, i) => ({
2844
+ keyHash: createHash("sha256").update(`cap-key-${i}`).digest("hex"),
2845
+ inputHash: createHash("sha256").update(`cap-input-${i}`).digest("hex"),
2846
+ turnDisposition: "stopped" as const,
2847
+ ownedWorkDisposition: "left_running" as const,
2848
+ responseState: "pending" as const,
2849
+ responsePayloadHash: createHash("sha256").update("p").digest("hex"),
2850
+ })),
2851
+ }));
2852
+ // The next idle abort finalizes a no-effect reservation: the oldest
2853
+ // tombstone expires instead of the finalization throwing.
2854
+ transport.feed("client", {
2855
+ type: "control_request",
2856
+ id: "cap-abort",
2857
+ operation: "turn.abort",
2858
+ input: { mode: "terminal" },
2859
+ idempotencyKey: "cap-abort-key",
2860
+ } as SdkFrame);
2861
+ const deadline = Date.now() + 15_000;
2862
+ while (!transport.sent.some(frame => frame.id === "cap-abort" && frame.type === "control_response")) {
2863
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the cap abort response");
2864
+ await Bun.sleep(20);
2865
+ }
2866
+ expect(transport.sent.find(frame => frame.id === "cap-abort")).toMatchObject({
2867
+ ok: true,
2868
+ result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
2869
+ });
2870
+ // The collection stays bounded at the cap.
2871
+ expect(reconciliationStore.snapshotTerminalKeys()).toHaveLength(4096);
2872
+ } finally {
2873
+ await handlers.get("session_shutdown")?.({}, ctx);
2874
+ await rm(cwd, { recursive: true, force: true });
2875
+ }
2876
+ });
2877
+
2878
+ test("SDK-only host cancels only the aborting requester's preflight while another connection is admitted", async () => {
2879
+ // Review thread P1: a queued requester's terminal abort rejects its own
2880
+ // wrapper callback but must NOT invoke the session-wide preflight abort
2881
+ // while another connection has an active pending admission — the seam
2882
+ // cancels the session's single controller, which would kill the other
2883
+ // connection's preflight while the aborting requester's queued admission
2884
+ // may still start on the newly reset controller.
2885
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-preflight-scope-"));
2886
+ let seamCancels = 0;
2887
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2888
+ const api = {
2889
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2890
+ handlers.set(event, handler);
2891
+ },
2892
+ sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
2893
+ await options?.onPreflightAcceptCommit?.();
2894
+ },
2895
+ } as unknown as ExtensionAPI;
2896
+ const transport = memoryTransport();
2897
+ const reconciliationStore = createReconciliationStore({
2898
+ sessionFile: path.join(cwd, "session.json"),
2899
+ sessionId: transport.sessionId,
2900
+ });
2901
+ createSdkSessionRuntimeExtension(api, {
2902
+ agentDir: cwd,
2903
+ createTransport: async () => transport,
2904
+ terminalAbortSeams: {
2905
+ getReconciliationStore: () => reconciliationStore,
2906
+ getTerminalTurnEpoch: () => 7,
2907
+ getActivePromptHandle: () => "exact-run-handle",
2908
+ getActivePromptOwnerConnectionId: () => undefined,
2909
+ cancelPendingPreflightForTerminalAbort: () => {
2910
+ seamCancels++;
2911
+ },
2912
+ abortPromptAndWaitWithTerminal: async (_handle, _options) => ({ status: "settled", terminalScope: {} }),
2913
+ },
2914
+ });
2915
+ const ctx = extensionContext(transport.sessionId, cwd);
2916
+ try {
2917
+ await handlers.get("session_start")?.({}, ctx);
2918
+ const waitResponse = async (id: string) => {
2919
+ const deadline = Date.now() + 15_000;
2920
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
2921
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
2922
+ await Bun.sleep(20);
2923
+ }
2924
+ };
2925
+ // Two connections admit prompts while idle; both preflights are pending.
2926
+ transport.feed("conn-a", {
2927
+ type: "control_request",
2928
+ id: "preflight-a",
2929
+ operation: "turn.prompt",
2930
+ input: { text: "a" },
2931
+ } as SdkFrame);
2932
+ await waitResponse("preflight-a");
2933
+ transport.feed("conn-b", {
2934
+ type: "control_request",
2935
+ id: "preflight-b",
2936
+ operation: "turn.prompt",
2937
+ input: { text: "b" },
2938
+ } as SdkFrame);
2939
+ await waitResponse("preflight-b");
2940
+ // Conn B terminal-aborts before its run starts: only B's wrapper
2941
+ // preflight may be cancelled; A's active preflight must survive.
2942
+ transport.feed("conn-b", {
2943
+ type: "control_request",
2944
+ id: "preflight-abort-b",
2945
+ operation: "turn.abort",
2946
+ input: { mode: "terminal" },
2947
+ idempotencyKey: "preflight-abort-b-key",
2948
+ } as SdkFrame);
2949
+ await waitResponse("preflight-abort-b");
2950
+ // The session-wide seam was never invoked while another connection's
2951
+ // preflight was pending — only the aborting requester's wrapper
2952
+ // callback was rejected.
2953
+ expect(seamCancels).toBe(0);
2954
+ } finally {
2955
+ await handlers.get("session_shutdown")?.({}, ctx);
2956
+ await rm(cwd, { recursive: true, force: true });
2957
+ }
2958
+ });
2959
+
2960
+ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips during the accept window", async () => {
2961
+ // Review thread P1: the production AgentSession begins its in-flight
2962
+ // bookkeeping BEFORE the preflight acceptance callback, so re-reading
2963
+ // isIdle() inside accept() would observe the session as already streaming
2964
+ // and record no pending owner — the submitting connection could then never
2965
+ // terminal-abort its own prompt (the abort would report no_active_turn and
2966
+ // ACP would treat the cancellation as unacknowledged). The startsOwnTurn
2967
+ // decision must come from the PRE-DISPATCH idle snapshot.
2968
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-startsown-snapshot-"));
2969
+ let idle = true;
2970
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2971
+ const api = {
2972
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2973
+ handlers.set(event, handler);
2974
+ },
2975
+ sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
2976
+ await options?.onPreflightAcceptCommit?.();
2977
+ // The session's in-flight bookkeeping begins during the accept
2978
+ // window: a re-read of isIdle() now reports streaming.
2979
+ idle = false;
2980
+ },
2981
+ } as unknown as ExtensionAPI;
2982
+ const transport = memoryTransport();
2983
+ const reconciliationStore = createReconciliationStore({
2984
+ sessionFile: path.join(cwd, "session.json"),
2985
+ sessionId: transport.sessionId,
2986
+ });
2987
+ createSdkSessionRuntimeExtension(api, {
2988
+ agentDir: cwd,
2989
+ createTransport: async () => transport,
2990
+ terminalAbortSeams: {
2991
+ getReconciliationStore: () => reconciliationStore,
2992
+ getTerminalTurnEpoch: () => 7,
2993
+ getActivePromptHandle: () => "exact-run-handle",
2994
+ getActivePromptOwnerConnectionId: () => undefined,
2995
+ cancelPendingPreflightForTerminalAbort: () => {},
2996
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
2997
+ },
2998
+ });
2999
+ const ctx = { ...extensionContext(transport.sessionId, cwd), isIdle: () => idle } as ExtensionContext;
3000
+ try {
3001
+ await handlers.get("session_start")?.({}, ctx);
3002
+ transport.feed("client", {
3003
+ type: "control_request",
3004
+ id: "idle-owner-prompt",
3005
+ operation: "turn.prompt",
3006
+ input: { text: "hello" },
3007
+ } as SdkFrame);
3008
+ const deadline = Date.now() + 15_000;
3009
+ while (!transport.sent.some(frame => frame.id === "idle-owner-prompt" && frame.type === "control_response")) {
3010
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the idle prompt acceptance");
3011
+ await Bun.sleep(20);
3012
+ }
3013
+ expect(transport.sent.find(frame => frame.id === "idle-owner-prompt")).toMatchObject({ ok: true });
3014
+ // The run starts: the dispatch-time idle snapshot recorded the pending
3015
+ // owner for the submitting connection.
3016
+ await handlers.get("agent_start")?.({}, ctx);
3017
+ transport.feed("client", {
3018
+ type: "control_request",
3019
+ id: "idle-owner-abort",
3020
+ operation: "turn.abort",
3021
+ input: { mode: "terminal" },
3022
+ idempotencyKey: "idle-owner-abort-key",
3023
+ } as SdkFrame);
3024
+ const abortDeadline = Date.now() + 15_000;
3025
+ while (!transport.sent.some(frame => frame.id === "idle-owner-abort" && frame.type === "control_response")) {
3026
+ if (Date.now() > abortDeadline) throw new Error("Timed out waiting for the idle-prompt abort");
3027
+ await Bun.sleep(20);
3028
+ }
3029
+ expect(transport.sent.find(frame => frame.id === "idle-owner-abort")).toMatchObject({
3030
+ ok: true,
3031
+ result: expect.objectContaining({ turn: "stopped" }),
3032
+ });
3033
+ } finally {
3034
+ await handlers.get("session_shutdown")?.({}, ctx);
3035
+ await rm(cwd, { recursive: true, force: true });
3036
+ }
3037
+ });
3038
+
3039
+ test("SDK-only host advances a finalized stopped row when the retry replay matches the stored replay hash", async () => {
3040
+ // Review thread P2: a row finalized with responseState still pending (the
3041
+ // process exited before the original response was written) stores the
3042
+ // ORIGINAL payload hash; a same-key retry delivers the replay-shaped
3043
+ // payload (replay envelope appended). The finalization now also stores the
3044
+ // replay-shaped hash, so the written retry response advances the row from
3045
+ // pending to sent instead of leaving it durably pending forever.
3046
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stopped-replay-advance-"));
3047
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
3048
+ const api = {
3049
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
3050
+ handlers.set(event, handler);
3051
+ },
3052
+ } as unknown as ExtensionAPI;
3053
+ const transport = memoryTransport();
3054
+ const reconciliationStore = createReconciliationStore({
3055
+ sessionFile: path.join(cwd, "session.json"),
3056
+ sessionId: transport.sessionId,
3057
+ });
3058
+ let captureCalls = 0;
3059
+ let discardCalls = 0;
3060
+ createSdkSessionRuntimeExtension(api, {
3061
+ agentDir: cwd,
3062
+ createTransport: async () => transport,
3063
+ terminalAbortSeams: {
3064
+ getReconciliationStore: () => reconciliationStore,
3065
+ getTerminalTurnEpoch: () => 7,
3066
+ getActivePromptHandle: () => "exact-run-handle",
3067
+ getActivePromptOwnerConnectionId: () => "client",
3068
+ cancelPendingPreflightForTerminalAbort: () => {},
3069
+ captureTerminalAbortSteeringSnapshot: () => {
3070
+ captureCalls += 1;
3071
+ return captureCalls;
3072
+ },
3073
+ discardTerminalAbortSteeringSnapshot: () => {
3074
+ discardCalls += 1;
3075
+ },
3076
+ abortPromptAndWaitWithTerminal: async (_handle, _options) => {
3077
+ return { status: "settled", terminalScope: {} };
3078
+ },
3079
+ },
3080
+ });
3081
+ const ctx = extensionContext(transport.sessionId, cwd);
3082
+ try {
3083
+ await handlers.get("session_start")?.({}, ctx);
3084
+ const keyHash = createHash("sha256").update("stopped-replay-key").digest("hex");
3085
+ const inputHash = createHash("sha256")
3086
+ .update(JSON.stringify({ mode: "terminal", scope: "turn" }))
3087
+ .digest("hex");
3088
+ const result = {
3089
+ ok: true,
3090
+ selection: "turn",
3091
+ turn: "stopped",
3092
+ ownedWork: "left_running",
3093
+ automaticDelivery: "enabled",
3094
+ resumeOnOwnedCompletion: true,
3095
+ };
3096
+ const payloadHash = createHash("sha256").update(JSON.stringify(result)).digest("hex");
3097
+ const replayResult = {
3098
+ ...result,
3099
+ // The replay envelope carries the POST-CAS publication flag the
3100
+ // stopped-row CAS observed (agent_end published); the seeded
3101
+ // replay-shaped hash must match that exact envelope (review
3102
+ // thread P2).
3103
+ replay: { responseState: "pending", responsePayloadHash: payloadHash, terminalPublished: true },
3104
+ };
3105
+ const replayPayloadHash = createHash("sha256").update(JSON.stringify(replayResult)).digest("hex");
3106
+ // Seed the POST-finalization durable state: the original stopped result
3107
+ // hash plus the replay-shaped hash a same-key retry delivers.
3108
+ await reconciliationStore.transactTerminalState(state => ({
3109
+ scopes: [
3110
+ {
3111
+ selection: "turn",
3112
+ idempotencyKeyHash: keyHash,
3113
+ idempotencyInputHash: inputHash,
3114
+ turnDisposition: "stopped",
3115
+ terminalPublished: true,
3116
+ ownedWorkDisposition: "left_running",
3117
+ automaticDeliveryDisposition: "enabled",
3118
+ resumeOnOwnedCompletion: true,
3119
+ turnContinuationFence: {
3120
+ state: "retained",
3121
+ abortedAttemptEpoch: 0,
3122
+ blockedContinuationIds: [],
3123
+ predecessorTombstones: [],
3124
+ ownedCompletionPolicy: "disabled",
3125
+ },
3126
+ responseState: "pending",
3127
+ responsePayloadHash: payloadHash,
3128
+ replayPayloadHash,
3129
+ acceptedAt: Date.now(),
3130
+ terminalAt: Date.now(),
3131
+ },
3132
+ ...state.scopes,
3133
+ ],
3134
+ keys: state.keys,
3135
+ }));
3136
+ transport.feed("client", {
3137
+ type: "control_request",
3138
+ id: "stopped-replay",
3139
+ operation: "turn.abort",
3140
+ input: { mode: "terminal" },
3141
+ idempotencyKey: "stopped-replay-key",
3142
+ } as SdkFrame);
3143
+ const deadline = Date.now() + 15_000;
3144
+ while (!transport.sent.some(frame => frame.id === "stopped-replay" && frame.type === "control_response")) {
3145
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the stopped-row replay");
3146
+ await Bun.sleep(20);
3147
+ }
3148
+ // The written replay's payload matches the stored replay-shaped hash, so
3149
+ // the delivery observer advances the durable row to sent.
3150
+ const stateDeadline = Date.now() + 15_000;
3151
+ while (reconciliationStore.snapshotTerminalScopes()[0]!.responseState !== "sent") {
3152
+ if (Date.now() > stateDeadline) throw new Error("Timed out waiting for the stopped-row response state");
3153
+ await Bun.sleep(20);
3154
+ }
3155
+ expect(transport.sent.find(frame => frame.id === "stopped-replay")).toMatchObject({
3156
+ ok: true,
3157
+ result: expect.objectContaining({
3158
+ turn: "stopped",
3159
+ replay: expect.objectContaining({ responseState: "pending" }),
3160
+ }),
3161
+ });
3162
+ // The durable replay (dispatch-cache eviction equivalent: the row was
3163
+ // seeded before this runtime admitted anything) captured a snapshot at
3164
+ // admission and then DISCARDED it — the replay path never settles, so
3165
+ // the FIFO holds no stale entry for a later real abort to consume
3166
+ // (review thread P1).
3167
+ expect(captureCalls).toBe(1);
3168
+ expect(discardCalls).toBe(1);
3169
+ } finally {
3170
+ await handlers.get("session_shutdown")?.({}, ctx);
3171
+ await rm(cwd, { recursive: true, force: true });
3172
+ }
3173
+ });
3174
+
3175
+ test("SDK-only host does not assign a follow-up requester ownership until the follow-up actually starts", async () => {
3176
+ // Review thread P1: a turn.follow_up accepted while ctx.isIdle() is true
3177
+ // but the follow-up is never promoted (compaction, transcript ending in a
3178
+ // user/tool-result message) must not record the requester in pending — a
3179
+ // later unrelated agent_start would shift the stale entry and let the
3180
+ // follow-up connection terminal-abort a turn it did not submit. Ownership
3181
+ // correlates only when the queued follow-up is actually promoted.
3182
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-followup-stale-"));
3183
+ const idle = true;
3184
+ let promoted: (() => void) | undefined;
3185
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
3186
+ const api = {
3187
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
3188
+ handlers.set(event, handler);
3189
+ },
3190
+ sendUserMessage: (
3191
+ _content: string,
3192
+ options:
3193
+ | {
3194
+ onPreflightAccepted?: () => void;
3195
+ onPreflightAcceptCommit?: () => void;
3196
+ onQueuedPromoted?: () => void;
3197
+ }
3198
+ | undefined,
3199
+ ) =>
3200
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
3201
+ promoted = options?.onQueuedPromoted;
3202
+ options?.onPreflightAccepted?.();
3203
+ return {};
3204
+ }),
3205
+ } as unknown as ExtensionAPI;
3206
+ const transport = memoryTransport();
3207
+ const reconciliationStore = createReconciliationStore({
3208
+ sessionFile: path.join(cwd, "session.json"),
3209
+ sessionId: transport.sessionId,
3210
+ });
3211
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
3212
+ createSdkSessionRuntimeExtension(api, {
3213
+ agentDir: cwd,
3214
+ createTransport: async () => transport,
3215
+ terminalAbortSeams: {
3216
+ getReconciliationStore: () => reconciliationStore,
3217
+ getTerminalTurnEpoch: () => 7,
3218
+ getActivePromptHandle: () => "exact-run-handle",
3219
+ cancelPendingPreflightForTerminalAbort: () => {},
3220
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
3221
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
3222
+ return { status: "settled", terminalScope: {} };
3223
+ },
3224
+ },
3225
+ });
3226
+ const ctx = {
3227
+ ...extensionContext(transport.sessionId, cwd),
3228
+ isIdle: () => idle,
3229
+ } as unknown as ExtensionContext;
3230
+ try {
3231
+ await handlers.get("session_start")?.({}, ctx);
3232
+ const waitResponse = async (id: string) => {
3233
+ const deadline = Date.now() + 15_000;
3234
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
3235
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
3236
+ await Bun.sleep(20);
3237
+ }
3238
+ };
3239
+ // B submits a follow-up while idle. The follow-up is QUEUED (never starts
3240
+ // inline), and with no promotion (e.g. compaction holds the continuation)
3241
+ // no pending ownership entry may exist — even though isIdle is true.
3242
+ transport.feed("conn-b", {
3243
+ type: "control_request",
3244
+ id: "followup-b",
3245
+ operation: "turn.follow_up",
3246
+ input: { text: "followup-b" },
3247
+ } as SdkFrame);
3248
+ await waitResponse("followup-b");
3249
+ // The promotion hook exists but is NOT fired in this scenario.
3250
+ expect(promoted).toBeDefined();
3251
+ // An unrelated turn starts: the pending queue is empty, so the owner is
3252
+ // NOT conn-b.
3253
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
3254
+ // B's abort must NOT stop the unrelated turn.
3255
+ transport.feed("conn-b", {
3256
+ type: "control_request",
3257
+ id: "followup-abort",
3258
+ operation: "turn.abort",
3259
+ input: { mode: "terminal" },
3260
+ idempotencyKey: "followup-abort-key",
3261
+ } as SdkFrame);
3262
+ await waitResponse("followup-abort");
3263
+ expect(transport.sent.find(frame => frame.id === "followup-abort")).toMatchObject({
3264
+ ok: true,
3265
+ result: expect.objectContaining({ turn: "no_active_turn" }),
3266
+ });
3267
+ expect(seamCalls).toHaveLength(0);
3268
+ // When the follow-up IS promoted, B owns its run and can abort it.
3269
+ promoted!();
3270
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
3271
+ transport.feed("conn-b", {
3272
+ type: "control_request",
3273
+ id: "followup-abort-2",
3274
+ operation: "turn.abort",
3275
+ input: { mode: "terminal" },
3276
+ idempotencyKey: "followup-abort-key-2",
3277
+ } as SdkFrame);
3278
+ await waitResponse("followup-abort-2");
3279
+ expect(transport.sent.find(frame => frame.id === "followup-abort-2")).toMatchObject({
3280
+ ok: true,
3281
+ result: expect.objectContaining({ turn: "stopped" }),
3282
+ });
3283
+ expect(seamCalls).toEqual([{ handle: "exact-run-handle", scope: "turn" }]);
3284
+ } finally {
3285
+ await handlers.get("session_shutdown")?.({}, ctx);
3286
+ await rm(cwd, { recursive: true, force: true });
3287
+ }
3288
+ });
3289
+
3290
+ test("SDK-only host lets every connection whose follow-up was promoted abort the shared run", async () => {
3291
+ // Review thread P2: two connections submit follow-ups while idle before any
3292
+ // scheduled continuation starts; ONE continuation drains both into one run.
3293
+ // The per-message promotion hooks (fired at actual dequeue) must make BOTH
3294
+ // connections owners of that run, so each can terminal-abort work it
3295
+ // submitted, while a foreign connection still cannot.
3296
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-multi-followup-"));
3297
+ const promoted: Array<() => void> = [];
3298
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
3299
+ const api = {
3300
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
3301
+ handlers.set(event, handler);
3302
+ },
3303
+ sendUserMessage: (
3304
+ _content: string,
3305
+ options:
3306
+ | {
3307
+ onPreflightAccepted?: () => void;
3308
+ onPreflightAcceptCommit?: () => void;
3309
+ onQueuedPromoted?: () => void;
3310
+ }
3311
+ | undefined,
3312
+ ) =>
3313
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
3314
+ if (options?.onQueuedPromoted) promoted.push(options.onQueuedPromoted);
3315
+ options?.onPreflightAccepted?.();
3316
+ return {};
3317
+ }),
3318
+ } as unknown as ExtensionAPI;
3319
+ const transport = memoryTransport();
3320
+ const reconciliationStore = createReconciliationStore({
3321
+ sessionFile: path.join(cwd, "session.json"),
3322
+ sessionId: transport.sessionId,
3323
+ });
3324
+ const seamCalls: Array<{ handle: string; scope: string }> = [];
3325
+ createSdkSessionRuntimeExtension(api, {
3326
+ agentDir: cwd,
3327
+ createTransport: async () => transport,
3328
+ terminalAbortSeams: {
3329
+ getReconciliationStore: () => reconciliationStore,
3330
+ getTerminalTurnEpoch: () => 7,
3331
+ getActivePromptHandle: () => "exact-run-handle",
3332
+ cancelPendingPreflightForTerminalAbort: () => {},
3333
+ abortPromptAndWaitWithTerminal: async (handle, options) => {
3334
+ seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
3335
+ return { status: "settled", terminalScope: {} };
3336
+ },
3337
+ },
3338
+ });
3339
+ const ctx = extensionContext(transport.sessionId, cwd);
3340
+ try {
3341
+ await handlers.get("session_start")?.({}, ctx);
3342
+ const waitResponse = async (id: string) => {
3343
+ const deadline = Date.now() + 15_000;
3344
+ while (!transport.sent.some(frame => frame.id === id && frame.type === "control_response")) {
3345
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${id}`);
3346
+ await Bun.sleep(20);
3347
+ }
3348
+ };
3349
+ const followUp = (connectionId: string, id: string) =>
3350
+ transport.feed(connectionId, {
3351
+ type: "control_request",
3352
+ id,
3353
+ operation: "turn.follow_up",
3354
+ input: { text: id },
3355
+ } as SdkFrame);
3356
+ // A and B submit follow-ups while idle: no pending entries at accept.
3357
+ followUp("conn-a", "fu-a");
3358
+ await waitResponse("fu-a");
3359
+ followUp("conn-b", "fu-b");
3360
+ await waitResponse("fu-b");
3361
+ expect(promoted).toHaveLength(2);
3362
+ // ONE continuation drains both follow-ups into one run: both per-message
3363
+ // hooks fire at dequeue, then the run starts.
3364
+ for (const hook of promoted) hook();
3365
+ await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
3366
+ // Both submitting connections can terminal-abort the shared run.
3367
+ for (const [connectionId, id] of [
3368
+ ["conn-a", "multi-abort-a"],
3369
+ ["conn-b", "multi-abort-b"],
3370
+ ] as const) {
3371
+ transport.feed(connectionId, {
3372
+ type: "control_request",
3373
+ id,
3374
+ operation: "turn.abort",
3375
+ input: { mode: "terminal" },
3376
+ idempotencyKey: `${id}-key`,
3377
+ } as SdkFrame);
3378
+ await waitResponse(id);
3379
+ expect(transport.sent.find(frame => frame.id === id)).toMatchObject({
3380
+ ok: true,
3381
+ result: expect.objectContaining({ turn: "stopped" }),
3382
+ });
3383
+ }
3384
+ expect(seamCalls).toHaveLength(2);
3385
+ // A foreign connection still cannot stop the run.
3386
+ transport.feed("conn-c", {
3387
+ type: "control_request",
3388
+ id: "multi-abort-c",
3389
+ operation: "turn.abort",
3390
+ input: { mode: "terminal" },
3391
+ idempotencyKey: "multi-abort-c-key",
3392
+ } as SdkFrame);
3393
+ await waitResponse("multi-abort-c");
3394
+ expect(transport.sent.find(frame => frame.id === "multi-abort-c")).toMatchObject({
3395
+ ok: true,
3396
+ result: expect.objectContaining({ turn: "no_active_turn" }),
3397
+ });
3398
+ expect(seamCalls).toHaveLength(2);
3399
+ // Review thread P1: EVERY follow-up batched into the shared run must
3400
+ // reach a terminal record. A promotion that only transitioned the first
3401
+ // invocation left the remaining follow-ups durably accepted — their
3402
+ // result lookups would never complete and a restart would report them
3403
+ // failed even though they ran in the shared turn.
3404
+ await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
3405
+ {
3406
+ const terminalDeadline = Date.now() + 15_000;
3407
+ const promptStatuses = () =>
3408
+ reconciliationStore
3409
+ .snapshot()
3410
+ .filter(record => record.kind === "prompt")
3411
+ .map(record => record.status);
3412
+ while (promptStatuses().some(status => status !== "terminal_ok")) {
3413
+ if (Date.now() > terminalDeadline)
3414
+ throw new Error("Timed out waiting for the batched follow-up records to terminalize");
3415
+ await Bun.sleep(20);
3416
+ }
3417
+ }
3418
+ const batchedTerminals = reconciliationStore
3419
+ .snapshot()
3420
+ .filter(record => record.kind === "prompt" && record.status === "terminal_ok");
3421
+ expect(batchedTerminals).toHaveLength(2);
3422
+ } finally {
3423
+ await handlers.get("session_shutdown")?.({}, ctx);
3424
+ await rm(cwd, { recursive: true, force: true });
3425
+ }
3426
+ });
3427
+
3428
+ test("SDK-only host rebinds the steering snapshot when the requester's turn wins the owner race", async () => {
3429
+ // Review thread P1: an abort admitted while another connection owns the
3430
+ // active turn captures its snapshot under that OLD turn; when the durable
3431
+ // no-effect reservation reveals that the ABORTING requester's own prompt
3432
+ // became active, the fall-through terminalizes that turn and must REBIND
3433
+ // the admission snapshot to it — otherwise the settlement rejects the
3434
+ // still-old token and the requester's turn keeps running.
3435
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-snapshot-rebind-"));
3436
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
3437
+ const api = {
3438
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
3439
+ handlers.set(event, handler);
3440
+ },
3441
+ } as unknown as ExtensionAPI;
3442
+ const transport = memoryTransport();
3443
+ const reconciliationStore = createReconciliationStore({
3444
+ sessionFile: path.join(cwd, "session.json"),
3445
+ sessionId: transport.sessionId,
3446
+ });
3447
+ let ownerReads = 0;
3448
+ let rebindCalls = 0;
3449
+ const settledOptions: Array<{ scope?: string; steeringSnapshotToken?: number }> = [];
3450
+ createSdkSessionRuntimeExtension(api, {
3451
+ agentDir: cwd,
3452
+ createTransport: async () => transport,
3453
+ terminalAbortSeams: {
3454
+ getReconciliationStore: () => reconciliationStore,
3455
+ getTerminalTurnEpoch: () => 7,
3456
+ getActivePromptHandle: () => "exact-run-handle",
3457
+ // First read: another connection owns the turn. The recheck after
3458
+ // the durable reservation: the aborting requester now owns it.
3459
+ getActivePromptOwnerConnectionId: () => (ownerReads++ === 0 ? undefined : "client"),
3460
+ cancelPendingPreflightForTerminalAbort: () => {},
3461
+ captureTerminalAbortSteeringSnapshot: () => 42,
3462
+ rebindTerminalAbortSteeringSnapshot: () => {
3463
+ rebindCalls += 1;
3464
+ },
3465
+ abortPromptAndWaitWithTerminal: async (_handle, options) => {
3466
+ settledOptions.push({
3467
+ scope: options.terminal?.scope,
3468
+ steeringSnapshotToken: options.terminal?.steeringSnapshotToken,
3469
+ });
3470
+ return { status: "settled", terminalScope: {} };
3471
+ },
3472
+ },
3473
+ });
3474
+ const ctx = extensionContext(transport.sessionId, cwd);
3475
+ try {
3476
+ await handlers.get("session_start")?.({}, ctx);
3477
+ transport.feed("client", {
3478
+ type: "control_request",
3479
+ id: "owner-race-abort",
3480
+ operation: "turn.abort",
3481
+ input: { mode: "terminal" },
3482
+ idempotencyKey: "owner-race-key",
3483
+ } as SdkFrame);
3484
+ const deadline = Date.now() + 15_000;
3485
+ while (!transport.sent.some(frame => frame.id === "owner-race-abort" && frame.type === "control_response")) {
3486
+ if (Date.now() > deadline) throw new Error("Timed out waiting for the owner-race abort response");
3487
+ await Bun.sleep(20);
3488
+ }
3489
+ // The fall-through terminalized the requester's rechecked turn: the
3490
+ // admission snapshot was rebound to it and the settlement received the
3491
+ // token instead of a stale rejection.
3492
+ expect(rebindCalls).toBe(1);
3493
+ expect(settledOptions).toEqual([{ scope: "turn", steeringSnapshotToken: 42 }]);
3494
+ expect(transport.sent.find(frame => frame.id === "owner-race-abort")).toMatchObject({
3495
+ ok: true,
3496
+ result: expect.objectContaining({ turn: "stopped" }),
3497
+ });
3498
+ } finally {
3499
+ await handlers.get("session_shutdown")?.({}, ctx);
3500
+ await rm(cwd, { recursive: true, force: true });
3501
+ }
3502
+ });