@gajae-code/coding-agent 0.18.11 → 0.19.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 (462) hide show
  1. package/CHANGELOG.md +760 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/capability/rule.d.ts +7 -2
  7. package/dist/types/cli/accounts-cli.d.ts +2 -2
  8. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  9. package/dist/types/cli/args.d.ts +7 -2
  10. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  11. package/dist/types/cli/customize-doctor.d.ts +4 -3
  12. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  13. package/dist/types/cli/install-activation.d.ts +10 -4
  14. package/dist/types/cli/public-command-errors.d.ts +2 -0
  15. package/dist/types/cli/root-flags.d.ts +0 -12
  16. package/dist/types/cli/update-cli.d.ts +21 -3
  17. package/dist/types/cli-main.d.ts +0 -6
  18. package/dist/types/cli-ordinary.d.ts +2 -2
  19. package/dist/types/commands/accounts.d.ts +2 -1
  20. package/dist/types/commands/launch.d.ts +0 -6
  21. package/dist/types/commands/migrate.d.ts +1 -1
  22. package/dist/types/commands/sdk.d.ts +13 -0
  23. package/dist/types/commit/changelog/index.d.ts +1 -0
  24. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  25. package/dist/types/config/autorouting-tier-map.d.ts +53 -1
  26. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  27. package/dist/types/config/config-hot-reload.d.ts +44 -0
  28. package/dist/types/config/file-lock.d.ts +52 -3
  29. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  30. package/dist/types/config/model-profile-activation.d.ts +79 -9
  31. package/dist/types/config/model-registry.d.ts +72 -8
  32. package/dist/types/config/model-resolver.d.ts +7 -4
  33. package/dist/types/config/models-config-schema.d.ts +10 -0
  34. package/dist/types/config/settings-schema.d.ts +57 -4
  35. package/dist/types/config/settings.d.ts +33 -1
  36. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  37. package/dist/types/customization/import.d.ts +2 -0
  38. package/dist/types/customization/types.d.ts +1 -1
  39. package/dist/types/dap/config.d.ts +8 -4
  40. package/dist/types/dap/session.d.ts +11 -2
  41. package/dist/types/debug/system-info.d.ts +6 -1
  42. package/dist/types/discovery/helpers.d.ts +7 -2
  43. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  44. package/dist/types/edit/index.d.ts +5 -0
  45. package/dist/types/edit/modes/patch.d.ts +14 -0
  46. package/dist/types/edit/modes/replace.d.ts +3 -0
  47. package/dist/types/eval/py/runtime.d.ts +9 -0
  48. package/dist/types/exec/bash-executor.d.ts +12 -0
  49. package/dist/types/export/ttsr.d.ts +19 -0
  50. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  51. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  52. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  53. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  54. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  55. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  56. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  57. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  58. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  59. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  60. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  61. package/dist/types/hindsight/content.d.ts +5 -6
  62. package/dist/types/internal-urls/types.d.ts +2 -0
  63. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  64. package/dist/types/main.d.ts +23 -18
  65. package/dist/types/migrate/action-planner.d.ts +10 -1
  66. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  67. package/dist/types/migrate/adapters/index.d.ts +42 -1
  68. package/dist/types/migrate/executor.d.ts +3 -1
  69. package/dist/types/migrate/hook-module.d.ts +23 -0
  70. package/dist/types/migrate/report.d.ts +2 -1
  71. package/dist/types/migrate/types.d.ts +82 -3
  72. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  73. package/dist/types/modes/components/model-selector.d.ts +43 -0
  74. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  75. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  76. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  77. package/dist/types/modes/theme/theme.d.ts +1 -0
  78. package/dist/types/modes/utils/host-status.d.ts +15 -11
  79. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  80. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  81. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  82. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  83. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  84. package/dist/types/sdk/broker/broker-exit.d.ts +3 -3
  85. package/dist/types/sdk/broker/broker.d.ts +29 -2
  86. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  87. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  88. package/dist/types/sdk/broker/ensure.d.ts +141 -9
  89. package/dist/types/sdk/broker/hop.d.ts +45 -0
  90. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  91. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  92. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  93. package/dist/types/sdk/broker/session-index.d.ts +6 -1
  94. package/dist/types/sdk/broker/transport.d.ts +2 -0
  95. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  96. package/dist/types/sdk/bus/index.d.ts +44 -0
  97. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  98. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -1
  99. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  100. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  101. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  102. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  103. package/dist/types/sdk/host/events.d.ts +4 -0
  104. package/dist/types/sdk/host/host.d.ts +2 -1
  105. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +40 -8
  107. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  109. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  111. package/dist/types/sdk/prompt-status.d.ts +11 -4
  112. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  113. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  114. package/dist/types/sdk/session-list.d.ts +1 -1
  115. package/dist/types/session/agent-session.d.ts +107 -9
  116. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  117. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  118. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  119. package/dist/types/session/messages.d.ts +5 -1
  120. package/dist/types/session/session-manager.d.ts +29 -3
  121. package/dist/types/session/startup-auth-config.d.ts +24 -2
  122. package/dist/types/session/streaming-output.d.ts +11 -0
  123. package/dist/types/session/terminal-abort.d.ts +3 -1
  124. package/dist/types/session-import/service.d.ts +1 -1
  125. package/dist/types/skill-state/workflow-mutation-guard.d.ts +20 -4
  126. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  127. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  128. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  129. package/dist/types/system-prompt.d.ts +3 -2
  130. package/dist/types/task/executor.d.ts +19 -2
  131. package/dist/types/task/role-body.d.ts +11 -0
  132. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  133. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  134. package/dist/types/tools/awaiting-input.d.ts +28 -0
  135. package/dist/types/tools/bash-allowed-prefixes.d.ts +17 -0
  136. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  137. package/dist/types/tools/bash.d.ts +7 -0
  138. package/dist/types/tools/descriptors.d.ts +15 -2
  139. package/dist/types/tools/fetch.d.ts +7 -1
  140. package/dist/types/tools/index.d.ts +2 -0
  141. package/dist/types/tools/irc.d.ts +5 -3
  142. package/dist/types/tools/python.d.ts +1 -1
  143. package/dist/types/tools/report-issue.d.ts +41 -0
  144. package/dist/types/tools/subagent.d.ts +23 -0
  145. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  146. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  147. package/dist/types/tools/write.d.ts +2 -0
  148. package/dist/types/utils/file-mentions.d.ts +5 -0
  149. package/dist/types/utils/image-limits.d.ts +7 -0
  150. package/dist/types/utils/image-loading.d.ts +0 -1
  151. package/dist/types/utils/open.d.ts +0 -1
  152. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  153. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  154. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  155. package/dist/types/utils/title-generator.d.ts +13 -3
  156. package/dist/types/web/scrapers/types.d.ts +8 -1
  157. package/dist/types/web/search/providers/codex.d.ts +6 -7
  158. package/dist/types/web/search/providers/insane.d.ts +7 -1
  159. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  160. package/package.json +10 -9
  161. package/scripts/acp-conformance-agent.ts +12 -2
  162. package/scripts/build-binary.ts +2 -1
  163. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  164. package/scripts/generate-docs-index.ts +8 -1
  165. package/scripts/generate-sdk-operation-inventory.ts +38 -4
  166. package/src/async/job-manager.ts +356 -56
  167. package/src/async/result-formatting.ts +40 -0
  168. package/src/autoresearch/git.ts +9 -2
  169. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  170. package/src/capability/fs.ts +58 -22
  171. package/src/capability/index.ts +2 -2
  172. package/src/capability/rule.ts +11 -2
  173. package/src/cli/accounts-cli.ts +176 -8
  174. package/src/cli/append-system-prompt.ts +12 -0
  175. package/src/cli/args.ts +11 -7
  176. package/src/cli/auth-broker-cli.ts +21 -11
  177. package/src/cli/customize-doctor-precedence.ts +31 -0
  178. package/src/cli/customize-doctor.ts +49 -39
  179. package/src/cli/doctor/install-repairs.ts +35 -5
  180. package/src/cli/fast-help.ts +1 -3
  181. package/src/cli/install-activation.ts +47 -4
  182. package/src/cli/migrate-cli.ts +19 -6
  183. package/src/cli/public-command-errors.ts +6 -0
  184. package/src/cli/root-flags.ts +2 -4
  185. package/src/cli/stats-cli.ts +9 -5
  186. package/src/cli/update-cli.ts +471 -13
  187. package/src/cli-ordinary.ts +7 -2
  188. package/src/commands/accounts.ts +10 -5
  189. package/src/commands/harness.ts +25 -15
  190. package/src/commands/migrate.ts +3 -3
  191. package/src/commands/sdk.ts +277 -50
  192. package/src/commands/ultragoal.ts +16 -8
  193. package/src/commit/agentic/agent.ts +6 -6
  194. package/src/commit/analysis/conventional.ts +14 -5
  195. package/src/commit/changelog/index.ts +39 -1
  196. package/src/commit/map-reduce/index.ts +7 -7
  197. package/src/commit/message.ts +10 -3
  198. package/src/commit/pipeline.ts +12 -12
  199. package/src/config/autorouting-tier-map.ts +50 -1
  200. package/src/config/config-file.ts +106 -3
  201. package/src/config/config-hot-reload-trust.ts +148 -0
  202. package/src/config/config-hot-reload.ts +742 -0
  203. package/src/config/file-lock.ts +1615 -328
  204. package/src/config/keybindings.ts +154 -7
  205. package/src/config/model-bindings-applier.ts +85 -47
  206. package/src/config/model-discovery-manager.ts +10 -0
  207. package/src/config/model-profile-activation.ts +889 -148
  208. package/src/config/model-registry.ts +1706 -205
  209. package/src/config/model-resolver.ts +52 -22
  210. package/src/config/models-config-schema.ts +6 -0
  211. package/src/config/settings-schema.ts +73 -4
  212. package/src/config/settings.ts +460 -24
  213. package/src/coordinator-mcp/policy.ts +26 -1
  214. package/src/coordinator-mcp/server.ts +37 -2
  215. package/src/crash/sanitize.ts +7 -1
  216. package/src/customization/import.ts +279 -8
  217. package/src/customization/inventory.ts +24 -8
  218. package/src/customization/types.ts +1 -0
  219. package/src/dap/client.ts +3 -2
  220. package/src/dap/config.ts +31 -11
  221. package/src/dap/defaults.json +1 -1
  222. package/src/dap/session.ts +161 -36
  223. package/src/debug/crash-diagnostics.ts +89 -6
  224. package/src/debug/report-bundle.ts +90 -2
  225. package/src/debug/system-info.ts +8 -3
  226. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  227. package/src/discovery/agents-md.ts +1 -1
  228. package/src/discovery/builtin.ts +4 -3
  229. package/src/discovery/helpers.ts +25 -16
  230. package/src/discovery/mcp-compat.ts +120 -17
  231. package/src/edit/apply-patch/index.ts +12 -1
  232. package/src/edit/index.ts +204 -22
  233. package/src/edit/modes/patch.ts +214 -8
  234. package/src/edit/modes/replace.ts +136 -0
  235. package/src/eval/js/context-manager.ts +29 -3
  236. package/src/eval/js/worker-entry.ts +51 -18
  237. package/src/eval/py/executor.ts +14 -4
  238. package/src/eval/py/runner.py +144 -5
  239. package/src/eval/py/runtime.ts +44 -4
  240. package/src/exec/bash-executor.ts +81 -4
  241. package/src/exec/bash-shell-guardian.ts +4 -1
  242. package/src/exec/bash-shell-supervisor.ts +8 -4
  243. package/src/exec/isolated-shell.ts +5 -1
  244. package/src/exec/non-interactive-env.ts +0 -1
  245. package/src/export/html/index.ts +86 -6
  246. package/src/export/ttsr.ts +176 -1
  247. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  248. package/src/extensibility/extensions/loader.ts +10 -1
  249. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  250. package/src/extensibility/extensions/types.ts +5 -1
  251. package/src/extensibility/hooks/loader.ts +10 -2
  252. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  253. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  254. package/src/extensibility/slash-commands.ts +5 -0
  255. package/src/gjc-runtime/gc-runtime.ts +37 -7
  256. package/src/gjc-runtime/launch-tmux.ts +30 -15
  257. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  258. package/src/gjc-runtime/launch-worktree.ts +101 -14
  259. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  260. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  261. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  262. package/src/gjc-runtime/session-resolution.ts +38 -6
  263. package/src/gjc-runtime/session-state-lock.ts +59 -14
  264. package/src/gjc-runtime/state-runtime.ts +23 -19
  265. package/src/gjc-runtime/state-writer.ts +109 -5
  266. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  267. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  268. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  269. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  270. package/src/harness-control-plane/finalize.ts +11 -0
  271. package/src/harness-control-plane/receipt-spool.ts +105 -28
  272. package/src/harness-control-plane/storage.ts +124 -6
  273. package/src/hindsight/content.ts +116 -16
  274. package/src/hindsight/mental-models.ts +22 -10
  275. package/src/hindsight/state.ts +7 -4
  276. package/src/hooks/native-skill-hook.ts +17 -12
  277. package/src/internal-urls/artifact-protocol.ts +3 -1
  278. package/src/internal-urls/docs-index.generated.ts +23 -22
  279. package/src/internal-urls/local-protocol.ts +167 -8
  280. package/src/internal-urls/types.ts +2 -0
  281. package/src/lsp/client.ts +2 -1
  282. package/src/lsp/clients/biome-client.ts +2 -0
  283. package/src/lsp/clients/swiftlint-client.ts +2 -0
  284. package/src/lsp/index.ts +29 -0
  285. package/src/lsp/workspace-path.ts +23 -0
  286. package/src/main.ts +330 -92
  287. package/src/memories/index.ts +25 -2
  288. package/src/migrate/action-planner.ts +574 -2
  289. package/src/migrate/adapters/claude-code.ts +162 -5
  290. package/src/migrate/adapters/codex.ts +77 -7
  291. package/src/migrate/adapters/index.ts +258 -5
  292. package/src/migrate/adapters/opencode.ts +10 -1
  293. package/src/migrate/executor.ts +222 -7
  294. package/src/migrate/hook-module.ts +193 -0
  295. package/src/migrate/report.ts +63 -2
  296. package/src/migrate/types.ts +84 -4
  297. package/src/modes/DESIGN.md +51 -0
  298. package/src/modes/acp/acp-agent.ts +10 -1
  299. package/src/modes/acp/permission-mode.ts +18 -1
  300. package/src/modes/components/agent-dashboard.ts +20 -4
  301. package/src/modes/components/assistant-message.ts +55 -1
  302. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  303. package/src/modes/components/customization/import-wizard.ts +29 -3
  304. package/src/modes/components/model-selector.ts +157 -103
  305. package/src/modes/components/settings-selector.ts +103 -37
  306. package/src/modes/components/welcome.ts +3 -1
  307. package/src/modes/controllers/command-controller.ts +300 -9
  308. package/src/modes/controllers/event-controller.ts +1 -1
  309. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  310. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  311. package/src/modes/controllers/input-controller.ts +2 -0
  312. package/src/modes/controllers/plan-mode-controller.ts +66 -11
  313. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  314. package/src/modes/controllers/selector-controller.ts +76 -7
  315. package/src/modes/interactive-mode.ts +123 -4
  316. package/src/modes/prompt-suggestion-controller.ts +1 -1
  317. package/src/modes/runtime-init.ts +5 -1
  318. package/src/modes/theme/theme.ts +15 -0
  319. package/src/modes/utils/host-status.ts +21 -10
  320. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  321. package/src/prompts/system/irc-incoming.md +3 -1
  322. package/src/prompts/tools/debug.md +1 -1
  323. package/src/prompts/tools/irc.md +1 -1
  324. package/src/prompts/tools/read.md +1 -1
  325. package/src/prompts/tools/report-issue.md +13 -0
  326. package/src/prompts/tools/task.md +1 -1
  327. package/src/runtime-credential-selector.ts +2 -2
  328. package/src/runtime-mcp/content-limits.ts +7 -0
  329. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  330. package/src/runtime-mcp/manager.ts +40 -3
  331. package/src/runtime-mcp/oauth-flow.ts +32 -6
  332. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  333. package/src/runtime-mcp/project-host-values.ts +12 -8
  334. package/src/runtime-mcp/render.ts +16 -1
  335. package/src/runtime-mcp/tool-bridge.ts +194 -21
  336. package/src/runtime-mcp/transports/stdio.ts +2 -1
  337. package/src/sdk/broker/broker-exit.ts +10 -5
  338. package/src/sdk/broker/broker.ts +484 -85
  339. package/src/sdk/broker/daemon-entry.ts +338 -116
  340. package/src/sdk/broker/doctor-restart.ts +2 -1
  341. package/src/sdk/broker/ensure.ts +1214 -268
  342. package/src/sdk/broker/hop.ts +286 -0
  343. package/src/sdk/broker/lifecycle-ledger.ts +38 -11
  344. package/src/sdk/broker/lifecycle.ts +102 -6
  345. package/src/sdk/broker/lock-host-identity.ts +29 -0
  346. package/src/sdk/broker/session-index.ts +91 -16
  347. package/src/sdk/broker/transport.ts +17 -1
  348. package/src/sdk/bus/chat-command-policy.ts +16 -3
  349. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  350. package/src/sdk/bus/index.ts +460 -35
  351. package/src/sdk/bus/reconciliation-store.ts +201 -8
  352. package/src/sdk/bus/session-control.ts +2 -0
  353. package/src/sdk/bus/slack-daemon.ts +105 -9
  354. package/src/sdk/bus/telegram-daemon-contract.ts +9 -1
  355. package/src/sdk/bus/telegram-daemon.ts +10 -2
  356. package/src/sdk/bus/threaded-render.ts +20 -1
  357. package/src/sdk/cli/session-cli.ts +23 -6
  358. package/src/sdk/host/control/dispatch.ts +13 -1
  359. package/src/sdk/host/control/operations.ts +10 -1
  360. package/src/sdk/host/events.ts +67 -4
  361. package/src/sdk/host/host.ts +8 -2
  362. package/src/sdk/host/prompt-image-upload.ts +438 -0
  363. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  364. package/src/sdk/host/session-runtime.test.ts +1737 -129
  365. package/src/sdk/host/session-runtime.ts +684 -83
  366. package/src/sdk/host/surface-policy.ts +4 -0
  367. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  368. package/src/sdk/host/websocket-transport.ts +19 -7
  369. package/src/sdk/lifecycle/service.ts +15 -1
  370. package/src/sdk/prompt-deadline-flush.ts +78 -5
  371. package/src/sdk/prompt-deadline-manager.ts +68 -14
  372. package/src/sdk/prompt-status.ts +6 -4
  373. package/src/sdk/protocol/adapter-validation.ts +81 -12
  374. package/src/sdk/protocol/operation-inventory.generated.json +261 -2
  375. package/src/sdk/protocol/operation-registry.ts +33 -3
  376. package/src/sdk/reconciliation-extensions.ts +1 -0
  377. package/src/sdk/session-list.ts +1 -0
  378. package/src/sdk/session.ts +237 -97
  379. package/src/session/agent-session.ts +2726 -479
  380. package/src/session/history-storage.ts +182 -12
  381. package/src/session/internal/managed-session-scope.ts +173 -57
  382. package/src/session/internal/managed-session-storage.ts +85 -12
  383. package/src/session/internal/native-publish-outcome.ts +5 -1
  384. package/src/session/messages.ts +47 -6
  385. package/src/session/session-manager.ts +326 -19
  386. package/src/session/startup-auth-config.ts +116 -8
  387. package/src/session/streaming-output.ts +262 -4
  388. package/src/session/terminal-abort.ts +14 -2
  389. package/src/session-import/redact.ts +53 -2
  390. package/src/session-import/service.ts +1 -0
  391. package/src/skill-state/workflow-mutation-guard.ts +2145 -206
  392. package/src/slash-commands/builtin-registry.ts +21 -6
  393. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  394. package/src/slash-commands/helpers/mcp.ts +11 -3
  395. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  396. package/src/ssh/ssh-executor.ts +9 -2
  397. package/src/stt/recorder.ts +26 -9
  398. package/src/system-prompt.ts +61 -16
  399. package/src/task/executor.ts +115 -20
  400. package/src/task/index.ts +389 -175
  401. package/src/task/role-body.ts +84 -0
  402. package/src/task/token-log-mode.test.ts +162 -0
  403. package/src/task/token-log.ts +91 -2
  404. package/src/tools/archive-reader.ts +650 -37
  405. package/src/tools/ask.ts +1 -0
  406. package/src/tools/ast-edit-write-path.ts +64 -0
  407. package/src/tools/ast-edit.ts +18 -1
  408. package/src/tools/ast-grep.ts +7 -2
  409. package/src/tools/atomic-file-write.ts +46 -15
  410. package/src/tools/auto-generated-guard.ts +15 -3
  411. package/src/tools/awaiting-input.ts +114 -0
  412. package/src/tools/bash-allowed-prefixes.ts +214 -6
  413. package/src/tools/bash-interactive.ts +14 -2
  414. package/src/tools/bash-skill-urls.ts +58 -5
  415. package/src/tools/bash.ts +113 -26
  416. package/src/tools/browser/tab-worker.ts +39 -13
  417. package/src/tools/browser.ts +17 -7
  418. package/src/tools/cron.ts +10 -1
  419. package/src/tools/debug.ts +16 -8
  420. package/src/tools/descriptors.test.ts +44 -0
  421. package/src/tools/descriptors.ts +50 -6
  422. package/src/tools/eval.ts +15 -0
  423. package/src/tools/fetch.ts +93 -4
  424. package/src/tools/gh.ts +27 -12
  425. package/src/tools/image-gen.ts +20 -6
  426. package/src/tools/index.ts +2 -0
  427. package/src/tools/irc.ts +5 -3
  428. package/src/tools/monitor.ts +9 -1
  429. package/src/tools/output-meta.ts +74 -5
  430. package/src/tools/python.ts +1 -1
  431. package/src/tools/read.ts +26 -37
  432. package/src/tools/recipe/runner.ts +23 -12
  433. package/src/tools/render-utils.ts +2 -2
  434. package/src/tools/report-issue.ts +184 -0
  435. package/src/tools/search.ts +3 -2
  436. package/src/tools/ssh.ts +274 -8
  437. package/src/tools/subagent-render.ts +114 -12
  438. package/src/tools/subagent.ts +118 -5
  439. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  440. package/src/tools/todo-write.ts +41 -3
  441. package/src/tools/tool-catalog.generated.ts +32 -4
  442. package/src/tools/ultragoal-ask-guard.ts +13 -1
  443. package/src/tools/write.ts +42 -3
  444. package/src/tui/tree-list.ts +15 -1
  445. package/src/utils/commit-message-generator.ts +12 -4
  446. package/src/utils/external-editor.ts +21 -7
  447. package/src/utils/file-mentions.ts +75 -7
  448. package/src/utils/git.ts +38 -7
  449. package/src/utils/image-limits.ts +8 -0
  450. package/src/utils/image-loading.ts +1 -1
  451. package/src/utils/open.ts +73 -4
  452. package/src/utils/pasted-image-loading.ts +9 -8
  453. package/src/utils/prompt-suggestion.ts +5 -4
  454. package/src/utils/shell-snapshot.ts +9 -1
  455. package/src/utils/title-generator.ts +72 -22
  456. package/src/web/scrapers/index.ts +3 -1
  457. package/src/web/scrapers/types.ts +8 -0
  458. package/src/web/scrapers/youtube.ts +3 -2
  459. package/src/web/search/index.ts +2 -1
  460. package/src/web/search/providers/codex.ts +29 -32
  461. package/src/web/search/providers/insane.ts +91 -28
  462. package/src/web/search/providers/searxng.ts +62 -10
