@gajae-code/coding-agent 0.18.12 → 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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -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,7 +51,8 @@ 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,
@@ -1661,6 +1675,62 @@ describe("SessionSdkSessionRuntime", () => {
1661
1675
  expect(transport.sent).toContainEqual({ type: "event", kind: "message_update", payload: {} });
1662
1676
  await runtime.stop();
1663
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
+ });
1664
1734
  test("ignores late negotiated capabilities after the authoritative connection closes", async () => {
1665
1735
  const transport = memoryTransport();
1666
1736
  let closeConnection: ((connectionId: string) => void) | undefined;
@@ -4552,6 +4622,7 @@ interface PreflightHooks {
4552
4622
  expectedSdkRunToken?: string;
4553
4623
  preflightSignal?: AbortSignal;
4554
4624
  onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
4625
+ onQueuedRunStartFailed?: (failure: QueuedRunStartFailure) => void;
4555
4626
  }
4556
4627
 
4557
4628
  interface ResponseFrame {
@@ -4567,6 +4638,59 @@ interface ResponseFrame {
4567
4638
  };
4568
4639
  }
4569
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
+
4570
4694
  interface InvocationHarness {
4571
4695
  control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
4572
4696
  controlAs(
@@ -4583,6 +4707,8 @@ interface InvocationHarness {
4583
4707
  sendToSession(sessionId: string, frame: Record<string, unknown>): void;
4584
4708
  sent(sessionId: string): SdkFrame[];
4585
4709
  readonly broadcasts: SdkFrame[];
4710
+ connect(connectionId: string): void;
4711
+ disconnect(connectionId: string): void;
4586
4712
  stop(): Promise<void>;
4587
4713
  }
4588
4714
 
@@ -4599,6 +4725,8 @@ async function invocationHarness(
4599
4725
  abort?: () => void;
4600
4726
  isIdle?: () => boolean;
4601
4727
  settings?: Settings;
4728
+ /** Install the authoritative test transport liveness and disconnect callbacks. */
4729
+ connectionLiveness?: boolean;
4602
4730
  /** Throw to inject a durable-write failure for matching transitions. */
4603
4731
  persistInterceptor?: (transition: { type: string }) => void;
4604
4732
  /** Hold one matching durable transition until the test releases it. */
@@ -4614,6 +4742,7 @@ async function invocationHarness(
4614
4742
  type: string | undefined;
4615
4743
  commandId?: string;
4616
4744
  turnId?: string;
4745
+ terminalAt?: number;
4617
4746
  outcome: "rejected" | "committed";
4618
4747
  }) => void;
4619
4748
  branch?: unknown[];
@@ -4628,6 +4757,8 @@ async function invocationHarness(
4628
4757
  const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
4629
4758
  const broadcasts: SdkFrame[] = [];
4630
4759
  let deliver: ((connectionId: string, frame: SdkFrame) => void) | undefined;
4760
+ const openConnections = new Set<string>();
4761
+ let connectionCloseHandler: ((connectionId: string) => void) | undefined;
4631
4762
  const deliveries = new Map<string, (connectionId: string, frame: SdkFrame) => void>();
4632
4763
  const sentFrames = new Map<string, SdkFrame[]>();
4633
4764
  let nextId = 0;
@@ -4651,13 +4782,16 @@ async function invocationHarness(
4651
4782
  createTestRuntimeExtension(api, {
4652
4783
  agentDir: cwd,
4653
4784
  ...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
4654
- ...(interceptorStore || hooks.terminalAbortSeams || hooks.onPersistenceInstrumentation
4785
+ ...(interceptorStore || hooks.terminalAbortSeams || hooks.onPersistenceInstrumentation || hooks.connectionLiveness
4655
4786
  ? {
4656
4787
  terminalAbortSeams: {
4657
4788
  getTerminalTurnEpoch: () => undefined,
4658
- getActivePromptHandle: () => undefined,
4789
+ getActivePromptHandle: () => (hooks.connectionLiveness ? "test-image-run" : undefined),
4659
4790
  cancelPendingPreflightForTerminalAbort: () => {},
4660
4791
  abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
4792
+ ...(hooks.connectionLiveness
4793
+ ? { waitForRunSettlement: async () => ({ status: "settled" as const }) }
4794
+ : {}),
4661
4795
  ...(interceptorStore ? { getReconciliationStore: () => interceptorStore } : {}),
4662
4796
  ...(hooks.onPersistenceInstrumentation
4663
4797
  ? { onPersistenceInstrumentationForTests: hooks.onPersistenceInstrumentation }
@@ -4685,6 +4819,17 @@ async function invocationHarness(
4685
4819
  if (deliveries.get(id) === handler) deliveries.delete(id);
4686
4820
  };
4687
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
+ : {}),
4688
4833
  sendFrame(_connectionId, frame) {
4689
4834
  const response = frame as ResponseFrame;
4690
4835
  const frames = sentFrames.get(id) ?? [];
@@ -4739,6 +4884,13 @@ async function invocationHarness(
4739
4884
  ),
4740
4885
  query: (name, input) => request({ type: "query_request", query: name, input }),
4741
4886
  broadcasts,
4887
+ connect: connectionId => {
4888
+ openConnections.add(connectionId);
4889
+ },
4890
+ disconnect: connectionId => {
4891
+ openConnections.delete(connectionId);
4892
+ connectionCloseHandler?.(connectionId);
4893
+ },
4742
4894
  emit: async (event, payload) => {
4743
4895
  await handlers.get(event)?.(payload ?? {}, ctx);
4744
4896
  },
@@ -4786,6 +4938,1288 @@ async function invocationHarness(
4786
4938
  };
4787
4939
  }
4788
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
+ };
5869
+ });
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;
5881
+ },
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 });
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" },
6041
+ );
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
+ });
6222
+
4789
6223
  test.each(["natural", "removed"] as const)("SDK-only text follow-up has a durable terminal (%s)", async mode => {
4790
6224
  const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-only-text-followup-${mode}-`));
4791
6225
  let harness: InvocationHarness | undefined;
@@ -5296,6 +6730,7 @@ function createInterceptorReconciliationStore(
5296
6730
  type: string | undefined;
5297
6731
  commandId?: string;
5298
6732
  turnId?: string;
6733
+ terminalAt?: number;
5299
6734
  outcome: "rejected" | "committed";
5300
6735
  }) => void,
5301
6736
  ): SdkOnlyReconciliationStore {
@@ -5310,6 +6745,7 @@ function createInterceptorReconciliationStore(
5310
6745
  let transitionType: string | undefined;
5311
6746
  for (const record of records as Array<{
5312
6747
  status?: string;
6748
+ startedAt?: number;
5313
6749
  terminalAt?: number;
5314
6750
  commandId?: string;
5315
6751
  pendingOutcome?: unknown;
@@ -5338,6 +6774,7 @@ function createInterceptorReconciliationStore(
5338
6774
  throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
5339
6775
  }
5340
6776
  const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
6777
+ if (record.startedAt !== undefined && previous?.startedAt === undefined) transitionType ??= "agent_start";
5341
6778
  const previousPendingOutcome = (
5342
6779
  previous as
5343
6780
  | (SdkOnlyInvocationRecord & { pendingOutcome?: { kind?: string; code?: string; reason?: string } })
@@ -5367,11 +6804,12 @@ function createInterceptorReconciliationStore(
5367
6804
  hold.onEntered();
5368
6805
  await hold.release;
5369
6806
  }
5370
- 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 }>)
5371
6808
  onDurableAttempt?.({
5372
6809
  type: transitionType,
5373
6810
  commandId: record?.commandId,
5374
6811
  turnId: record?.turnId,
6812
+ terminalAt: record?.terminalAt,
5375
6813
  outcome: "committed",
5376
6814
  });
5377
6815
  backing.records = records;
@@ -5508,7 +6946,7 @@ function realSendUserMessage(session: AgentSession) {
5508
6946
  return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
5509
6947
  await options?.onPreflightAcceptCommit?.();
5510
6948
  const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
5511
- await session.sendUserMessage(content as string, dispatchOptions as never);
6949
+ await session.sendUserMessage(content as string, dispatchOptions as SendUserMessageOptions);
5512
6950
  };
5513
6951
  }
5514
6952
 
@@ -8540,8 +9978,25 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8540
9978
  }
8541
9979
  });
8542
9980
 
8543
- 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 () => {
8544
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
+ });
8545
10000
  const sessionId = "deadline-captured-uncertain";
8546
10001
  const sessionFile = path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`);
8547
10002
  await Bun.write(sessionFile, "");
@@ -8564,15 +10019,18 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8564
10019
  const persistenceLog: Array<PersistenceInstrumentationEvent & { label: string }> = [];
8565
10020
  let boundaryWaitStarted = false;
8566
10021
  let abortCalls = 0;
10022
+ let sends = 0;
8567
10023
  let harness: InvocationHarness | undefined;
8568
10024
  try {
8569
10025
  harness = await invocationHarness(sessionId, cwd, {
10026
+ connectionLiveness: true,
8570
10027
  settings: {
8571
10028
  // This test covers deferred terminal recovery, not deadline-triggered worktree persistence.
8572
10029
  get: (key: string) =>
8573
10030
  key === "sdk.promptDeadlineMs" ? 150 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
8574
10031
  } as unknown as Settings,
8575
10032
  sendUserMessage: async (_content, options) => {
10033
+ sends++;
8576
10034
  await options?.onPreflightAcceptCommit?.();
8577
10035
  await neverSettlingPromise();
8578
10036
  },
@@ -8601,7 +10059,13 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8601
10059
  },
8602
10060
  },
8603
10061
  });
8604
- 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
+ });
8605
10069
  expect(accepted.ok).toBe(true);
8606
10070
  const commandId = accepted.result?.commandId;
8607
10071
  const turnId = accepted.result?.turnId;
@@ -8620,6 +10084,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8620
10084
  await harness.emit("agent_end", { stopReason: "cancelled" });
8621
10085
  await Bun.sleep(5_200);
8622
10086
  expect(abortCalls).toBe(0);
10087
+ expect(releaseCalls).toEqual([]);
8623
10088
  expect((await harness.query("turn.prompt_status", correlation)).result).toMatchObject({ status: "in_flight" });
8624
10089
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toEqual([]);
8625
10090
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toEqual([]);
@@ -8631,6 +10096,17 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8631
10096
  deadlineRecoveryPending: true,
8632
10097
  pendingOutcome: { kind: "stopped", reason: "cancelled" },
8633
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([]);
8634
10110
  const reopened = createInvocationReconciliation({
8635
10111
  store: createReconciliationStore({ sessionFile, sessionId }),
8636
10112
  });
@@ -8679,8 +10155,10 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8679
10155
  outcome: { kind: "stopped", reason: "cancelled" },
8680
10156
  });