@@ -4,10 +4,23 @@ import * as fsPromises from "node:fs/promises";
4
4
  import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
- import { Agent, type AgentOptions, markNonDispatchedToolEvent, ThinkingLevel } from "@gajae-code/agent-core";
7
+ import { deflateSync } from "node:zlib";
8
+ import {
9
+ Agent,
10
+ type AgentOptions,
11
+ createRunResourceLedger,
12
+ markNonDispatchedToolEvent,
13
+ ThinkingLevel,
14
+ } from "@gajae-code/agent-core";
8
15
  import { createAttemptMinter } from "@gajae-code/agent-core/attempt-scope";
9
16
  import { CompactionCancelledError } from "@gajae-code/agent-core/compaction";
10
- import { type AssistantMessage, getBundledModel, type Model, type UserMessage } from "@gajae-code/ai/core";
17
+ import {
18
+ type AssistantMessage,
19
+ getBundledModel,
20
+ type ImageContent,
21
+ type Model,
22
+ type UserMessage,
23
+ } from "@gajae-code/ai/core";
11
24
  import { createMockModel } from "@gajae-code/ai/providers/mock";
12
25
  import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream";
13
26
  import { logger } from "@gajae-code/utils";
@@ -17,7 +30,7 @@ import { ModelRegistry } from "../../config/model-registry";
17
30
  import { Settings } from "../../config/settings";
18
31
  import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
19
32
  import { ExtensionRunner } from "../../extensibility/extensions/runner";
20
- import { AgentSession } from "../../session/agent-session";
33
+ import { AgentSession, type QueuedRunStartFailure, type SendUserMessageOptions } from "../../session/agent-session";
21
34
  import { AuthStorage } from "../../session/auth-storage";
22
35
  import { SessionManager } from "../../session/session-manager";
23
36
  import {
@@ -38,14 +51,17 @@ import {
38
51
  } from "../bus/reconciliation-store";
39
52
  import { PromptDeadlineManager } from "../prompt-deadline-manager";
40
53
  import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
41
- import { SESSION_HOST_OBSERVER_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
54
+ import { SESSION_HOST_OBSERVER_CAPABILITY, TOOL_ACTIVITY_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
55
+ import { getPromptImageProcessBudgetSnapshot, PromptImageUploadStore } from "./prompt-image-upload";
42
56
  import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
43
57
  import {
44
58
  type CreateSdkSessionRuntimeOptions,
45
59
  createInvocationReconciliation,
46
60
  createSdkSessionRuntimeExtension,
47
61
  createSdkSurfaceFactory,
62
+ type PersistenceInstrumentationEvent,
48
63
  RetainedTerminalBoundaryRegistry,
64
+ type SdkOnlyDeadlineRecoveryCheckpoint,
49
65
  type SdkOnlyInvocationRecord,
50
66
  type SdkOnlyReconciliationStore,
51
67
  type SdkOnlyTerminalAbortSeams,
@@ -143,7 +159,11 @@ test("thinking.set uses the control setter and reports the applied session level
143
159
  const transport = memoryTransport(frame => {
144
160
  if (frame.type === "control_response" && frame.id === "thinking-set") response.resolve(frame);
145
161
  });
146
- createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
162
+ createSdkSessionRuntimeExtension(api, {
163
+ agentDir: cwd,
164
+ ensureBrokerImpl: async () => testBrokerDiscovery,
165
+ createTransport: async () => transport,
166
+ });
147
167
  const ctx = extensionContext(transport.sessionId, cwd);
148
168
  try {
149
169
  await handlers.get("session_start")?.({}, ctx);
@@ -1655,6 +1675,62 @@ describe("SessionSdkSessionRuntime", () => {
1655
1675
  expect(transport.sent).toContainEqual({ type: "event", kind: "message_update", payload: {} });
1656
1676
  await runtime.stop();
1657
1677
  });
1678
+ test("applies authoritative replay capabilities before live delivery when the native callback lags", async () => {
1679
+ const replayResponse = Promise.withResolvers<SdkFrame>();
1680
+ const transport = memoryTransport(frame => {
1681
+ if (frame.type === "event_replay_result" && frame.id === "replay-before-capability-update")
1682
+ replayResponse.resolve(frame);
1683
+ });
1684
+ let negotiateCapabilities: ((connectionId: string, capabilities: readonly string[]) => void) | undefined;
1685
+ const hookedTransport: SessionSdkTransport = {
1686
+ ...transport,
1687
+ onNegotiatedCapabilities(handler) {
1688
+ negotiateCapabilities = handler;
1689
+ return () => {
1690
+ if (negotiateCapabilities === handler) negotiateCapabilities = undefined;
1691
+ };
1692
+ },
1693
+ };
1694
+ const runtime = new SessionSdkSessionRuntime({ transport: hookedTransport });
1695
+ await runtime.start();
1696
+ try {
1697
+ if (!negotiateCapabilities) throw new Error("transport capability callback was not registered");
1698
+ const observerCapabilities = [
1699
+ TOOL_ACTIVITY_CAPABILITY,
1700
+ TURN_STREAM_CAPABILITY,
1701
+ SESSION_HOST_OBSERVER_CAPABILITY,
1702
+ ];
1703
+ runtime.emitEvent({ kind: "tool_activity", payload: { phase: "started" } });
1704
+ transport.feed("observer", {
1705
+ type: "event_replay",
1706
+ id: "replay-before-capability-update",
1707
+ sinceGeneration: runtime.generation,
1708
+ sinceSeq: 0,
1709
+ capabilities: observerCapabilities,
1710
+ });
1711
+ const replay = await replayResponse.promise;
1712
+ expect(replay.events).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "tool_activity" })]));
1713
+ const observerRequirements = [TURN_STREAM_CAPABILITY, SESSION_HOST_OBSERVER_CAPABILITY];
1714
+ expect(runtime.connectionIdsWithCapabilities(observerRequirements)).toEqual(["observer"]);
1715
+
1716
+ runtime.sendFrameToCapabilities(observerRequirements, {
1717
+ type: "event",
1718
+ kind: "tool_activity",
1719
+ payload: { phase: "updated" },
1720
+ });
1721
+ expect(transport.sent).toContainEqual({
1722
+ type: "event",
1723
+ kind: "tool_activity",
1724
+ payload: { phase: "updated" },
1725
+ });
1726
+
1727
+ // A delayed hello snapshot can omit event_replay additions; capability sets only grow per connection.
1728
+ negotiateCapabilities("observer", [TOOL_ACTIVITY_CAPABILITY]);
1729
+ expect(runtime.connectionIdsWithCapabilities(observerRequirements)).toEqual(["observer"]);
1730
+ } finally {
1731
+ await runtime.stop();
1732
+ }
1733
+ });
1658
1734
  test("ignores late negotiated capabilities after the authoritative connection closes", async () => {
1659
1735
  const transport = memoryTransport();
1660
1736
  let closeConnection: ((connectionId: string) => void) | undefined;
@@ -4443,7 +4519,10 @@ describe("SessionSdkSessionRuntime", () => {
4443
4519
  function createTestRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {
4444
4520
  createSdkSessionRuntimeExtension(api, {
4445
4521
  ...options,
4446
- ensureBrokerImpl: options.ensureBrokerImpl ?? (async () => testBrokerDiscovery),
4522
+ ensureBrokerImpl: async input => {
4523
+ if (options.ensureBrokerImpl) return await options.ensureBrokerImpl(input);
4524
+ return testBrokerDiscovery;
4525
+ },
4447
4526
  });
4448
4527
  }
4449
4528
 
@@ -4543,6 +4622,7 @@ interface PreflightHooks {
4543
4622
  expectedSdkRunToken?: string;
4544
4623
  preflightSignal?: AbortSignal;
4545
4624
  onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
4625
+ onQueuedRunStartFailed?: (failure: QueuedRunStartFailure) => void;
4546
4626
  }
4547
4627
 
4548
4628
  interface ResponseFrame {
@@ -4558,6 +4638,59 @@ interface ResponseFrame {
4558
4638
  };
4559
4639
  }
4560
4640
 
4641
+ const TEST_PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
4642
+
4643
+ function testPngCrc32(bytes: Uint8Array): number {
4644
+ let crc = 0xffffffff;
4645
+ for (const byte of bytes) {
4646
+ crc ^= byte;
4647
+ for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
4648
+ }
4649
+ return (crc ^ 0xffffffff) >>> 0;
4650
+ }
4651
+
4652
+ function testPngChunk(type: string, data: Uint8Array): Buffer {
4653
+ const typeBytes = Buffer.from(type, "ascii");
4654
+ const payload = Buffer.from(data);
4655
+ const chunk = Buffer.alloc(12 + payload.length);
4656
+ chunk.writeUInt32BE(payload.length, 0);
4657
+ typeBytes.copy(chunk, 4);
4658
+ payload.copy(chunk, 8);
4659
+ chunk.writeUInt32BE(testPngCrc32(Buffer.concat([typeBytes, payload])), 8 + payload.length);
4660
+ return chunk;
4661
+ }
4662
+
4663
+ /** A valid deterministic 300x300 PNG whose source exceeds the old 256 KiB frame limit. */
4664
+ function makeTestPng(): Buffer {
4665
+ const width = 300;
4666
+ const height = 300;
4667
+ const ihdr = Buffer.alloc(13);
4668
+ ihdr.writeUInt32BE(width, 0);
4669
+ ihdr.writeUInt32BE(height, 4);
4670
+ ihdr[8] = 8;
4671
+ ihdr[9] = 6;
4672
+ const stride = 1 + width * 4;
4673
+ const pixels = Buffer.alloc(stride * height);
4674
+ let state = 0x12345678;
4675
+ for (let y = 0; y < height; y++) {
4676
+ const row = y * stride;
4677
+ for (let x = 0; x < width; x++) {
4678
+ const pixel = row + 1 + x * 4;
4679
+ for (let channel = 0; channel < 3; channel++) {
4680
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
4681
+ pixels[pixel + channel] = state >>> 24;
4682
+ }
4683
+ pixels[pixel + 3] = 255;
4684
+ }
4685
+ }
4686
+ return Buffer.concat([
4687
+ TEST_PNG_SIGNATURE,
4688
+ testPngChunk("IHDR", ihdr),
4689
+ testPngChunk("IDAT", deflateSync(pixels, { level: 0 })),
4690
+ testPngChunk("IEND", Buffer.alloc(0)),
4691
+ ]);
4692
+ }
4693
+
4561
4694
  interface InvocationHarness {
4562
4695
  control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
4563
4696
  controlAs(
@@ -4574,6 +4707,8 @@ interface InvocationHarness {
4574
4707
  sendToSession(sessionId: string, frame: Record<string, unknown>): void;
4575
4708
  sent(sessionId: string): SdkFrame[];
4576
4709
  readonly broadcasts: SdkFrame[];
4710
+ connect(connectionId: string): void;
4711
+ disconnect(connectionId: string): void;
4577
4712
  stop(): Promise<void>;
4578
4713
  }
4579
4714
 
@@ -4590,6 +4725,8 @@ async function invocationHarness(
4590
4725
  abort?: () => void;
4591
4726
  isIdle?: () => boolean;
4592
4727
  settings?: Settings;
4728
+ /** Install the authoritative test transport liveness and disconnect callbacks. */
4729
+ connectionLiveness?: boolean;
4593
4730
  /** Throw to inject a durable-write failure for matching transitions. */
4594
4731
  persistInterceptor?: (transition: { type: string }) => void;
4595
4732
  /** Hold one matching durable transition until the test releases it. */
@@ -4605,10 +4742,13 @@ async function invocationHarness(
4605
4742
  type: string | undefined;
4606
4743
  commandId?: string;
4607
4744
  turnId?: string;
4745
+ terminalAt?: number;
4608
4746
  outcome: "rejected" | "committed";
4609
4747
  }) => void;
4610
4748
  branch?: unknown[];
4611
4749
  onInvocationCompletionReconciled?: (kind: string, correlation: { commandId: string; turnId: string }) => void;
4750
+ /** Test-only instrumentation of the persistence chain. */
4751
+ onPersistenceInstrumentation?: (event: PersistenceInstrumentationEvent) => void;
4612
4752
  /** Override/extend the INTERNAL terminal-abort seams the runtime is threaded. */
4613
4753
  terminalAbortSeams?: Partial<SdkOnlyTerminalAbortSeams>;
4614
4754
  },
@@ -4617,6 +4757,8 @@ async function invocationHarness(
4617
4757
  const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
4618
4758
  const broadcasts: SdkFrame[] = [];
4619
4759
  let deliver: ((connectionId: string, frame: SdkFrame) => void) | undefined;
4760
+ const openConnections = new Set<string>();
4761
+ let connectionCloseHandler: ((connectionId: string) => void) | undefined;
4620
4762
  const deliveries = new Map<string, (connectionId: string, frame: SdkFrame) => void>();
4621
4763
  const sentFrames = new Map<string, SdkFrame[]>();
4622
4764
  let nextId = 0;
@@ -4640,14 +4782,20 @@ async function invocationHarness(
4640
4782
  createTestRuntimeExtension(api, {
4641
4783
  agentDir: cwd,
4642
4784
  ...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
4643
- ...(interceptorStore || hooks.terminalAbortSeams
4785
+ ...(interceptorStore || hooks.terminalAbortSeams || hooks.onPersistenceInstrumentation || hooks.connectionLiveness
4644
4786
  ? {
4645
4787
  terminalAbortSeams: {
4646
4788
  getTerminalTurnEpoch: () => undefined,
4647
- getActivePromptHandle: () => undefined,
4789
+ getActivePromptHandle: () => (hooks.connectionLiveness ? "test-image-run" : undefined),
4648
4790
  cancelPendingPreflightForTerminalAbort: () => {},
4649
4791
  abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
4792
+ ...(hooks.connectionLiveness
4793
+ ? { waitForRunSettlement: async () => ({ status: "settled" as const }) }
4794
+ : {}),
4650
4795
  ...(interceptorStore ? { getReconciliationStore: () => interceptorStore } : {}),
4796
+ ...(hooks.onPersistenceInstrumentation
4797
+ ? { onPersistenceInstrumentationForTests: hooks.onPersistenceInstrumentation }
4798
+ : {}),
4651
4799
  ...hooks.terminalAbortSeams,
4652
4800
  },
4653
4801
  }
@@ -4671,6 +4819,17 @@ async function invocationHarness(
4671
4819
  if (deliveries.get(id) === handler) deliveries.delete(id);
4672
4820
  };
4673
4821
  },
4822
+ ...(hooks.connectionLiveness
4823
+ ? {
4824
+ isConnectionOpen: (connectionId: string) => openConnections.has(connectionId),
4825
+ onConnectionClose(handler: (connectionId: string) => void) {
4826
+ connectionCloseHandler = handler;
4827
+ return () => {
4828
+ if (connectionCloseHandler === handler) connectionCloseHandler = undefined;
4829
+ };
4830
+ },
4831
+ }
4832
+ : {}),
4674
4833
  sendFrame(_connectionId, frame) {
4675
4834
  const response = frame as ResponseFrame;
4676
4835
  const frames = sentFrames.get(id) ?? [];
@@ -4684,93 +4843,1382 @@ async function invocationHarness(
4684
4843
  hooks.broadcastInterceptor?.(frame);
4685
4844
  broadcasts.push(frame);
4686
4845
  },
4687
- start: async () => ({ url: "ws://127.0.0.1:1" }),
4688
- stop: async () => {},
4689
- }),
4846
+ start: async () => ({ url: "ws://127.0.0.1:1" }),
4847
+ stop: async () => {},
4848
+ }),
4849
+ });
4850
+ const ctx = {
4851
+ cwd,
4852
+ workflowGate: undefined,
4853
+ sdkBindings: () => (hooks.invokeSkill ? ["invokeSkill"] : []),
4854
+ isIdle: hooks.isIdle ?? (() => true),
4855
+ abort: hooks.abort ?? (() => {}),
4856
+ ...(hooks.invokeSkill ? { invokeSkill: hooks.invokeSkill } : {}),
4857
+ sessionManager: {
4858
+ getSessionId: () => sessionId,
4859
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
4860
+ getSessionName: () => undefined,
4861
+ getBranch: () => hooks.branch ?? [],
4862
+ },
4863
+ };
4864
+ await handlers.get("session_start")?.({}, ctx);
4865
+ const request = (frame: Record<string, unknown>, connectionId = "client"): Promise<ResponseFrame> => {
4866
+ const id = `frame-${nextId}`;
4867
+ nextId += 1;
4868
+ const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
4869
+ waiters.set(id, resolve);
4870
+ deliver?.(connectionId, { ...frame, id } as SdkFrame);
4871
+ return promise;
4872
+ };
4873
+ return {
4874
+ control: (operation, input) => request({ type: "control_request", operation, input }),
4875
+ controlAs: (connectionId, operation, input, idempotencyKey) =>
4876
+ request(
4877
+ {
4878
+ type: "control_request",
4879
+ operation,
4880
+ input,
4881
+ ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
4882
+ },
4883
+ connectionId,
4884
+ ),
4885
+ query: (name, input) => request({ type: "query_request", query: name, input }),
4886
+ broadcasts,
4887
+ connect: connectionId => {
4888
+ openConnections.add(connectionId);
4889
+ },
4890
+ disconnect: connectionId => {
4891
+ openConnections.delete(connectionId);
4892
+ connectionCloseHandler?.(connectionId);
4893
+ },
4894
+ emit: async (event, payload) => {
4895
+ await handlers.get(event)?.(payload ?? {}, ctx);
4896
+ },
4897
+ stop: async () => {
4898
+ await handlers.get("session_shutdown")?.({}, ctx);
4899
+ },
4900
+ switchSession: async sessionId => {
4901
+ await handlers.get("session_switch")?.(
4902
+ {},
4903
+ {
4904
+ ...ctx,
4905
+ sessionManager: {
4906
+ ...ctx.sessionManager,
4907
+ getSessionId: () => sessionId,
4908
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
4909
+ },
4910
+ },
4911
+ );
4912
+ },
4913
+ branch: async sessionId => {
4914
+ await handlers.get("session_branch")?.(
4915
+ {},
4916
+ {
4917
+ ...ctx,
4918
+ sessionManager: {
4919
+ ...ctx.sessionManager,
4920
+ getSessionId: () => sessionId,
4921
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
4922
+ },
4923
+ },
4924
+ );
4925
+ },
4926
+ requestOnSession: (sessionId, frame) => {
4927
+ const id = `frame-${nextId}`;
4928
+ nextId += 1;
4929
+ const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
4930
+ waiters.set(id, resolve);
4931
+ deliveries.get(sessionId)?.("client", { ...frame, id } as SdkFrame);
4932
+ return promise;
4933
+ },
4934
+ sendToSession: (sessionId, frame) => {
4935
+ deliveries.get(sessionId)?.("client", frame as SdkFrame);
4936
+ },
4937
+ sent: sessionId => sentFrames.get(sessionId) ?? [],
4938
+ };
4939
+ }
4940
+
4941
+ async function stageRuntimeImage(harness: InvocationHarness, bytes: Buffer, connectionId = "client"): Promise<string> {
4942
+ const begun = await harness.controlAs(connectionId, "turn.image.begin", {
4943
+ mimeType: "image/png",
4944
+ byteLength: bytes.length,
4945
+ sha256: createHash("sha256").update(bytes).digest("hex"),
4946
+ });
4947
+ expect(begun).toMatchObject({ ok: true });
4948
+ const id = (begun.result as { id?: unknown } | undefined)?.id;
4949
+ if (typeof id !== "string") throw new Error("SDK runtime did not return a staged image ID.");
4950
+ let sequence = 0;
4951
+ for (let offset = 0; offset < bytes.length; offset += 96 * 1024) {
4952
+ const data = bytes.subarray(offset, offset + 96 * 1024).toString("base64");
4953
+ expect(Buffer.byteLength(JSON.stringify({ id, sequence, data }))).toBeLessThan(256 * 1024);
4954
+ expect(await harness.controlAs(connectionId, "turn.image.append", { id, sequence, data })).toMatchObject({
4955
+ ok: true,
4956
+ });
4957
+ sequence++;
4958
+ }
4959
+ expect(await harness.controlAs(connectionId, "turn.image.finish", { id })).toMatchObject({ ok: true });
4960
+ return id;
4961
+ }
4962
+
4963
+ test.each([
4964
+ "liveness",
4965
+ "settlement",
4966
+ ] as const)("SDK-only custom transports without authoritative image ownership omit image controls (%s)", async missing => {
4967
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-no-image-transport-"));
4968
+ let harness: InvocationHarness | undefined;
4969
+ try {
4970
+ harness = await invocationHarness("sdk-no-image-transport", cwd, {
4971
+ connectionLiveness: missing !== "liveness",
4972
+ terminalAbortSeams: {
4973
+ waitForRunSettlement: missing === "settlement" ? undefined : async () => ({ status: "settled" }),
4974
+ },
4975
+ });
4976
+ harness.connect("client");
4977
+ const capabilities = await harness.query("runtime.capabilities", {});
4978
+ const operations = (capabilities as unknown as { page?: { items?: Array<{ operations?: string[] }> } }).page
4979
+ ?.items?.[0]?.operations;
4980
+ expect(operations?.some(operation => operation.startsWith("turn.image."))).toBe(false);
4981
+ const response = await harness.control("turn.image.begin", {
4982
+ mimeType: "image/png",
4983
+ byteLength: 1,
4984
+ sha256: "0".repeat(64),
4985
+ });
4986
+ expect(response).toMatchObject({ ok: false, error: { code: "operation_not_session_owned" } });
4987
+ } finally {
4988
+ await harness?.stop();
4989
+ await rm(cwd, { recursive: true, force: true });
4990
+ }
4991
+ });
4992
+
4993
+ test("SDK-only private staged images are connection-owned, one-shot, and reach the provider losslessly", async () => {
4994
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-provider-staged-image-"));
4995
+ const bytes = makeTestPng();
4996
+ expect(bytes.length).toBeGreaterThan(256 * 1024);
4997
+ const releaseCalls: string[] = [];
4998
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
4999
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5000
+ this: PromptImageUploadStore,
5001
+ owner,
5002
+ ids,
5003
+ ) {
5004
+ const reservation = originalRedeem.call(this, owner, ids);
5005
+ return {
5006
+ images: reservation.images,
5007
+ release: () => {
5008
+ releaseCalls.push("released");
5009
+ reservation.release();
5010
+ },
5011
+ };
5012
+ });
5013
+ let harness: InvocationHarness | undefined;
5014
+ let session: AgentSession | undefined;
5015
+ let authStorage: AuthStorage | undefined;
5016
+ try {
5017
+ const providerCalled = Promise.withResolvers<void>();
5018
+ let providerImages: ImageContent[] = [];
5019
+ const mock = createMockModel({ responses: [{ content: ["The staged image reached the model."] }] });
5020
+ const real = await createTerminalizationSession(cwd, (model, context, options) => {
5021
+ providerImages = context.messages.flatMap(message =>
5022
+ message.role === "user" && Array.isArray(message.content)
5023
+ ? message.content.filter((block): block is ImageContent => block.type === "image")
5024
+ : [],
5025
+ );
5026
+ providerCalled.resolve();
5027
+ return mock.stream(model, context, options);
5028
+ });
5029
+ session = real.session;
5030
+ authStorage = real.authStorage;
5031
+ const send = realSendUserMessage(session);
5032
+ harness = await invocationHarness("sdk-provider-staged-image", cwd, {
5033
+ connectionLiveness: true,
5034
+ terminalAbortSeams: {
5035
+ getActivePromptHandle: () => real.session.activePromptHandle,
5036
+ waitForRunSettlement: handle =>
5037
+ real.session.agent.resourceLedger.waitForSettlement(handle, { graceMs: null }),
5038
+ },
5039
+ sendUserMessage: (content, options) => send(content, options),
5040
+ });
5041
+ harness.connect("image-owner");
5042
+ harness.connect("image-observer");
5043
+ session.subscribe(async event => {
5044
+ await harness?.emit(event.type, event);
5045
+ });
5046
+
5047
+ const id = await stageRuntimeImage(harness, bytes, "image-owner");
5048
+ const forgedOwnerPrompt = await harness.controlAs("image-observer", "turn.prompt", {
5049
+ text: "A different authenticated connection cannot redeem this image.",
5050
+ stagedImages: [{ id }],
5051
+ });
5052
+ expect(forgedOwnerPrompt).toMatchObject({ ok: false, error: { code: "image_upload_unavailable" } });
5053
+ const mixedPrompt = await harness.controlAs("image-owner", "turn.prompt", {
5054
+ text: "Direct and staged image blocks cannot be mixed.",
5055
+ images: [{ data: "cG5n", mimeType: "image/png" }],
5056
+ stagedImages: [{ id }],
5057
+ });
5058
+ expect(mixedPrompt).toMatchObject({ ok: false, error: { code: "invalid_input" } });
5059
+ expect(releaseCalls).toEqual([]);
5060
+
5061
+ const accepted = await harness.controlAs("image-owner", "turn.prompt", {
5062
+ text: "Inspect this losslessly staged image.",
5063
+ stagedImages: [{ id }],
5064
+ });
5065
+ expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
5066
+ await providerCalled.promise;
5067
+ expect(providerImages).toEqual([{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" }]);
5068
+ await session.waitForIdle();
5069
+ await settledStatus(harness, "turn.result", {
5070
+ kind: "prompt",
5071
+ commandId: accepted.result?.commandId,
5072
+ turnId: accepted.result?.turnId,
5073
+ });
5074
+ for (let attempt = 0; releaseCalls.length === 0 && attempt < 200; attempt++) await Bun.sleep(10);
5075
+ expect(releaseCalls).toHaveLength(1);
5076
+ expect(
5077
+ await harness.controlAs("image-owner", "turn.prompt", {
5078
+ text: "The image descriptor is one-shot.",
5079
+ stagedImages: [{ id }],
5080
+ }),
5081
+ ).toMatchObject({ ok: false, error: { code: "image_upload_unavailable" } });
5082
+
5083
+ const rejectedId = await stageRuntimeImage(harness, bytes, "image-owner");
5084
+ expect(
5085
+ await harness.controlAs("image-owner", "turn.prompt", {
5086
+ text: "Rejected before admission.",
5087
+ clientRef: " ",
5088
+ stagedImages: [{ id: rejectedId }],
5089
+ }),
5090
+ ).toMatchObject({ ok: false, error: { code: "invalid_input" } });
5091
+ // Invalid clientRef admission must not consume or release staged bytes.
5092
+ expect(releaseCalls).toHaveLength(1);
5093
+ expect(await harness.controlAs("image-owner", "turn.image.discard", { id: rejectedId })).toMatchObject({
5094
+ ok: true,
5095
+ });
5096
+
5097
+ const disconnectedId = await stageRuntimeImage(harness, bytes, "image-owner");
5098
+ harness.disconnect("image-owner");
5099
+ expect(await harness.controlAs("image-owner", "turn.image.finish", { id: disconnectedId })).toMatchObject({
5100
+ ok: false,
5101
+ error: { code: "resource_gone" },
5102
+ });
5103
+ } finally {
5104
+ await session?.waitForIdle().catch(() => undefined);
5105
+ await harness?.stop();
5106
+ await session?.dispose();
5107
+ authStorage?.close();
5108
+ redeemSpy.mockRestore();
5109
+ await rm(cwd, { recursive: true, force: true });
5110
+ }
5111
+ });
5112
+
5113
+ test("SDK-only real queued image refusal before agent_start fails Q26 and retires only its reservation", async () => {
5114
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-real-queued-image-refusal-"));
5115
+ const before = getPromptImageProcessBudgetSnapshot();
5116
+ const entered = Promise.withResolvers<void>();
5117
+ const releasePredecessor = Promise.withResolvers<void>();
5118
+ const enqueueReturned = Promise.withResolvers<void>();
5119
+ const released = Promise.withResolvers<void>();
5120
+ let releases = 0;
5121
+ let providerCalls = 0;
5122
+ const actualRefusals: QueuedRunStartFailure[] = [];
5123
+ let session: AgentSession | undefined;
5124
+ let authStorage: AuthStorage | undefined;
5125
+ let harness: InvocationHarness | undefined;
5126
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5127
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5128
+ this: PromptImageUploadStore,
5129
+ owner,
5130
+ ids,
5131
+ ) {
5132
+ const reservation = originalRedeem.call(this, owner, ids);
5133
+ return {
5134
+ images: reservation.images,
5135
+ release: () => {
5136
+ releases++;
5137
+ reservation.release();
5138
+ released.resolve();
5139
+ },
5140
+ };
5141
+ });
5142
+ try {
5143
+ const real = await createTerminalizationSession(cwd, (model, context, options) => {
5144
+ const call = ++providerCalls;
5145
+ return createMockModel({
5146
+ responses: [
5147
+ async () => {
5148
+ if (call === 1) {
5149
+ entered.resolve();
5150
+ await releasePredecessor.promise;
5151
+ }
5152
+ return { content: ["independent completion"] };
5153
+ },
5154
+ ],
5155
+ }).stream(model, context, options);
5156
+ });
5157
+ session = real.session;
5158
+ authStorage = real.authStorage;
5159
+ const send = realSendUserMessage(session);
5160
+ harness = await invocationHarness("real-queued-image-refusal", cwd, {
5161
+ connectionLiveness: true,
5162
+ isIdle: () => !real.session.isStreaming,
5163
+ terminalAbortSeams: {
5164
+ getActivePromptHandle: () => real.session.activePromptHandle,
5165
+ waitForRunSettlement: handle =>
5166
+ real.session.agent.resourceLedger.waitForSettlement(handle, { graceMs: null }),
5167
+ },
5168
+ sendUserMessage: async (content, options) => {
5169
+ // Exercise the real follow-up queue with the staged-image SDK prompt.
5170
+ const image = Array.isArray(content) && content.some(block => block.type === "image");
5171
+ await send(
5172
+ content,
5173
+ image
5174
+ ? {
5175
+ ...options,
5176
+ deliverAs: "followUp",
5177
+ onQueuedRunStartFailed: failure => {
5178
+ actualRefusals.push(failure);
5179
+ options?.onQueuedRunStartFailed?.(failure);
5180
+ },
5181
+ }
5182
+ : options,
5183
+ );
5184
+ if (image) enqueueReturned.resolve();
5185
+ },
5186
+ });
5187
+ harness.connect("client");
5188
+ session.subscribe(event => harness?.emit(event.type, event));
5189
+ expect(await harness.control("turn.prompt", { text: "hold predecessor" })).toMatchObject({ ok: true });
5190
+ await entered.promise;
5191
+ const id = await stageRuntimeImage(harness, makeTestPng());
5192
+ const accepted = await harness.control("turn.prompt", {
5193
+ text: "queued image requires a model",
5194
+ stagedImages: [{ id }],
5195
+ clientRef: "real-refused-image",
5196
+ });
5197
+ expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
5198
+ await enqueueReturned.promise;
5199
+ expect(releases).toBe(0);
5200
+ expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBeGreaterThan(before.sourceBytes);
5201
+ session.agent.setModel(undefined);
5202
+ releasePredecessor.resolve();
5203
+ const failed = await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "real-refused-image" });
5204
+ expect(failed).toMatchObject({
5205
+ status: "failed",
5206
+ outcome: { kind: "failed" },
5207
+ error: { code: "internal", message: "Prompt submission failed." },
5208
+ });
5209
+ await released.promise;
5210
+ await session.waitForIdle();
5211
+ expect(actualRefusals).toHaveLength(1);
5212
+ expect(actualRefusals[0]).toMatchObject({
5213
+ disposition: "not-started",
5214
+ error: { message: "No model configured" },
5215
+ });
5216
+ expect(providerCalls).toBe(1);
5217
+ expect(releases).toBe(1);
5218
+ expect(getPromptImageProcessBudgetSnapshot()).toEqual(before);
5219
+ // The same session store can stage/discard bytes again after retirement.
5220
+ const laterImage = await stageRuntimeImage(harness, makeTestPng());
5221
+ expect(await harness.control("turn.image.discard", { id: laterImage })).toMatchObject({ ok: true });
5222
+ expect(getPromptImageProcessBudgetSnapshot()).toEqual(before);
5223
+ session.agent.setModel(real.model);
5224
+ const fresh = await harness.control("turn.prompt", { text: "restored model starts independent work" });
5225
+ expect(
5226
+ await settledStatus(harness, "turn.result", {
5227
+ kind: "prompt",
5228
+ commandId: fresh.result?.commandId,
5229
+ turnId: fresh.result?.turnId,
5230
+ }),
5231
+ ).toMatchObject({ status: "terminal_ok" });
5232
+ expect(await harness.query("turn.result", { kind: "prompt", clientRef: "real-refused-image" })).toMatchObject({
5233
+ result: { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId, status: "failed" },
5234
+ });
5235
+ expect(providerCalls).toBe(2);
5236
+ expect(releases).toBe(1);
5237
+ } finally {
5238
+ releasePredecessor.resolve();
5239
+ await session?.waitForIdle().catch(() => undefined);
5240
+ await harness?.stop();
5241
+ await session?.dispose();
5242
+ authStorage?.close();
5243
+ redeemSpy.mockRestore();
5244
+ await rm(cwd, { recursive: true, force: true });
5245
+ }
5246
+ });
5247
+
5248
+ test.each([
5249
+ "pending-producer",
5250
+ "returned-producer",
5251
+ "promoted-returned-producer",
5252
+ "post-start",
5253
+ "same-text-rejection",
5254
+ "late-refusal",
5255
+ ] as const)("SDK-only queued image refusal respects producer and execution authority (%s)", async mode => {
5256
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-refusal-authority-"));
5257
+ const before = getPromptImageProcessBudgetSnapshot();
5258
+ const producer = Promise.withResolvers<void>();
5259
+ const returned = Promise.withResolvers<void>();
5260
+ const released = Promise.withResolvers<void>();
5261
+ const proofRequested = Promise.withResolvers<void>();
5262
+ const settlement = Promise.withResolvers<{ status: "settled" }>();
5263
+ let callbacks: PreflightHooks | undefined;
5264
+ let releases = 0;
5265
+ const proofHandles: string[] = [];
5266
+ const cleanupReservations: Array<() => void> = [];
5267
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5268
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5269
+ this: PromptImageUploadStore,
5270
+ owner,
5271
+ ids,
5272
+ ) {
5273
+ const reservation = originalRedeem.call(this, owner, ids);
5274
+ cleanupReservations.push(reservation.release);
5275
+ return {
5276
+ images: reservation.images,
5277
+ release: () => {
5278
+ releases++;
5279
+ reservation.release();
5280
+ released.resolve();
5281
+ },
5282
+ };
5283
+ });
5284
+ let harness: InvocationHarness | undefined;
5285
+ try {
5286
+ harness = await invocationHarness(`image-refusal-${mode}`, cwd, {
5287
+ connectionLiveness: true,
5288
+ // Deliberately idle snapshot, then actual queue diversion.
5289
+ isIdle: () => true,
5290
+ terminalAbortSeams: {
5291
+ getActivePromptHandle: () => (mode === "post-start" ? "started-image-run" : undefined),
5292
+ waitForRunSettlement: handle => {
5293
+ proofHandles.push(handle);
5294
+ proofRequested.resolve();
5295
+ return settlement.promise;
5296
+ },
5297
+ },
5298
+ sendUserMessage: async (_content, options) => {
5299
+ callbacks = options;
5300
+ await options?.onPreflightAcceptCommit?.();
5301
+ options?.onDispatchDisposition?.({ startsOwnRun: false });
5302
+ try {
5303
+ await producer.promise;
5304
+ } finally {
5305
+ returned.resolve();
5306
+ }
5307
+ },
5308
+ });
5309
+ harness.connect("client");
5310
+ const id = await stageRuntimeImage(harness, makeTestPng());
5311
+ const accepted = await harness.control("turn.prompt", {
5312
+ text: "image queue authority",
5313
+ stagedImages: [{ id }],
5314
+ clientRef: `authority-${mode}`,
5315
+ });
5316
+ expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
5317
+ if (mode === "promoted-returned-producer") callbacks?.onQueuedPromoted?.({ startsOwnRun: true });
5318
+ if (mode === "returned-producer" || mode === "promoted-returned-producer") {
5319
+ producer.resolve();
5320
+ await returned.promise;
5321
+ // Yield through the submit.then fallback: the diverted idle snapshot is not completion.
5322
+ expect(await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` })).toMatchObject({
5323
+ result: { status: "accepted" },
5324
+ });
5325
+ }
5326
+ if (mode !== "promoted-returned-producer") callbacks?.onQueuedPromoted?.({ startsOwnRun: true });
5327
+ if (mode === "post-start") await harness.emit("agent_start");
5328
+ const failure: QueuedRunStartFailure = { disposition: "not-started", error: new Error("No model configured") };
5329
+ if (mode === "same-text-rejection" || mode === "late-refusal") {
5330
+ producer.reject(
5331
+ Object.assign(
5332
+ new Error("No model configured"),
5333
+ mode === "late-refusal" ? { code: "original_failure" } : {},
5334
+ ),
5335
+ );
5336
+ } else {
5337
+ callbacks?.onQueuedRunStartFailed?.(failure);
5338
+ callbacks?.onQueuedRunStartFailed?.(failure);
5339
+ }
5340
+ if (mode === "post-start") {
5341
+ expect(await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` })).toMatchObject({
5342
+ result: { status: "in_flight" },
5343
+ });
5344
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "done" }] });
5345
+ await proofRequested.promise;
5346
+ expect(proofHandles).toEqual(["started-image-run"]);
5347
+ expect(releases).toBe(0);
5348
+ settlement.resolve({ status: "settled" });
5349
+ } else {
5350
+ expect(
5351
+ await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: `authority-${mode}` }),
5352
+ ).toMatchObject({
5353
+ status: "failed",
5354
+ outcome: { kind: "failed" },
5355
+ error: {
5356
+ code: mode === "late-refusal" ? "original_failure" : "internal",
5357
+ message: "Prompt submission failed.",
5358
+ },
5359
+ });
5360
+ if (mode !== "returned-producer" && mode !== "promoted-returned-producer") {
5361
+ expect(releases).toBe(0);
5362
+ expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBeGreaterThan(before.sourceBytes);
5363
+ }
5364
+ }
5365
+ producer.resolve();
5366
+ await returned.promise;
5367
+ if (mode === "late-refusal") callbacks?.onQueuedRunStartFailed?.(failure);
5368
+ if (mode === "same-text-rejection") {
5369
+ // A text-identical generic rejection is not proof; teardown cannot release either.
5370
+ await harness.stop();
5371
+ expect(releases).toBe(0);
5372
+ expect(proofHandles).toEqual([]);
5373
+ } else {
5374
+ await released.promise;
5375
+ expect(releases).toBe(1);
5376
+ expect(getPromptImageProcessBudgetSnapshot()).toEqual(before);
5377
+ callbacks?.onQueuedRunStartFailed?.(failure);
5378
+ if (mode !== "post-start") callbacks?.onQueuedPromoted?.({ startsOwnRun: true });
5379
+ await harness.emit("agent_start");
5380
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "later" }] });
5381
+ expect(await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` })).toMatchObject({
5382
+ result: { status: mode === "post-start" ? "terminal_ok" : "failed" },
5383
+ });
5384
+ expect(releases).toBe(1);
5385
+ if (mode === "late-refusal")
5386
+ expect(
5387
+ await harness.query("turn.result", { kind: "prompt", clientRef: `authority-${mode}` }),
5388
+ ).toMatchObject({ result: { error: { code: "original_failure" } } });
5389
+ }
5390
+ } finally {
5391
+ producer.resolve();
5392
+ settlement.resolve({ status: "settled" });
5393
+ await harness?.stop().catch(() => undefined);
5394
+ for (const release of cleanupReservations) release();
5395
+ redeemSpy.mockRestore();
5396
+ await rm(cwd, { recursive: true, force: true });
5397
+ }
5398
+ });
5399
+
5400
+ test("SDK-only staged prompts reconcile clientRef before redeeming during preflight and in-flight execution", async () => {
5401
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-staged-client-ref-"));
5402
+ const allowAcceptance = Promise.withResolvers<void>();
5403
+ const producer = Promise.withResolvers<void>();
5404
+ const entered = Promise.withResolvers<void>();
5405
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5406
+ let releases = 0;
5407
+ let sends = 0;
5408
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5409
+ this: PromptImageUploadStore,
5410
+ owner,
5411
+ ids,
5412
+ ) {
5413
+ const reservation = originalRedeem.call(this, owner, ids);
5414
+ return {
5415
+ images: reservation.images,
5416
+ release: () => {
5417
+ releases++;
5418
+ reservation.release();
5419
+ },
5420
+ };
5421
+ });
5422
+ let harness: InvocationHarness | undefined;
5423
+ try {
5424
+ harness = await invocationHarness("staged-client-ref", cwd, {
5425
+ connectionLiveness: true,
5426
+ sendUserMessage: async (_content, options) => {
5427
+ sends++;
5428
+ entered.resolve();
5429
+ await allowAcceptance.promise;
5430
+ await options?.onPreflightAcceptCommit?.();
5431
+ await producer.promise;
5432
+ },
5433
+ });
5434
+ harness.connect("image-owner");
5435
+ harness.connect("reconnecting-client");
5436
+ const id = await stageRuntimeImage(harness, makeTestPng(), "image-owner");
5437
+ const input = { text: "Original staged producer", clientRef: "staged-ref", stagedImages: [{ id }] };
5438
+ const first = harness.controlAs("image-owner", "turn.prompt", input);
5439
+ void first.catch(() => {});
5440
+ await entered.promise;
5441
+ // Ordered controls serialize per session, even on separate connections.
5442
+ // A retry arriving during preflight cannot finish before the first receipt.
5443
+ const duplicate = harness.controlAs("reconnecting-client", "turn.prompt", input);
5444
+ void duplicate.catch(() => {});
5445
+ expect(redeemSpy.mock.calls).toHaveLength(1);
5446
+ expect(sends).toBe(1);
5447
+ expect(releases).toBe(0);
5448
+ allowAcceptance.resolve();
5449
+ const accepted = await first;
5450
+ expect(accepted).toMatchObject({ ok: true, result: { accepted: true, clientRef: "staged-ref" } });
5451
+ expect(await duplicate).toMatchObject({ ok: false, error: { code: "client_ref_conflict" } });
5452
+ await harness.emit("agent_start");
5453
+ // The original receipt may have been lost. Reconnection never replays it,
5454
+ // and cannot replace the original producer's retained image ownership.
5455
+ expect(await harness.controlAs("image-owner", "turn.prompt", input)).toMatchObject({
5456
+ ok: false,
5457
+ error: { code: "client_ref_conflict" },
5458
+ });
5459
+ expect(await harness.query("turn.result", { kind: "prompt", clientRef: "staged-ref" })).toMatchObject({
5460
+ result: { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId, status: "in_flight" },
5461
+ });
5462
+ expect(redeemSpy.mock.calls).toHaveLength(1);
5463
+ expect(sends).toBe(1);
5464
+ expect(releases).toBe(0);
5465
+ await harness.emit("agent_end", {
5466
+ messages: [{ role: "assistant", stopReason: "stop", content: "done" }],
5467
+ });
5468
+ producer.resolve();
5469
+ await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "staged-ref" });
5470
+ expect(await harness.controlAs("image-owner", "turn.prompt", input)).toMatchObject({
5471
+ ok: false,
5472
+ error: { code: "client_ref_conflict" },
5473
+ });
5474
+ expect(redeemSpy.mock.calls).toHaveLength(1);
5475
+ expect(sends).toBe(1);
5476
+ } finally {
5477
+ allowAcceptance.resolve();
5478
+ producer.resolve();
5479
+ try {
5480
+ await harness?.stop();
5481
+ } finally {
5482
+ redeemSpy.mockRestore();
5483
+ await rm(cwd, { recursive: true, force: true });
5484
+ }
5485
+ }
5486
+ });
5487
+
5488
+ test("SDK-only staged image reservations follow the exact consuming run across disconnect and unrelated end", async () => {
5489
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-staged-image-run-owner-"));
5490
+ const bytes = makeTestPng();
5491
+ const releases: string[] = [];
5492
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5493
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5494
+ this: PromptImageUploadStore,
5495
+ owner,
5496
+ ids,
5497
+ ) {
5498
+ const reservation = originalRedeem.call(this, owner, ids);
5499
+ return {
5500
+ images: reservation.images,
5501
+ release: () => {
5502
+ releases.push("released");
5503
+ reservation.release();
5504
+ },
5505
+ };
5506
+ });
5507
+ let harness: InvocationHarness | undefined;
5508
+ const imagePromotions: Array<PreflightHooks["onQueuedPromoted"]> = [];
5509
+ let promptDeadlineMs = 60_000;
5510
+ try {
5511
+ harness = await invocationHarness("staged-image-run-owner", cwd, {
5512
+ connectionLiveness: true,
5513
+ settings: {
5514
+ get: (key: string) =>
5515
+ key === "sdk.promptDeadlineMs"
5516
+ ? promptDeadlineMs
5517
+ : key === "sdk.promptMaxRuntimeMs"
5518
+ ? 60_000
5519
+ : undefined,
5520
+ } as unknown as Settings,
5521
+ sendUserMessage: async (content, options) => {
5522
+ await options?.onPreflightAcceptCommit?.();
5523
+ if (content === "root run") await neverSettlingPromise();
5524
+ if (options?.deliverAs === "followUp") {
5525
+ options.onDispatchDisposition?.({ startsOwnRun: false });
5526
+ options.preflightSignal?.addEventListener("abort", () => options.onQueuedPromoted?.({ removed: true }), {
5527
+ once: true,
5528
+ });
5529
+ return;
5530
+ }
5531
+ if (Array.isArray(content)) {
5532
+ expect(
5533
+ content.some(block => typeof block === "object" && block !== null && block.type === "image"),
5534
+ ).toBe(true);
5535
+ options?.onDispatchDisposition?.({ startsOwnRun: false });
5536
+ imagePromotions.push(options?.onQueuedPromoted);
5537
+ }
5538
+ },
5539
+ });
5540
+ harness.connect("root-owner");
5541
+ harness.connect("image-owner");
5542
+ harness.connect("cancel-owner");
5543
+ const root = await harness.controlAs("root-owner", "turn.prompt", { text: "root run" });
5544
+ expect(root).toMatchObject({ ok: true, result: { accepted: true } });
5545
+ await harness.emit("agent_start");
5546
+ const id = await stageRuntimeImage(harness, bytes, "image-owner");
5547
+ const image = await harness.controlAs("image-owner", "turn.prompt", {
5548
+ text: "queued image run",
5549
+ stagedImages: [{ id }],
5550
+ });
5551
+ expect(image).toMatchObject({ ok: true, result: { accepted: true } });
5552
+ const correlation = { commandId: image.result?.commandId, turnId: image.result?.turnId };
5553
+ const cancelledId = await stageRuntimeImage(harness, bytes, "cancel-owner");
5554
+ const cancelled = await harness.controlAs("cancel-owner", "turn.prompt", {
5555
+ text: "queued image that will be removed",
5556
+ stagedImages: [{ id: cancelledId }],
5557
+ });
5558
+ expect(cancelled).toMatchObject({ ok: true, result: { accepted: true } });
5559
+ const cancelledCorrelation = { commandId: cancelled.result?.commandId, turnId: cancelled.result?.turnId };
5560
+ expect(imagePromotions).toHaveLength(2);
5561
+ expect(releases).toEqual([]);
5562
+
5563
+ // The stage lease was redeemed already. Losing its source connection and
5564
+ // completing the unrelated root run must not release quota owned by the
5565
+ // still-queued image submission.
5566
+ harness.disconnect("image-owner");
5567
+ await harness.emit("agent_end", {
5568
+ messages: [{ role: "assistant", stopReason: "stop", content: "root finished" }],
5569
+ });
5570
+ expect(releases).toEqual([]);
5571
+
5572
+ imagePromotions[1]?.({ startsOwnRun: false, removed: true });
5573
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...cancelledCorrelation })).toMatchObject({
5574
+ status: "failed",
5575
+ error: { code: "cancelled" },
5576
+ });
5577
+ expect(releases).toEqual(["released"]);
5578
+
5579
+ imagePromotions[0]?.({ startsOwnRun: true });
5580
+ await harness.emit("agent_start");
5581
+ expect(releases).toEqual(["released"]);
5582
+ promptDeadlineMs = 25;
5583
+ const unrelatedFollowUp = await harness.controlAs("cancel-owner", "turn.follow_up", {
5584
+ text: "unrelated follow-up past its admission deadline",
5585
+ });
5586
+ expect(unrelatedFollowUp).toMatchObject({ ok: true, result: { accepted: true } });
5587
+ await Bun.sleep(100);
5588
+ expect(
5589
+ await harness.query("turn.result", {
5590
+ kind: "prompt",
5591
+ commandId: unrelatedFollowUp.result?.commandId,
5592
+ turnId: unrelatedFollowUp.result?.turnId,
5593
+ }),
5594
+ ).toMatchObject({ result: { status: "accepted" } });
5595
+ expect(releases).toEqual(["released"]);
5596
+ await harness.emit("agent_end", {
5597
+ messages: [{ role: "assistant", stopReason: "stop", content: "image finished" }],
5598
+ });
5599
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
5600
+ status: "terminal_ok",
5601
+ });
5602
+ expect(releases).toEqual(["released", "released"]);
5603
+ } finally {
5604
+ try {
5605
+ await harness?.stop();
5606
+ } finally {
5607
+ redeemSpy.mockRestore();
5608
+ await rm(cwd, { recursive: true, force: true });
5609
+ }
5610
+ }
5611
+ });
5612
+
5613
+ test("SDK-only terminal preflight abort retains staged-image quota until submission settlement", async () => {
5614
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-preflight-abort-"));
5615
+ const sessionId = "image-preflight-abort";
5616
+ const sessionFile = path.join(cwd, "session.json");
5617
+ await Bun.write(sessionFile, "");
5618
+ const reconciliationStore = createReconciliationStore({ sessionFile, sessionId });
5619
+ const producer = Promise.withResolvers<void>();
5620
+ const submissionStarted = Promise.withResolvers<void>();
5621
+ const preflightAborted = Promise.withResolvers<void>();
5622
+ const released = Promise.withResolvers<void>();
5623
+ let releases = 0;
5624
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5625
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5626
+ this: PromptImageUploadStore,
5627
+ owner,
5628
+ ids,
5629
+ ) {
5630
+ const reservation = originalRedeem.call(this, owner, ids);
5631
+ return {
5632
+ images: reservation.images,
5633
+ release: () => {
5634
+ releases++;
5635
+ reservation.release();
5636
+ released.resolve();
5637
+ },
5638
+ };
5639
+ });
5640
+ let harness: InvocationHarness | undefined;
5641
+ try {
5642
+ harness = await invocationHarness(sessionId, cwd, {
5643
+ connectionLiveness: true,
5644
+ terminalAbortSeams: {
5645
+ getReconciliationStore: () => reconciliationStore,
5646
+ getTerminalTurnEpoch: () => undefined,
5647
+ getActivePromptHandle: () => undefined,
5648
+ cancelPendingPreflightForTerminalAbort: () => {},
5649
+ abortPromptAndWaitWithTerminal: async () => {
5650
+ throw new Error("idle preflight abort must not stop an active run");
5651
+ },
5652
+ },
5653
+ sendUserMessage: async (content, options) => {
5654
+ expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
5655
+ options?.preflightSignal?.addEventListener("abort", () => preflightAborted.resolve(), { once: true });
5656
+ submissionStarted.resolve();
5657
+ await producer.promise;
5658
+ expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
5659
+ },
5660
+ });
5661
+ harness.connect("client");
5662
+ const id = await stageRuntimeImage(harness, makeTestPng());
5663
+ const prompt = harness.control("turn.prompt", {
5664
+ text: "hold the pending staged-image producer",
5665
+ stagedImages: [{ id }],
5666
+ });
5667
+ await submissionStarted.promise;
5668
+ const abort = await harness.controlAs("client", "turn.abort", { mode: "terminal" }, "image-preflight-abort-key");
5669
+ expect(abort).toMatchObject({
5670
+ ok: true,
5671
+ result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
5672
+ });
5673
+ expect(await prompt).toMatchObject({ ok: false, error: { code: "busy" } });
5674
+ await preflightAborted.promise;
5675
+ expect(releases).toBe(0);
5676
+
5677
+ producer.resolve();
5678
+ await released.promise;
5679
+ expect(releases).toBe(1);
5680
+ } finally {
5681
+ producer.resolve();
5682
+ try {
5683
+ await harness?.stop();
5684
+ } finally {
5685
+ redeemSpy.mockRestore();
5686
+ await rm(cwd, { recursive: true, force: true });
5687
+ }
5688
+ }
5689
+ });
5690
+
5691
+ test("SDK-only shutdown after acceptance retains unadopted staged-image quota until submission settlement", async () => {
5692
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-accepted-unadopted-"));
5693
+ const producer = Promise.withResolvers<void>();
5694
+ const released = Promise.withResolvers<void>();
5695
+ let releases = 0;
5696
+ let preflightSignal: AbortSignal | undefined;
5697
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5698
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5699
+ this: PromptImageUploadStore,
5700
+ owner,
5701
+ ids,
5702
+ ) {
5703
+ const reservation = originalRedeem.call(this, owner, ids);
5704
+ return {
5705
+ images: reservation.images,
5706
+ release: () => {
5707
+ releases++;
5708
+ reservation.release();
5709
+ released.resolve();
5710
+ },
5711
+ };
5712
+ });
5713
+ let harness: InvocationHarness | undefined;
5714
+ try {
5715
+ harness = await invocationHarness("image-accepted-unadopted", cwd, {
5716
+ connectionLiveness: true,
5717
+ sendUserMessage: async (content, options) => {
5718
+ preflightSignal = options?.preflightSignal;
5719
+ await options?.onPreflightAcceptCommit?.();
5720
+ await producer.promise;
5721
+ expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
5722
+ },
5723
+ });
5724
+ harness.connect("client");
5725
+ const id = await stageRuntimeImage(harness, makeTestPng());
5726
+ const accepted = await harness.control("turn.prompt", {
5727
+ text: "accepted producer has not adopted an execution",
5728
+ stagedImages: [{ id }],
5729
+ });
5730
+ expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
5731
+ expect(releases).toBe(0);
5732
+ await harness.stop();
5733
+ expect(preflightSignal?.aborted).toBe(true);
5734
+ expect(releases).toBe(0);
5735
+ producer.resolve();
5736
+ await released.promise;
5737
+ expect(releases).toBe(1);
5738
+ } finally {
5739
+ producer.resolve();
5740
+ try {
5741
+ await harness?.stop();
5742
+ } finally {
5743
+ redeemSpy.mockRestore();
5744
+ await rm(cwd, { recursive: true, force: true });
5745
+ }
5746
+ }
5747
+ });
5748
+
5749
+ test("SDK-only shutdown during durable preflight acceptance retains staged-image quota until submission settlement", async () => {
5750
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-shutdown-preflight-"));
5751
+ const sessionId = "image-shutdown-preflight";
5752
+ const sessionFile = path.join(cwd, "session.json");
5753
+ await Bun.write(sessionFile, "");
5754
+ const store = createReconciliationStore({ sessionFile, sessionId });
5755
+ const clientRef = "image-shutdown-preflight-client";
5756
+ const producer = Promise.withResolvers<void>();
5757
+ const acceptanceHeld = Promise.withResolvers<void>();
5758
+ const resumeAcceptance = Promise.withResolvers<void>();
5759
+ const acceptanceResumed = Promise.withResolvers<void>();
5760
+ const submissionFinished = Promise.withResolvers<void>();
5761
+ const released = Promise.withResolvers<void>();
5762
+ let submissionEntered = false;
5763
+ let held = false;
5764
+ let releases = 0;
5765
+ const originalRename = fsPromises.rename;
5766
+ const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
5767
+ if (!held && String(to) === store.path) {
5768
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
5769
+ if (document.records.some(record => record.clientRef === clientRef && record.status === "accepted")) {
5770
+ held = true;
5771
+ acceptanceHeld.resolve();
5772
+ await resumeAcceptance.promise;
5773
+ }
5774
+ }
5775
+ await originalRename(from, to);
5776
+ });
5777
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5778
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5779
+ this: PromptImageUploadStore,
5780
+ owner,
5781
+ ids,
5782
+ ) {
5783
+ const reservation = originalRedeem.call(this, owner, ids);
5784
+ return {
5785
+ images: reservation.images,
5786
+ release: () => {
5787
+ releases++;
5788
+ reservation.release();
5789
+ released.resolve();
5790
+ },
5791
+ };
5792
+ });
5793
+ let harness: InvocationHarness | undefined;
5794
+ let preflightSignal: AbortSignal | undefined;
5795
+ try {
5796
+ harness = await invocationHarness(sessionId, cwd, {
5797
+ connectionLiveness: true,
5798
+ terminalAbortSeams: { getReconciliationStore: () => store },
5799
+ sendUserMessage: async (content, options) => {
5800
+ expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
5801
+ submissionEntered = true;
5802
+ preflightSignal = options?.preflightSignal;
5803
+ await options?.onPreflightAcceptCommit?.();
5804
+ acceptanceResumed.resolve();
5805
+ await producer.promise;
5806
+ submissionFinished.resolve();
5807
+ expect(Array.isArray(content) && content.some(block => block.type === "image")).toBe(true);
5808
+ },
5809
+ });
5810
+ harness.connect("client");
5811
+ const id = await stageRuntimeImage(harness, makeTestPng());
5812
+ void harness
5813
+ .control("turn.prompt", {
5814
+ text: "hold the staged-image producer through shutdown acceptance",
5815
+ stagedImages: [{ id }],
5816
+ clientRef,
5817
+ })
5818
+ .catch(() => undefined);
5819
+ await acceptanceHeld.promise;
5820
+ expect(releases).toBe(0);
5821
+
5822
+ await harness.stop();
5823
+ expect(preflightSignal?.aborted).toBe(true);
5824
+ expect(releases).toBe(0);
5825
+
5826
+ resumeAcceptance.resolve();
5827
+ await acceptanceResumed.promise;
5828
+ expect(releases).toBe(0);
5829
+ producer.resolve();
5830
+ await submissionFinished.promise;
5831
+ await released.promise;
5832
+ expect(releases).toBe(1);
5833
+ } finally {
5834
+ resumeAcceptance.resolve();
5835
+ producer.resolve();
5836
+ if (submissionEntered) await submissionFinished.promise;
5837
+ await harness?.stop().catch(() => undefined);
5838
+ redeemSpy.mockRestore();
5839
+ renameSpy.mockRestore();
5840
+ await rm(cwd, { recursive: true, force: true });
5841
+ }
5842
+ });
5843
+
5844
+ test("SDK-only queued image promotion retains quota through shutdown until exact-run settlement proof", async () => {
5845
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-queued-adoption-"));
5846
+ const proofAttempted = Promise.withResolvers<void>();
5847
+ const released = Promise.withResolvers<void>();
5848
+ const promotionReady = Promise.withResolvers<void>();
5849
+ let activeHandle: string | undefined;
5850
+ const settlement = Promise.withResolvers<{ status: "settled" }>();
5851
+ let releases = 0;
5852
+ let promoted: PreflightHooks["onQueuedPromoted"];
5853
+ const proofHandles: string[] = [];
5854
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
5855
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5856
+ this: PromptImageUploadStore,
5857
+ owner,
5858
+ ids,
5859
+ ) {
5860
+ const reservation = originalRedeem.call(this, owner, ids);
5861
+ return {
5862
+ images: reservation.images,
5863
+ release: () => {
5864
+ releases++;
5865
+ reservation.release();
5866
+ released.resolve();
5867
+ },
5868
+ };
4690
5869
  });
4691
- const ctx = {
4692
- cwd,
4693
- workflowGate: undefined,
4694
- sdkBindings: () => (hooks.invokeSkill ? ["invokeSkill"] : []),
4695
- isIdle: hooks.isIdle ?? (() => true),
4696
- abort: hooks.abort ?? (() => {}),
4697
- ...(hooks.invokeSkill ? { invokeSkill: hooks.invokeSkill } : {}),
4698
- sessionManager: {
4699
- getSessionId: () => sessionId,
4700
- getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
4701
- getSessionName: () => undefined,
4702
- getBranch: () => hooks.branch ?? [],
4703
- },
4704
- };
4705
- await handlers.get("session_start")?.({}, ctx);
4706
- const request = (frame: Record<string, unknown>, connectionId = "client"): Promise<ResponseFrame> => {
4707
- const id = `frame-${nextId}`;
4708
- nextId += 1;
4709
- const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
4710
- waiters.set(id, resolve);
4711
- deliver?.(connectionId, { ...frame, id } as SdkFrame);
4712
- return promise;
4713
- };
4714
- return {
4715
- control: (operation, input) => request({ type: "control_request", operation, input }),
4716
- controlAs: (connectionId, operation, input, idempotencyKey) =>
4717
- request(
4718
- {
4719
- type: "control_request",
4720
- operation,
4721
- input,
4722
- ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
4723
- },
4724
- connectionId,
4725
- ),
4726
- query: (name, input) => request({ type: "query_request", query: name, input }),
4727
- broadcasts,
4728
- emit: async (event, payload) => {
4729
- await handlers.get(event)?.(payload ?? {}, ctx);
4730
- },
4731
- stop: async () => {
4732
- await handlers.get("session_shutdown")?.({}, ctx);
4733
- },
4734
- switchSession: async sessionId => {
4735
- await handlers.get("session_switch")?.(
4736
- {},
4737
- {
4738
- ...ctx,
4739
- sessionManager: {
4740
- ...ctx.sessionManager,
4741
- getSessionId: () => sessionId,
4742
- getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
4743
- },
5870
+ let harness: InvocationHarness | undefined;
5871
+ try {
5872
+ harness = await invocationHarness("queued-image-adoption", cwd, {
5873
+ connectionLiveness: true,
5874
+ isIdle: () => false,
5875
+ terminalAbortSeams: {
5876
+ getActivePromptHandle: () => activeHandle,
5877
+ waitForRunSettlement: handle => {
5878
+ proofHandles.push(handle);
5879
+ proofAttempted.resolve();
5880
+ return settlement.promise;
4744
5881
  },
4745
- );
4746
- },
4747
- branch: async sessionId => {
4748
- await handlers.get("session_branch")?.(
4749
- {},
4750
- {
4751
- ...ctx,
4752
- sessionManager: {
4753
- ...ctx.sessionManager,
4754
- getSessionId: () => sessionId,
4755
- getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
4756
- },
5882
+ },
5883
+ sendUserMessage: async (content, options) => {
5884
+ await options?.onPreflightAcceptCommit?.();
5885
+ if (!Array.isArray(content) || !content.some(block => block.type === "image"))
5886
+ throw new Error("Expected the queued submission to retain its staged image.");
5887
+ options?.onDispatchDisposition?.({ startsOwnRun: false });
5888
+ promoted = options?.onQueuedPromoted;
5889
+ promotionReady.resolve();
5890
+ },
5891
+ });
5892
+ harness.connect("client");
5893
+ const id = await stageRuntimeImage(harness, makeTestPng());
5894
+ expect(
5895
+ await harness.control("turn.prompt", {
5896
+ text: "queue image work for a later own-run promotion",
5897
+ stagedImages: [{ id }],
5898
+ }),
5899
+ ).toMatchObject({ ok: true, result: { accepted: true } });
5900
+ await promotionReady.promise;
5901
+ expect(promoted).toBeDefined();
5902
+ promoted?.({ startsOwnRun: true });
5903
+ await harness.stop();
5904
+ expect(releases).toBe(0);
5905
+
5906
+ activeHandle = "queued-image-run";
5907
+ await harness.emit("agent_start");
5908
+ await proofAttempted.promise;
5909
+ expect(proofHandles).toEqual(["queued-image-run"]);
5910
+ expect(releases).toBe(0);
5911
+
5912
+ await harness.stop();
5913
+ expect(proofHandles).toEqual(["queued-image-run"]);
5914
+ settlement.resolve({ status: "settled" });
5915
+ await released.promise;
5916
+ expect(releases).toBe(1);
5917
+ } finally {
5918
+ settlement.resolve({ status: "settled" });
5919
+ await harness?.stop().catch(() => undefined);
5920
+ redeemSpy.mockRestore();
5921
+ await rm(cwd, { recursive: true, force: true });
5922
+ }
5923
+ });
5924
+
5925
+ test.each([
5926
+ "pending",
5927
+ "slow-pending",
5928
+ "shutdown-slow-pending",
5929
+ "quarantined",
5930
+ "held-start-pending",
5931
+ "held-start-quarantined",
5932
+ "held-start-missing-handle",
5933
+ ] as const)("SDK-only forced image terminals retain unsettled producer capacity (%s)", async mode => {
5934
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-forced-"));
5935
+ const sourceBytesBefore = getPromptImageProcessBudgetSnapshot().sourceBytes;
5936
+ const ledger = createRunResourceLedger();
5937
+ ledger.open("image-run");
5938
+ const tool = Promise.withResolvers<void>();
5939
+ const run = Promise.withResolvers<void>();
5940
+ const released = Promise.withResolvers<void>();
5941
+ const startEntered = Promise.withResolvers<void>();
5942
+ const releaseStart = Promise.withResolvers<void>();
5943
+ const holdStart = mode.startsWith("held-start");
5944
+ const proofAttempted = Promise.withResolvers<void>();
5945
+ const proofHandles: string[] = [];
5946
+ let drainTimeouts = 0;
5947
+ ledger.track("image-run", "tool", "abort-ignoring-tool", tool.promise);
5948
+ let releases = 0;
5949
+ const cleanupReservations: Array<() => void> = [];
5950
+ const original = PromptImageUploadStore.prototype.redeem;
5951
+ const spy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
5952
+ this: PromptImageUploadStore,
5953
+ owner,
5954
+ ids,
5955
+ ) {
5956
+ const reservation = original.call(this, owner, ids);
5957
+ cleanupReservations.push(reservation.release);
5958
+ return {
5959
+ images: reservation.images,
5960
+ release: () => {
5961
+ releases++;
5962
+ reservation.release();
5963
+ released.resolve();
5964
+ },
5965
+ };
5966
+ });
5967
+ let harness: InvocationHarness | undefined;
5968
+ try {
5969
+ harness = await invocationHarness(`image-forced-${mode}`, cwd, {
5970
+ connectionLiveness: true,
5971
+ ...(holdStart
5972
+ ? {
5973
+ persistInterceptor: () => {},
5974
+ persistHold: { type: "agent_start", onEntered: startEntered.resolve, release: releaseStart.promise },
5975
+ onLifecycleDrainTimeout: () => {
5976
+ drainTimeouts++;
5977
+ },
5978
+ }
5979
+ : {}),
5980
+ terminalAbortSeams: {
5981
+ getActivePromptHandle: () => (mode.endsWith("missing-handle") ? undefined : "image-run"),
5982
+ waitForRunSettlement: handle => {
5983
+ proofHandles.push(handle);
5984
+ proofAttempted.resolve();
5985
+ return ledger.waitForSettlement(handle, { graceMs: null });
4757
5986
  },
5987
+ },
5988
+ sendUserMessage: async (_content, options) => {
5989
+ await options?.onPreflightAcceptCommit?.();
5990
+ await run.promise;
5991
+ },
5992
+ });
5993
+ harness.connect("client");
5994
+ const id = await stageRuntimeImage(harness, makeTestPng());
5995
+ expect(
5996
+ await harness.control("turn.prompt", { text: "hold the exact producer", stagedImages: [{ id }] }),
5997
+ ).toMatchObject({ ok: true });
5998
+ await harness.emit("agent_start");
5999
+ if (holdStart) await startEntered.promise;
6000
+ harness.disconnect("client");
6001
+ ledger.seal("image-run");
6002
+ if (mode.endsWith("quarantined")) ledger.quarantine("image-run");
6003
+ if (holdStart) {
6004
+ await harness.stop();
6005
+ expect(drainTimeouts).toBe(1);
6006
+ expect(releases).toBe(0);
6007
+ releaseStart.resolve();
6008
+ } else await harness.emit("agent_end", { stopReason: "cancelled" });
6009
+ if (mode.includes("slow")) {
6010
+ await proofAttempted.promise;
6011
+ const chargedBytes = getPromptImageProcessBudgetSnapshot().sourceBytes;
6012
+ expect(chargedBytes).toBeGreaterThan(sourceBytesBefore);
6013
+ await harness.emit("agent_end", { stopReason: "cancelled" });
6014
+ if (mode.startsWith("shutdown")) {
6015
+ run.resolve();
6016
+ await harness.stop();
6017
+ await harness.stop();
6018
+ }
6019
+ // Exceed the retired helper's entire 100 x 100 ms polling window.
6020
+ await Bun.sleep(10_100);
6021
+ expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBe(chargedBytes);
6022
+ expect(proofHandles).toEqual(["image-run"]);
6023
+ } else await Bun.sleep(150);
6024
+ expect(releases).toBe(0);
6025
+ tool.resolve();
6026
+ if (mode.endsWith("pending")) {
6027
+ await released.promise;
6028
+ expect(releases).toBe(1);
6029
+ if (mode.includes("slow")) {
6030
+ expect(getPromptImageProcessBudgetSnapshot().sourceBytes).toBe(sourceBytesBefore);
6031
+ await harness.stop();
6032
+ expect(proofHandles).toEqual(["image-run"]);
6033
+ expect(releases).toBe(1);
6034
+ }
6035
+ } else {
6036
+ await harness.stop();
6037
+ await Bun.sleep(150);
6038
+ expect(releases).toBe(0);
6039
+ expect(await ledger.waitForSettlement("image-run", { graceMs: 0 })).toMatchObject(
6040
+ mode.endsWith("quarantined") ? { status: "unfenced", reason: "quarantined" } : { status: "settled" },
4758
6041
  );
4759
- },
4760
- requestOnSession: (sessionId, frame) => {
4761
- const id = `frame-${nextId}`;
4762
- nextId += 1;
4763
- const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
4764
- waiters.set(id, resolve);
4765
- deliveries.get(sessionId)?.("client", { ...frame, id } as SdkFrame);
4766
- return promise;
4767
- },
4768
- sendToSession: (sessionId, frame) => {
4769
- deliveries.get(sessionId)?.("client", frame as SdkFrame);
4770
- },
4771
- sent: sessionId => sentFrames.get(sessionId) ?? [],
4772
- };
4773
- }
6042
+ }
6043
+ } finally {
6044
+ releaseStart.resolve();
6045
+ tool.resolve();
6046
+ run.resolve();
6047
+ try {
6048
+ await harness?.stop();
6049
+ } finally {
6050
+ spy.mockRestore();
6051
+ for (const release of cleanupReservations) release();
6052
+ await rm(cwd, { recursive: true, force: true });
6053
+ }
6054
+ }
6055
+ });
6056
+
6057
+ test("SDK-only failed queued cancellation retains the predecessor image cleanup owner for retry", async () => {
6058
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-image-cleanup-retry-"));
6059
+ let failTerminal = true;
6060
+ let deadlineMs = 60_000;
6061
+ let releases = 0;
6062
+ let queued: { commandId?: unknown; turnId?: unknown } | undefined;
6063
+ const producer = Promise.withResolvers<void>();
6064
+ const released = Promise.withResolvers<void>();
6065
+ const repaired = Promise.withResolvers<void>();
6066
+ const original = PromptImageUploadStore.prototype.redeem;
6067
+ const spy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
6068
+ this: PromptImageUploadStore,
6069
+ owner,
6070
+ ids,
6071
+ ) {
6072
+ const reservation = original.call(this, owner, ids);
6073
+ return {
6074
+ images: reservation.images,
6075
+ release: () => {
6076
+ releases++;
6077
+ reservation.release();
6078
+ released.resolve();
6079
+ },
6080
+ };
6081
+ });
6082
+ let harness: InvocationHarness | undefined;
6083
+ try {
6084
+ harness = await invocationHarness("image-cleanup-retry", cwd, {
6085
+ connectionLiveness: true,
6086
+ settings: {
6087
+ get: (key: string) =>
6088
+ key === "sdk.promptDeadlineMs" ? deadlineMs : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
6089
+ } as unknown as Settings,
6090
+ terminalAbortSeams: {
6091
+ getActivePromptHandle: () => undefined,
6092
+ },
6093
+ persistInterceptor: transition => {
6094
+ if (failTerminal && transition.type === "agent_end") throw new Error("held queue terminal persistence");
6095
+ },
6096
+ onDurableAttempt: attempt => {
6097
+ if (
6098
+ !failTerminal &&
6099
+ attempt.type === "agent_end" &&
6100
+ attempt.outcome === "committed" &&
6101
+ attempt.terminalAt !== undefined &&
6102
+ attempt.commandId === queued?.commandId &&
6103
+ attempt.turnId === queued?.turnId
6104
+ )
6105
+ repaired.resolve();
6106
+ },
6107
+ sendUserMessage: async (content, options) => {
6108
+ await options?.onPreflightAcceptCommit?.();
6109
+ if (Array.isArray(content)) {
6110
+ options?.onDispatchDisposition?.({ startsOwnRun: true });
6111
+ await producer.promise;
6112
+ }
6113
+ options?.onDispatchDisposition?.({ startsOwnRun: false });
6114
+ options?.preflightSignal?.addEventListener("abort", () => options.onQueuedPromoted?.({ removed: true }), {
6115
+ once: true,
6116
+ });
6117
+ },
6118
+ });
6119
+ harness.connect("client");
6120
+ const id = await stageRuntimeImage(harness, makeTestPng());
6121
+ expect(
6122
+ await harness.control("turn.prompt", { text: "retain predecessor image", stagedImages: [{ id }] }),
6123
+ ).toMatchObject({ ok: true });
6124
+ deadlineMs = 100;
6125
+ const followUp = await harness.control("turn.follow_up", { text: "queue a removal with failed publication" });
6126
+ expect(followUp).toMatchObject({ ok: true });
6127
+ queued = followUp.result;
6128
+ await expect(harness.stop()).rejects.toMatchObject({ code: "sdk_reconciliation_teardown_failed" });
6129
+ expect(releases).toBe(0);
6130
+ failTerminal = false;
6131
+ await repaired.promise;
6132
+ expect(releases).toBe(0);
6133
+ // Either the original removal publisher or deadline recovery can retire
6134
+ // this cancellation. Until then shutdown must still fail closed.
6135
+ const retryUntil = Date.now() + 5_000;
6136
+ for (;;) {
6137
+ try {
6138
+ await harness.stop();
6139
+ break;
6140
+ } catch (error) {
6141
+ expect(error).toMatchObject({ code: "sdk_reconciliation_teardown_failed" });
6142
+ expect(releases).toBe(0);
6143
+ if (Date.now() >= retryUntil) throw error;
6144
+ await Bun.sleep(10);
6145
+ }
6146
+ }
6147
+ expect(releases).toBe(0);
6148
+ await harness.switchSession("image-cleanup-successor");
6149
+ expect(releases).toBe(0);
6150
+ producer.resolve();
6151
+ await released.promise;
6152
+ expect(releases).toBe(1);
6153
+ } finally {
6154
+ failTerminal = false;
6155
+ producer.resolve();
6156
+ try {
6157
+ await harness?.stop();
6158
+ } finally {
6159
+ spy.mockRestore();
6160
+ await rm(cwd, { recursive: true, force: true });
6161
+ }
6162
+ }
6163
+ });
6164
+
6165
+ test("SDK-only shutdown retains staged-image quota when runtime stop fails and releases it after retry", async () => {
6166
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-staged-image-shutdown-"));
6167
+ const bytes = makeTestPng();
6168
+ const releases: string[] = [];
6169
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
6170
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
6171
+ this: PromptImageUploadStore,
6172
+ owner,
6173
+ ids,
6174
+ ) {
6175
+ const reservation = originalRedeem.call(this, owner, ids);
6176
+ return {
6177
+ images: reservation.images,
6178
+ release: () => {
6179
+ releases.push("released");
6180
+ reservation.release();
6181
+ },
6182
+ };
6183
+ });
6184
+ let harness: InvocationHarness | undefined;
6185
+ let stopSpy: { mockRestore(): void } | undefined;
6186
+ try {
6187
+ harness = await invocationHarness("staged-image-shutdown", cwd, {
6188
+ connectionLiveness: true,
6189
+ sendUserMessage: async (_content, options) => {
6190
+ await options?.onPreflightAcceptCommit?.();
6191
+ options?.onDispatchDisposition?.({ startsOwnRun: true });
6192
+ await neverSettlingPromise();
6193
+ },
6194
+ });
6195
+ harness.connect("client");
6196
+ const id = await stageRuntimeImage(harness, bytes);
6197
+ const accepted = await harness.control("turn.prompt", {
6198
+ text: "retain through failed stop",
6199
+ stagedImages: [{ id }],
6200
+ });
6201
+ expect(accepted).toMatchObject({ ok: true, result: { accepted: true } });
6202
+ await harness.emit("agent_start");
6203
+ expect(releases).toEqual([]);
6204
+
6205
+ stopSpy = spyOn(SessionSdkSessionRuntime.prototype, "stop").mockImplementationOnce(async () => {
6206
+ throw new Error("injected transport stop failure");
6207
+ });
6208
+ await expect(harness.stop()).rejects.toThrow("injected transport stop failure");
6209
+ expect(releases).toEqual([]);
6210
+
6211
+ stopSpy.mockRestore();
6212
+ stopSpy = undefined;
6213
+ await harness.stop();
6214
+ expect(releases).toEqual(["released"]);
6215
+ } finally {
6216
+ stopSpy?.mockRestore();
6217
+ await harness?.stop().catch(() => undefined);
6218
+ redeemSpy.mockRestore();
6219
+ await rm(cwd, { recursive: true, force: true });
6220
+ }
6221
+ });
4774
6222
 
4775
6223
  test.each(["natural", "removed"] as const)("SDK-only text follow-up has a durable terminal (%s)", async mode => {
4776
6224
  const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-only-text-followup-${mode}-`));