8681
10157
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toHaveLength(1);
10158
+ expect(releaseCalls).toEqual(["released"]);
8682
10159
  } finally {
8683
10160
  await harness?.stop();
10161
+ redeemSpy.mockRestore();
8684
10162
  await Bun.sleep(10);
8685
10163
  await rm(cwd, { recursive: true, force: true });
8686
10164
  }
@@ -8808,7 +10286,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8808
10286
  toolName: "bash",
8809
10287
  args: {},
8810
10288
  });
8811
- 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;
8812
10292
  while (!boundaryWaitStarted && Date.now() < boundaryDeadline) await Bun.sleep(10);
8813
10293
  expect(boundaryWaitStarted).toBe(true);
8814
10294
  expect(abortCalls).toHaveLength(0);
@@ -8822,7 +10302,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8822
10302
  });
8823
10303
  await Bun.sleep(30);
8824
10304
  expect(abortCalls).toHaveLength(0);
8825
- const abortDeadline = Date.now() + 2_000;
10305
+ const abortDeadline = Date.now() + 10_000;
8826
10306
  while (abortCalls.length === 0 && Date.now() < abortDeadline) await Bun.sleep(10);
8827
10307
  expect(abortCalls).toEqual([{ handle: "deadline-tool-run", scope: "owned", expectedEpoch: epoch }]);