@@ -5282,6 +6730,7 @@ function createInterceptorReconciliationStore(
5282
6730
  type: string | undefined;
5283
6731
  commandId?: string;
5284
6732
  turnId?: string;
6733
+ terminalAt?: number;
5285
6734
  outcome: "rejected" | "committed";
5286
6735
  }) => void,
5287
6736
  ): SdkOnlyReconciliationStore {
@@ -5296,6 +6745,7 @@ function createInterceptorReconciliationStore(
5296
6745
  let transitionType: string | undefined;
5297
6746
  for (const record of records as Array<{
5298
6747
  status?: string;
6748
+ startedAt?: number;
5299
6749
  terminalAt?: number;
5300
6750
  commandId?: string;
5301
6751
  pendingOutcome?: unknown;
@@ -5324,6 +6774,7 @@ function createInterceptorReconciliationStore(
5324
6774
  throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
5325
6775
  }
5326
6776
  const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
6777
+ if (record.startedAt !== undefined && previous?.startedAt === undefined) transitionType ??= "agent_start";
5327
6778
  const previousPendingOutcome = (
5328
6779
  previous as
5329
6780
  | (SdkOnlyInvocationRecord & { pendingOutcome?: { kind?: string; code?: string; reason?: string } })
@@ -5353,11 +6804,12 @@ function createInterceptorReconciliationStore(
5353
6804
  hold.onEntered();
5354
6805
  await hold.release;
5355
6806
  }
5356
- for (const record of records as Array<{ commandId?: string; turnId?: string }>)
6807
+ for (const record of records as Array<{ commandId?: string; turnId?: string; terminalAt?: number }>)
5357
6808
  onDurableAttempt?.({
5358
6809
  type: transitionType,
5359
6810
  commandId: record?.commandId,
5360
6811
  turnId: record?.turnId,
6812
+ terminalAt: record?.terminalAt,
5361
6813
  outcome: "committed",
5362
6814
  });
5363
6815
  backing.records = records;
@@ -5494,7 +6946,7 @@ function realSendUserMessage(session: AgentSession) {
5494
6946
  return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
5495
6947
  await options?.onPreflightAcceptCommit?.();
5496
6948
  const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
5497
- await session.sendUserMessage(content as string, dispatchOptions as never);
6949
+ await session.sendUserMessage(content as string, dispatchOptions as SendUserMessageOptions);
5498
6950
  };
5499
6951
  }
5500
6952
 
@@ -6176,6 +7628,7 @@ describe("post-acceptance invocation terminalization", () => {
6176
7628
  let harness: InvocationHarness | undefined;
6177
7629
  let session: AgentSession | undefined;
6178
7630
  let authStorage: AuthStorage | undefined;
7631
+ let restoreEmission: (() => void) | undefined;
6179
7632
  let providerCalls = 0;
6180
7633
  try {
6181
7634
  const real = await createTerminalizationSession(
@@ -6186,6 +7639,7 @@ describe("post-acceptance invocation terminalization", () => {
6186
7639
  return createMockModel({ responses: [{ content: ["started"] }] }).stream(model, context, options);
6187
7640
  },
6188
7641
  { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1, "retry.enabled": false },
7642
+ true,
6189
7643
  );
6190
7644
  session = real.session;
6191
7645
  session.setConfiguredModelChain("default", [selector(real.model)], "terminal-throw-test");
@@ -6197,9 +7651,16 @@ describe("post-acceptance invocation terminalization", () => {
6197
7651
  isIdle: () => !session?.isStreaming,
6198
7652
  sendUserMessage: realSendUserMessage(session),
6199
7653
  });
6200
- session.subscribe(async event => {
6201
- await harness?.emit(event.type, event);
7654
+ const runner = session.extensionRunner;
7655
+ if (!runner) throw new Error("Expected the real awaited terminal extension bridge.");
7656
+ const emit = runner.emit.bind(runner);
7657
+ const emission = spyOn(runner, "emit").mockImplementation(async event => {
7658
+ const result = await emit(event);
7659
+ if (event.type === "agent_start" || event.type === "agent_failed" || event.type === "agent_end")
7660
+ await harness?.emit(event.type, event);
7661
+ return result;
6202
7662
  });
7663
+ restoreEmission = () => emission.mockRestore();
6203
7664
  const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
6204
7665
  expect(accepted.ok).toBe(true);
6205
7666
  const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
@@ -6226,6 +7687,7 @@ describe("post-acceptance invocation terminalization", () => {
6226
7687
  outcome: { kind: "failed" },
6227
7688
  });
6228
7689
  } finally {
7690
+ restoreEmission?.();
6229
7691
  await session?.dispose();
6230
7692
  authStorage?.close();
6231
7693
  await harness?.stop();
@@ -8516,40 +9978,80 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8516
9978
  }
8517
9979
  });
8518
9980
 
8519
- test("a captured cancelled end stays private and recoverable while tools are unproven", async () => {
9981
+ test("a captured cancelled end retains staged-image quota until confirmed tool recovery", async () => {
8520
9982
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-captured-uncertain-"));
9983
+ const bytes = makeTestPng();
9984
+ const releaseCalls: string[] = [];
9985
+ const originalRedeem = PromptImageUploadStore.prototype.redeem;
9986
+ const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function (
9987
+ this: PromptImageUploadStore,
9988
+ owner,
9989
+ ids,
9990
+ ) {
9991
+ const reservation = originalRedeem.call(this, owner, ids);
9992
+ return {
9993
+ images: reservation.images,
9994
+ release: () => {
9995
+ releaseCalls.push("released");
9996
+ reservation.release();
9997
+ },
9998
+ };
9999
+ });
8521
10000
  const sessionId = "deadline-captured-uncertain";
8522
10001
  const sessionFile = path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`);
8523
10002
  await Bun.write(sessionFile, "");
8524
- const store = createReconciliationStore({ sessionFile, sessionId });
10003
+ // Chain-level evidence for the CI-only hang: every serialized store op
10004
+ // (including the terminal variants the runtime persistence log cannot see).
10005
+ const storeLog: string[] = [];
10006
+ const store = createReconciliationStore({
10007
+ sessionFile,
10008
+ sessionId,
10009
+ onInstrumentationForTests: event => {
10010
+ storeLog.push(
10011
+ `${event.at}:${event.phase}:${event.operation ?? ""}:${event.substep ?? ""}:${event.detail ?? ""}${event.error ? `:${event.error}` : ""}`,
10012
+ );
10013
+ },
10014
+ });
8525
10015
  const activeTools = new Set(["unfenced-tool"]);
10016
+ const toolDrainObserved = Promise.withResolvers<void>();
10017
+ const terminalPublication = Promise.withResolvers<SdkOnlyDeadlineRecoveryCheckpoint>();
10018
+ const recoveryCheckpoints: SdkOnlyDeadlineRecoveryCheckpoint[] = [];
10019
+ const persistenceLog: Array<PersistenceInstrumentationEvent & { label: string }> = [];
8526
10020
  let boundaryWaitStarted = false;
8527
10021
  let abortCalls = 0;
10022
+ let sends = 0;
8528
10023
  let harness: InvocationHarness | undefined;
8529
10024
  try {
8530
10025
  harness = await invocationHarness(sessionId, cwd, {
10026
+ connectionLiveness: true,
8531
10027
  settings: {
8532
10028
  // This test covers deferred terminal recovery, not deadline-triggered worktree persistence.
8533
10029
  get: (key: string) =>
8534
- key === "sdk.promptDeadlineMs"
8535
- ? 150
8536
- : key === "sdk.promptMaxRuntimeMs"
8537
- ? 60_000
8538
- : key === "sdk.flushWorktreeOnDeadline"
8539
- ? false
8540
- : undefined,
10030
+ key === "sdk.promptDeadlineMs" ? 150 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
8541
10031
  } as unknown as Settings,
8542
10032
  sendUserMessage: async (_content, options) => {
10033
+ sends++;
8543
10034
  await options?.onPreflightAcceptCommit?.();
8544
10035
  await neverSettlingPromise();
8545
10036
  },
10037
+ onPersistenceInstrumentation: event => {
10038
+ const label = `${event.phase}:${event.operation ?? "main"}:${event.substep ?? ""}`;
10039
+ persistenceLog.push({ ...event, label });
10040
+ },
8546
10041
  terminalAbortSeams: {
8547
10042
  getReconciliationStore: () => store,
8548
10043
  getTerminalTurnEpoch: () => 109,
8549
10044
  getActivePromptHandle: () => "deadline-captured-uncertain-run",
10045
+ onDeadlineRecoveryCheckpointForTests: checkpoint => {
10046
+ recoveryCheckpoints.push(checkpoint);
10047
+ if (checkpoint.stage === "publication-result" && checkpoint.result === "published")
10048
+ terminalPublication.resolve(checkpoint);
10049
+ },
8550
10050
  pendingToolExecutions: () => {
8551
10051
  if (activeTools.size > 0) boundaryWaitStarted = true;
8552
- return [...activeTools];
10052
+ const pending = [...activeTools];
10053
+ if (pending.length === 0) toolDrainObserved.resolve();
10054
+ return pending;
8553
10055
  },
8554
10056
  abortPromptAndWaitWithTerminal: async () => {
8555
10057
  abortCalls += 1;
@@ -8557,9 +10059,18 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8557
10059
  },
8558
10060
  },
8559
10061
  });
8560
- const accepted = await harness.control("turn.prompt", { text: "capture uncertain end" });
10062
+ harness.connect("client");
10063
+ const id = await stageRuntimeImage(harness, bytes);
10064
+ const accepted = await harness.control("turn.prompt", {
10065
+ text: "capture uncertain end",
10066
+ clientRef: "captured-uncertain-image",
10067
+ stagedImages: [{ id }],
10068
+ });
8561
10069
  expect(accepted.ok).toBe(true);
8562
- const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
10070
+ const commandId = accepted.result?.commandId;
10071
+ const turnId = accepted.result?.turnId;
10072
+ if (commandId === undefined || turnId === undefined) throw new Error("Expected prompt correlation");
10073
+ const correlation = { commandId, turnId };
8563
10074
  await harness.emit("agent_start");
8564
10075
  await harness.emit("tool_execution_start", {
8565
10076
  type: "tool_execution_start",
@@ -8573,6 +10084,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8573
10084
  await harness.emit("agent_end", { stopReason: "cancelled" });
8574
10085
  await Bun.sleep(5_200);
8575
10086
  expect(abortCalls).toBe(0);
10087
+ expect(releaseCalls).toEqual([]);
8576
10088
  expect((await harness.query("turn.prompt_status", correlation)).result).toMatchObject({ status: "in_flight" });
8577
10089
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toEqual([]);
8578
10090
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toEqual([]);
@@ -8584,6 +10096,17 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8584
10096
  deadlineRecoveryPending: true,
8585
10097
  pendingOutcome: { kind: "stopped", reason: "cancelled" },
8586
10098
  });
10099
+ // An uncertain retained outcome is reconciliation-only, never replayable.
10100
+ expect(
10101
+ await harness.control("turn.prompt", {
10102
+ text: "capture uncertain end",
10103
+ clientRef: "captured-uncertain-image",
10104
+ stagedImages: [{ id }],
10105
+ }),
10106
+ ).toMatchObject({ ok: false, error: { code: "client_ref_conflict" } });
10107
+ expect(redeemSpy.mock.calls).toHaveLength(1);
10108
+ expect(sends).toBe(1);
10109
+ expect(releaseCalls).toEqual([]);
8587
10110
  const reopened = createInvocationReconciliation({
8588
10111
  store: createReconciliationStore({ sessionFile, sessionId }),
8589
10112
  });
@@ -8597,14 +10120,45 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8597
10120
  });
8598
10121
 
8599
10122
  activeTools.clear();
8600
- // Keep this inside the 60s hard-runtime budget while tolerating a slow CI retry tick.
8601
- expect(await settledStatus(harness, "turn.prompt_status", correlation, 30_000)).toMatchObject({
10123
+ // Wait until the deadline recovery loop has actually observed the exact
10124
+ // tool-set drain; clearing the test seam alone does not wake its bounded
10125
+ // retry timer.
10126
+ await toolDrainObserved.promise;
10127
+ // Wait for the durable terminal boundary itself, not an arbitrary status
10128
+ // polling budget that can expire while a slow CI filesystem is flushing it.
10129
+ const published = await terminalPublication.promise;
10130
+ expect(published.correlation).toEqual(correlation);
10131
+ let terminalStatus: NonNullable<ResponseFrame["result"]>;
10132
+ try {
10133
+ terminalStatus = await settledStatus(harness, "turn.prompt_status", correlation);
10134
+ } catch (error) {
10135
+ const finalRecord = store.snapshot().find(record => record.commandId === correlation.commandId) as
10136
+ | (SdkOnlyInvocationRecord & {
10137
+ pendingOutcome?: unknown;
10138
+ deadlineRecoveryPending?: boolean;
10139
+ terminalAt?: number;
10140
+ })
10141
+ | undefined;
10142
+ throw new Error(
10143
+ `${error instanceof Error ? error.message : String(error)}; persistence_log=${JSON.stringify(persistenceLog)}; store_log=${JSON.stringify(storeLog)}; deadline checkpoints=${JSON.stringify(recoveryCheckpoints)}; durable=${JSON.stringify(
10144
+ {
10145
+ status: finalRecord?.status,
10146
+ terminalAt: finalRecord?.terminalAt,
10147
+ deadlineRecoveryPending: finalRecord?.deadlineRecoveryPending,
10148
+ pendingOutcome: finalRecord?.pendingOutcome,
10149
+ },
10150
+ )}`,
10151
+ );
10152
+ }
10153
+ expect(terminalStatus).toMatchObject({
8602
10154
  status: "terminal_ok",
8603
10155
  outcome: { kind: "stopped", reason: "cancelled" },
8604
10156
  });
8605
10157
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toHaveLength(1);
10158
+ expect(releaseCalls).toEqual(["released"]);
8606
10159
  } finally {
8607
10160
  await harness?.stop();
10161
+ redeemSpy.mockRestore();
8608
10162
  await Bun.sleep(10);
8609
10163
  await rm(cwd, { recursive: true, force: true });
8610
10164
  }
@@ -8618,6 +10172,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8618
10172
  const sessionFile = path.join(cwd, "session.json");
8619
10173
  await Bun.write(sessionFile, "");
8620
10174
  const store = createReconciliationStore({ sessionFile, sessionId });
10175
+ const deadlineStarted = Promise.withResolvers<void>();
8621
10176
  let abortCalls = 0;
8622
10177
  let harness: InvocationHarness | undefined;
8623
10178
  try {
@@ -8633,7 +10188,10 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8633
10188
  terminalAbortSeams: {
8634
10189
  getReconciliationStore: () => store,
8635
10190
  getTerminalTurnEpoch: () => 17,
8636
- getActivePromptHandle: () => "unobservable-tool-run",
10191
+ getActivePromptHandle: () => {
10192
+ deadlineStarted.resolve();
10193
+ return "unobservable-tool-run";
10194
+ },
8637
10195
  abortPromptAndWaitWithTerminal: async () => {
8638
10196
  abortCalls += 1;
8639
10197
  return { status: "settled" };
@@ -8647,7 +10205,10 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8647
10205
  await harness.emit("agent_failed", {
8648
10206
  error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }),
8649
10207
  });
8650
- await Bun.sleep(150);
10208
+ // Expiry must install its recoverable observation before the real end
10209
+ // arrives; otherwise an end racing the durable uncertainty write can
10210
+ // terminalize from the diagnostic alone.
10211
+ await deadlineStarted.promise;
8651
10212
  expect(abortCalls).toBe(0);
8652
10213
  expect(await harness.query("turn.prompt_status", correlation)).toMatchObject({
8653
10214
  result: { status: "in_flight" },
@@ -8655,7 +10216,6 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8655
10216
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toHaveLength(1);
8656
10217
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toEqual([]);
8657
10218
  await harness.emit("agent_end", { stopReason: "cancelled" });
8658
- await Bun.sleep(100);
8659
10219
  expect(await harness.query("turn.prompt_status", correlation)).toMatchObject({
8660
10220
  result: { status: "in_flight" },
8661
10221
  });
@@ -8726,7 +10286,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8726
10286
  toolName: "bash",
8727
10287
  args: {},
8728
10288
  });
8729
- const boundaryDeadline = Date.now() + 2_000;
10289
+ // This checks ordering, not prompt latency; allow the scheduler to reach
10290
+ // the boundary when many CI shards are contending for the event loop.
10291
+ const boundaryDeadline = Date.now() + 10_000;
8730
10292
  while (!boundaryWaitStarted && Date.now() < boundaryDeadline) await Bun.sleep(10);
8731
10293
  expect(boundaryWaitStarted).toBe(true);
8732
10294
  expect(abortCalls).toHaveLength(0);
@@ -8740,7 +10302,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8740
10302
  });
8741
10303
  await Bun.sleep(30);
8742
10304
  expect(abortCalls).toHaveLength(0);
8743
- const abortDeadline = Date.now() + 2_000;
10305
+ const abortDeadline = Date.now() + 10_000;
8744
10306
  while (abortCalls.length === 0 && Date.now() < abortDeadline) await Bun.sleep(10);
8745
10307
  expect(abortCalls).toEqual([{ handle: "deadline-tool-run", scope: "owned", expectedEpoch: epoch }]);
8746
10308
  expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
@@ -8794,7 +10356,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8794
10356
  : key === "sdk.flushWorktreeOnDeadline"
8795
10357
  ? true
8796
10358
  : undefined,
8797
- has: (key: string) => key === "sdk.flushWorktreeOnDeadline",
10359
+ // Primary checkout: only a user/global true opts in. `has()` would
10360
+ // also be true for a project file, which must not authorize this.
10361
+ getGlobal: (key: string) => (key === "sdk.flushWorktreeOnDeadline" ? true : undefined),
8798
10362
  getAgentDir: () => path.join(cwd, ".gjc", "agent"),
8799
10363
  } as unknown as Settings,
8800
10364
  sendUserMessage: async (_content, options) => {
@@ -9003,8 +10567,11 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9003
10567
  expect(await harness.query("turn.prompt_status", queuedIds)).toMatchObject({ result: { status: "accepted" } });
9004
10568
  expect(abortCalls).toBe(0);
9005
10569
  expect(correlatedFrames(harness, queuedIds).filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
9006
- promoted?.({ startsOwnRun: true });
10570
+ // The lease is captured when the follow-up is promoted, not when the
10571
+ // setting later changes. Restore the long deadline first so this run
10572
+ // does not inherit the 100ms queue probe and expire under settledStatus.
9007
10573
  promptDeadlineMs = 600_000;
10574
+ promoted?.({ startsOwnRun: true });
9008
10575
  await Bun.sleep(150);
9009
10576
  expect((await harness.query("turn.prompt_status", queuedIds)).result?.status).toMatch(/accepted|in_flight/);
9010
10577
  expect(await harness.query("turn.prompt_status", activeIds)).toMatchObject({
@@ -9039,6 +10606,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9039
10606
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-start-race-"));
9040
10607
  const claimEntered = Promise.withResolvers<void>();
9041
10608
  const releaseClaim = Promise.withResolvers<void>();
10609
+ const persistenceEvents: PersistenceInstrumentationEvent[] = [];
10610
+ const durableAttempts: string[] = [];
9042
10611
  const epoch = 83;
9043
10612
  let harness: InvocationHarness | undefined;
9044
10613
  let startPromise: Promise<void> | undefined;
@@ -9054,6 +10623,20 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9054
10623
  },
9055
10624
  persistInterceptor: () => {},
9056
10625
  agentFailedWriteFailures: 0,
10626
+ onPersistenceInstrumentation: event => {
10627
+ if (persistenceEvents.length < 64)
10628
+ persistenceEvents.push({
10629
+ ...event,
10630
+ error: event.error?.slice(0, 256),
10631
+ detail: event.detail?.slice(0, 256),
10632
+ });
10633
+ },
10634
+ onDurableAttempt: attempt => {
10635
+ if (durableAttempts.length < 64)
10636
+ durableAttempts.push(
10637
+ `${attempt.type ?? "unclassified"}:${attempt.outcome}:${attempt.commandId ?? ""}:${attempt.turnId ?? ""}:${attempt.terminalAt ?? ""}`,
10638
+ );
10639
+ },
9057
10640
  persistHolds: [
9058
10641
  {
9059
10642
  type: "deadline_claim",
@@ -9085,12 +10668,30 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9085
10668
  const accepted = await harness.control("turn.prompt", { text: "start while expiring" });
9086
10669
  expect(accepted.ok).toBe(true);
9087
10670
  const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
9088
- const claimDeadline = Date.now() + 2_000;
10671
+ const claimWaitStartedAt = Date.now();
10672
+ const claimDeadline = claimWaitStartedAt + 10_000;
9089
10673
  while (!claimWasEntered && Date.now() < claimDeadline) await Bun.sleep(5);
9090
- expect(claimWasEntered).toBe(true);
10674
+ try {
10675
+ expect(claimWasEntered).toBe(true);
10676
+ } catch (error) {
10677
+ throw new Error(
10678
+ `${error instanceof Error ? error.message : String(error)}; claim_setup=${JSON.stringify({
10679
+ accepted,
10680
+ correlation,
10681
+ elapsedMs: Date.now() - claimWaitStartedAt,
10682
+ claimWasEntered,
10683
+ handleReads,
10684
+ abortCalls,
10685
+ persistenceEvents,
10686
+ durableAttempts,
10687
+ broadcasts: harness.broadcasts.slice(-16),
10688
+ })}`,
10689
+ { cause: error },
10690
+ );
10691
+ }
9091
10692
  await claimEntered.promise;
9092
10693
  startPromise = harness.emit("agent_start");
9093
- const captureDeadline = Date.now() + 2_000;
10694
+ const captureDeadline = Date.now() + 10_000;
9094
10695
  while (handleReads === 0 && Date.now() < captureDeadline) await Bun.sleep(5);
9095
10696
  expect(handleReads).toBeGreaterThan(0);
9096
10697
  releaseClaim.resolve();
@@ -9109,7 +10710,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9109
10710
  await harness?.stop();
9110
10711
  await rm(cwd, { recursive: true, force: true });
9111
10712
  }
9112
- });
10713
+ }, 15_000);
9113
10714
 
9114
10715
  test("agent_start during an expiry claim keeps the queued correlation owned", async () => {
9115
10716
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-start-race-"));
@@ -9122,8 +10723,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9122
10723
  let claimWasEntered = false;
9123
10724
  let handleReads = 0;
9124
10725
  let boundaryWaitStarted = false;
10726
+ let toolDrainWasObserved = false;
9125
10727
  let abortCalls = 0;
9126
- let terminalEventPromise: Promise<void> | undefined;
9127
10728
  try {
9128
10729
  harness = await invocationHarness("deadline-start-race", cwd, {
9129
10730
  settings: zeroProgressSettings,
@@ -9151,8 +10752,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9151
10752
  },
9152
10753
  pendingToolExecutions: () => {
9153
10754
  if (activeTools.size > 0) boundaryWaitStarted = true;
9154
- else if (boundaryWaitStarted && terminalEventPromise === undefined)
9155
- terminalEventPromise = harness?.emit("agent_end", { stopReason: "cancelled" });
10755
+ else if (boundaryWaitStarted) toolDrainWasObserved = true;
9156
10756
  return [...activeTools];
9157
10757
  },
9158
10758
  abortPromptAndWaitWithTerminal: async () => {
@@ -9177,15 +10777,14 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9177
10777
  while (!boundaryWaitStarted && Date.now() < boundaryDeadline) await Bun.sleep(5);
9178
10778
  expect(boundaryWaitStarted).toBe(true);
9179
10779
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_start")).toHaveLength(1);
10780
+ // The producer emits its real end after tool settlement; the ledger observer stays read-only.
9180
10781
  activeTools.clear();
9181
- const terminalEventDeadline = Date.now() + 2_000;
9182
- while (terminalEventPromise === undefined && Date.now() < terminalEventDeadline) await Bun.sleep(5);
9183
- expect(terminalEventPromise).toBeDefined();
9184
- await terminalEventPromise;
10782
+ await harness.emit("agent_end", { stopReason: "cancelled" });
9185
10783
  expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
9186
10784
  status: "terminal_ok",
9187
10785
  outcome: { kind: "stopped", reason: "cancelled" },
9188
10786
  });
10787
+ expect(toolDrainWasObserved).toBe(true);
9189
10788
  expect(abortCalls).toBeLessThanOrEqual(1);
9190
10789
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toHaveLength(1);
9191
10790
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
@@ -9194,7 +10793,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9194
10793
  await harness?.stop();
9195
10794
  await rm(cwd, { recursive: true, force: true });
9196
10795
  }
9197
- });
10796
+ // Real timers plus a reconciliation claim exceed the default 5s on a loaded shard.
10797
+ }, 20_000);
9198
10798
 
9199
10799
  test("a sibling publication failure does not hide the expiring correlation's end", async () => {
9200
10800
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-sibling-publication-"));
@@ -11489,6 +13089,7 @@ test.each([
11489
13089
  let transportCreations = 0;
11490
13090
  createSdkSessionRuntimeExtension(api, {
11491
13091
  agentDir: cwd,
13092
+ ensureBrokerImpl: async () => testBrokerDiscovery,
11492
13093
  createTransport: async () => {
11493
13094
  transportCreations++;
11494
13095
  return transport;
@@ -11631,6 +13232,7 @@ test.each([
11631
13232
  ])("SDK-only queued terminal recovery permits replacement after EIO (early shutdown=%s)", async earlyShutdown => {
11632
13233
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-recovery-"));
11633
13234
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
13235
+ const recoveryRetired = Promise.withResolvers<SdkOnlyDeadlineRecoveryCheckpoint>();
11634
13236
  let transport = memoryTransport();
11635
13237
  const sessionId = transport.sessionId;
11636
13238
  const sessionFile = path.join(cwd, `${sessionId}.json`);
@@ -11665,6 +13267,7 @@ test.each([
11665
13267
  get: (key: string) =>
11666
13268
  key === "sdk.promptDeadlineMs" ? 1_500 : key === "sdk.promptMaxRuntimeMs" ? 15_000 : undefined,
11667
13269
  } as unknown as Settings,
13270
+ ensureBrokerImpl: async () => testBrokerDiscovery,
11668
13271
  createTransport: async () => {
11669
13272
  creations++;
11670
13273
  transport = memoryTransport();
@@ -11676,6 +13279,9 @@ test.each([
11676
13279
  getActivePromptHandle: () => "unrelated-handle",
11677
13280
  cancelPendingPreflightForTerminalAbort: () => {},
11678
13281
  abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
13282
+ onDeadlineRecoveryCheckpointForTests: checkpoint => {
13283
+ if (checkpoint.stage === "deadline-recovery-retired") recoveryRetired.resolve(checkpoint);
13284
+ },
11679
13285
  },
11680
13286
  });
11681
13287
  const ctx = { ...extensionContext(sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext;
@@ -11717,7 +13323,7 @@ test.each([
11717
13323
  expect(accepted.ok).toBe(true);
11718
13324
  commandId = accepted.result?.commandId;
11719
13325
  const turnId = accepted.result?.turnId;
11720
- expect(commandId).toBeDefined();
13326
+ if (commandId === undefined || turnId === undefined) throw new Error("Expected queued prompt correlation");
11721
13327
  await waitFor(() => queued && queueSignal !== undefined, "queued admission");
11722
13328
  expect(await request({ type: "control_request", operation: "turn.abort", input: {} })).toMatchObject({
11723
13329
  ok: true,
@@ -11742,6 +13348,8 @@ test.each([
11742
13348
  status: "failed",
11743
13349
  error: { code: "cancelled" },
11744
13350
  });
13351
+ const retired = await recoveryRetired.promise;
13352
+ expect(retired.correlation).toEqual({ commandId, turnId });
11745
13353
  if (!earlyShutdown) await handlers.get("session_shutdown")?.({}, ctx);
11746
13354
  await handlers.get("session_start")?.({}, ctx);
11747
13355
  expect(creations).toBe(2);