8828
10308
  expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
@@ -9126,6 +10606,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9126
10606
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-start-race-"));
9127
10607
  const claimEntered = Promise.withResolvers<void>();
9128
10608
  const releaseClaim = Promise.withResolvers<void>();
10609
+ const persistenceEvents: PersistenceInstrumentationEvent[] = [];
10610
+ const durableAttempts: string[] = [];
9129
10611
  const epoch = 83;
9130
10612
  let harness: InvocationHarness | undefined;
9131
10613
  let startPromise: Promise<void> | undefined;
@@ -9141,6 +10623,20 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9141
10623
  },
9142
10624
  persistInterceptor: () => {},
9143
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
+ },
9144
10640
  persistHolds: [
9145
10641
  {
9146
10642
  type: "deadline_claim",
@@ -9172,12 +10668,30 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9172
10668
  const accepted = await harness.control("turn.prompt", { text: "start while expiring" });
9173
10669
  expect(accepted.ok).toBe(true);
9174
10670
  const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
9175
- const claimDeadline = Date.now() + 2_000;
10671
+ const claimWaitStartedAt = Date.now();
10672
+ const claimDeadline = claimWaitStartedAt + 10_000;
9176
10673
  while (!claimWasEntered && Date.now() < claimDeadline) await Bun.sleep(5);
9177
- 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
+ }
9178
10692
  await claimEntered.promise;
9179
10693
  startPromise = harness.emit("agent_start");
9180
- const captureDeadline = Date.now() + 2_000;
10694
+ const captureDeadline = Date.now() + 10_000;
9181
10695
  while (handleReads === 0 && Date.now() < captureDeadline) await Bun.sleep(5);
9182
10696
  expect(handleReads).toBeGreaterThan(0);
9183
10697
  releaseClaim.resolve();
@@ -9196,7 +10710,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9196
10710
  await harness?.stop();
9197
10711
  await rm(cwd, { recursive: true, force: true });
9198
10712
  }
9199
- });
10713
+ }, 15_000);
9200
10714
 
9201
10715
  test("agent_start during an expiry claim keeps the queued correlation owned", async () => {
9202
10716
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-start-race-"));
@@ -9209,8 +10723,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9209
10723
  let claimWasEntered = false;
9210
10724
  let handleReads = 0;
9211
10725
  let boundaryWaitStarted = false;
10726
+ let toolDrainWasObserved = false;
9212
10727
  let abortCalls = 0;
9213
- let terminalEventPromise: Promise<void> | undefined;
9214
10728
  try {
9215
10729
  harness = await invocationHarness("deadline-start-race", cwd, {
9216
10730
  settings: zeroProgressSettings,
@@ -9238,8 +10752,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9238
10752
  },
9239
10753
  pendingToolExecutions: () => {
9240
10754
  if (activeTools.size > 0) boundaryWaitStarted = true;
9241
- else if (boundaryWaitStarted && terminalEventPromise === undefined)
9242
- terminalEventPromise = harness?.emit("agent_end", { stopReason: "cancelled" });
10755
+ else if (boundaryWaitStarted) toolDrainWasObserved = true;
9243
10756
  return [...activeTools];
9244
10757
  },
9245
10758
  abortPromptAndWaitWithTerminal: async () => {
@@ -9264,15 +10777,14 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9264
10777
  while (!boundaryWaitStarted && Date.now() < boundaryDeadline) await Bun.sleep(5);
9265
10778
  expect(boundaryWaitStarted).toBe(true);
9266
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.
9267
10781
  activeTools.clear();
9268
- const terminalEventDeadline = Date.now() + 2_000;
9269
- while (terminalEventPromise === undefined && Date.now() < terminalEventDeadline) await Bun.sleep(5);
9270
- expect(terminalEventPromise).toBeDefined();
9271
- await terminalEventPromise;
10782
+ await harness.emit("agent_end", { stopReason: "cancelled" });
9272
10783
  expect(await settledStatus(harness, "turn.prompt_status", correlation)).toMatchObject({
9273
10784
  status: "terminal_ok",
9274
10785
  outcome: { kind: "stopped", reason: "cancelled" },
9275
10786
  });
10787
+ expect(toolDrainWasObserved).toBe(true);
9276
10788
  expect(abortCalls).toBeLessThanOrEqual(1);
9277
10789
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toHaveLength(1);
9278
10790
  expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
@@ -9281,7 +10793,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
9281
10793
  await harness?.stop();
9282
10794
  await rm(cwd, { recursive: true, force: true });
9283
10795
  }
9284
- });
10796
+ // Real timers plus a reconciliation claim exceed the default 5s on a loaded shard.
10797
+ }, 20_000);
9285
10798
 
9286
10799
  test("a sibling publication failure does not hide the expiring correlation's end", async () => {
9287
10800
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-deadline-sibling-publication-"));