@gajae-code/coding-agent 0.18.6 → 0.18.8

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 (302) hide show
  1. package/CHANGELOG.md +318 -1
  2. package/dist/types/async/job-manager.d.ts +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/capability/skill.d.ts +2 -0
  5. package/dist/types/capability/types.d.ts +6 -0
  6. package/dist/types/cli/public-command-errors.d.ts +3 -1
  7. package/dist/types/cli/setup-cli.d.ts +1 -1
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  10. package/dist/types/config/file-lock.d.ts +1 -0
  11. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  12. package/dist/types/config/model-profile-activation.d.ts +11 -3
  13. package/dist/types/config/model-registry.d.ts +4 -9
  14. package/dist/types/config/settings.d.ts +4 -0
  15. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  16. package/dist/types/cursor-delete-path.d.ts +1 -0
  17. package/dist/types/cursor-mcp-name.d.ts +8 -0
  18. package/dist/types/customization/inventory.d.ts +2 -0
  19. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  22. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  23. package/dist/types/discovery/helpers.d.ts +8 -3
  24. package/dist/types/export/html/css-color.d.ts +2 -0
  25. package/dist/types/export/html/template.generated.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  27. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  28. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  29. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  30. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  31. package/dist/types/extensibility/skill-management.d.ts +3 -1
  32. package/dist/types/extensibility/skills.d.ts +2 -0
  33. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  34. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  35. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  36. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  37. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  38. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  39. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  40. package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
  41. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  42. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  43. package/dist/types/lsp/workspace-path.d.ts +1 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  45. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  46. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  47. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  48. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  49. package/dist/types/modes/interactive-mode.d.ts +1 -0
  50. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  51. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  52. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  53. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  54. package/dist/types/progress/progress-contract.d.ts +154 -0
  55. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  56. package/dist/types/progress/project-progress.d.ts +141 -0
  57. package/dist/types/progress/render-progress.d.ts +28 -0
  58. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  59. package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
  60. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  61. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  62. package/dist/types/runtime-mcp/types.d.ts +6 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  64. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  65. package/dist/types/sdk/broker/broker.d.ts +18 -1
  66. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  67. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  68. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  69. package/dist/types/sdk/bus/index.d.ts +12 -1
  70. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  71. package/dist/types/sdk/bus/telegram-daemon.d.ts +3 -2
  72. package/dist/types/sdk/client/client.d.ts +2 -0
  73. package/dist/types/sdk/host/host.d.ts +6 -0
  74. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  75. package/dist/types/sdk/index.d.ts +1 -0
  76. package/dist/types/sdk/router/session-router.d.ts +9 -0
  77. package/dist/types/sdk/session-list.d.ts +1 -1
  78. package/dist/types/sdk/session.d.ts +8 -0
  79. package/dist/types/session/agent-session.d.ts +38 -9
  80. package/dist/types/session/artifacts.d.ts +2 -2
  81. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  82. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  83. package/dist/types/session/internal/managed-session-storage.d.ts +36 -1
  84. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  85. package/dist/types/session/internal/task-artifact-owner-access.d.ts +19 -0
  86. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +11 -0
  87. package/dist/types/session/session-manager.d.ts +31 -5
  88. package/dist/types/session/session-retirement.d.ts +71 -0
  89. package/dist/types/session/session-storage.d.ts +62 -48
  90. package/dist/types/session/session-transcript-header.d.ts +2 -0
  91. package/dist/types/session/task-artifact-owner-codec.d.ts +109 -0
  92. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  93. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  94. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  95. package/dist/types/setup/credential-import.d.ts +6 -4
  96. package/dist/types/skill-state/active-state.d.ts +38 -0
  97. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  98. package/dist/types/task/executor.d.ts +3 -0
  99. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  100. package/dist/types/tools/index.d.ts +3 -0
  101. package/dist/types/tools/python.d.ts +9 -1
  102. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  103. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  104. package/dist/types/tools/todo-write.d.ts +6 -0
  105. package/dist/types/utils/tools-checksum.d.ts +7 -0
  106. package/package.json +7 -7
  107. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  108. package/scripts/resident-memory-bench.ts +174 -20
  109. package/scripts/verify-managed-empty-stop.ts +28 -0
  110. package/src/async/job-manager.ts +228 -122
  111. package/src/capability/index.ts +18 -0
  112. package/src/capability/mcp.ts +2 -0
  113. package/src/capability/skill.ts +2 -0
  114. package/src/capability/types.ts +6 -0
  115. package/src/cli/auth-broker-cli.ts +1 -1
  116. package/src/cli/config-cli.ts +8 -18
  117. package/src/cli/customize-doctor.ts +15 -2
  118. package/src/cli/public-command-errors.ts +21 -1
  119. package/src/cli/setup-cli.ts +4 -4
  120. package/src/cli/skills-cli.ts +5 -0
  121. package/src/cli-main.ts +7 -8
  122. package/src/commands/deep-interview.ts +47 -8
  123. package/src/commands/sdk.ts +140 -29
  124. package/src/config/autorouting-tier-map.ts +117 -7
  125. package/src/config/file-lock.ts +307 -68
  126. package/src/config/mcp-schema.json +4 -0
  127. package/src/config/model-bindings-applier.ts +20 -0
  128. package/src/config/model-profile-activation.ts +240 -46
  129. package/src/config/model-registry.ts +47 -153
  130. package/src/config/settings.ts +44 -4
  131. package/src/config/skill-settings-defaults.ts +7 -0
  132. package/src/coordinator-mcp/policy.ts +3 -2
  133. package/src/cursor-delete-path.ts +10 -0
  134. package/src/cursor-mcp-name.ts +15 -0
  135. package/src/cursor.ts +13 -2
  136. package/src/customization/inventory.ts +3 -0
  137. package/src/debug/report-bundle-parent.ts +22 -0
  138. package/src/debug/report-bundle.ts +3 -0
  139. package/src/debug/system-info.ts +13 -1
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  141. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  142. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  143. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  144. package/src/defaults/gjc-defaults.test.ts +18 -1
  145. package/src/defaults/gjc-defaults.ts +3 -0
  146. package/src/defaults/gjc-skills.generated.ts +58 -36
  147. package/src/discovery/agents.ts +6 -1
  148. package/src/discovery/builtin.ts +10 -0
  149. package/src/discovery/helpers.ts +168 -23
  150. package/src/discovery/mcp-json.ts +6 -0
  151. package/src/discovery/ssh.ts +9 -4
  152. package/src/discovery/substitute-plugin-root.ts +3 -1
  153. package/src/edit/file-read-cache.ts +1 -1
  154. package/src/eval/py/executor.ts +380 -120
  155. package/src/export/html/css-color.ts +9 -0
  156. package/src/export/html/index.ts +5 -4
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/extensibility/extensions/runner.ts +4 -0
  159. package/src/extensibility/extensions/types.ts +7 -0
  160. package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
  161. package/src/extensibility/plugins/manager.ts +3 -2
  162. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  163. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  164. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  165. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  166. package/src/extensibility/skill-management.ts +6 -2
  167. package/src/extensibility/skills.ts +6 -1
  168. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  169. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  170. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  171. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  172. package/src/gjc-runtime/gc-runtime.ts +206 -8
  173. package/src/gjc-runtime/launch-worktree.ts +14 -4
  174. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  175. package/src/gjc-runtime/state-argv.ts +2 -0
  176. package/src/gjc-runtime/state-migrations.ts +91 -29
  177. package/src/gjc-runtime/state-runtime.ts +3716 -135
  178. package/src/gjc-runtime/state-writer.ts +83 -8
  179. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  180. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  181. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  182. package/src/harness-control-plane/operate.ts +12 -4
  183. package/src/harness-control-plane/owner.ts +5 -1
  184. package/src/hashline/parser.ts +48 -9
  185. package/src/hashline/recovery.ts +13 -4
  186. package/src/hindsight/config.ts +9 -3
  187. package/src/internal-urls/docs-index.generated.ts +19 -18
  188. package/src/internal-urls/skill-path.ts +19 -0
  189. package/src/internal-urls/skill-protocol.ts +2 -0
  190. package/src/lsp/edits.ts +6 -0
  191. package/src/lsp/workspace-path.ts +51 -0
  192. package/src/main.ts +195 -31
  193. package/src/modes/acp/acp-agent.ts +163 -7
  194. package/src/modes/components/custom-editor.ts +14 -0
  195. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  196. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  197. package/src/modes/components/extensions/state-manager.ts +15 -5
  198. package/src/modes/components/model-selector.ts +1 -4
  199. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  200. package/src/modes/components/status-line/segments.ts +4 -0
  201. package/src/modes/controllers/event-controller.ts +11 -4
  202. package/src/modes/controllers/input-controller.ts +74 -29
  203. package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
  204. package/src/modes/controllers/selector-controller.ts +28 -15
  205. package/src/modes/interactive-mode.ts +4 -0
  206. package/src/modes/runtime-init.ts +4 -0
  207. package/src/modes/utils/abort-message.ts +3 -1
  208. package/src/modes/utils/interrupt-ownership.ts +12 -0
  209. package/src/modes/utils/keybinding-matchers.ts +4 -0
  210. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  211. package/src/progress/collect-project-progress.ts +153 -0
  212. package/src/progress/progress-contract.ts +172 -0
  213. package/src/progress/progress-snapshot.ts +140 -0
  214. package/src/progress/project-progress.ts +446 -0
  215. package/src/progress/render-progress.ts +209 -0
  216. package/src/prompts/system/master-mode.md +1 -0
  217. package/src/prompts/tools/skill-discovery.md +1 -1
  218. package/src/runtime-mcp/client.ts +13 -4
  219. package/src/runtime-mcp/config-writer.ts +10 -6
  220. package/src/runtime-mcp/config.ts +1 -0
  221. package/src/runtime-mcp/elicitation.ts +142 -0
  222. package/src/runtime-mcp/oauth-flow.ts +5 -0
  223. package/src/runtime-mcp/oauth-public-url.ts +8 -0
  224. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  225. package/src/runtime-mcp/smithery-registry.ts +22 -3
  226. package/src/runtime-mcp/transports/http.ts +2 -2
  227. package/src/runtime-mcp/types.ts +6 -0
  228. package/src/sdk/acp/adapter.ts +93 -8
  229. package/src/sdk/broker/broker-exit.ts +52 -23
  230. package/src/sdk/broker/broker.ts +83 -7
  231. package/src/sdk/broker/ensure.ts +2 -0
  232. package/src/sdk/broker/lifecycle.ts +820 -73
  233. package/src/sdk/broker/session-index.ts +191 -81
  234. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  235. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  236. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  238. package/src/sdk/bus/index.ts +241 -46
  239. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  240. package/src/sdk/bus/telegram-daemon.ts +4 -2
  241. package/src/sdk/cli/session-cli.ts +17 -3
  242. package/src/sdk/client/client.ts +28 -8
  243. package/src/sdk/host/host.ts +6 -0
  244. package/src/sdk/host/query/handlers.ts +9 -1
  245. package/src/sdk/host/session-runtime.test.ts +2805 -411
  246. package/src/sdk/host/session-runtime.ts +428 -111
  247. package/src/sdk/host/surface-policy.ts +1 -0
  248. package/src/sdk/index.ts +8 -0
  249. package/src/sdk/lifecycle-session.ts +3 -1
  250. package/src/sdk/mcp/server.ts +15 -3
  251. package/src/sdk/prompt-deadline-manager.ts +15 -0
  252. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  253. package/src/sdk/protocol/operation-registry.ts +9 -2
  254. package/src/sdk/router/session-router.ts +94 -14
  255. package/src/sdk/session-list.ts +1 -0
  256. package/src/sdk/session.ts +81 -38
  257. package/src/session/agent-session.ts +674 -224
  258. package/src/session/artifacts.ts +174 -70
  259. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  260. package/src/session/internal/managed-session-scope.ts +3457 -462
  261. package/src/session/internal/managed-session-storage.ts +482 -61
  262. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  263. package/src/session/internal/task-artifact-owner-access.ts +245 -0
  264. package/src/session/internal/task-artifact-owner-transcript.ts +614 -0
  265. package/src/session/session-manager.ts +939 -133
  266. package/src/session/session-retirement.ts +714 -0
  267. package/src/session/session-storage.ts +418 -174
  268. package/src/session/session-transcript-header.ts +17 -0
  269. package/src/session/task-artifact-owner-codec.ts +641 -0
  270. package/src/session/task-artifact-owner-retirement.ts +477 -0
  271. package/src/session/task-artifact-owner.ts +289 -0
  272. package/src/setup/credential-auto-import.ts +12 -3
  273. package/src/setup/credential-import.ts +176 -5
  274. package/src/setup/paseo/announce.ts +6 -1
  275. package/src/skill-state/active-state.ts +132 -2
  276. package/src/slash-commands/builtin-registry.ts +39 -9
  277. package/src/slash-commands/helpers/progress-report.ts +23 -0
  278. package/src/stt/setup.ts +3 -5
  279. package/src/task/commands.ts +3 -1
  280. package/src/task/executor.ts +4 -0
  281. package/src/task/index.ts +6 -0
  282. package/src/tools/ask.ts +205 -6
  283. package/src/tools/ast-edit.ts +4 -0
  284. package/src/tools/bash-allowed-prefixes.ts +15 -0
  285. package/src/tools/bash-skill-urls.ts +56 -3
  286. package/src/tools/bash.ts +48 -79
  287. package/src/tools/descriptors.ts +5 -1
  288. package/src/tools/eval.ts +56 -36
  289. package/src/tools/image-gen.ts +1 -1
  290. package/src/tools/index.ts +3 -0
  291. package/src/tools/python.ts +187 -66
  292. package/src/tools/recipe/index.ts +9 -0
  293. package/src/tools/recipe/recipe-guard.ts +19 -0
  294. package/src/tools/restricted-cwd.ts +10 -0
  295. package/src/tools/skill-discovery.ts +5 -0
  296. package/src/tools/skill.ts +28 -1
  297. package/src/tools/todo-write.ts +10 -1
  298. package/src/tools/tool-catalog.generated.ts +1 -1
  299. package/src/tools/write.ts +1 -0
  300. package/src/utils/tools-checksum.ts +34 -0
  301. package/src/utils/tools-manager.ts +24 -6
  302. package/src/web/search/providers/codex.ts +3 -2
@@ -1,13 +1,25 @@
1
1
  import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test";
2
2
  import { createHash } from "node:crypto";
3
+ import * as fsPromises from "node:fs/promises";
3
4
  import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
6
- import { markNonDispatchedToolEvent } from "@gajae-code/agent-core";
7
+ import { Agent, type AgentOptions, markNonDispatchedToolEvent, ThinkingLevel } from "@gajae-code/agent-core";
8
+ import { createAttemptMinter } from "@gajae-code/agent-core/attempt-scope";
9
+ import { CompactionCancelledError } from "@gajae-code/agent-core/compaction";
10
+ import { type AssistantMessage, getBundledModel, type Model, type UserMessage } from "@gajae-code/ai/core";
11
+ import { createMockModel } from "@gajae-code/ai/providers/mock";
12
+ import { AssistantMessageEventStream } from "@gajae-code/ai/utils/event-stream";
7
13
  import { logger } from "@gajae-code/utils";
14
+ import { createTestSession } from "../../../test/utilities";
8
15
  import { AsyncJobManager } from "../../async";
9
- import type { Settings } from "../../config/settings";
16
+ import { ModelRegistry } from "../../config/model-registry";
17
+ import { Settings } from "../../config/settings";
10
18
  import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
19
+ import { ExtensionRunner } from "../../extensibility/extensions/runner";
20
+ import { AgentSession } from "../../session/agent-session";
21
+ import { AuthStorage } from "../../session/auth-storage";
22
+ import { SessionManager } from "../../session/session-manager";
11
23
  import {
12
24
  registerOwnedRegistration,
13
25
  resetTerminalAbortRegistriesForTests,
@@ -15,14 +27,21 @@ import {
15
27
  unregisterOwnedRegistration,
16
28
  } from "../../session/terminal-abort";
17
29
  import { Broker } from "../broker/broker";
30
+ import type { BrokerDiscovery } from "../broker/discovery";
31
+ import { SessionIndex, type SessionIndexEvent } from "../broker/session-index";
18
32
  import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
19
33
  import { createPromptReconciliation } from "../bus/prompt-reconciliation";
20
- import { createReconciliationStore } from "../bus/reconciliation-store";
34
+ import {
35
+ createReconciliationStore,
36
+ type ReconciliationStoreDocument,
37
+ reconciliationStorePath,
38
+ } from "../bus/reconciliation-store";
21
39
  import { PromptDeadlineManager } from "../prompt-deadline-manager";
22
40
  import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
23
41
  import { SESSION_HOST_OBSERVER_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host";
24
42
  import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
25
43
  import {
44
+ type CreateSdkSessionRuntimeOptions,
26
45
  createInvocationReconciliation,
27
46
  createSdkSessionRuntimeExtension,
28
47
  createSdkSurfaceFactory,
@@ -103,7 +122,46 @@ test("runtime capabilities preserve explicit primary control surface", () => {
103
122
  expect(createSdkCapabilities(policy, false, "cli")).toMatchObject({ primaryControlSurface: "cli" });
104
123
  });
105
124
 
106
- function memoryTransport(): SessionSdkTransport & {
125
+ test("thinking.set uses the control setter and reports the applied session level", async () => {
126
+ const sessionContext = await createTestSession({ inMemory: true });
127
+ const { session, tempDir: cwd, cleanup } = sessionContext;
128
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
129
+ const api = {
130
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
131
+ handlers.set(event, handler);
132
+ },
133
+ sendUserMessage: async () => {},
134
+ setThinkingLevel: () => {
135
+ throw new Error("thinking.set must not use the legacy setter");
136
+ },
137
+ setThinkingLevelForControl: async (level: ThinkingLevel, persist: boolean) => {
138
+ expect(persist).toBe(false);
139
+ await session.setThinkingLevelForControl(level, persist);
140
+ },
141
+ } as unknown as ExtensionAPI;
142
+ const response = Promise.withResolvers<SdkFrame>();
143
+ const transport = memoryTransport(frame => {
144
+ if (frame.type === "control_response" && frame.id === "thinking-set") response.resolve(frame);
145
+ });
146
+ createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
147
+ const ctx = extensionContext(transport.sessionId, cwd);
148
+ try {
149
+ await handlers.get("session_start")?.({}, ctx);
150
+ transport.feed("client", {
151
+ type: "control_request",
152
+ id: "thinking-set",
153
+ operation: "thinking.set",
154
+ input: { level: ThinkingLevel.Medium },
155
+ } as SdkFrame);
156
+ await response.promise;
157
+ expect(session.thinkingLevel).toBe(ThinkingLevel.Medium);
158
+ } finally {
159
+ await handlers.get("session_shutdown")?.({}, ctx);
160
+ await cleanup();
161
+ }
162
+ });
163
+
164
+ function memoryTransport(onSend?: (frame: SdkFrame) => void): SessionSdkTransport & {
107
165
  feed(connectionId: string, frame: SdkFrame): void;
108
166
  readonly sent: SdkFrame[];
109
167
  readonly broadcasts: SdkFrame[];
@@ -126,6 +184,7 @@ function memoryTransport(): SessionSdkTransport & {
126
184
  },
127
185
  sendFrame(_connectionId, frame) {
128
186
  sent.push(frame);
187
+ onSend?.(frame);
129
188
  },
130
189
  start: async () => {
131
190
  started = true;
@@ -1652,7 +1711,7 @@ describe("SessionSdkSessionRuntime", () => {
1652
1711
  sendUserMessage: async () => {},
1653
1712
  } as unknown as ExtensionAPI;
1654
1713
  const transport = memoryTransport();
1655
- createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1714
+ createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1656
1715
  const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
1657
1716
  const ctx = {
1658
1717
  ...extensionContext(transport.sessionId, cwd),
@@ -1708,7 +1767,7 @@ describe("SessionSdkSessionRuntime", () => {
1708
1767
  }
1709
1768
  broadcastFrame?.(frame);
1710
1769
  };
1711
- createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1770
+ createTestRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1712
1771
  const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
1713
1772
  const ctx = {
1714
1773
  ...extensionContext(transport.sessionId, cwd),
@@ -1769,7 +1828,7 @@ describe("SessionSdkSessionRuntime", () => {
1769
1828
  let activeHandle: string | undefined = "exact-run-handle";
1770
1829
  let activeEpoch: number | undefined = 7;
1771
1830
  let captureCalls = 0;
1772
- createSdkSessionRuntimeExtension(api, {
1831
+ createTestRuntimeExtension(api, {
1773
1832
  agentDir: cwd,
1774
1833
  createTransport: async () => transport,
1775
1834
  onSdkRequest: undefined,
@@ -1968,7 +2027,7 @@ describe("SessionSdkSessionRuntime", () => {
1968
2027
  });
1969
2028
  const seamCalls: Array<{ handle: string; scope: string }> = [];
1970
2029
  let captureCalls = 0;
1971
- createSdkSessionRuntimeExtension(api, {
2030
+ createTestRuntimeExtension(api, {
1972
2031
  agentDir: cwd,
1973
2032
  createTransport: async () => transport,
1974
2033
  terminalAbortSeams: {
@@ -2065,7 +2124,7 @@ describe("SessionSdkSessionRuntime", () => {
2065
2124
  // exactly the window where writeNoEffect awaits the store.
2066
2125
  let promptReads = 0;
2067
2126
  let captureCalls = 0;
2068
- createSdkSessionRuntimeExtension(api, {
2127
+ createTestRuntimeExtension(api, {
2069
2128
  agentDir: cwd,
2070
2129
  createTransport: async () => transport,
2071
2130
  terminalAbortSeams: {
@@ -2150,7 +2209,7 @@ describe("SessionSdkSessionRuntime", () => {
2150
2209
  });
2151
2210
  const seamCalls: string[] = [];
2152
2211
  let promptReads = 0;
2153
- createSdkSessionRuntimeExtension(api, {
2212
+ createTestRuntimeExtension(api, {
2154
2213
  agentDir: cwd,
2155
2214
  createTransport: async () => transport,
2156
2215
  terminalAbortSeams: {
@@ -2220,7 +2279,7 @@ describe("SessionSdkSessionRuntime", () => {
2220
2279
  // (review thread P1).
2221
2280
  let reads = 0;
2222
2281
  let captureCalls = 0;
2223
- createSdkSessionRuntimeExtension(api, {
2282
+ createTestRuntimeExtension(api, {
2224
2283
  agentDir: cwd,
2225
2284
  createTransport: async () => transport,
2226
2285
  terminalAbortSeams: {
@@ -2313,7 +2372,7 @@ describe("SessionSdkSessionRuntime", () => {
2313
2372
  sessionId: transport.sessionId,
2314
2373
  });
2315
2374
  let captureCalls = 0;
2316
- createSdkSessionRuntimeExtension(api, {
2375
+ createTestRuntimeExtension(api, {
2317
2376
  agentDir: cwd,
2318
2377
  createTransport: async () => transport,
2319
2378
  terminalAbortSeams: {
@@ -2380,7 +2439,7 @@ describe("SessionSdkSessionRuntime", () => {
2380
2439
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2381
2440
  let seamCount = 0;
2382
2441
  let captureCalls = 0;
2383
- createSdkSessionRuntimeExtension(api, {
2442
+ createTestRuntimeExtension(api, {
2384
2443
  agentDir: cwd,
2385
2444
  createTransport: async () => transport,
2386
2445
  terminalAbortSeams: {
@@ -2489,7 +2548,7 @@ describe("SessionSdkSessionRuntime", () => {
2489
2548
  let activeHandle = "predecessor";
2490
2549
  let abortCalls = 0;
2491
2550
  const abortRelease = Promise.withResolvers<void>();
2492
- createSdkSessionRuntimeExtension(api, {
2551
+ createTestRuntimeExtension(api, {
2493
2552
  agentDir: cwd,
2494
2553
  createTransport: async () => transport,
2495
2554
  terminalAbortSeams: {
@@ -2577,7 +2636,7 @@ describe("SessionSdkSessionRuntime", () => {
2577
2636
  sessionFile: path.join(cwd, "session.json"),
2578
2637
  sessionId: transport.sessionId,
2579
2638
  });
2580
- createSdkSessionRuntimeExtension(api, {
2639
+ createTestRuntimeExtension(api, {
2581
2640
  agentDir: cwd,
2582
2641
  createTransport: async () => transport,
2583
2642
  terminalAbortSeams: {
@@ -2624,24 +2683,38 @@ describe("SessionSdkSessionRuntime", () => {
2624
2683
  }
2625
2684
  });
2626
2685
 
2627
- test("SDK-only host cancels only the preflights admitted at abort time, never a pipelined successor", async () => {
2686
+ test.each([
2687
+ "ordinary",
2688
+ "terminal",
2689
+ ] as const)("SDK-only %s abort cancels only its admitted preflight snapshot", async mode => {
2628
2690
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-preflight-snapshot-"));
2691
+ const signals = new Map<string, AbortSignal>();
2692
+ const acceptCallbacks = new Map<string, () => void | Promise<void>>();
2693
+ let rootAbortCalls = 0;
2629
2694
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
2630
2695
  const api = {
2631
2696
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2632
2697
  handlers.set(event, handler);
2633
2698
  },
2634
- // Hold every prompt preflight open: acceptance is never signalled, so
2635
- // each submission stays pending until the abort (or nothing) settles it.
2636
- // Deferred via Promise.withResolvers per the repository contract.
2637
- sendUserMessage: () => Promise.withResolvers<void>().promise,
2699
+ // Keep actual admission capabilities pending, rather than inventing
2700
+ // an acceptance or terminal. Record before the runtime can reject.
2701
+ sendUserMessage: (
2702
+ content: Parameters<ExtensionAPI["sendUserMessage"]>[0],
2703
+ options: PreflightHooks | undefined,
2704
+ ) => {
2705
+ const text = typeof content === "string" ? content : content.find(block => block.type === "text")?.text;
2706
+ if (text !== undefined && options?.preflightSignal) signals.set(text, options.preflightSignal);
2707
+ if (text !== undefined && options?.onPreflightAcceptCommit)
2708
+ acceptCallbacks.set(text, options.onPreflightAcceptCommit);
2709
+ return Promise.withResolvers<void>().promise;
2710
+ },
2638
2711
  } as unknown as ExtensionAPI;
2639
2712
  const transport = memoryTransport();
2640
2713
  const reconciliationStore = createReconciliationStore({
2641
2714
  sessionFile: path.join(cwd, "session.json"),
2642
2715
  sessionId: transport.sessionId,
2643
2716
  });
2644
- createSdkSessionRuntimeExtension(api, {
2717
+ createTestRuntimeExtension(api, {
2645
2718
  agentDir: cwd,
2646
2719
  createTransport: async () => transport,
2647
2720
  terminalAbortSeams: {
@@ -2656,7 +2729,12 @@ describe("SessionSdkSessionRuntime", () => {
2656
2729
  },
2657
2730
  },
2658
2731
  });
2659
- const ctx = extensionContext(transport.sessionId, cwd);
2732
+ const ctx = {
2733
+ ...extensionContext(transport.sessionId, cwd),
2734
+ abort: async () => {
2735
+ rootAbortCalls++;
2736
+ },
2737
+ };
2660
2738
  try {
2661
2739
  await handlers.get("session_start")?.({}, ctx);
2662
2740
  const prompt = (id: string) =>
@@ -2667,14 +2745,22 @@ describe("SessionSdkSessionRuntime", () => {
2667
2745
  input: { text: `hold-${id}`, images: [] },
2668
2746
  } as SdkFrame);
2669
2747
  prompt("pre-1");
2670
- // Let the serialized (ordered) turn.prompt work register its preflight
2671
- // callback, so the abort's admission snapshot below sees it.
2672
- await Bun.sleep(0);
2748
+ transport.feed("conn-b", {
2749
+ type: "control_request",
2750
+ id: "foreign-preflight",
2751
+ operation: "turn.prompt",
2752
+ input: { text: "foreign-pending", images: [] },
2753
+ } as SdkFrame);
2754
+ const readyDeadline = Date.now() + 2_000;
2755
+ while (!signals.has("hold-pre-1")) {
2756
+ if (Date.now() >= readyDeadline) throw new Error("Owned preflight was not admitted.");
2757
+ await Bun.sleep(10);
2758
+ }
2673
2759
  transport.feed("conn-a", {
2674
2760
  type: "control_request",
2675
2761
  id: "pre-abort",
2676
2762
  operation: "turn.abort",
2677
- input: { mode: "terminal" },
2763
+ input: mode === "terminal" ? { mode: "terminal" } : {},
2678
2764
  idempotencyKey: "pre-snapshot-key",
2679
2765
  } as SdkFrame);
2680
2766
  // A successor prompt pipelined by the SAME connection while the abort
@@ -2684,12 +2770,30 @@ describe("SessionSdkSessionRuntime", () => {
2684
2770
  const deadline = Date.now() + 15_000;
2685
2771
  while (
2686
2772
  !transport.sent.some(frame => frame.id === "pre-abort" && frame.type === "control_response") ||
2687
- !transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response")
2773
+ !transport.sent.some(frame => frame.id === "pre-1" && frame.type === "control_response") ||
2774
+ !signals.has("foreign-pending")
2688
2775
  ) {
2689
2776
  if (Date.now() > deadline)
2690
2777
  throw new Error("Timed out waiting for the abort and admitted-preflight responses");
2691
2778
  await Bun.sleep(20);
2692
2779
  }
2780
+ expect(rootAbortCalls).toBe(0);
2781
+ expect(signals.get("hold-pre-1")?.aborted).toBe(true);
2782
+ expect(signals.get("foreign-pending")?.aborted).toBe(false);
2783
+ // Prompt controls are serialized. Commit the untouched foreign
2784
+ // admission through its real callback so the later request can enter.
2785
+ const acceptForeign = acceptCallbacks.get("foreign-pending");
2786
+ expect(acceptForeign).toBeDefined();
2787
+ await acceptForeign!();
2788
+ while (!signals.has("hold-pre-2")) {
2789
+ if (Date.now() > deadline) throw new Error("Later admission did not enter after foreign acceptance.");
2790
+ await Bun.sleep(10);
2791
+ }
2792
+ expect(signals.get("hold-pre-2")?.aborted).toBe(false);
2793
+ expect(transport.sent.find(frame => frame.id === "foreign-preflight")).toMatchObject({
2794
+ ok: true,
2795
+ result: { accepted: true },
2796
+ });
2693
2797
  // The admitted preflight is cancelled by the abort.
2694
2798
  expect(transport.sent.find(frame => frame.id === "pre-1")).toMatchObject({
2695
2799
  ok: false,
@@ -2697,7 +2801,7 @@ describe("SessionSdkSessionRuntime", () => {
2697
2801
  });
2698
2802
  expect(transport.sent.find(frame => frame.id === "pre-abort")).toMatchObject({
2699
2803
  ok: true,
2700
- result: expect.objectContaining({ turn: "no_active_turn" }),
2804
+ result: expect.objectContaining(mode === "terminal" ? { turn: "no_active_turn" } : { aborted: true }),
2701
2805
  });
2702
2806
  // The successor preflight was NOT part of the abort's snapshot: it is
2703
2807
  // neither cancelled nor failed, so no control response is emitted for it.
@@ -2722,7 +2826,7 @@ describe("SessionSdkSessionRuntime", () => {
2722
2826
  sessionId: transport.sessionId,
2723
2827
  });
2724
2828
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2725
- createSdkSessionRuntimeExtension(api, {
2829
+ createTestRuntimeExtension(api, {
2726
2830
  agentDir: cwd,
2727
2831
  createTransport: async () => transport,
2728
2832
  terminalAbortSeams: {
@@ -2840,7 +2944,7 @@ describe("SessionSdkSessionRuntime", () => {
2840
2944
  sessionId: transport.sessionId,
2841
2945
  });
2842
2946
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2843
- createSdkSessionRuntimeExtension(api, {
2947
+ createTestRuntimeExtension(api, {
2844
2948
  agentDir: cwd,
2845
2949
  createTransport: async () => transport,
2846
2950
  terminalAbortSeams: {
@@ -2931,12 +3035,10 @@ describe("SessionSdkSessionRuntime", () => {
2931
3035
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
2932
3036
  handlers.set(event, handler);
2933
3037
  },
2934
- sendUserMessage: (
2935
- _content: string,
2936
- options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
2937
- ) =>
3038
+ sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
2938
3039
  Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
2939
3040
  options?.onPreflightAccepted?.();
3041
+ if (!idle) options?.onQueuedPromoted?.({ startsOwnRun: false });
2940
3042
  return {};
2941
3043
  }),
2942
3044
  } as unknown as ExtensionAPI;
@@ -2947,7 +3049,7 @@ describe("SessionSdkSessionRuntime", () => {
2947
3049
  });
2948
3050
  const seamCalls: Array<{ handle: string; scope: string }> = [];
2949
3051
  let idle = true;
2950
- createSdkSessionRuntimeExtension(api, {
3052
+ createTestRuntimeExtension(api, {
2951
3053
  agentDir: cwd,
2952
3054
  createTransport: async () => transport,
2953
3055
  terminalAbortSeams: {
@@ -3026,11 +3128,18 @@ describe("SessionSdkSessionRuntime", () => {
3026
3128
  handlers.set(event, handler);
3027
3129
  },
3028
3130
  sendUserMessage: (
3029
- _content: string,
3030
- options: { onPreflightAccepted?: () => void; onPreflightAcceptCommit?: () => void } | undefined,
3131
+ content: string,
3132
+ options:
3133
+ | {
3134
+ onPreflightAccepted?: () => void;
3135
+ onPreflightAcceptCommit?: () => void;
3136
+ onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
3137
+ }
3138
+ | undefined,
3031
3139
  ) =>
3032
3140
  Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
3033
3141
  options?.onPreflightAccepted?.();
3142
+ if (content === "follow up b") options?.onQueuedPromoted?.({ startsOwnRun: false });
3034
3143
  return {};
3035
3144
  }),
3036
3145
  } as unknown as ExtensionAPI;
@@ -3041,7 +3150,7 @@ describe("SessionSdkSessionRuntime", () => {
3041
3150
  });
3042
3151
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3043
3152
  let idle = true;
3044
- createSdkSessionRuntimeExtension(api, {
3153
+ createTestRuntimeExtension(api, {
3045
3154
  agentDir: cwd,
3046
3155
  createTransport: async () => transport,
3047
3156
  terminalAbortSeams: {
@@ -3200,7 +3309,7 @@ describe("SessionSdkSessionRuntime", () => {
3200
3309
  sessionId: transport.sessionId,
3201
3310
  });
3202
3311
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3203
- createSdkSessionRuntimeExtension(api, {
3312
+ createTestRuntimeExtension(api, {
3204
3313
  agentDir: cwd,
3205
3314
  createTransport: async () => transport,
3206
3315
  terminalAbortSeams: {
@@ -3320,7 +3429,7 @@ describe("SessionSdkSessionRuntime", () => {
3320
3429
  sessionId: transport.sessionId,
3321
3430
  });
3322
3431
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3323
- createSdkSessionRuntimeExtension(api, {
3432
+ createTestRuntimeExtension(api, {
3324
3433
  agentDir: cwd,
3325
3434
  createTransport: async () => transport,
3326
3435
  terminalAbortSeams: {
@@ -3382,7 +3491,7 @@ describe("SessionSdkSessionRuntime", () => {
3382
3491
  sessionFile: path.join(cwd, "session.json"),
3383
3492
  sessionId: transport.sessionId,
3384
3493
  });
3385
- createSdkSessionRuntimeExtension(api, {
3494
+ createTestRuntimeExtension(api, {
3386
3495
  agentDir: cwd,
3387
3496
  createTransport: async () => transport,
3388
3497
  terminalAbortSeams: {
@@ -3473,7 +3582,7 @@ describe("SessionSdkSessionRuntime", () => {
3473
3582
  sessionFile: path.join(cwd, "session.json"),
3474
3583
  sessionId: transport.sessionId,
3475
3584
  });
3476
- createSdkSessionRuntimeExtension(api, {
3585
+ createTestRuntimeExtension(api, {
3477
3586
  agentDir: cwd,
3478
3587
  createTransport: async () => transport,
3479
3588
  terminalAbortSeams: {
@@ -3565,7 +3674,7 @@ describe("SessionSdkSessionRuntime", () => {
3565
3674
  sessionId: transport.sessionId,
3566
3675
  });
3567
3676
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3568
- createSdkSessionRuntimeExtension(api, {
3677
+ createTestRuntimeExtension(api, {
3569
3678
  agentDir: cwd,
3570
3679
  createTransport: async () => transport,
3571
3680
  onFrameAdmitted: admissions.onFrameAdmitted,
@@ -3656,7 +3765,7 @@ describe("SessionSdkSessionRuntime", () => {
3656
3765
  sessionId: transport.sessionId,
3657
3766
  });
3658
3767
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3659
- createSdkSessionRuntimeExtension(api, {
3768
+ createTestRuntimeExtension(api, {
3660
3769
  agentDir: cwd,
3661
3770
  createTransport: async () => transport,
3662
3771
  terminalAbortSeams: {
@@ -3752,7 +3861,7 @@ describe("SessionSdkSessionRuntime", () => {
3752
3861
  };
3753
3862
  registerOwnedRegistration(registration as never, { isJobTerminal: () => false });
3754
3863
  const seamCalls: Array<{ handle: string; scope: string }> = [];
3755
- createSdkSessionRuntimeExtension(api, {
3864
+ createTestRuntimeExtension(api, {
3756
3865
  agentDir: cwd,
3757
3866
  createTransport: async () => transport,
3758
3867
  terminalAbortSeams: {
@@ -3827,7 +3936,7 @@ describe("SessionSdkSessionRuntime", () => {
3827
3936
  sessionFile: path.join(cwd, "session.json"),
3828
3937
  sessionId: transport.sessionId,
3829
3938
  });
3830
- createSdkSessionRuntimeExtension(api, {
3939
+ createTestRuntimeExtension(api, {
3831
3940
  agentDir: cwd,
3832
3941
  createTransport: async () => transport,
3833
3942
  onFrameAdmitted: admissions.onFrameAdmitted,
@@ -3888,7 +3997,7 @@ describe("SessionSdkSessionRuntime", () => {
3888
3997
  sessionFile: path.join(cwd, "session.json"),
3889
3998
  sessionId: transport.sessionId,
3890
3999
  });
3891
- createSdkSessionRuntimeExtension(api, {
4000
+ createTestRuntimeExtension(api, {
3892
4001
  agentDir: cwd,
3893
4002
  createTransport: async () => transport,
3894
4003
  onFrameAdmitted: admissions.onFrameAdmitted,
@@ -3978,7 +4087,7 @@ describe("SessionSdkSessionRuntime", () => {
3978
4087
  },
3979
4088
  } as unknown as ExtensionAPI;
3980
4089
  const transports: Array<{ starts: number; stops: number }> = [];
3981
- createSdkSessionRuntimeExtension(api, {
4090
+ createTestRuntimeExtension(api, {
3982
4091
  agentDir: path.join(cwd, ".gjc", "agent"),
3983
4092
  createTransport: async ({ sessionId, stateRoot, token }) => {
3984
4093
  const stats = { starts: 0, stops: 0 };
@@ -4049,8 +4158,12 @@ describe("SessionSdkSessionRuntime", () => {
4049
4158
  } as any;
4050
4159
  const sessionId = "broker-recovery";
4051
4160
  const endpointUrl = "ws://127.0.0.1:1";
4052
- createSdkSessionRuntimeExtension(api, {
4161
+ createTestRuntimeExtension(api, {
4053
4162
  agentDir,
4163
+ ensureBrokerImpl: async () => {
4164
+ if (!broker) throw Object.assign(new Error("injected unavailable broker"), { code: "acquire_timeout" });
4165
+ return testBrokerDiscovery;
4166
+ },
4054
4167
  createTransport: async ({ stateRoot, token }) => ({
4055
4168
  sessionId,
4056
4169
  stateRoot,
@@ -4075,13 +4188,19 @@ describe("SessionSdkSessionRuntime", () => {
4075
4188
  await chmod(agentDir, 0o700);
4076
4189
  broker = new Broker({ agentDir });
4077
4190
  await broker.start();
4191
+ // Optional registration now completes independently of the event handler.
4192
+ const deadline = Date.now() + 15_000;
4078
4193
  await handlers.get("turn_start")?.({}, context);
4079
- expect(await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 })).toMatchObject(
4080
- {
4081
- ok: true,
4082
- result: { sessionId, token: expect.any(String) },
4083
- },
4084
- );
4194
+ let endpoint = await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 });
4195
+ while (!endpoint.ok && Date.now() < deadline) {
4196
+ await Bun.sleep(20);
4197
+ await handlers.get("turn_start")?.({}, context);
4198
+ endpoint = await broker.handleRequest("session.get_endpoint", { sessionId, endpointGeneration: 1 });
4199
+ }
4200
+ expect(endpoint).toMatchObject({
4201
+ ok: true,
4202
+ result: { sessionId, token: expect.any(String) },
4203
+ });
4085
4204
  await handlers.get("session_shutdown")?.({}, context);
4086
4205
  // DR-1 keeps the unregistered row listed, so the two refusals stay distinct:
4087
4206
  // a matching generation on a terminal row is terminally gone (no endpoint will
@@ -4105,6 +4224,185 @@ describe("SessionSdkSessionRuntime", () => {
4105
4224
  }
4106
4225
  });
4107
4226
 
4227
+ test("optional blocked broker registration leaves startup and turns responsive and fences shutdown", async () => {
4228
+ const gate = Promise.withResolvers<BrokerDiscovery>();
4229
+ let attempts = 0;
4230
+ const harness = await brokerRegistrationHarness({
4231
+ ensureBrokerImpl: () => {
4232
+ attempts += 1;
4233
+ return gate.promise;
4234
+ },
4235
+ });
4236
+ const publication = spyOn(SessionSdkSessionRuntime.prototype, "registerWithBroker");
4237
+ try {
4238
+ await harness.emit("session_start");
4239
+ await Promise.all([harness.emit("turn_start"), harness.emit("turn_start")]);
4240
+ expect(attempts).toBe(1);
4241
+ expect(harness.turnStarts()).toBe(2);
4242
+ // Recovery arms only once the startup attempt settles.
4243
+ expect(harness.recoveryActive()).toBe(false);
4244
+ await harness.emit("session_shutdown");
4245
+ expect(harness.stops()).toBe(1);
4246
+ gate.resolve(testBrokerDiscovery);
4247
+ await Bun.sleep(0);
4248
+ await harness.emit("turn_start");
4249
+ expect(attempts).toBe(1);
4250
+ expect(publication).not.toHaveBeenCalled();
4251
+ // An attempt that settles after shutdown must not arm recovery either.
4252
+ expect(harness.recoveryActive()).toBe(false);
4253
+ } finally {
4254
+ gate.resolve(testBrokerDiscovery);
4255
+ await harness.dispose();
4256
+ publication.mockRestore();
4257
+ }
4258
+ });
4259
+
4260
+ test("optional rejected broker registration reports diagnostics and retries without blocking turns", async () => {
4261
+ const first = Promise.withResolvers<BrokerDiscovery>();
4262
+ const retry = Promise.withResolvers<BrokerDiscovery>();
4263
+ let attempts = 0;
4264
+ const warning = spyOn(logger, "warn").mockImplementation(() => {});
4265
+ const harness = await brokerRegistrationHarness({
4266
+ ensureBrokerImpl: () => (++attempts === 1 ? first.promise : retry.promise),
4267
+ });
4268
+ try {
4269
+ await harness.emit("session_start");
4270
+ first.reject(Object.assign(new Error("injected broker contention"), { code: "acquire_timeout" }));
4271
+ await Bun.sleep(0);
4272
+ expect(warning).toHaveBeenCalledWith("sdk broker registration unavailable", { code: "acquire_timeout" });
4273
+ // The settled startup attempt armed recovery, and its failure holds the next tick.
4274
+ expect(harness.recoveryActive()).toBe(true);
4275
+ harness.recover();
4276
+ await harness.emit("turn_start");
4277
+ harness.recover();
4278
+ expect(attempts).toBe(2);
4279
+ expect(harness.turnStarts()).toBe(1);
4280
+ expect(harness.stops()).toBe(0);
4281
+ retry.reject(Object.assign(new Error("injected retry failure"), { code: "acquire_timeout" }));
4282
+ await Bun.sleep(0);
4283
+ expect(
4284
+ warning.mock.calls.filter(([message]) => message === "sdk broker registration unavailable"),
4285
+ ).toHaveLength(2);
4286
+ } finally {
4287
+ first.resolve(testBrokerDiscovery);
4288
+ retry.resolve(testBrokerDiscovery);
4289
+ await harness.dispose();
4290
+ warning.mockRestore();
4291
+ }
4292
+ });
4293
+
4294
+ test("optional broker publication completing after shutdown is unregistered with its ownership proof", async () => {
4295
+ const published = Promise.withResolvers<SessionIndexEvent>();
4296
+ const release = Promise.withResolvers<void>();
4297
+ const unregistered = Promise.withResolvers<void>();
4298
+ const append = SessionIndex.prototype.append;
4299
+ const unregister = SessionIndex.prototype.unregisterIfCurrent;
4300
+ const publication = spyOn(SessionIndex.prototype, "append").mockImplementation(async function (
4301
+ this: SessionIndex,
4302
+ input,
4303
+ ) {
4304
+ const event = await append.call(this, input);
4305
+ if (input.type === "host_registered") {
4306
+ published.resolve(event);
4307
+ await release.promise;
4308
+ }
4309
+ return event;
4310
+ });
4311
+ const cleanup = spyOn(SessionIndex.prototype, "unregisterIfCurrent").mockImplementation(async function (
4312
+ this: SessionIndex,
4313
+ ...args
4314
+ ) {
4315
+ const result = await unregister.apply(this, args);
4316
+ unregistered.resolve();
4317
+ return result;
4318
+ });
4319
+ const harness = await brokerRegistrationHarness({ ensureBrokerImpl: async () => testBrokerDiscovery });
4320
+ try {
4321
+ await harness.emit("session_start");
4322
+ const ownership = await published.promise;
4323
+ await harness.emit("session_shutdown");
4324
+ expect(cleanup).not.toHaveBeenCalled();
4325
+ release.resolve();
4326
+ await unregistered.promise;
4327
+ expect(cleanup).toHaveBeenCalledWith(ownership);
4328
+ expect(harness.recoveryActive()).toBe(false);
4329
+ } finally {
4330
+ release.resolve();
4331
+ await harness.dispose();
4332
+ publication.mockRestore();
4333
+ cleanup.mockRestore();
4334
+ }
4335
+ });
4336
+
4337
+ for (const outcome of ["success", "failure"] as const) {
4338
+ test(`required broker registration blocks startup and turns until ${outcome}`, async () => {
4339
+ const gate = Promise.withResolvers<BrokerDiscovery>();
4340
+ const entered = Promise.withResolvers<void>();
4341
+ let attempts = 0;
4342
+ const harness = await brokerRegistrationHarness({
4343
+ brokerRegistrationRequired: true,
4344
+ lifecycleRequestId: "required-registration-marker",
4345
+ ensureBrokerImpl: () => {
4346
+ attempts += 1;
4347
+ entered.resolve();
4348
+ return gate.promise;
4349
+ },
4350
+ });
4351
+ let startupSettled = false;
4352
+ let turnSettled = false;
4353
+ const startup = harness.emit("session_start").then(
4354
+ () => {
4355
+ startupSettled = true;
4356
+ return undefined;
4357
+ },
4358
+ error => {
4359
+ startupSettled = true;
4360
+ return error;
4361
+ },
4362
+ );
4363
+ await entered.promise;
4364
+ const turn = harness.emit("turn_start").then(
4365
+ () => {
4366
+ turnSettled = true;
4367
+ return undefined;
4368
+ },
4369
+ error => {
4370
+ turnSettled = true;
4371
+ return error;
4372
+ },
4373
+ );
4374
+ try {
4375
+ await Bun.sleep(0);
4376
+ expect(startupSettled).toBe(false);
4377
+ expect(turnSettled).toBe(false);
4378
+ expect(harness.turnStarts()).toBe(0);
4379
+ expect(attempts).toBe(1);
4380
+ expect(harness.recoveryActive()).toBe(false);
4381
+ if (outcome === "failure") {
4382
+ const error = Object.assign(new Error("required broker unavailable"), { code: "acquire_timeout" });
4383
+ gate.reject(error);
4384
+ expect(await startup).toBe(error);
4385
+ expect(await turn).toBe(error);
4386
+ expect(harness.stops()).toBe(1);
4387
+ expect(harness.turnStarts()).toBe(0);
4388
+ expect(harness.recoveryActive()).toBe(false);
4389
+ } else {
4390
+ gate.resolve(testBrokerDiscovery);
4391
+ expect(await startup).toBeUndefined();
4392
+ expect(await turn).toBeUndefined();
4393
+ expect(harness.turnStarts()).toBe(1);
4394
+ expect(harness.recoveryActive()).toBe(true);
4395
+ await harness.emit("turn_start");
4396
+ expect(attempts).toBe(1);
4397
+ }
4398
+ } finally {
4399
+ gate.resolve(testBrokerDiscovery);
4400
+ await Promise.all([startup, turn]);
4401
+ await harness.dispose();
4402
+ }
4403
+ });
4404
+ }
4405
+
4108
4406
  test("rejects lifecycle-required SDK-only startup when broker registration fails", async () => {
4109
4407
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-broker-required-"));
4110
4408
  const agentDir = path.join(cwd, ".gjc", "agent");
@@ -4116,7 +4414,7 @@ describe("SessionSdkSessionRuntime", () => {
4116
4414
  handlers.set(event, handler);
4117
4415
  },
4118
4416
  } as any;
4119
- createSdkSessionRuntimeExtension(api, {
4417
+ createTestRuntimeExtension(api, {
4120
4418
  agentDir,
4121
4419
  brokerRegistrationRequired: true,
4122
4420
  lifecycleRequestId: "broker-required-marker",
@@ -4139,10 +4437,112 @@ describe("SessionSdkSessionRuntime", () => {
4139
4437
  }
4140
4438
  });
4141
4439
  });
4440
+
4441
+ // Production discovery deliberately launches a daemon that outlives its caller.
4442
+ // Unit fixtures must never launch it: shutting down a host does not own that daemon.
4443
+ function createTestRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void {
4444
+ createSdkSessionRuntimeExtension(api, {
4445
+ ...options,
4446
+ ensureBrokerImpl: options.ensureBrokerImpl ?? (async () => testBrokerDiscovery),
4447
+ });
4448
+ }
4449
+
4450
+ const testBrokerDiscovery: BrokerDiscovery = {
4451
+ version: 1,
4452
+ protocolVersion: 3,
4453
+ packageGeneration: "test",
4454
+ ownerId: "test-broker",
4455
+ pid: process.pid,
4456
+ incarnation: "test-incarnation",
4457
+ host: "127.0.0.1",
4458
+ port: 1,
4459
+ url: "ws://127.0.0.1:1",
4460
+ token: "test-token",
4461
+ startedAt: 0,
4462
+ heartbeatAt: 0,
4463
+ };
4464
+
4465
+ async function brokerRegistrationHarness(
4466
+ options: Pick<
4467
+ CreateSdkSessionRuntimeOptions,
4468
+ "ensureBrokerImpl" | "brokerRegistrationRequired" | "lifecycleRequestId"
4469
+ >,
4470
+ ) {
4471
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-registration-"));
4472
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
4473
+ const broadcasts: SdkFrame[] = [];
4474
+ let stops = 0;
4475
+ let recovery: (() => void) | undefined;
4476
+ let recoveryActive = false;
4477
+ const timer = { unref() {} } as NodeJS.Timeout;
4478
+ const api = {
4479
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
4480
+ handlers.set(event, handler);
4481
+ },
4482
+ } as unknown as ExtensionAPI;
4483
+ createTestRuntimeExtension(api, {
4484
+ ...options,
4485
+ agentDir: path.join(cwd, "agent"),
4486
+ setIntervalImpl: ((callback: () => void) => {
4487
+ recovery = callback;
4488
+ recoveryActive = true;
4489
+ return timer;
4490
+ }) as typeof setInterval,
4491
+ clearIntervalImpl: (() => {
4492
+ recoveryActive = false;
4493
+ }) as typeof clearInterval,
4494
+ createTransport: async ({ sessionId, stateRoot, token }) => ({
4495
+ sessionId,
4496
+ stateRoot,
4497
+ token,
4498
+ onFrame: () => undefined,
4499
+ sendFrame: () => {},
4500
+ broadcastFrame: frame => {
4501
+ broadcasts.push(frame);
4502
+ },
4503
+ start: async () => {
4504
+ const url = "ws://127.0.0.1:1";
4505
+ await Bun.write(
4506
+ path.join(stateRoot, "sdk", `${sessionId}.json`),
4507
+ JSON.stringify({
4508
+ sessionId,
4509
+ token,
4510
+ pid: process.pid,
4511
+ url,
4512
+ }),
4513
+ );
4514
+ return { url };
4515
+ },
4516
+ stop: async () => {
4517
+ stops += 1;
4518
+ },
4519
+ }),
4520
+ });
4521
+ const context = extensionContext("broker-registration", cwd);
4522
+ const emit = async (event: string): Promise<void> => {
4523
+ await handlers.get(event)?.({}, context);
4524
+ };
4525
+ return {
4526
+ emit,
4527
+ recover: () => recovery?.(),
4528
+ recoveryActive: () => recoveryActive,
4529
+ stops: () => stops,
4530
+ turnStarts: () => broadcasts.filter(frame => frame.type === "event" && frame.kind === "turn_start").length,
4531
+ dispose: async () => {
4532
+ await emit("session_shutdown");
4533
+ await Bun.sleep(0);
4534
+ await rm(cwd, { recursive: true, force: true });
4535
+ },
4536
+ };
4537
+ }
4538
+
4142
4539
  interface PreflightHooks {
4540
+ onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
4143
4541
  onPreflightAccepted?: () => void;
4144
4542
  onPreflightAcceptCommit?: () => void | Promise<void>;
4145
4543
  expectedSdkRunToken?: string;
4544
+ preflightSignal?: AbortSignal;
4545
+ onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
4146
4546
  }
4147
4547
 
4148
4548
  interface ResponseFrame {
@@ -4150,11 +4550,22 @@ interface ResponseFrame {
4150
4550
  ok?: boolean;
4151
4551
  code?: string;
4152
4552
  error?: { code: string; message: string };
4153
- result?: { status?: string; commandId?: string; turnId?: string; error?: { code: string; message: string } };
4553
+ result?: {
4554
+ status?: string;
4555
+ commandId?: string;
4556
+ turnId?: string;
4557
+ error?: { code: string; message: string };
4558
+ };
4154
4559
  }
4155
4560
 
4156
4561
  interface InvocationHarness {
4157
4562
  control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
4563
+ controlAs(
4564
+ connectionId: string,
4565
+ operation: string,
4566
+ input: Record<string, unknown>,
4567
+ idempotencyKey?: string,
4568
+ ): Promise<ResponseFrame>;
4158
4569
  query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
4159
4570
  emit(event: string, payload?: unknown): Promise<void>;
4160
4571
  switchSession(sessionId: string): Promise<void>;
@@ -4226,7 +4637,7 @@ async function invocationHarness(
4226
4637
  hooks.onDurableAttempt,
4227
4638
  )
4228
4639
  : undefined;
4229
- createSdkSessionRuntimeExtension(api, {
4640
+ createTestRuntimeExtension(api, {
4230
4641
  agentDir: cwd,
4231
4642
  ...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
4232
4643
  ...(interceptorStore || hooks.terminalAbortSeams
@@ -4292,16 +4703,26 @@ async function invocationHarness(
4292
4703
  },
4293
4704
  };
4294
4705
  await handlers.get("session_start")?.({}, ctx);
4295
- const request = (frame: Record<string, unknown>): Promise<ResponseFrame> => {
4706
+ const request = (frame: Record<string, unknown>, connectionId = "client"): Promise<ResponseFrame> => {
4296
4707
  const id = `frame-${nextId}`;
4297
4708
  nextId += 1;
4298
4709
  const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
4299
4710
  waiters.set(id, resolve);
4300
- deliver?.("client", { ...frame, id } as SdkFrame);
4711
+ deliver?.(connectionId, { ...frame, id } as SdkFrame);
4301
4712
  return promise;
4302
4713
  };
4303
4714
  return {
4304
4715
  control: (operation, input) => request({ type: "control_request", operation, input }),
4716
+ controlAs: (connectionId, operation, input, idempotencyKey) =>
4717
+ request(
4718
+ {
4719
+ type: "control_request",
4720
+ operation,
4721
+ input,
4722
+ ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
4723
+ },
4724
+ connectionId,
4725
+ ),
4305
4726
  query: (name, input) => request({ type: "query_request", query: name, input }),
4306
4727
  broadcasts,
4307
4728
  emit: async (event, payload) => {
@@ -4351,93 +4772,587 @@ async function invocationHarness(
4351
4772
  };
4352
4773
  }
4353
4774
 
4354
- /** Reconciliation store used to inject durable-write failures from tests: wraps a
4355
- * session-file-backed store and fails the write whenever the staged records contain
4356
- * the intermediate failed-without-terminal state an agent_failed transition persists. */
4357
- function createInterceptorReconciliationStore(
4358
- interceptor: (transition: { type: string }) => void,
4359
- agentFailedWriteFailures = 1,
4360
- persistHolds?: Array<{ type: string; onEntered: () => void; release: Promise<void> }>,
4361
- // TEST-ONLY observability. `interceptor` fires only on REJECTED agent_failed
4362
- // writes, so a successful recovery write is invisible to it. This observer sees
4363
- // EVERY durable attempt with its correlation and whether it committed, so a
4364
- // branch test can assert the third attempt actually happened instead of
4365
- // inferring it from a missing callback.
4366
- onDurableAttempt?: (attempt: {
4367
- type: string | undefined;
4368
- commandId?: string;
4369
- turnId?: string;
4370
- outcome: "rejected" | "committed";
4371
- }) => void,
4372
- ): SdkOnlyReconciliationStore {
4373
- const failureCounts = new Map<string, number>();
4374
- let nextHold = 0;
4375
- const backing = createInterceptorBackingStore();
4376
- return {
4377
- path: null,
4378
- load: async () => backing.records.map(record => ({ ...record })),
4379
- async transact(mutator) {
4380
- const records = mutator(backing.records.map(record => ({ ...record })));
4381
- let transitionType: string | undefined;
4382
- for (const record of records as Array<{
4383
- status?: string;
4384
- terminalAt?: number;
4385
- commandId?: string;
4386
- pendingOutcome?: unknown;
4387
- }>) {
4388
- // The intermediate durable state an agent_failed write produces:
4389
- // the reason is set but the row is not terminal yet. Tests choose how
4390
- // many consecutive writes fail before the recovery replay succeeds.
4391
- const identity = String(record?.commandId ?? "unknown");
4392
- const failureCount = failureCounts.get(identity) ?? 0;
4393
- if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
4394
- transitionType ??= "agent_failed";
4395
- if (
4396
- record?.terminalAt === undefined &&
4397
- (record as { error?: unknown }).error !== undefined &&
4398
- failureCount < agentFailedWriteFailures
4399
- ) {
4400
- transitionType = "agent_failed";
4401
- failureCounts.set(identity, failureCount + 1);
4402
- interceptor({ type: "agent_failed" });
4403
- onDurableAttempt?.({
4404
- type: "agent_failed",
4405
- commandId: record?.commandId,
4406
- turnId: (record as { turnId?: string }).turnId,
4407
- outcome: "rejected",
4408
- });
4409
- throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
4410
- }
4411
- const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
4412
- const previousPendingOutcome = (
4413
- previous as
4414
- | (SdkOnlyInvocationRecord & { pendingOutcome?: { kind?: string; code?: string; reason?: string } })
4415
- | undefined
4416
- )?.pendingOutcome;
4417
- const pendingOutcome = record.pendingOutcome as
4418
- | { kind?: string; code?: string; reason?: string }
4419
- | undefined;
4420
- if (record.terminalAt === undefined && pendingOutcome !== undefined) {
4421
- if (previousPendingOutcome === undefined) transitionType ??= "deadline_claim";
4422
- else if (
4423
- pendingOutcome.kind !== previousPendingOutcome.kind ||
4424
- pendingOutcome.code !== previousPendingOutcome.code ||
4425
- pendingOutcome.reason !== previousPendingOutcome.reason
4426
- )
4427
- transitionType = "pending_terminal_outcome";
4428
- }
4429
- if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
4430
- transitionType = "agent_end";
4431
- interceptor({ type: "agent_end" });
4775
+ test.each(["natural", "removed"] as const)("SDK-only text follow-up has a durable terminal (%s)", async mode => {
4776
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-only-text-followup-${mode}-`));
4777
+ let harness: InvocationHarness | undefined;
4778
+ let promotion: PreflightHooks["onQueuedPromoted"];
4779
+ try {
4780
+ harness = await invocationHarness(`sdk-only-text-followup-${mode}`, cwd, {
4781
+ sendUserMessage: async (content, options) => {
4782
+ await options?.onPreflightAcceptCommit?.();
4783
+ if (options?.deliverAs === "followUp") {
4784
+ expect(content).toBe("queued generic follow-up");
4785
+ options.onDispatchDisposition?.({ startsOwnRun: false });
4786
+ promotion = options.onQueuedPromoted;
4787
+ return;
4432
4788
  }
4789
+ await neverSettlingPromise();
4790
+ },
4791
+ });
4792
+ const root = await harness.controlAs("connection-root", "turn.prompt", { text: "root text" });
4793
+ expect(root).toMatchObject({ ok: true, result: { accepted: true } });
4794
+ await harness.emit("agent_start");
4795
+ const followUp = await harness.controlAs("connection-followup", "turn.follow_up", {
4796
+ text: "queued generic follow-up",
4797
+ });
4798
+ expect(followUp).toMatchObject({ ok: true, result: { accepted: true } });
4799
+ const correlation = { commandId: followUp.result?.commandId, turnId: followUp.result?.turnId };
4800
+ expect(await harness.query("turn.result", { kind: "prompt", ...correlation })).toMatchObject({
4801
+ result: { status: "accepted" },
4802
+ });
4803
+ expect(promotion).toBeDefined();
4804
+ promotion?.({ startsOwnRun: false, ...(mode === "removed" ? { removed: true } : {}) });
4805
+ if (mode === "natural")
4806
+ await harness.emit("agent_end", {
4807
+ messages: [{ role: "assistant", stopReason: "stop", content: "root complete" }],
4808
+ });
4809
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject(
4810
+ mode === "natural" ? { status: "terminal_ok" } : { status: "failed", error: { code: "cancelled" } },
4811
+ );
4812
+ await harness.emit("agent_start");
4813
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "unrelated" }] });
4814
+ expect(
4815
+ harness.broadcasts.filter(
4816
+ frame =>
4817
+ frame.type === "event" &&
4818
+ frame.kind === "agent_end" &&
4819
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId ===
4820
+ correlation.commandId &&
4821
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === correlation.turnId,
4822
+ ),
4823
+ ).toHaveLength(1);
4824
+ } finally {
4825
+ await harness?.stop();
4826
+ await rm(cwd, { recursive: true, force: true });
4827
+ }
4828
+ });
4829
+
4830
+ test.each([
4831
+ "accepting",
4832
+ "accepted",
4833
+ ] as const)("SDK-only ordinary abort fences a real %s prompt before agent_start", async phase => {
4834
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-owned-preflight-${phase}-`));
4835
+ const entered = Promise.withResolvers<void>();
4836
+ const release = Promise.withResolvers<void>();
4837
+ const clientRef = `owned-before-start-${phase}`;
4838
+ const manager = SessionManager.inMemory(cwd);
4839
+ const sessionId = manager.getSessionId();
4840
+ const store = createReconciliationStore({ sessionFile: path.join(cwd, "session.jsonl"), sessionId });
4841
+ const originalRename = fsPromises.rename;
4842
+ let held = false;
4843
+ let modelCalls = 0;
4844
+ let rootAbortCalls = 0;
4845
+ let ownedSignal: AbortSignal | undefined;
4846
+ let harness: InvocationHarness | undefined;
4847
+ let session: AgentSession | undefined;
4848
+ let authStorage: AuthStorage | undefined;
4849
+ const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
4850
+ if (phase === "accepting" && !held && String(to) === store.path) {
4851
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
4852
+ if (document.records.some(record => record.clientRef === clientRef && record.status === "accepted")) {
4853
+ held = true;
4854
+ entered.resolve();
4855
+ await release.promise;
4856
+ }
4857
+ }
4858
+ await originalRename(from, to);
4859
+ });
4860
+ try {
4861
+ authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
4862
+ const mock = createMockModel({
4863
+ responses: [
4864
+ () => {
4865
+ modelCalls++;
4866
+ return { content: ["unexpected model execution"] };
4867
+ },
4868
+ ],
4869
+ });
4870
+ authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
4871
+ session = new AgentSession({
4872
+ agent: new Agent({
4873
+ getApiKey: () => "test-key",
4874
+ initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
4875
+ streamFn: mock.stream,
4876
+ }),
4877
+ sessionManager: manager,
4878
+ settings: Settings.isolated({ "compaction.enabled": false }),
4879
+ modelRegistry: new ModelRegistry(authStorage),
4880
+ });
4881
+ harness = await invocationHarness(sessionId, cwd, {
4882
+ isIdle: () => !session!.isStreaming,
4883
+ abort: () => {
4884
+ rootAbortCalls++;
4885
+ },
4886
+ terminalAbortSeams: { getReconciliationStore: () => store },
4887
+ sendUserMessage: (content, options) => {
4888
+ if (typeof content !== "string") throw new Error("Expected a text-only admission.");
4889
+ const { deliverAs, ...admissionOptions } = options ?? {};
4890
+ if (deliverAs !== undefined) throw new Error("Expected an ordinary prompt admission.");
4891
+ ownedSignal = admissionOptions.preflightSignal;
4892
+ return session!.sendUserMessage(content, {
4893
+ ...admissionOptions,
4894
+ onPreflightAcceptCommit: async () => {
4895
+ await admissionOptions.onPreflightAcceptCommit?.();
4896
+ if (phase === "accepted") {
4897
+ entered.resolve();
4898
+ await release.promise;
4899
+ }
4900
+ },
4901
+ });
4902
+ },
4903
+ });
4904
+ session.subscribe(async event => {
4905
+ await harness?.emit(event.type, event);
4906
+ });
4907
+ const prompt = harness.controlAs("owner", "turn.prompt", { text: "cancel before execution", clientRef });
4908
+ await entered.promise;
4909
+ if (phase === "accepted") expect(await prompt).toMatchObject({ ok: true, result: { accepted: true } });
4910
+ expect(ownedSignal?.aborted).toBe(false);
4911
+ expect(modelCalls).toBe(0);
4912
+ expect(await harness.controlAs("owner", "turn.abort", {})).toMatchObject({ ok: true, result: { aborted: true } });
4913
+ expect(ownedSignal?.aborted).toBe(true);
4914
+ expect(rootAbortCalls).toBe(0);
4915
+ release.resolve();
4916
+ if (phase === "accepting") expect(await prompt).toMatchObject({ ok: false, error: { code: "busy" } });
4917
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef })).toMatchObject({
4918
+ status: "failed",
4919
+ });
4920
+ await session.waitForIdle();
4921
+ expect(modelCalls).toBe(0);
4922
+ expect(rootAbortCalls).toBe(0);
4923
+ const document = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
4924
+ const durable = document.records.find(record => record.clientRef === clientRef);
4925
+ expect(durable?.status).toBe("failed");
4926
+ expect(durable?.terminalAt).toBeNumber();
4927
+ } finally {
4928
+ release.resolve();
4929
+ await session?.waitForIdle().catch(() => undefined);
4930
+ await harness?.stop();
4931
+ await session?.dispose();
4932
+ authStorage?.close();
4933
+ renameSpy.mockRestore();
4934
+ await rm(cwd, { recursive: true, force: true });
4935
+ }
4936
+ }, 30_000);
4937
+
4938
+ test("SDK-only ordinary abort preserves real foreign and later admissions behind a cancelled durable preflight", async () => {
4939
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-real-admission-snapshot-"));
4940
+ const entered = Promise.withResolvers<void>();
4941
+ const release = Promise.withResolvers<void>();
4942
+ const foreignStarted = Promise.withResolvers<void>();
4943
+ const releaseForeign = Promise.withResolvers<void>();
4944
+ const manager = SessionManager.inMemory(cwd);
4945
+ const sessionId = manager.getSessionId();
4946
+ const store = createReconciliationStore({ sessionFile: path.join(cwd, "session.jsonl"), sessionId });
4947
+ const originalRename = fsPromises.rename;
4948
+ const signals = new Map<string, AbortSignal | undefined>();
4949
+ const modelCalls: string[] = [];
4950
+ let held = false;
4951
+ let rootAbortCalls = 0;
4952
+ let harness: InvocationHarness | undefined;
4953
+ let session: AgentSession | undefined;
4954
+ let authStorage: AuthStorage | undefined;
4955
+ const renameSpy = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
4956
+ if (!held && String(to) === store.path) {
4957
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
4958
+ if (
4959
+ document.records.some(record => record.clientRef === "cancel-owned-real" && record.status === "accepted")
4960
+ ) {
4961
+ held = true;
4962
+ entered.resolve();
4963
+ await release.promise;
4964
+ }
4965
+ }
4966
+ await originalRename(from, to);
4967
+ });
4968
+ try {
4969
+ authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
4970
+ const mock = createMockModel({
4971
+ responses: [
4972
+ () => {
4973
+ modelCalls.push("seed");
4974
+ return { content: ["natural seed completed"] };
4975
+ },
4976
+ async () => {
4977
+ modelCalls.push("foreign");
4978
+ foreignStarted.resolve();
4979
+ await releaseForeign.promise;
4980
+ return { content: ["foreign admission completed"] };
4981
+ },
4982
+ () => {
4983
+ modelCalls.push("later-owner");
4984
+ return { content: ["later owner admission completed"] };
4985
+ },
4986
+ ],
4987
+ });
4988
+ authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
4989
+ session = new AgentSession({
4990
+ agent: new Agent({
4991
+ getApiKey: () => "test-key",
4992
+ initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
4993
+ streamFn: mock.stream,
4994
+ }),
4995
+ sessionManager: manager,
4996
+ settings: Settings.isolated({ "compaction.enabled": false }),
4997
+ modelRegistry: new ModelRegistry(authStorage),
4998
+ });
4999
+ harness = await invocationHarness(sessionId, cwd, {
5000
+ isIdle: () => !session!.isStreaming,
5001
+ abort: () => {
5002
+ rootAbortCalls++;
5003
+ },
5004
+ terminalAbortSeams: { getReconciliationStore: () => store },
5005
+ sendUserMessage: (content, options) => {
5006
+ if (typeof content !== "string") throw new Error("Expected text-only real admission.");
5007
+ signals.set(content, options?.preflightSignal);
5008
+ const { deliverAs, ...admissionOptions } = options ?? {};
5009
+ if (deliverAs !== undefined && deliverAs !== "steer" && deliverAs !== "followUp")
5010
+ throw new Error("Unsupported real admission delivery mode.");
5011
+ return session!.sendUserMessage(content, {
5012
+ ...admissionOptions,
5013
+ ...(deliverAs === undefined ? {} : { deliverAs }),
5014
+ });
5015
+ },
5016
+ });
5017
+ session.subscribe(async event => {
5018
+ await harness?.emit(event.type, event);
5019
+ });
5020
+ expect(
5021
+ await harness.controlAs("seed-owner", "turn.prompt", { text: "seed-real", clientRef: "seed-real" }),
5022
+ ).toMatchObject({ ok: true, result: { accepted: true } });
5023
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "seed-real" })).toMatchObject({
5024
+ status: "terminal_ok",
5025
+ });
5026
+ await session.waitForIdle();
5027
+ const owned = harness.controlAs("owner", "turn.prompt", {
5028
+ text: "cancel-owned-real",
5029
+ clientRef: "cancel-owned-real",
5030
+ });
5031
+ await entered.promise;
5032
+ expect(modelCalls).toEqual(["seed"]);
5033
+ expect(signals.get("cancel-owned-real")?.aborted).toBe(false);
5034
+ const foreign = harness.controlAs("foreign", "turn.prompt", { text: "foreign-real", clientRef: "foreign-real" });
5035
+ expect(await harness.controlAs("owner", "turn.abort", {})).toMatchObject({ ok: true, result: { aborted: true } });
5036
+ expect(signals.get("cancel-owned-real")?.aborted).toBe(true);
5037
+ expect(rootAbortCalls).toBe(0);
5038
+ release.resolve();
5039
+ expect(await owned).toMatchObject({ ok: false, error: { code: "busy" } });
5040
+ expect(
5041
+ await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "cancel-owned-real" }),
5042
+ ).toMatchObject({ status: "failed" });
5043
+ expect(await foreign).toMatchObject({ ok: true, result: { accepted: true } });
5044
+ await foreignStarted.promise;
5045
+ const later = harness.controlAs("owner", "turn.prompt", {
5046
+ text: "later-owner-real",
5047
+ clientRef: "later-owner-real",
5048
+ });
5049
+ expect(await later).toMatchObject({ ok: true, result: { accepted: true } });
5050
+ expect(signals.get("foreign-real")?.aborted).toBe(false);
5051
+ expect(signals.get("later-owner-real")?.aborted).toBe(false);
5052
+ expect(modelCalls).toEqual(["seed", "foreign"]);
5053
+ const pendingDocument = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
5054
+ const pendingLater = pendingDocument.records.find(record => record.clientRef === "later-owner-real");
5055
+ expect(pendingLater?.status).toBe("accepted");
5056
+ expect(pendingLater?.startedAt).toBeUndefined();
5057
+ expect(pendingLater?.terminalAt).toBeUndefined();
5058
+ releaseForeign.resolve();
5059
+ await session.waitForIdle();
5060
+ expect(modelCalls).toEqual(["seed", "foreign", "later-owner"]);
5061
+ expect(rootAbortCalls).toBe(0);
5062
+ for (const clientRef of ["foreign-real", "later-owner-real"]) {
5063
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef })).toMatchObject({
5064
+ status: "terminal_ok",
5065
+ });
5066
+ }
5067
+ const document = (await Bun.file(store.path!).json()) as ReconciliationStoreDocument;
5068
+ for (const [clientRef, status] of [
5069
+ ["cancel-owned-real", "failed"],
5070
+ ["foreign-real", "terminal_ok"],
5071
+ ["later-owner-real", "terminal_ok"],
5072
+ ] as const) {
5073
+ const record = document.records.find(candidate => candidate.clientRef === clientRef);
5074
+ expect(record?.status).toBe(status);
5075
+ expect(record?.terminalAt).toBeNumber();
5076
+ }
5077
+ } finally {
5078
+ release.resolve();
5079
+ releaseForeign.resolve();
5080
+ await session?.waitForIdle().catch(() => undefined);
5081
+ await harness?.stop();
5082
+ await session?.dispose();
5083
+ authStorage?.close();
5084
+ renameSpy.mockRestore();
5085
+ await rm(cwd, { recursive: true, force: true });
5086
+ }
5087
+ }, 30_000);
5088
+
5089
+ test.each([
5090
+ "ordinary",
5091
+ "terminal",
5092
+ ] as const)("SDK-only %s abort cancels only its generic queued owner behind a real active AgentSession run", async mode => {
5093
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-queued-text-${mode}-`));
5094
+ const rootGate = Promise.withResolvers<void>();
5095
+ const rootStarted = Promise.withResolvers<void>();
5096
+ const failedRemovalWrite = Promise.withResolvers<void>();
5097
+ const modelCalls: number[] = [];
5098
+ let rootAbortCalls = 0;
5099
+ let queuedPreflightSignal: AbortSignal | undefined;
5100
+ let harness: InvocationHarness | undefined;
5101
+ let session: AgentSession | undefined;
5102
+ let authStorage: AuthStorage | undefined;
5103
+ try {
5104
+ authStorage = await AuthStorage.create(path.join(cwd, "testauth.db"));
5105
+ const mock = createMockModel({
5106
+ responses: [
5107
+ async () => {
5108
+ modelCalls.push(Date.now());
5109
+ rootStarted.resolve();
5110
+ await rootGate.promise;
5111
+ return { content: ["root completed"] };
5112
+ },
5113
+ () => {
5114
+ modelCalls.push(Date.now());
5115
+ return { content: ["unrelated follow-up completed"] };
5116
+ },
5117
+ ],
5118
+ });
5119
+ authStorage.setRuntimeApiKey(mock.model.provider, "test-key");
5120
+ const modelRegistry = new ModelRegistry(authStorage);
5121
+ const agent = new Agent({
5122
+ getApiKey: () => "test-key",
5123
+ initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] },
5124
+ streamFn: mock.stream,
5125
+ });
5126
+ session = new AgentSession({
5127
+ agent,
5128
+ sessionManager: SessionManager.inMemory(cwd),
5129
+ settings: Settings.isolated({ "compaction.enabled": false }),
5130
+ modelRegistry,
5131
+ });
5132
+ const hooks: Parameters<typeof invocationHarness>[2] = {
5133
+ isIdle: () => !session!.isStreaming,
5134
+ abort: () => {
5135
+ rootAbortCalls += 1;
5136
+ },
5137
+ sendUserMessage: (content, options) => {
5138
+ if (content === "cancel only this queued text") queuedPreflightSignal = options?.preflightSignal;
5139
+ return session!.sendUserMessage(content as never, options as never);
5140
+ },
5141
+ ...(mode === "ordinary"
5142
+ ? {
5143
+ persistInterceptor: transition => {
5144
+ if (transition.type === "agent_failed") failedRemovalWrite.resolve();
5145
+ },
5146
+ agentFailedWriteFailures: 1,
5147
+ onDurableAttempt: attempt => {
5148
+ if (attempt.type === "agent_failed" && attempt.outcome === "rejected")
5149
+ failedRemovalWrite.resolve();
5150
+ },
5151
+ }
5152
+ : {
5153
+ terminalAbortSeams: {
5154
+ getTerminalTurnEpoch: () => (session?.isStreaming ? 17 : undefined),
5155
+ getActivePromptHandle: () => (session?.isStreaming ? "root-run-handle" : undefined),
5156
+ getActivePromptOwnerConnectionId: () => (session?.isStreaming ? "connection-root" : undefined),
5157
+ cancelPendingPreflightForTerminalAbort: () => {},
5158
+ abortPromptAndWaitWithTerminal: async () => {
5159
+ rootAbortCalls += 1;
5160
+ return { status: "settled", terminalScope: {} };
5161
+ },
5162
+ },
5163
+ }),
5164
+ };
5165
+ harness = await invocationHarness(`queued-text-${mode}`, cwd, hooks);
5166
+ session.subscribe(async event => {
5167
+ await harness?.emit(event.type, event);
5168
+ });
5169
+
5170
+ const root = await harness.controlAs("connection-root", "turn.prompt", { text: "root owned by B" });
5171
+ expect(root).toMatchObject({ ok: true, result: { accepted: true } });
5172
+ await rootStarted.promise;
5173
+ const unrelated = await harness.controlAs("connection-other", "turn.prompt", {
5174
+ text: "keep unrelated queued text",
5175
+ });
5176
+ expect(unrelated).toMatchObject({ ok: true, result: { accepted: true } });
5177
+ const queued = await harness.controlAs("connection-requester", "turn.prompt", {
5178
+ text: "cancel only this queued text",
5179
+ });
5180
+ expect(queued).toMatchObject({ ok: true, result: { accepted: true } });
5181
+ const correlation = { commandId: queued.result?.commandId, turnId: queued.result?.turnId };
5182
+ expect(queuedPreflightSignal).toBeInstanceOf(AbortSignal);
5183
+ expect(queuedPreflightSignal?.aborted).toBe(false);
5184
+ expect(session.getQueuedMessages().steering).toEqual([
5185
+ "keep unrelated queued text",
5186
+ "cancel only this queued text",
5187
+ ]);
5188
+
5189
+ let abortCompleted = false;
5190
+ const abortRequest =
5191
+ mode === "ordinary"
5192
+ ? harness.controlAs("connection-requester", "turn.abort", { mode: "turn" })
5193
+ : harness.controlAs("connection-requester", "turn.abort", { mode: "terminal" }, "queued-text-terminal");
5194
+ const observedAbort = abortRequest.then(response => {
5195
+ abortCompleted = true;
5196
+ return response;
5197
+ });
5198
+ if (mode === "ordinary") {
5199
+ await failedRemovalWrite.promise;
5200
+ await Bun.sleep(0);
5201
+ expect(abortCompleted).toBe(false);
5202
+ }
5203
+ const abortResponse = await observedAbort;
5204
+ if (mode === "ordinary") expect(abortResponse).toMatchObject({ ok: true, result: { aborted: true } });
5205
+ else
5206
+ expect(abortResponse).toMatchObject({
5207
+ ok: true,
5208
+ result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
5209
+ });
5210
+ expect(queuedPreflightSignal?.aborted).toBe(true);
5211
+ expect(rootAbortCalls).toBe(0);
5212
+ expect(session.isStreaming).toBe(true);
5213
+ expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queued text"]);
5214
+ expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
5215
+ status: "failed",
5216
+ error: { code: "cancelled" },
5217
+ });
5218
+ const repeatedAbort = await harness.controlAs(
5219
+ "connection-requester",
5220
+ "turn.abort",
5221
+ mode === "ordinary" ? { mode: "turn" } : { mode: "terminal" },
5222
+ mode === "terminal" ? "queued-text-terminal-retry" : undefined,
5223
+ );
5224
+ if (mode === "ordinary")
5225
+ expect(repeatedAbort).toMatchObject({ ok: true, result: { aborted: false, turn: "no_active_turn" } });
5226
+ else
5227
+ expect(repeatedAbort).toMatchObject({
5228
+ ok: true,
5229
+ result: { turn: "no_active_turn", terminal: "terminal_no_effect" },
5230
+ });
5231
+ expect(rootAbortCalls).toBe(0);
5232
+ expect(session.isStreaming).toBe(true);
5233
+ expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queued text"]);
5234
+
5235
+ rootGate.resolve();
5236
+ await session.waitForIdle();
5237
+ const userMessages = session.agent.state.messages.filter(
5238
+ (message): message is UserMessage => message.role === "user",
5239
+ );
5240
+ const userText = (message: (typeof userMessages)[number]): string =>
5241
+ typeof message.content === "string"
5242
+ ? message.content
5243
+ : message.content
5244
+ .filter(block => block.type === "text")
5245
+ .map(block => block.text)
5246
+ .join("");
5247
+ expect(userMessages.map(userText)).toEqual(["root owned by B", "keep unrelated queued text"]);
5248
+ expect(modelCalls).toHaveLength(2);
5249
+ expect(
5250
+ harness.broadcasts.filter(
5251
+ frame =>
5252
+ frame.type === "event" &&
5253
+ frame.kind === "agent_end" &&
5254
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId ===
5255
+ correlation.commandId &&
5256
+ (frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === correlation.turnId,
5257
+ ),
5258
+ ).toHaveLength(1);
5259
+ } finally {
5260
+ rootGate.resolve();
5261
+ await session?.waitForIdle().catch(() => undefined);
5262
+ await harness?.stop();
5263
+ await session?.dispose();
5264
+ authStorage?.close();
5265
+ await rm(cwd, { recursive: true, force: true });
5266
+ }
5267
+ });
5268
+
5269
+ /** Reconciliation store used to inject durable-write failures from tests: wraps a
5270
+ * session-file-backed store and fails the write whenever the staged records contain
5271
+ * the intermediate failed-without-terminal state an agent_failed transition persists. */
5272
+ function createInterceptorReconciliationStore(
5273
+ interceptor: (transition: { type: string }) => void,
5274
+ agentFailedWriteFailures = 1,
5275
+ persistHolds?: Array<{ type: string; onEntered: () => void; release: Promise<void> }>,
5276
+ // TEST-ONLY observability. `interceptor` fires only on REJECTED agent_failed
5277
+ // writes, so a successful recovery write is invisible to it. This observer sees
5278
+ // EVERY durable attempt with its correlation and whether it committed, so a
5279
+ // branch test can assert the third attempt actually happened instead of
5280
+ // inferring it from a missing callback.
5281
+ onDurableAttempt?: (attempt: {
5282
+ type: string | undefined;
5283
+ commandId?: string;
5284
+ turnId?: string;
5285
+ outcome: "rejected" | "committed";
5286
+ }) => void,
5287
+ ): SdkOnlyReconciliationStore {
5288
+ const failureCounts = new Map<string, number>();
5289
+ let nextHold = 0;
5290
+ const backing = createInterceptorBackingStore();
5291
+ return {
5292
+ path: null,
5293
+ load: async () => backing.records.map(record => ({ ...record })),
5294
+ async transact(mutator) {
5295
+ const records = mutator(backing.records.map(record => ({ ...record })));
5296
+ let transitionType: string | undefined;
5297
+ for (const record of records as Array<{
5298
+ status?: string;
5299
+ terminalAt?: number;
5300
+ commandId?: string;
5301
+ pendingOutcome?: unknown;
5302
+ }>) {
5303
+ // The intermediate durable state an agent_failed write produces:
5304
+ // the reason is set but the row is not terminal yet. Tests choose how
5305
+ // many consecutive writes fail before the recovery replay succeeds.
5306
+ const identity = String(record?.commandId ?? "unknown");
5307
+ const failureCount = failureCounts.get(identity) ?? 0;
5308
+ if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
5309
+ transitionType ??= "agent_failed";
5310
+ if (
5311
+ record?.terminalAt === undefined &&
5312
+ (record as { error?: unknown }).error !== undefined &&
5313
+ failureCount < agentFailedWriteFailures
5314
+ ) {
5315
+ transitionType = "agent_failed";
5316
+ failureCounts.set(identity, failureCount + 1);
5317
+ interceptor({ type: "agent_failed" });
5318
+ onDurableAttempt?.({
5319
+ type: "agent_failed",
5320
+ commandId: record?.commandId,
5321
+ turnId: (record as { turnId?: string }).turnId,
5322
+ outcome: "rejected",
5323
+ });
5324
+ throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
5325
+ }
5326
+ const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
5327
+ const previousPendingOutcome = (
5328
+ previous as
5329
+ | (SdkOnlyInvocationRecord & { pendingOutcome?: { kind?: string; code?: string; reason?: string } })
5330
+ | undefined
5331
+ )?.pendingOutcome;
5332
+ const pendingOutcome = record.pendingOutcome as
5333
+ | { kind?: string; code?: string; reason?: string }
5334
+ | undefined;
5335
+ if (record.terminalAt === undefined && pendingOutcome !== undefined) {
5336
+ if (previousPendingOutcome === undefined) transitionType ??= "deadline_claim";
5337
+ else if (
5338
+ pendingOutcome.kind !== previousPendingOutcome.kind ||
5339
+ pendingOutcome.code !== previousPendingOutcome.code ||
5340
+ pendingOutcome.reason !== previousPendingOutcome.reason
5341
+ )
5342
+ transitionType = "pending_terminal_outcome";
5343
+ }
5344
+ if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
5345
+ transitionType = "agent_end";
5346
+ interceptor({ type: "agent_end" });
5347
+ }
5348
+ }
5349
+ if (transitionType === "pending_terminal_outcome") interceptor({ type: transitionType });
5350
+ const hold = persistHolds?.[nextHold];
5351
+ if (hold && hold.type === transitionType) {
5352
+ nextHold += 1;
5353
+ hold.onEntered();
5354
+ await hold.release;
4433
5355
  }
4434
- if (transitionType === "pending_terminal_outcome") interceptor({ type: transitionType });
4435
- const hold = persistHolds?.[nextHold];
4436
- if (hold && hold.type === transitionType) {
4437
- nextHold += 1;
4438
- hold.onEntered();
4439
- await hold.release;
4440
- }
4441
5356
  for (const record of records as Array<{ commandId?: string; turnId?: string }>)
4442
5357
  onDurableAttempt?.({
4443
5358
  type: transitionType,
@@ -4479,48 +5394,981 @@ async function settledStatus(
4479
5394
  }
4480
5395
  }
4481
5396
 
4482
- function neverSettlingPromise(): Promise<void> {
4483
- return Promise.withResolvers<void>().promise;
4484
- }
5397
+ function neverSettlingPromise(): Promise<void> {
5398
+ return Promise.withResolvers<void>().promise;
5399
+ }
5400
+
5401
+ function typedContextOverflowStream(model: Model, onEmitted?: () => void): AssistantMessageEventStream {
5402
+ const stream = new AssistantMessageEventStream();
5403
+ queueMicrotask(() => {
5404
+ const message: AssistantMessage & {
5405
+ transportFailure: { kind: "transport"; status: number; openaiErrorCode: string };
5406
+ } = {
5407
+ role: "assistant",
5408
+ content: [],
5409
+ api: model.api,
5410
+ provider: model.provider,
5411
+ model: model.id,
5412
+ usage: {
5413
+ input: 0,
5414
+ output: 0,
5415
+ cacheRead: 0,
5416
+ cacheWrite: 0,
5417
+ totalTokens: 0,
5418
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
5419
+ },
5420
+ stopReason: "error",
5421
+ errorMessage: "",
5422
+ errorStatus: 400,
5423
+ timestamp: Date.now(),
5424
+ transportFailure: { kind: "transport", status: 400, openaiErrorCode: "context_length_exceeded" },
5425
+ };
5426
+ stream.push({ type: "start", partial: message });
5427
+ stream.push({ type: "error", reason: "error", error: message });
5428
+ onEmitted?.();
5429
+ });
5430
+ return stream;
5431
+ }
5432
+
5433
+ function selector(model: Model): string {
5434
+ return `${model.provider}/${model.id}`;
5435
+ }
5436
+
5437
+ async function createTerminalizationSession(
5438
+ cwd: string,
5439
+ streamFn: AgentOptions["streamFn"],
5440
+ settingsOverrides: Record<string, unknown> = {},
5441
+ withExtensions = false,
5442
+ ): Promise<{ session: AgentSession; authStorage: AuthStorage; model: Model }> {
5443
+ const authStorage = await AuthStorage.create(path.join(cwd, "testauth.db"));
5444
+ const model = getBundledModel("anthropic", "claude-sonnet-4-5");
5445
+ const fallback = getBundledModel("openai", "gpt-4o-mini");
5446
+ if (!model || !fallback) throw new Error("Expected bundled test models");
5447
+ authStorage.setRuntimeApiKey(model.provider, "test-key");
5448
+ authStorage.setRuntimeApiKey(fallback.provider, "test-key");
5449
+ const settings = Settings.isolated({
5450
+ "compaction.enabled": false,
5451
+ "contextPromotion.enabled": false,
5452
+ "fallback.maxAttempts": 3,
5453
+ "retry.baseDelayMs": 1,
5454
+ ...settingsOverrides,
5455
+ });
5456
+ settings.setModelRole("default", selector(model));
5457
+ const agent = new Agent({
5458
+ getApiKey: provider => `${provider}-key`,
5459
+ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
5460
+ streamFn,
5461
+ });
5462
+ const sessionManager = SessionManager.inMemory(cwd);
5463
+ const modelRegistry = new ModelRegistry(authStorage);
5464
+ const session = new AgentSession({
5465
+ agent,
5466
+ sessionManager,
5467
+ settings,
5468
+ modelRegistry,
5469
+ extensionRunner: withExtensions
5470
+ ? new ExtensionRunner(
5471
+ [],
5472
+ { flagValues: new Map(), pendingProviderRegistrations: [] } as never,
5473
+ cwd,
5474
+ sessionManager,
5475
+ modelRegistry,
5476
+ undefined,
5477
+ settings,
5478
+ )
5479
+ : undefined,
5480
+ });
5481
+ session.setConfiguredModelChain("default", [selector(model), selector(fallback)], "test");
5482
+ return { session, authStorage, model };
5483
+ }
5484
+
5485
+ function deferredRealSendUserMessage(session: AgentSession) {
5486
+ return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
5487
+ await options?.onPreflightAcceptCommit?.();
5488
+ const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
5489
+ void session.sendUserMessage(content as string, dispatchOptions as never);
5490
+ await neverSettlingPromise();
5491
+ };
5492
+ }
5493
+
5494
+ function realSendUserMessage(session: AgentSession) {
5495
+ return async (content: unknown, options?: PreflightHooks & { deliverAs?: string }): Promise<void> => {
5496
+ await options?.onPreflightAcceptCommit?.();
5497
+ const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = options ?? {};
5498
+ await session.sendUserMessage(content as string, dispatchOptions as never);
5499
+ };
5500
+ }
5501
+
5502
+ test.each([
5503
+ "clearContext",
5504
+ "newSession",
5505
+ "compact",
5506
+ ] as const)("SDK %s disconnect cancels only its accepted publication owner before disposal", async operation => {
5507
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-${operation}-publication-`));
5508
+ const entered = Promise.withResolvers<void>();
5509
+ const freshEntered = Promise.withResolvers<void>();
5510
+ const aborted = Promise.withResolvers<void>();
5511
+ const release = Promise.withResolvers<void>();
5512
+ let session: AgentSession | undefined;
5513
+ let authStorage: AuthStorage | undefined;
5514
+ let harness: InvocationHarness | undefined;
5515
+ let submission: Promise<void> | undefined;
5516
+ let providerCalls = 0;
5517
+ try {
5518
+ const real = await createTerminalizationSession(cwd, (model, context, options) => {
5519
+ providerCalls++;
5520
+ if (providerCalls === 2) freshEntered.resolve();
5521
+ if (providerCalls === 1)
5522
+ options?.signal?.addEventListener(
5523
+ "abort",
5524
+ () => {
5525
+ if (operation === "compact") session?.abortCompaction();
5526
+ aborted.resolve();
5527
+ },
5528
+ { once: true },
5529
+ );
5530
+ return createMockModel({
5531
+ responses: [
5532
+ async () => {
5533
+ if (providerCalls === 1) {
5534
+ entered.resolve();
5535
+ await release.promise;
5536
+ }
5537
+ return { content: ["fresh independent completion"] };
5538
+ },
5539
+ ],
5540
+ }).stream(model, context, options);
5541
+ });
5542
+ session = real.session;
5543
+ authStorage = real.authStorage;
5544
+ const send = realSendUserMessage(session);
5545
+ harness = await invocationHarness(`disconnect-publication-${operation}`, cwd, {
5546
+ isIdle: () => !session?.isStreaming,
5547
+ sendUserMessage: (content, options) => {
5548
+ submission = send(content, options);
5549
+ void submission.catch(() => undefined);
5550
+ return submission;
5551
+ },
5552
+ });
5553
+ session.subscribe(event => harness?.emit(event.type, event));
5554
+ expect((await harness.control("turn.prompt", { text: "cancel this disconnected owner" })).ok).toBe(true);
5555
+ await entered.promise;
5556
+ const transition = operation === "compact" ? session.compact() : session[operation]();
5557
+ void transition.catch(() => undefined);
5558
+ await aborted.promise;
5559
+ release.resolve();
5560
+ if (operation === "compact") await expect(transition).rejects.toBeInstanceOf(CompactionCancelledError);
5561
+ else expect(await transition).toBe(true);
5562
+ if (!submission) throw new Error("Expected the accepted actual SDK submission promise.");
5563
+ await expect(submission).rejects.toMatchObject({ code: "cancelled" });
5564
+ const fresh = await harness.control("turn.prompt", { text: "run a fresh independent prompt" });
5565
+ expect(fresh.ok).toBe(true);
5566
+ await freshEntered.promise;
5567
+ await expect(submission).resolves.toBeUndefined();
5568
+ expect(
5569
+ await settledStatus(harness, "turn.result", {
5570
+ kind: "prompt",
5571
+ commandId: fresh.result?.commandId,
5572
+ turnId: fresh.result?.turnId,
5573
+ }),
5574
+ ).toMatchObject({ status: "terminal_ok" });
5575
+ expect(session.agent.state.messages.at(-1)).toMatchObject({
5576
+ role: "assistant",
5577
+ content: [{ type: "text", text: "fresh independent completion" }],
5578
+ });
5579
+ expect(providerCalls).toBe(2);
5580
+ } finally {
5581
+ release.resolve();
5582
+ await session?.dispose();
5583
+ authStorage?.close();
5584
+ await harness?.stop();
5585
+ await rm(cwd, { recursive: true, force: true });
5586
+ }
5587
+ });
5588
+
5589
+ test("SDK disconnect preserves an already published projected terminal's actual submission", async () => {
5590
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-publishing-disconnect-"));
5591
+ const delivered = Promise.withResolvers<void>();
5592
+ const release = Promise.withResolvers<void>();
5593
+ let session: AgentSession | undefined;
5594
+ let authStorage: AuthStorage | undefined;
5595
+ let harness: InvocationHarness | undefined;
5596
+ let submission: Promise<void> | undefined;
5597
+ let restoreEmission: (() => void) | undefined;
5598
+ let terminalCount = 0;
5599
+ try {
5600
+ const real = await createTerminalizationSession(
5601
+ cwd,
5602
+ (model, context, options) =>
5603
+ createMockModel({ responses: [{ content: ["projected completion"] }] }).stream(model, context, options),
5604
+ {},
5605
+ true,
5606
+ );
5607
+ session = real.session;
5608
+ authStorage = real.authStorage;
5609
+ const send = realSendUserMessage(session);
5610
+ harness = await invocationHarness("publishing-disconnect", cwd, {
5611
+ isIdle: () => !session?.isStreaming,
5612
+ sendUserMessage: (content, options) => {
5613
+ submission = send(content, options);
5614
+ void submission.catch(() => undefined);
5615
+ return submission;
5616
+ },
5617
+ });
5618
+ session.subscribe(event => (event.type === "agent_end" ? undefined : harness?.emit(event.type, event)));
5619
+ const runner = session.extensionRunner;
5620
+ if (!runner) throw new Error("Expected the real projected terminal extension bridge.");
5621
+ const emit = runner.emit.bind(runner);
5622
+ const emission = spyOn(runner, "emit").mockImplementation(async event => {
5623
+ const result = await emit(event);
5624
+ if (event.type === "agent_end") {
5625
+ terminalCount++;
5626
+ await harness?.emit(event.type, event);
5627
+ delivered.resolve();
5628
+ await release.promise;
5629
+ }
5630
+ return result;
5631
+ });
5632
+ restoreEmission = () => emission.mockRestore();
5633
+ const accepted = await harness.control("turn.prompt", { text: "publish then disconnect this owner" });
5634
+ expect(accepted.ok).toBe(true);
5635
+ await delivered.promise;
5636
+ expect(
5637
+ await settledStatus(harness, "turn.result", {
5638
+ kind: "prompt",
5639
+ commandId: accepted.result?.commandId,
5640
+ turnId: accepted.result?.turnId,
5641
+ }),
5642
+ ).toMatchObject({ status: "terminal_ok", content: { text: "projected completion" } });
5643
+ const transition = session.clearContext();
5644
+ release.resolve();
5645
+ expect(await transition).toBe(true);
5646
+ if (!submission) throw new Error("Expected the actual publishing SDK submission promise.");
5647
+ await expect(submission).resolves.toBeUndefined();
5648
+ expect(terminalCount).toBe(1);
5649
+ } finally {
5650
+ release.resolve();
5651
+ restoreEmission?.();
5652
+ await session?.dispose();
5653
+ authStorage?.close();
5654
+ await harness?.stop();
5655
+ await rm(cwd, { recursive: true, force: true });
5656
+ }
5657
+ });
5658
+
5659
+ test("SDK disposal rejects an accepted real submission whose terminal bridge is disconnected", async () => {
5660
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-dispose-publication-"));
5661
+ const entered = Promise.withResolvers<void>();
5662
+ const release = Promise.withResolvers<void>();
5663
+ let session: AgentSession | undefined;
5664
+ let authStorage: AuthStorage | undefined;
5665
+ let harness: InvocationHarness | undefined;
5666
+ let submission: Promise<void> | undefined;
5667
+ try {
5668
+ const real = await createTerminalizationSession(cwd, (model, context, options) =>
5669
+ createMockModel({
5670
+ responses: [
5671
+ async () => {
5672
+ entered.resolve();
5673
+ await release.promise;
5674
+ return { content: ["completed"] };
5675
+ },
5676
+ ],
5677
+ }).stream(model, context, options),
5678
+ );
5679
+ session = real.session;
5680
+ authStorage = real.authStorage;
5681
+ const send = realSendUserMessage(session);
5682
+ harness = await invocationHarness("dispose-publication", cwd, {
5683
+ isIdle: () => !session?.isStreaming,
5684
+ sendUserMessage: (content, options) => {
5685
+ submission = send(content, options);
5686
+ void submission.catch(() => undefined);
5687
+ return submission;
5688
+ },
5689
+ });
5690
+ session.subscribe(event => harness?.emit(event.type, event));
5691
+ expect((await harness.control("turn.prompt", { text: "dispose this accepted prompt" })).ok).toBe(true);
5692
+ await entered.promise;
5693
+ const disposal = session.dispose();
5694
+ release.resolve();
5695
+ if (!submission) throw new Error("Expected the actual accepted submission promise.");
5696
+ await expect(submission).rejects.toMatchObject({ code: "cancelled" });
5697
+ await disposal;
5698
+ } finally {
5699
+ release.resolve();
5700
+ await session?.dispose();
5701
+ authStorage?.close();
5702
+ await harness?.stop();
5703
+ await rm(cwd, { recursive: true, force: true });
5704
+ }
5705
+ });
5706
+
5707
+ test.each([
5708
+ false,
5709
+ true,
5710
+ ])("SDK terminal publication rejection is observed independently of its cohort waiter (queued=%s)", async queued => {
5711
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-publication-rejection-"));
5712
+ const entered = Promise.withResolvers<void>();
5713
+ const release = Promise.withResolvers<void>();
5714
+ const rejected = Promise.withResolvers<void>();
5715
+ const failure = new Error("Injected terminal extension delivery failure.");
5716
+ const unhandled: unknown[] = [];
5717
+ const onUnhandled = (reason: unknown) => {
5718
+ unhandled.push(reason);
5719
+ };
5720
+ let session: AgentSession | undefined;
5721
+ let authStorage: AuthStorage | undefined;
5722
+ let harness: InvocationHarness | undefined;
5723
+ let rootSubmission: Promise<void> | undefined;
5724
+ let providerCalls = 0;
5725
+ let targetToken: string | undefined;
5726
+ let injected = false;
5727
+ const projectedTerminals: Array<{ calls: number; token: string | undefined }> = [];
5728
+ let restoreFault: (() => void) | undefined;
5729
+ process.on("unhandledRejection", onUnhandled);
5730
+ try {
5731
+ const real = await createTerminalizationSession(
5732
+ cwd,
5733
+ (model, context, options) => {
5734
+ providerCalls++;
5735
+ return createMockModel({
5736
+ responses: [
5737
+ async () => {
5738
+ if (providerCalls === 1) {
5739
+ entered.resolve();
5740
+ await release.promise;
5741
+ }
5742
+ return { content: ["completed"] };
5743
+ },
5744
+ ],
5745
+ }).stream(model, context, options);
5746
+ },
5747
+ {},
5748
+ true,
5749
+ );
5750
+ session = real.session;
5751
+ authStorage = real.authStorage;
5752
+ const runner = session.extensionRunner;
5753
+ if (!runner) throw new Error("Expected the actual extension runner.");
5754
+ const emit = runner.emit.bind(runner);
5755
+ const fault = spyOn(runner, "emit").mockImplementation(async (event, ...args) => {
5756
+ if (event.type === "agent_end") projectedTerminals.push({ calls: providerCalls, token: event.sdkRunToken });
5757
+ if (!injected && event.type === "agent_end" && event.sdkRunToken === targetToken) {
5758
+ injected = true;
5759
+ rejected.resolve();
5760
+ throw failure;
5761
+ }
5762
+ return emit(event, ...args);
5763
+ });
5764
+ restoreFault = () => fault.mockRestore();
5765
+ const send = realSendUserMessage(session);
5766
+ harness = await invocationHarness("publication-rejection", cwd, {
5767
+ isIdle: () => !session?.isStreaming,
5768
+ sendUserMessage: (content, options) => {
5769
+ const submission = send(content, options);
5770
+ if (!rootSubmission) rootSubmission = submission;
5771
+ void submission.catch(() => undefined);
5772
+ return submission;
5773
+ },
5774
+ });
5775
+ session.subscribe(event => harness?.emit(event.type, event));
5776
+ const active = await harness.control("turn.prompt", { text: "active prompt" });
5777
+ expect(active.ok).toBe(true);
5778
+ await entered.promise;
5779
+ const target = queued ? await harness.control("turn.follow_up", { text: "queued prompt" }) : active;
5780
+ expect(target.ok).toBe(true);
5781
+ targetToken = `${target.result?.commandId}:${target.result?.turnId}`;
5782
+ release.resolve();
5783
+ await rejected.promise;
5784
+ if (!rootSubmission) throw new Error("Expected the actual root submission promise.");
5785
+ if (queued) await rootSubmission;
5786
+ else await expect(rootSubmission).rejects.toBe(failure);
5787
+ await Bun.sleep(0);
5788
+ await Bun.sleep(0);
5789
+ expect(injected).toBe(true);
5790
+ expect(providerCalls).toBe(queued ? 2 : 1);
5791
+ expect(projectedTerminals).toHaveLength(queued ? 2 : 1);
5792
+ expect(unhandled).toEqual([]);
5793
+ } finally {
5794
+ process.off("unhandledRejection", onUnhandled);
5795
+ restoreFault?.();
5796
+ release.resolve();
5797
+ await session?.dispose().catch(error => {
5798
+ expect(error).toBeInstanceOf(AggregateError);
5799
+ expect((error as AggregateError).errors).toEqual([failure]);
5800
+ });
5801
+ authStorage?.close();
5802
+ await harness?.stop();
5803
+ await rm(cwd, { recursive: true, force: true });
5804
+ }
5805
+ });
5806
+
5807
+ test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
5808
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
5809
+ const staleToken = "ended-command:ended-turn";
5810
+ const clientRef = "stale-steer-ref";
5811
+ const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
5812
+ let harness: InvocationHarness | undefined;
5813
+ try {
5814
+ harness = await invocationHarness("steer-run-token", cwd, {
5815
+ sendUserMessage: async (content, options) => {
5816
+ calls.push({
5817
+ content,
5818
+ deliverAs: options?.deliverAs,
5819
+ expectedSdkRunToken: options?.expectedSdkRunToken,
5820
+ });
5821
+ if (options?.expectedSdkRunToken === staleToken)
5822
+ throw Object.assign(new Error("The expected SDK run is not active."), { code: "turn_not_active" });
5823
+ return "completed";
5824
+ },
5825
+ });
5826
+
5827
+ const rejected = await harness.control("turn.steer", {
5828
+ text: "stale steer",
5829
+ expectedSdkRunToken: staleToken,
5830
+ clientRef,
5831
+ });
5832
+ expect(rejected).toMatchObject({ ok: false, error: { code: "turn_not_active" } });
5833
+ expect(calls).toEqual([{ content: "stale steer", deliverAs: "steer", expectedSdkRunToken: staleToken }]);
5834
+ expect(await harness.query("turn.steer_status", { clientRef })).toMatchObject({
5835
+ ok: true,
5836
+ result: { status: "rejected" },
5837
+ });
5838
+ } finally {
5839
+ if (harness) await harness.stop();
5840
+ await rm(cwd, { recursive: true, force: true });
5841
+ }
5842
+ });
5843
+
5844
+ describe("post-acceptance invocation terminalization", () => {
5845
+ test.each([
5846
+ false,
5847
+ true,
5848
+ ])("a returning SDK bridge waits for its real overflow continuation terminal (managed=%s)", async managed => {
5849
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-returning-bridge-"));
5850
+ let harness: InvocationHarness | undefined;
5851
+ let session: AgentSession | undefined;
5852
+ let authStorage: AuthStorage | undefined;
5853
+ let submission: Promise<void> | undefined;
5854
+ let submissionSettled = false;
5855
+ let providerCalls = 0;
5856
+ const recoveryEntered = Promise.withResolvers<void>();
5857
+ const releaseRecovery = Promise.withResolvers<void>();
5858
+ try {
5859
+ const real = await createTerminalizationSession(cwd, (model, context, options) => {
5860
+ providerCalls++;
5861
+ if (providerCalls === 1) return typedContextOverflowStream(model);
5862
+ return createMockModel({
5863
+ responses: [
5864
+ async () => {
5865
+ recoveryEntered.resolve();
5866
+ await releaseRecovery.promise;
5867
+ return { content: ["FINAL_OVERFLOW_RECOVERY"] };
5868
+ },
5869
+ ],
5870
+ }).stream(model, context, options);
5871
+ });
5872
+ session = real.session;
5873
+ authStorage = real.authStorage;
5874
+ if (!managed) session.setConfiguredModelChain("default", [selector(real.model)], "returning-overflow-test");
5875
+ const send = realSendUserMessage(session);
5876
+ harness = await invocationHarness("overflow-returning-bridge", cwd, {
5877
+ isIdle: () => !session?.isStreaming,
5878
+ sendUserMessage: (content, options) => {
5879
+ submission = send(content, options);
5880
+ void submission.then(
5881
+ () => {
5882
+ submissionSettled = true;
5883
+ },
5884
+ () => {
5885
+ submissionSettled = true;
5886
+ },
5887
+ );
5888
+ return submission;
5889
+ },
5890
+ });
5891
+ session.subscribe(event => harness?.emit(event.type, event));
5892
+ const accepted = await harness.control("turn.prompt", { text: "finish after the context overflow" });
5893
+ expect(accepted.ok).toBe(true);
5894
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
5895
+ expect(correlation.commandId).toBeDefined();
5896
+ expect(correlation.turnId).toBeDefined();
5897
+ await recoveryEntered.promise;
5898
+ expect(submissionSettled).toBe(false);
5899
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
5900
+ expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
5901
+ status: expect.stringMatching(/^(accepted|in_flight)$/),
5902
+ });
5903
+ releaseRecovery.resolve();
5904
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
5905
+ expect(terminal).toMatchObject({
5906
+ ...correlation,
5907
+ status: "terminal_ok",
5908
+ content: { text: "FINAL_OVERFLOW_RECOVERY" },
5909
+ outcome: { kind: "stopped", reason: "end_turn" },
5910
+ });
5911
+ expect(terminal.error).toBeUndefined();
5912
+ if (!submission) throw new Error("Expected the actual accepted overflow submission promise.");
5913
+ await submission;
5914
+ expect(submissionSettled).toBe(true);
5915
+ await session.waitForIdle();
5916
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
5917
+ expect(ends).toHaveLength(1);
5918
+ expect(ends[0]).toMatchObject({
5919
+ payload: { ...correlation, outcome: { kind: "stopped", reason: "end_turn" } },
5920
+ });
5921
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(managed ? 1 : 2);
5922
+ expect(providerCalls).toBe(2);
5923
+ } finally {
5924
+ releaseRecovery.resolve();
5925
+ await session?.dispose();
5926
+ authStorage?.close();
5927
+ await harness?.stop();
5928
+ await rm(cwd, { recursive: true, force: true });
5929
+ }
5930
+ });
5931
+
5932
+ test("preserves prompt correlation across a real overflow retry", async () => {
5933
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-correlation-"));
5934
+ let harness: InvocationHarness | undefined;
5935
+ let session: AgentSession | undefined;
5936
+ let authStorage: AuthStorage | undefined;
5937
+ let providerCalls = 0;
5938
+ try {
5939
+ const real = await createTerminalizationSession(cwd, async (model, context, options) => {
5940
+ providerCalls++;
5941
+ if (providerCalls === 1) return typedContextOverflowStream(model);
5942
+ return createMockModel({ responses: [{ content: ["recovered"] }] }).stream(model, context, options);
5943
+ });
5944
+ session = real.session;
5945
+ authStorage = real.authStorage;
5946
+
5947
+ harness = await invocationHarness("overflow-retry-correlation", cwd, {
5948
+ isIdle: () => !session?.isStreaming,
5949
+ sendUserMessage: deferredRealSendUserMessage(session),
5950
+ });
5951
+ session.subscribe(async event => {
5952
+ await harness?.emit(event.type, event);
5953
+ });
5954
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
5955
+ expect(accepted.ok).toBe(true);
5956
+ const correlation = {
5957
+ commandId: accepted.result?.commandId,
5958
+ turnId: accepted.result?.turnId,
5959
+ };
5960
+
5961
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
5962
+ expect(terminal).toMatchObject({
5963
+ status: "terminal_ok",
5964
+ commandId: correlation.commandId,
5965
+ turnId: correlation.turnId,
5966
+ });
5967
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
5968
+ expect(ends).toHaveLength(1);
5969
+ expect(ends[0]).toMatchObject({
5970
+ payload: {
5971
+ commandId: correlation.commandId,
5972
+ turnId: correlation.turnId,
5973
+ },
5974
+ });
5975
+ expect(providerCalls).toBe(2);
5976
+ } finally {
5977
+ await session?.dispose();
5978
+ authStorage?.close();
5979
+ await harness?.stop();
5980
+ await rm(cwd, { recursive: true, force: true });
5981
+ }
5982
+ });
5983
+
5984
+ test("preserves prompt correlation across a todo-reminder continuation", async () => {
5985
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-continuation-"));
5986
+ let harness: InvocationHarness | undefined;
5987
+ let session: AgentSession | undefined;
5988
+ let authStorage: AuthStorage | undefined;
5989
+ let providerCalls = 0;
5990
+ const secondEntered = Promise.withResolvers<void>();
5991
+ const releaseSecond = Promise.withResolvers<void>();
5992
+ try {
5993
+ const real = await createTerminalizationSession(
5994
+ cwd,
5995
+ async (model, context, options) => {
5996
+ providerCalls++;
5997
+ return createMockModel({
5998
+ responses: [
5999
+ providerCalls === 2
6000
+ ? async () => {
6001
+ secondEntered.resolve();
6002
+ await releaseSecond.promise;
6003
+ return { content: ["FINAL"] };
6004
+ }
6005
+ : { content: ["FIRST"] },
6006
+ ],
6007
+ }).stream(model, context, options);
6008
+ },
6009
+ { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 },
6010
+ );
6011
+ session = real.session;
6012
+ authStorage = real.authStorage;
6013
+
6014
+ session.setTodoPhases([
6015
+ { name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
6016
+ ]);
6017
+ harness = await invocationHarness("todo-reminder-continuation", cwd, {
6018
+ isIdle: () => !session?.isStreaming,
6019
+ sendUserMessage: realSendUserMessage(session),
6020
+ });
6021
+ session.subscribe(async event => {
6022
+ await harness?.emit(event.type, event);
6023
+ });
6024
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
6025
+ expect(accepted.ok).toBe(true);
6026
+ const correlation = {
6027
+ commandId: accepted.result?.commandId,
6028
+ turnId: accepted.result?.turnId,
6029
+ };
6030
+ expect(correlation.commandId).toBeDefined();
6031
+ expect(correlation.turnId).toBeDefined();
6032
+ await secondEntered.promise;
6033
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
6034
+ expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
6035
+ status: expect.stringMatching(/^(accepted|in_flight)$/),
6036
+ });
6037
+ releaseSecond.resolve();
6038
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6039
+ expect(terminal).toMatchObject({
6040
+ status: "terminal_ok",
6041
+ commandId: correlation.commandId,
6042
+ turnId: correlation.turnId,
6043
+ content: { text: "FINAL" },
6044
+ });
6045
+ expect(terminal.error).toBeUndefined();
6046
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(2);
6047
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
6048
+ expect(ends).toHaveLength(1);
6049
+ expect(ends[0]).toMatchObject({
6050
+ payload: {
6051
+ commandId: correlation.commandId,
6052
+ turnId: correlation.turnId,
6053
+ },
6054
+ });
6055
+ expect(providerCalls).toBe(2);
6056
+ } finally {
6057
+ releaseSecond.resolve();
6058
+ await session?.dispose();
6059
+ authStorage?.close();
6060
+ await harness?.stop();
6061
+ await rm(cwd, { recursive: true, force: true });
6062
+ }
6063
+ });
6064
+
6065
+ test("terminalizes cancellation during a todo-reminder continuation", async () => {
6066
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-cancel-"));
6067
+ let harness: InvocationHarness | undefined;
6068
+ let session: AgentSession | undefined;
6069
+ let authStorage: AuthStorage | undefined;
6070
+ let providerCalls = 0;
6071
+ let secondSignal: AbortSignal | undefined;
6072
+ const secondEntered = Promise.withResolvers<void>();
6073
+ const releaseSecond = Promise.withResolvers<void>();
6074
+ try {
6075
+ const real = await createTerminalizationSession(
6076
+ cwd,
6077
+ async (model, context, options) => {
6078
+ providerCalls++;
6079
+ if (providerCalls === 2) {
6080
+ secondSignal = options?.signal;
6081
+ secondEntered.resolve();
6082
+ }
6083
+ const response =
6084
+ providerCalls === 2
6085
+ ? async () => {
6086
+ await releaseSecond.promise;
6087
+ return { content: ["completed"] };
6088
+ }
6089
+ : { content: ["started"] };
6090
+ return createMockModel({ responses: [response] }).stream(model, context, options);
6091
+ },
6092
+ { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 },
6093
+ );
6094
+ session = real.session;
6095
+ authStorage = real.authStorage;
6096
+ session.setTodoPhases([
6097
+ { name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
6098
+ ]);
6099
+ harness = await invocationHarness("todo-reminder-cancel", cwd, {
6100
+ isIdle: () => !session?.isStreaming,
6101
+ sendUserMessage: realSendUserMessage(session),
6102
+ });
6103
+ session.subscribe(async event => {
6104
+ await harness?.emit(event.type, event);
6105
+ });
6106
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
6107
+ expect(accepted.ok).toBe(true);
6108
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6109
+ expect(correlation.commandId).toBeDefined();
6110
+ expect(correlation.turnId).toBeDefined();
6111
+ await secondEntered.promise;
6112
+ expect(secondSignal?.aborted).toBe(false);
6113
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0);
6114
+ await session.abort();
6115
+ expect(secondSignal?.aborted).toBe(true);
6116
+ releaseSecond.resolve();
6117
+ await session.waitForIdle();
6118
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6119
+ expect(terminal).toMatchObject(correlation);
6120
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
6121
+ expect(ends).toHaveLength(1);
6122
+ expect(ends[0]).toMatchObject({ payload: correlation });
6123
+ expect(providerCalls).toBe(2);
6124
+ } finally {
6125
+ releaseSecond.resolve();
6126
+ await session?.dispose();
6127
+ authStorage?.close();
6128
+ await harness?.stop();
6129
+ await rm(cwd, { recursive: true, force: true });
6130
+ }
6131
+ });
6132
+
6133
+ test("terminalizes a synchronous throw during a todo-reminder continuation", async () => {
6134
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-throw-"));
6135
+ let harness: InvocationHarness | undefined;
6136
+ let session: AgentSession | undefined;
6137
+ let authStorage: AuthStorage | undefined;
6138
+ let providerCalls = 0;
6139
+ try {
6140
+ const real = await createTerminalizationSession(
6141
+ cwd,
6142
+ (model, context, options) => {
6143
+ providerCalls++;
6144
+ if (providerCalls === 2) throw new Error("todo continuation stream failed synchronously");
6145
+ return createMockModel({ responses: [{ content: ["started"] }] }).stream(model, context, options);
6146
+ },
6147
+ { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1, "retry.enabled": false },
6148
+ );
6149
+ session = real.session;
6150
+ session.setConfiguredModelChain("default", [selector(real.model)], "terminal-throw-test");
6151
+ authStorage = real.authStorage;
6152
+ session.setTodoPhases([
6153
+ { name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] },
6154
+ ]);
6155
+ harness = await invocationHarness("todo-reminder-throw", cwd, {
6156
+ isIdle: () => !session?.isStreaming,
6157
+ sendUserMessage: realSendUserMessage(session),
6158
+ });
6159
+ session.subscribe(async event => {
6160
+ await harness?.emit(event.type, event);
6161
+ });
6162
+ const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" });
6163
+ expect(accepted.ok).toBe(true);
6164
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6165
+ expect(correlation.commandId).toBeDefined();
6166
+ expect(correlation.turnId).toBeDefined();
6167
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6168
+ expect(terminal).toMatchObject({
6169
+ ...correlation,
6170
+ status: "failed",
6171
+ outcome: { kind: "failed" },
6172
+ error: { code: "provider_rejected" },
6173
+ });
6174
+ // Wait for session to settle and ensure no further continuations are queued
6175
+ await session?.waitForIdle();
6176
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(2);
6177
+ const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end");
6178
+ expect(ends).toHaveLength(1);
6179
+ expect(ends[0]).toMatchObject({ payload: { ...correlation, outcome: { kind: "failed" } } });
6180
+ expect(providerCalls).toBe(2);
6181
+ await session.waitForIdle();
6182
+ expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result).toMatchObject({
6183
+ ...correlation,
6184
+ status: "failed",
6185
+ outcome: { kind: "failed" },
6186
+ });
6187
+ } finally {
6188
+ await session?.dispose();
6189
+ authStorage?.close();
6190
+ await harness?.stop();
6191
+ await rm(cwd, { recursive: true, force: true });
6192
+ }
6193
+ });
6194
+
6195
+ test("terminalizes a second overflow during overflow maintenance", async () => {
6196
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-maintenance-failure-"));
6197
+ let harness: InvocationHarness | undefined;
6198
+ let session: AgentSession | undefined;
6199
+ let authStorage: AuthStorage | undefined;
6200
+ try {
6201
+ const real = await createTerminalizationSession(cwd, model => {
6202
+ return typedContextOverflowStream(model);
6203
+ });
6204
+ session = real.session;
6205
+ authStorage = real.authStorage;
6206
+ harness = await invocationHarness("overflow-maintenance-failure", cwd, {
6207
+ isIdle: () => !session?.isStreaming,
6208
+ sendUserMessage: deferredRealSendUserMessage(session),
6209
+ });
6210
+ session.subscribe(async event => {
6211
+ await harness?.emit(event.type, event);
6212
+ });
6213
+ const accepted = await harness.control("turn.prompt", { text: "retry the outstanding work" });
6214
+ expect(accepted.ok).toBe(true);
6215
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6216
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6217
+ expect(terminal).toMatchObject({
6218
+ status: "failed",
6219
+ commandId: correlation.commandId,
6220
+ turnId: correlation.turnId,
6221
+ });
6222
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
6223
+ } finally {
6224
+ await session?.dispose();
6225
+ authStorage?.close();
6226
+ await harness?.stop();
6227
+ await rm(cwd, { recursive: true, force: true });
6228
+ }
6229
+ });
6230
+
6231
+ test("terminalizes cancellation during a scheduled overflow retry", async () => {
6232
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-cancel-"));
6233
+ let harness: InvocationHarness | undefined;
6234
+ let session: AgentSession | undefined;
6235
+ let authStorage: AuthStorage | undefined;
6236
+ let providerCalls = 0;
6237
+ const overflowEmitted = Promise.withResolvers<void>();
6238
+ try {
6239
+ const real = await createTerminalizationSession(
6240
+ cwd,
6241
+ model => {
6242
+ providerCalls++;
6243
+ return typedContextOverflowStream(
6244
+ model,
6245
+ providerCalls === 1 ? () => overflowEmitted.resolve() : undefined,
6246
+ );
6247
+ },
6248
+ { "retry.baseDelayMs": 1_000 },
6249
+ );
6250
+ session = real.session;
6251
+ authStorage = real.authStorage;
6252
+ session.setClientBridge({ capabilities: {}, deferAgentInitiatedTurns: true });
6253
+ harness = await invocationHarness("overflow-retry-cancel", cwd, {
6254
+ isIdle: () => !session?.isStreaming,
6255
+ sendUserMessage: deferredRealSendUserMessage(session),
6256
+ });
6257
+ session.subscribe(async event => {
6258
+ await harness?.emit(event.type, event);
6259
+ });
6260
+ const accepted = await harness.control("turn.prompt", { text: "cancel the retry" });
6261
+ expect(accepted.ok).toBe(true);
6262
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6263
+ await overflowEmitted.promise;
6264
+ await harness.control("turn.cancel", correlation);
6265
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6266
+ expect(terminal.status).toBe("failed");
6267
+ expect(terminal).toMatchObject(correlation);
6268
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
6269
+ await session.waitForIdle();
6270
+ expect(
6271
+ harness.broadcasts.filter(
6272
+ frame =>
6273
+ frame.kind === "agent_end" &&
6274
+ (frame.payload as { commandId?: string }).commandId === correlation.commandId,
6275
+ ),
6276
+ ).toHaveLength(1);
6277
+ } finally {
6278
+ await session?.dispose();
6279
+ authStorage?.close();
6280
+ await harness?.stop();
6281
+ await rm(cwd, { recursive: true, force: true });
6282
+ }
6283
+ });
6284
+
6285
+ test("terminalizes a synchronous throw from the overflow retry stream", async () => {
6286
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-retry-throw-"));
6287
+ let harness: InvocationHarness | undefined;
6288
+ let session: AgentSession | undefined;
6289
+ let authStorage: AuthStorage | undefined;
6290
+ let providerCalls = 0;
6291
+ try {
6292
+ const real = await createTerminalizationSession(cwd, async model => {
6293
+ providerCalls++;
6294
+ if (providerCalls === 2) throw new Error("retry stream failed synchronously");
6295
+ return typedContextOverflowStream(model);
6296
+ });
6297
+ session = real.session;
6298
+ authStorage = real.authStorage;
6299
+ harness = await invocationHarness("overflow-retry-throw", cwd, {
6300
+ isIdle: () => !session?.isStreaming,
6301
+ sendUserMessage: deferredRealSendUserMessage(session),
6302
+ });
6303
+ session.subscribe(async event => {
6304
+ await harness?.emit(event.type, event);
6305
+ });
6306
+ const accepted = await harness.control("turn.prompt", { text: "retry with a throwing provider" });
6307
+ expect(accepted.ok).toBe(true);
6308
+ const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
6309
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6310
+ expect(terminal).toMatchObject({
6311
+ status: "failed",
6312
+ commandId: correlation.commandId,
6313
+ turnId: correlation.turnId,
6314
+ });
6315
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
6316
+ } finally {
6317
+ await session?.dispose();
6318
+ authStorage?.close();
6319
+ await harness?.stop();
6320
+ await rm(cwd, { recursive: true, force: true });
6321
+ }
6322
+ });
4485
6323
 
4486
- test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
4487
- const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
4488
- const staleToken = "ended-command:ended-turn";
4489
- const clientRef = "stale-steer-ref";
4490
- const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
4491
- let harness: InvocationHarness | undefined;
4492
- try {
4493
- harness = await invocationHarness("steer-run-token", cwd, {
4494
- sendUserMessage: async (content, options) => {
4495
- calls.push({
4496
- content,
4497
- deliverAs: options?.deliverAs,
4498
- expectedSdkRunToken: options?.expectedSdkRunToken,
4499
- });
4500
- if (options?.expectedSdkRunToken === staleToken)
4501
- throw Object.assign(new Error("The expected SDK run is not active."), { code: "turn_not_active" });
4502
- return "completed";
4503
- },
4504
- });
6324
+ test("preserves external prompt correlation across a retryable provider error", async () => {
6325
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-retryable-provider-correlation-"));
6326
+ let harness: InvocationHarness | undefined;
6327
+ try {
6328
+ harness = await invocationHarness("retryable-provider-correlation", cwd, {
6329
+ sendUserMessage: async (_content, options) => {
6330
+ await options?.onPreflightAcceptCommit?.();
6331
+ await neverSettlingPromise();
6332
+ },
6333
+ });
6334
+ const accepted = await harness.control("turn.prompt", {
6335
+ text: "recover after a provider overload",
6336
+ clientRef: "retryable-provider-correlation",
6337
+ });
6338
+ expect(accepted.ok).toBe(true);
6339
+ const correlation = {
6340
+ commandId: accepted.result?.commandId,
6341
+ turnId: accepted.result?.turnId,
6342
+ };
6343
+ const retryScope = {};
4505
6344
 
4506
- const rejected = await harness.control("turn.steer", {
4507
- text: "stale steer",
4508
- expectedSdkRunToken: staleToken,
4509
- clientRef,
4510
- });
4511
- expect(rejected).toMatchObject({ ok: false, error: { code: "turn_not_active" } });
4512
- expect(calls).toEqual([{ content: "stale steer", deliverAs: "steer", expectedSdkRunToken: staleToken }]);
4513
- expect(await harness.query("turn.steer_status", { clientRef })).toMatchObject({
4514
- ok: true,
4515
- result: { status: "rejected" },
4516
- });
4517
- } finally {
4518
- if (harness) await harness.stop();
4519
- await rm(cwd, { recursive: true, force: true });
4520
- }
4521
- });
6345
+ for (let attempt = 0; attempt < 3; attempt++) {
6346
+ await harness.emit("agent_start", { lifecycleScope: retryScope });
6347
+ }
6348
+ await harness.emit("agent_end", {
6349
+ messages: [{ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "recovered" }] }],
6350
+ });
6351
+
6352
+ const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation });
6353
+ expect(terminal).toMatchObject({
6354
+ status: "terminal_ok",
6355
+ commandId: correlation.commandId,
6356
+ turnId: correlation.turnId,
6357
+ outcome: { kind: "stopped", reason: "end_turn" },
6358
+ });
6359
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end").at(-1)).toMatchObject({
6360
+ payload: {
6361
+ commandId: correlation.commandId,
6362
+ turnId: correlation.turnId,
6363
+ outcome: { kind: "stopped", reason: "end_turn" },
6364
+ },
6365
+ });
6366
+ } finally {
6367
+ await harness?.stop();
6368
+ await rm(cwd, { recursive: true, force: true });
6369
+ }
6370
+ });
4522
6371
 
4523
- describe("post-acceptance invocation terminalization", () => {
4524
6372
  test.each([
4525
6373
  { status: 402, code: "provider_http_402" },
4526
6374
  { status: 429, code: "provider_http_429" },
@@ -5168,6 +7016,85 @@ describe("post-acceptance invocation terminalization", () => {
5168
7016
  }
5169
7017
  });
5170
7018
 
7019
+ test("tokenless retry boundaries release replacement terminal publication and replay", async () => {
7020
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-tokenless-retry-reopen-"));
7021
+ const sessionId = "tokenless-retry-reopen";
7022
+ const harness = await invocationHarness(sessionId, cwd, {});
7023
+ const scopes = createAttemptMinter();
7024
+ const activity = spyOn(SessionSdkSessionRuntime.prototype, "reportActivity");
7025
+ try {
7026
+ // Accepted ordinary retries suppress predecessor ends. Three attempts
7027
+ // therefore share one public lifecycle boundary, not three unmatched ends.
7028
+ const lifecycleScope = scopes.mint("main");
7029
+ for (let attempt = 0; attempt < 3; attempt++) {
7030
+ await harness.emit("agent_start", { lifecycleScope });
7031
+ }
7032
+ await harness.emit("agent_end");
7033
+ for (let turn = 0; turn < 2; turn++) {
7034
+ await harness.emit("agent_start", { lifecycleScope: scopes.mint("main") });
7035
+ await harness.emit("agent_end");
7036
+ }
7037
+ await harness.switchSession("different-session");
7038
+ await harness.switchSession(sessionId);
7039
+ const priorEnds = harness.broadcasts.filter(frame => frame.kind === "agent_end").length;
7040
+ await harness.emit("agent_start", { lifecycleScope: scopes.mint("main") });
7041
+ await harness.emit("agent_end");
7042
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(priorEnds + 1);
7043
+ expect(activity.mock.calls.at(-1)?.[0]).toBe("idle");
7044
+ expect(await harness.requestOnSession(sessionId, { type: "event_replay", sinceSeq: 0 })).toMatchObject({
7045
+ type: "event_replay_result",
7046
+ events: expect.arrayContaining([expect.objectContaining({ kind: "agent_end" })]),
7047
+ });
7048
+ } finally {
7049
+ activity.mockRestore();
7050
+ await harness.stop();
7051
+ await rm(cwd, { recursive: true, force: true });
7052
+ }
7053
+ });
7054
+
7055
+ test("tokenless retry preserves attached owners alongside newly promoted SDK work", async () => {
7056
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-tokenless-retry-owners-"));
7057
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7058
+ const harness = await invocationHarness("tokenless-retry-owners", cwd, {
7059
+ sendUserMessage: async (_content, options) => {
7060
+ await options?.onPreflightAcceptCommit?.();
7061
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7062
+ options?.onPreflightAccepted?.();
7063
+ },
7064
+ });
7065
+ try {
7066
+ const lifecycleScope = createAttemptMinter().mint("main");
7067
+ await harness.emit("agent_start", { lifecycleScope });
7068
+ const attached = await harness.control("turn.follow_up", { text: "attached work" });
7069
+ const queued = await harness.control("turn.follow_up", { text: "promoted work" });
7070
+ expect(attached.ok).toBe(true);
7071
+ expect(queued.ok).toBe(true);
7072
+ expect(promotions).toHaveLength(2);
7073
+ promotions[0]!({ startsOwnRun: false });
7074
+ promotions[1]!({ startsOwnRun: true });
7075
+ await harness.emit("agent_start", { lifecycleScope });
7076
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "shared retry final" }] });
7077
+ for (const accepted of [attached, queued]) {
7078
+ expect(
7079
+ await harness.query("turn.result", {
7080
+ kind: "prompt",
7081
+ commandId: accepted.result?.commandId,
7082
+ turnId: accepted.result?.turnId,
7083
+ }),
7084
+ ).toMatchObject({
7085
+ result: {
7086
+ status: "terminal_ok",
7087
+ receiptState: "present",
7088
+ content: { text: "shared retry final" },
7089
+ },
7090
+ });
7091
+ }
7092
+ } finally {
7093
+ await harness.stop();
7094
+ await rm(cwd, { recursive: true, force: true });
7095
+ }
7096
+ });
7097
+
5171
7098
  test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
5172
7099
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
5173
7100
  try {
@@ -5233,6 +7160,129 @@ describe("post-acceptance invocation terminalization", () => {
5233
7160
  await rm(cwd, { recursive: true, force: true });
5234
7161
  }
5235
7162
  });
7163
+ test("unowned predecessor end cannot publish a queued successor receipt", async () => {
7164
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-predecessor-"));
7165
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7166
+ const harness = await invocationHarness("unowned-predecessor", cwd, {
7167
+ sendUserMessage: async (_content, options) => {
7168
+ await options?.onPreflightAcceptCommit?.();
7169
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7170
+ options?.onPreflightAccepted?.();
7171
+ },
7172
+ });
7173
+ try {
7174
+ await harness.emit("agent_start");
7175
+ const accepted = await harness.control("turn.follow_up", { text: "queued verification" });
7176
+ expect(accepted.ok).toBe(true);
7177
+ expect(promotions).toHaveLength(1);
7178
+ promotions[0]!({ startsOwnRun: true });
7179
+ await harness.emit("agent_start");
7180
+ const selector = {
7181
+ kind: "prompt",
7182
+ commandId: accepted.result?.commandId,
7183
+ turnId: accepted.result?.turnId,
7184
+ };
7185
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "predecessor final" }] });
7186
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7187
+ result: { status: "in_flight", receiptState: "absent" },
7188
+ });
7189
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "verification final" }] });
7190
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7191
+ result: {
7192
+ status: "terminal_ok",
7193
+ turnId: accepted.result?.turnId,
7194
+ receiptState: "present",
7195
+ content: { text: "verification final" },
7196
+ },
7197
+ });
7198
+ } finally {
7199
+ await harness.stop();
7200
+ await rm(cwd, { recursive: true, force: true });
7201
+ }
7202
+ });
7203
+
7204
+ test("unowned run stays paired after predecessor SDK references retire", async () => {
7205
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-retirement-"));
7206
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7207
+ const harness = await invocationHarness("unowned-retirement", cwd, {
7208
+ sendUserMessage: async (_content, options) => {
7209
+ await options?.onPreflightAcceptCommit?.();
7210
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7211
+ options?.onPreflightAccepted?.();
7212
+ },
7213
+ });
7214
+ try {
7215
+ const first = await harness.control("turn.follow_up", { text: "first" });
7216
+ expect(promotions).toHaveLength(1);
7217
+ promotions[0]!({ startsOwnRun: true });
7218
+ await harness.emit("agent_start");
7219
+ await harness.emit("agent_start");
7220
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "first final" }] });
7221
+ expect(
7222
+ await harness.query("turn.result", {
7223
+ kind: "prompt",
7224
+ commandId: first.result?.commandId,
7225
+ turnId: first.result?.turnId,
7226
+ }),
7227
+ ).toMatchObject({ result: { status: "terminal_ok", content: { text: "first final" } } });
7228
+ const next = await harness.control("turn.follow_up", { text: "next" });
7229
+ expect(promotions).toHaveLength(2);
7230
+ promotions[1]!({ startsOwnRun: true });
7231
+ await harness.emit("agent_start");
7232
+ const selector = { kind: "prompt", commandId: next.result?.commandId, turnId: next.result?.turnId };
7233
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "unowned final" }] });
7234
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7235
+ result: { status: "in_flight", receiptState: "absent" },
7236
+ });
7237
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "next final" }] });
7238
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7239
+ result: { status: "terminal_ok", content: { text: "next final" } },
7240
+ });
7241
+ } finally {
7242
+ await harness.stop();
7243
+ await rm(cwd, { recursive: true, force: true });
7244
+ }
7245
+ });
7246
+
7247
+ test("unowned run retains attached invocation across predecessor completion", async () => {
7248
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "sdk-unowned-attached-"));
7249
+ const promotions: Array<(promotion: { startsOwnRun: boolean }) => void> = [];
7250
+ const harness = await invocationHarness("unowned-attached", cwd, {
7251
+ sendUserMessage: async (_content, options) => {
7252
+ await options?.onPreflightAcceptCommit?.();
7253
+ if (options?.onQueuedPromoted) promotions.push(options.onQueuedPromoted);
7254
+ options?.onPreflightAccepted?.();
7255
+ },
7256
+ });
7257
+ try {
7258
+ await harness.control("turn.follow_up", { text: "predecessor" });
7259
+ expect(promotions).toHaveLength(1);
7260
+ promotions[0]!({ startsOwnRun: true });
7261
+ await harness.emit("agent_start");
7262
+ await harness.emit("agent_start");
7263
+ const attached = await harness.control("turn.follow_up", { text: "attach to current unowned run" });
7264
+ expect(attached.ok).toBe(true);
7265
+ expect(promotions).toHaveLength(2);
7266
+ promotions[1]!({ startsOwnRun: false });
7267
+ const selector = {
7268
+ kind: "prompt",
7269
+ commandId: attached.result?.commandId,
7270
+ turnId: attached.result?.turnId,
7271
+ };
7272
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "predecessor final" }] });
7273
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7274
+ result: { status: expect.stringMatching(/accepted|in_flight/), receiptState: "absent" },
7275
+ });
7276
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "attached final" }] });
7277
+ expect(await harness.query("turn.result", selector)).toMatchObject({
7278
+ result: { status: "terminal_ok", content: { text: "attached final" } },
7279
+ });
7280
+ } finally {
7281
+ await harness.stop();
7282
+ await rm(cwd, { recursive: true, force: true });
7283
+ }
7284
+ });
7285
+
5236
7286
  test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
5237
7287
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
5238
7288
  try {
@@ -5696,104 +7746,101 @@ describe("post-acceptance invocation terminalization", () => {
5696
7746
  await rm(cwd, { recursive: true, force: true });
5697
7747
  }
5698
7748
  });
5699
- test("fails closed without independent terminal evidence", async () => {
5700
- const cases = [
5701
- { name: "usage-omitted", content: [{ type: "thinking", thinking: "" }], omitUsage: true },
5702
- { name: "usage-null", content: [{ type: "thinking", thinking: "" }], usage: null },
5703
- { name: "primitive-usage", content: [{ type: "thinking", thinking: "" }], usage: "bad" },
5704
- { name: "array-usage", content: [{ type: "thinking", thinking: "" }], usage: [] },
5705
- { name: "missing-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { input: 0 } },
5706
- {
5707
- name: "undefined-total-tokens",
5708
- content: [{ type: "thinking", thinking: "" }],
5709
- usage: { totalTokens: undefined },
5710
- },
5711
- { name: "negative-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { totalTokens: -1 } },
5712
- {
5713
- name: "nan-total-tokens",
5714
- content: [{ type: "thinking", thinking: "" }],
5715
- usage: { totalTokens: Number.NaN },
5716
- },
5717
- {
5718
- name: "infinite-total-tokens",
5719
- content: [{ type: "thinking", thinking: "" }],
5720
- usage: { totalTokens: Number.POSITIVE_INFINITY },
5721
- },
5722
- {
5723
- name: "incomplete-tool-call",
5724
- content: [
5725
- {
5726
- type: "toolCall",
5727
- id: "call-1",
5728
- name: "read",
5729
- arguments: {},
5730
- incompleteArguments: true,
5731
- incompleteArgumentsReason: "truncated",
5732
- },
5733
- ],
5734
- usage: { totalTokens: 0 },
5735
- },
5736
- {
5737
- name: "malformed-tool-arguments",
5738
- content: [{ type: "toolCall", id: "call-1", name: "read", arguments: null }],
5739
- usage: { totalTokens: 0 },
5740
- },
5741
- {
5742
- name: "orphaned-incomplete-reason",
5743
- content: [
5744
- {
5745
- type: "toolCall",
5746
- id: "call-1",
5747
- name: "read",
5748
- arguments: {},
5749
- incompleteArgumentsReason: "malformed",
5750
- },
5751
- ],
5752
- usage: { totalTokens: 0 },
5753
- },
5754
- ] as const;
5755
- for (const testCase of cases) {
5756
- const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-malformed-${testCase.name}-`));
5757
- try {
5758
- const harness = await invocationHarness(`terminal-malformed-${testCase.name}`, cwd, {
5759
- sendUserMessage: async (_content, options) => {
5760
- await options?.onPreflightAcceptCommit?.();
5761
- await Promise.withResolvers<void>().promise;
5762
- },
5763
- });
5764
- const accepted = await harness.control("turn.prompt", { text: "hello" });
5765
- await harness.emit("agent_start");
5766
- await harness.emit("agent_end", {
5767
- messages: [
5768
- {
5769
- role: "assistant",
5770
- content: testCase.content,
5771
- ...("omitUsage" in testCase ? {} : { usage: testCase.usage }),
5772
- },
5773
- ],
5774
- });
5775
- expect(
5776
- await settledStatus(harness, "turn.result", {
5777
- kind: "prompt",
5778
- commandId: accepted.result?.commandId,
5779
- turnId: accepted.result?.turnId,
5780
- }),
5781
- ).toMatchObject({
5782
- status: "failed",
5783
- error: { code: "prompt_failed", message: "Agent run failed after execution started." },
5784
- outcome: {
5785
- kind: "failed",
5786
- code: "prompt_failed",
5787
- message: "Agent run failed after execution started.",
5788
- provenance: "agent_failed",
5789
- phase: "post_start",
5790
- category: "agent_runtime",
7749
+ test.each([
7750
+ { name: "usage-omitted", content: [{ type: "thinking", thinking: "" }], omitUsage: true },
7751
+ { name: "usage-null", content: [{ type: "thinking", thinking: "" }], usage: null },
7752
+ { name: "primitive-usage", content: [{ type: "thinking", thinking: "" }], usage: "bad" },
7753
+ { name: "array-usage", content: [{ type: "thinking", thinking: "" }], usage: [] },
7754
+ { name: "missing-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { input: 0 } },
7755
+ {
7756
+ name: "undefined-total-tokens",
7757
+ content: [{ type: "thinking", thinking: "" }],
7758
+ usage: { totalTokens: undefined },
7759
+ },
7760
+ { name: "negative-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { totalTokens: -1 } },
7761
+ {
7762
+ name: "nan-total-tokens",
7763
+ content: [{ type: "thinking", thinking: "" }],
7764
+ usage: { totalTokens: Number.NaN },
7765
+ },
7766
+ {
7767
+ name: "infinite-total-tokens",
7768
+ content: [{ type: "thinking", thinking: "" }],
7769
+ usage: { totalTokens: Number.POSITIVE_INFINITY },
7770
+ },
7771
+ {
7772
+ name: "incomplete-tool-call",
7773
+ content: [
7774
+ {
7775
+ type: "toolCall",
7776
+ id: "call-1",
7777
+ name: "read",
7778
+ arguments: {},
7779
+ incompleteArguments: true,
7780
+ incompleteArgumentsReason: "truncated",
7781
+ },
7782
+ ],
7783
+ usage: { totalTokens: 0 },
7784
+ },
7785
+ {
7786
+ name: "malformed-tool-arguments",
7787
+ content: [{ type: "toolCall", id: "call-1", name: "read", arguments: null }],
7788
+ usage: { totalTokens: 0 },
7789
+ },
7790
+ {
7791
+ name: "orphaned-incomplete-reason",
7792
+ content: [
7793
+ {
7794
+ type: "toolCall",
7795
+ id: "call-1",
7796
+ name: "read",
7797
+ arguments: {},
7798
+ incompleteArgumentsReason: "malformed",
7799
+ },
7800
+ ],
7801
+ usage: { totalTokens: 0 },
7802
+ },
7803
+ ] as const)("fails closed without independent terminal evidence ($name)", async testCase => {
7804
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-malformed-${testCase.name}-`));
7805
+ try {
7806
+ const harness = await invocationHarness(`terminal-malformed-${testCase.name}`, cwd, {
7807
+ sendUserMessage: async (_content, options) => {
7808
+ await options?.onPreflightAcceptCommit?.();
7809
+ await Promise.withResolvers<void>().promise;
7810
+ },
7811
+ });
7812
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
7813
+ await harness.emit("agent_start");
7814
+ await harness.emit("agent_end", {
7815
+ messages: [
7816
+ {
7817
+ role: "assistant",
7818
+ content: testCase.content,
7819
+ ...("omitUsage" in testCase ? {} : { usage: testCase.usage }),
5791
7820
  },
5792
- });
5793
- await harness.stop();
5794
- } finally {
5795
- await rm(cwd, { recursive: true, force: true });
5796
- }
7821
+ ],
7822
+ });
7823
+ expect(
7824
+ await settledStatus(harness, "turn.result", {
7825
+ kind: "prompt",
7826
+ commandId: accepted.result?.commandId,
7827
+ turnId: accepted.result?.turnId,
7828
+ }),
7829
+ ).toMatchObject({
7830
+ status: "failed",
7831
+ error: { code: "prompt_failed", message: "Agent run failed after execution started." },
7832
+ outcome: {
7833
+ kind: "failed",
7834
+ code: "prompt_failed",
7835
+ message: "Agent run failed after execution started.",
7836
+ provenance: "agent_failed",
7837
+ phase: "post_start",
7838
+ category: "agent_runtime",
7839
+ },
7840
+ });
7841
+ await harness.stop();
7842
+ } finally {
7843
+ await rm(cwd, { recursive: true, force: true });
5797
7844
  }
5798
7845
  });
5799
7846
  test("preserves explicit cancellation for an empty zero-token turn", async () => {
@@ -5857,114 +7904,111 @@ describe("post-acceptance invocation terminalization", () => {
5857
7904
  await rm(cwd, { recursive: true, force: true });
5858
7905
  }
5859
7906
  });
5860
- test("reconciles contract-valid skill completion and agent_end ordering", async () => {
5861
- const cases = [
5862
- {
5863
- name: "completion-real-agent-real",
5864
- order: "completion-first",
5865
- completion: "completion",
5866
- agent: "agent",
5867
- expected: "completion",
5868
- },
5869
- {
5870
- name: "completion-blank-agent-real",
5871
- order: "completion-first",
5872
- completion: " ",
5873
- agent: "agent",
5874
- expected: "agent",
5875
- },
5876
- {
5877
- name: "completion-none-agent-real",
5878
- order: "completion-first",
5879
- completion: null,
5880
- agent: "agent",
5881
- expected: "agent",
5882
- },
5883
- {
5884
- name: "agent-real-completion-real",
5885
- order: "agent-first",
5886
- completion: "completion",
5887
- agent: "agent",
5888
- expected: "agent",
5889
- },
5890
- {
5891
- name: "agent-real-completion-blank",
5892
- order: "agent-first",
5893
- completion: " ",
5894
- agent: "agent",
5895
- expected: "agent",
5896
- },
5897
- {
5898
- name: "agent-blank-completion-real",
5899
- order: "agent-first",
5900
- completion: "completion",
5901
- agent: " ",
5902
- expected: "completion",
5903
- },
5904
- {
5905
- name: "agent-blank-completion-none",
5906
- order: "agent-first",
5907
- completion: null,
5908
- agent: " ",
5909
- expected: undefined,
5910
- },
5911
- {
5912
- name: "agent-none-completion-real",
5913
- order: "agent-first",
5914
- completion: "completion",
5915
- agent: null,
5916
- expected: "completion",
5917
- },
5918
- {
5919
- name: "agent-none-completion-blank",
5920
- order: "agent-first",
5921
- completion: " ",
5922
- agent: null,
5923
- expected: undefined,
5924
- },
5925
- ] as const;
5926
- for (const testCase of cases) {
5927
- const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`));
5928
- const completion = Promise.withResolvers<unknown>();
5929
- const completionReconciled = Promise.withResolvers<void>();
5930
- try {
5931
- const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, {
5932
- onInvocationCompletionReconciled: kind => {
5933
- if (kind === "skill") completionReconciled.resolve();
5934
- },
5935
- invokeSkill: async (_name, _args, options) => {
5936
- await options?.onPreflightAcceptCommit?.();
5937
- return await completion.promise;
5938
- },
7907
+ test.each([
7908
+ {
7909
+ name: "completion-real-agent-real",
7910
+ order: "completion-first",
7911
+ completion: "completion",
7912
+ agent: "agent",
7913
+ expected: "completion",
7914
+ },
7915
+ {
7916
+ name: "completion-blank-agent-real",
7917
+ order: "completion-first",
7918
+ completion: " ",
7919
+ agent: "agent",
7920
+ expected: "agent",
7921
+ },
7922
+ {
7923
+ name: "completion-none-agent-real",
7924
+ order: "completion-first",
7925
+ completion: null,
7926
+ agent: "agent",
7927
+ expected: "agent",
7928
+ },
7929
+ {
7930
+ name: "agent-real-completion-real",
7931
+ order: "agent-first",
7932
+ completion: "completion",
7933
+ agent: "agent",
7934
+ expected: "agent",
7935
+ },
7936
+ {
7937
+ name: "agent-real-completion-blank",
7938
+ order: "agent-first",
7939
+ completion: " ",
7940
+ agent: "agent",
7941
+ expected: "agent",
7942
+ },
7943
+ {
7944
+ name: "agent-blank-completion-real",
7945
+ order: "agent-first",
7946
+ completion: "completion",
7947
+ agent: " ",
7948
+ expected: "completion",
7949
+ },
7950
+ {
7951
+ name: "agent-blank-completion-none",
7952
+ order: "agent-first",
7953
+ completion: null,
7954
+ agent: " ",
7955
+ expected: undefined,
7956
+ },
7957
+ {
7958
+ name: "agent-none-completion-real",
7959
+ order: "agent-first",
7960
+ completion: "completion",
7961
+ agent: null,
7962
+ expected: "completion",
7963
+ },
7964
+ {
7965
+ name: "agent-none-completion-blank",
7966
+ order: "agent-first",
7967
+ completion: " ",
7968
+ agent: null,
7969
+ expected: undefined,
7970
+ },
7971
+ ] as const)("reconciles contract-valid skill completion and agent_end ordering ($name)", async testCase => {
7972
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`));
7973
+ const completion = Promise.withResolvers<unknown>();
7974
+ const completionReconciled = Promise.withResolvers<void>();
7975
+ try {
7976
+ const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, {
7977
+ onInvocationCompletionReconciled: kind => {
7978
+ if (kind === "skill") completionReconciled.resolve();
7979
+ },
7980
+ invokeSkill: async (_name, _args, options) => {
7981
+ await options?.onPreflightAcceptCommit?.();
7982
+ return await completion.promise;
7983
+ },
7984
+ });
7985
+ const accepted = await harness.control("skill.invoke", { name: "ralplan" });
7986
+ const selector = {
7987
+ kind: "skill" as const,
7988
+ commandId: accepted.result?.commandId,
7989
+ turnId: accepted.result?.turnId,
7990
+ };
7991
+ await harness.emit("agent_start");
7992
+ const emitAgentEnd = () =>
7993
+ harness.emit("agent_end", {
7994
+ messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
5939
7995
  });
5940
- const accepted = await harness.control("skill.invoke", { name: "ralplan" });
5941
- const selector = {
5942
- kind: "skill" as const,
5943
- commandId: accepted.result?.commandId,
5944
- turnId: accepted.result?.turnId,
5945
- };
5946
- await harness.emit("agent_start");
5947
- const emitAgentEnd = () =>
5948
- harness.emit("agent_end", {
5949
- messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
5950
- });
5951
- if (testCase.order === "completion-first") {
5952
- completion.resolve(testCase.completion);
5953
- await completionReconciled.promise;
5954
- await emitAgentEnd();
5955
- } else {
5956
- await emitAgentEnd();
5957
- completion.resolve(testCase.completion);
5958
- await completionReconciled.promise;
5959
- }
5960
- const result = await harness.query("turn.result", selector);
5961
- const content = (result.result as { content?: { text?: string } } | undefined)?.content;
5962
- expect(content?.text).toBe(testCase.expected);
5963
- await harness.stop();
5964
- } finally {
5965
- completion.resolve(undefined);
5966
- await rm(cwd, { recursive: true, force: true });
7996
+ if (testCase.order === "completion-first") {
7997
+ completion.resolve(testCase.completion);
7998
+ await completionReconciled.promise;
7999
+ await emitAgentEnd();
8000
+ } else {
8001
+ await emitAgentEnd();
8002
+ completion.resolve(testCase.completion);
8003
+ await completionReconciled.promise;
5967
8004
  }
8005
+ const result = await harness.query("turn.result", selector);
8006
+ const content = (result.result as { content?: { text?: string } } | undefined)?.content;
8007
+ expect(content?.text).toBe(testCase.expected);
8008
+ await harness.stop();
8009
+ } finally {
8010
+ completion.resolve(undefined);
8011
+ await rm(cwd, { recursive: true, force: true });
5968
8012
  }
5969
8013
  }, 30_000);
5970
8014
  test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
@@ -5975,6 +8019,11 @@ describe("post-acceptance invocation terminalization", () => {
5975
8019
  sendUserMessage: async (_content, options) => {
5976
8020
  if (options?.deliverAs === "followUp") {
5977
8021
  await options?.onPreflightAcceptCommit?.();
8022
+ options.preflightSignal?.addEventListener(
8023
+ "abort",
8024
+ () => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
8025
+ { once: true },
8026
+ );
5978
8027
  // #queueFollowUp resolves immediately; the turn has not run yet.
5979
8028
  return;
5980
8029
  }
@@ -6037,6 +8086,11 @@ describe("post-acceptance invocation terminalization", () => {
6037
8086
  sendUserMessage: async (_content, options) => {
6038
8087
  // Session is streaming: sendUserMessage diverts to #queueSteer and resolves.
6039
8088
  await options?.onPreflightAcceptCommit?.();
8089
+ options?.preflightSignal?.addEventListener(
8090
+ "abort",
8091
+ () => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
8092
+ { once: true },
8093
+ );
6040
8094
  },
6041
8095
  isIdle: () => false,
6042
8096
  });
@@ -6061,6 +8115,11 @@ describe("post-acceptance invocation terminalization", () => {
6061
8115
  // The session is streaming when the submission starts (divert to steer).
6062
8116
  // During accept()->persist(), the prior turn unwinds and isIdle flips to true.
6063
8117
  await options?.onPreflightAcceptCommit?.();
8118
+ options?.preflightSignal?.addEventListener(
8119
+ "abort",
8120
+ () => options.onQueuedPromoted?.({ startsOwnRun: false, removed: true }),
8121
+ { once: true },
8122
+ );
6064
8123
  idle = true;
6065
8124
  },
6066
8125
  isIdle: () => idle,
@@ -6887,10 +8946,16 @@ describe("accepted-control zero-execution bound (#4668)", () => {
6887
8946
  promptDeadlineMs = 100;
6888
8947
  const queued = await harness.control("turn.follow_up", { text: "queued prompt" });
6889
8948
  expect(queued.ok).toBe(true);
6890
- promoted?.({ startsOwnRun: true });
6891
- promptDeadlineMs = 600_000;
6892
8949
  const activeIds = { commandId: active.result?.commandId, turnId: active.result?.turnId };
6893
8950
  const queuedIds = { commandId: queued.result?.commandId, turnId: queued.result?.turnId };
8951
+ // The short lease belongs to an unconsumed queue entry, not to the root
8952
+ // run: it stays accepted past two lease periods without borrowing root abort.
8953
+ await Bun.sleep(250);
8954
+ expect(await harness.query("turn.prompt_status", queuedIds)).toMatchObject({ result: { status: "accepted" } });
8955
+ expect(abortCalls).toBe(0);
8956
+ expect(correlatedFrames(harness, queuedIds).filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
8957
+ promoted?.({ startsOwnRun: true });
8958
+ promptDeadlineMs = 600_000;
6894
8959
  await Bun.sleep(150);
6895
8960
  expect((await harness.query("turn.prompt_status", queuedIds)).result?.status).toMatch(/accepted|in_flight/);
6896
8961
  expect(await harness.query("turn.prompt_status", activeIds)).toMatchObject({
@@ -7670,7 +9735,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
7670
9735
  sessionId: transport.sessionId,
7671
9736
  });
7672
9737
  const seamCalls: Array<{ handle: string; scope: string }> = [];
7673
- createSdkSessionRuntimeExtension(api, {
9738
+ createTestRuntimeExtension(api, {
7674
9739
  agentDir: cwd,
7675
9740
  createTransport: async () => transport,
7676
9741
  terminalAbortSeams: {
@@ -7733,6 +9798,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
7733
9798
  expect((await statusOf(idsB, "inrun-status-b")).result?.status).toBe("terminal_ok");
7734
9799
  // A separate later turn starts: the consumed correlation must NOT be
7735
9800
  // drained into it, so conn-b owns nothing and its abort is refused.
9801
+ idle = true;
7736
9802
  transport.feed("conn-c", {
7737
9803
  type: "control_request",
7738
9804
  id: "inrun-c",
@@ -8743,7 +10809,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
8743
10809
  sessionId: transport.sessionId,
8744
10810
  });
8745
10811
  const seamCalls: Array<{ handle: string; scope: string }> = [];
8746
- createSdkSessionRuntimeExtension(api, {
10812
+ createTestRuntimeExtension(api, {
8747
10813
  agentDir: cwd,
8748
10814
  createTransport: async () => transport,
8749
10815
  terminalAbortSeams: {
@@ -8884,7 +10950,7 @@ test("SDK-only host never advances a finalized uncertain row for a mismatched re
8884
10950
  sessionId: transport.sessionId,
8885
10951
  });
8886
10952
  const seamCalls: Array<{ handle: string; scope: string }> = [];
8887
- createSdkSessionRuntimeExtension(api, {
10953
+ createTestRuntimeExtension(api, {
8888
10954
  agentDir: cwd,
8889
10955
  createTransport: async () => transport,
8890
10956
  terminalAbortSeams: {
@@ -8977,7 +11043,7 @@ test("SDK-only host FIFO-expires tombstones instead of failing the finalization
8977
11043
  sessionFile: path.join(cwd, "session.json"),
8978
11044
  sessionId: transport.sessionId,
8979
11045
  });
8980
- createSdkSessionRuntimeExtension(api, {
11046
+ createTestRuntimeExtension(api, {
8981
11047
  agentDir: cwd,
8982
11048
  createTransport: async () => transport,
8983
11049
  terminalAbortSeams: {
@@ -9053,7 +11119,7 @@ test("SDK-only host cancels only the aborting requester's preflight while anothe
9053
11119
  sessionFile: path.join(cwd, "session.json"),
9054
11120
  sessionId: transport.sessionId,
9055
11121
  });
9056
- createSdkSessionRuntimeExtension(api, {
11122
+ createTestRuntimeExtension(api, {
9057
11123
  agentDir: cwd,
9058
11124
  createTransport: async () => transport,
9059
11125
  terminalAbortSeams: {
@@ -9143,7 +11209,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
9143
11209
  sessionFile: path.join(cwd, "session.json"),
9144
11210
  sessionId: transport.sessionId,
9145
11211
  });
9146
- createSdkSessionRuntimeExtension(api, {
11212
+ createTestRuntimeExtension(api, {
9147
11213
  agentDir: cwd,
9148
11214
  createTransport: async () => transport,
9149
11215
  terminalAbortSeams: {
@@ -9216,7 +11282,7 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
9216
11282
  });
9217
11283
  let captureCalls = 0;
9218
11284
  let discardCalls = 0;
9219
- createSdkSessionRuntimeExtension(api, {
11285
+ createTestRuntimeExtension(api, {
9220
11286
  agentDir: cwd,
9221
11287
  createTransport: async () => transport,
9222
11288
  terminalAbortSeams: {
@@ -9331,6 +11397,318 @@ test("SDK-only host advances a finalized stopped row when the retry replay match
9331
11397
  }
9332
11398
  });
9333
11399
 
11400
+ test.each([
11401
+ "hold",
11402
+ "fail",
11403
+ "removed-before-shutdown",
11404
+ "timeout",
11405
+ "failed-stop",
11406
+ ] as const)("SDK-only shutdown joins queued terminal publication (%s)", async mode => {
11407
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-queued-shutdown-"));
11408
+ let removeQueued: (() => void) | undefined;
11409
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
11410
+ let queued = false;
11411
+ let queueSignal: AbortSignal | undefined;
11412
+ const api = {
11413
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
11414
+ handlers.set(event, handler);
11415
+ },
11416
+ sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
11417
+ Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
11418
+ queued = true;
11419
+ queueSignal = options?.preflightSignal;
11420
+ removeQueued = () => {
11421
+ if (!queued) return;
11422
+ queued = false;
11423
+ options?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
11424
+ };
11425
+ queueSignal?.addEventListener("abort", removeQueued, { once: true });
11426
+ options?.onPreflightAccepted?.();
11427
+ return {};
11428
+ }),
11429
+ } as unknown as ExtensionAPI;
11430
+ const transport = memoryTransport();
11431
+ let transportStops = 0;
11432
+ const originalStop = transport.stop.bind(transport);
11433
+ const stopSpy = spyOn(transport, "stop").mockImplementation(async () => {
11434
+ transportStops++;
11435
+ if (mode === "failed-stop" && transportStops === 1) throw new Error("Injected first transport stop failure");
11436
+ await originalStop();
11437
+ });
11438
+ const sessionFile = path.join(cwd, "session.json");
11439
+ const store = createReconciliationStore({ sessionFile, sessionId: transport.sessionId });
11440
+ let transportCreations = 0;
11441
+ createSdkSessionRuntimeExtension(api, {
11442
+ agentDir: cwd,
11443
+ createTransport: async () => {
11444
+ transportCreations++;
11445
+ return transport;
11446
+ },
11447
+ terminalAbortSeams: {
11448
+ getReconciliationStore: () => store,
11449
+ getTerminalTurnEpoch: () => 7,
11450
+ getActivePromptHandle: () => "unrelated-handle",
11451
+ cancelPendingPreflightForTerminalAbort: () => {},
11452
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
11453
+ },
11454
+ });
11455
+ const ctx = { ...extensionContext(transport.sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext;
11456
+ const started = Promise.withResolvers<void>();
11457
+ const release = Promise.withResolvers<void>();
11458
+ const target = reconciliationStorePath(sessionFile, transport.sessionId);
11459
+ const originalRename = fsPromises.rename.bind(fsPromises);
11460
+ let commandId: string | undefined;
11461
+ let armed = false;
11462
+ let interrupted = false;
11463
+ const fault = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
11464
+ if (armed && String(to) === target) {
11465
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
11466
+ if (document.records.some(record => record.commandId === commandId && record.terminalAt !== undefined)) {
11467
+ interrupted = true;
11468
+ started.resolve();
11469
+ if (mode === "fail" || mode === "failed-stop")
11470
+ throw Object.assign(new Error("Injected queued terminal EIO"), { code: "EIO" });
11471
+ await release.promise;
11472
+ }
11473
+ }
11474
+ return originalRename(from, to);
11475
+ });
11476
+ const waitFor = async (predicate: () => boolean, label: string): Promise<void> => {
11477
+ const deadline = Date.now() + 15_000;
11478
+ while (!predicate()) {
11479
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
11480
+ await Bun.sleep(10);
11481
+ }
11482
+ };
11483
+ let shutdown: Promise<void> | undefined;
11484
+ try {
11485
+ await handlers.get("session_start")?.({}, ctx);
11486
+ transport.feed("requester", {
11487
+ type: "control_request",
11488
+ id: "followup",
11489
+ operation: "turn.follow_up",
11490
+ input: { text: "unconsumed followup" },
11491
+ } as SdkFrame);
11492
+ await waitFor(() => transport.sent.some(frame => frame.id === "followup"), "follow-up admission");
11493
+ const accepted = transport.sent.find(frame => frame.id === "followup") as ResponseFrame;
11494
+ expect(accepted.ok).toBe(true);
11495
+ commandId = accepted.result?.commandId;
11496
+ expect(commandId).toBeDefined();
11497
+ await waitFor(() => queued && queueSignal !== undefined, "queued follow-up");
11498
+ armed = true;
11499
+ if (mode === "removed-before-shutdown") {
11500
+ removeQueued?.();
11501
+ await started.promise;
11502
+ }
11503
+ let settled = false;
11504
+ shutdown = Promise.resolve(handlers.get("session_shutdown")?.({}, ctx));
11505
+ void shutdown.then(
11506
+ () => {
11507
+ settled = true;
11508
+ },
11509
+ () => {
11510
+ settled = true;
11511
+ },
11512
+ );
11513
+ await started.promise;
11514
+ expect(interrupted).toBe(true);
11515
+ expect(queueSignal?.aborted).toBe(true);
11516
+ expect(queued).toBe(false);
11517
+ if (mode === "fail" || mode === "timeout" || mode === "failed-stop") {
11518
+ await expect(shutdown).rejects.toMatchObject({ code: "sdk_reconciliation_teardown_failed" });
11519
+ expect(transportStops).toBe(1);
11520
+ if (mode === "fail" || mode === "timeout") {
11521
+ await expect(Promise.resolve(handlers.get("session_start")?.({}, ctx))).rejects.toMatchObject({
11522
+ code: "sdk_reconciliation_teardown_failed",
11523
+ });
11524
+ expect(transportCreations).toBe(1);
11525
+ if (mode === "timeout") {
11526
+ release.resolve();
11527
+ const deadline = Date.now() + 5_000;
11528
+ while (
11529
+ !((await Bun.file(target).json()) as ReconciliationStoreDocument).records.some(
11530
+ record => record.commandId === commandId && record.terminalAt !== undefined,
11531
+ )
11532
+ ) {
11533
+ if (Date.now() > deadline) throw new Error("Timed out awaiting original publisher after teardown");
11534
+ await Bun.sleep(10);
11535
+ }
11536
+ await handlers.get("session_start")?.({}, ctx);
11537
+ expect(transportCreations).toBe(2);
11538
+ await handlers.get("session_shutdown")?.({}, ctx);
11539
+ }
11540
+ }
11541
+ if (mode === "failed-stop") {
11542
+ await expect(Promise.resolve(handlers.get("session_shutdown")?.({}, ctx))).rejects.toMatchObject({
11543
+ code: "sdk_reconciliation_teardown_failed",
11544
+ });
11545
+ expect(transportStops).toBe(2);
11546
+ }
11547
+ } else {
11548
+ await Bun.sleep(25);
11549
+ expect(settled).toBe(false);
11550
+ expect(transportStops).toBe(0);
11551
+ transport.feed("requester", {
11552
+ type: "control_request",
11553
+ id: "draining-followup",
11554
+ operation: "turn.follow_up",
11555
+ input: { text: "must not enter during drain" },
11556
+ } as SdkFrame);
11557
+ await waitFor(() => transport.sent.some(frame => frame.id === "draining-followup"), "fenced admission");
11558
+ expect(transport.sent.find(frame => frame.id === "draining-followup")).toMatchObject({
11559
+ ok: false,
11560
+ error: { code: "session_quiescing" },
11561
+ });
11562
+ const pending = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11563
+ expect(pending.records.find(record => record.commandId === commandId)?.terminalAt).toBeUndefined();
11564
+ release.resolve();
11565
+ await shutdown;
11566
+ expect(transportStops).toBe(1);
11567
+ const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11568
+ expect(durable.records.find(record => record.commandId === commandId)?.terminalAt).toBeDefined();
11569
+ }
11570
+ } finally {
11571
+ release.resolve();
11572
+ await shutdown?.catch(() => undefined);
11573
+ fault.mockRestore();
11574
+ stopSpy.mockRestore();
11575
+ await rm(cwd, { recursive: true, force: true });
11576
+ }
11577
+ });
11578
+
11579
+ test.each([
11580
+ false,
11581
+ true,
11582
+ ])("SDK-only queued terminal recovery permits replacement after EIO (early shutdown=%s)", async earlyShutdown => {
11583
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-recovery-"));
11584
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
11585
+ let transport = memoryTransport();
11586
+ const sessionId = transport.sessionId;
11587
+ const sessionFile = path.join(cwd, `${sessionId}.json`);
11588
+ const store = createReconciliationStore({ sessionFile, sessionId });
11589
+ let creations = 0;
11590
+ let queued = false;
11591
+ let queueSignal: AbortSignal | undefined;
11592
+ const api = {
11593
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
11594
+ handlers.set(event, handler);
11595
+ },
11596
+ sendUserMessage: (_content: string, hooks?: PreflightHooks) =>
11597
+ Promise.resolve(hooks?.onPreflightAcceptCommit?.()).then(() => {
11598
+ queued = true;
11599
+ queueSignal = hooks?.preflightSignal;
11600
+ queueSignal?.addEventListener(
11601
+ "abort",
11602
+ () => {
11603
+ if (!queued) return;
11604
+ queued = false;
11605
+ hooks?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
11606
+ },
11607
+ { once: true },
11608
+ );
11609
+ hooks?.onPreflightAccepted?.();
11610
+ return {};
11611
+ }),
11612
+ } as unknown as ExtensionAPI;
11613
+ createSdkSessionRuntimeExtension(api, {
11614
+ agentDir: cwd,
11615
+ settings: {
11616
+ get: (key: string) =>
11617
+ key === "sdk.promptDeadlineMs" ? 1_500 : key === "sdk.promptMaxRuntimeMs" ? 15_000 : undefined,
11618
+ } as unknown as Settings,
11619
+ createTransport: async () => {
11620
+ creations++;
11621
+ transport = memoryTransport();
11622
+ return transport;
11623
+ },
11624
+ terminalAbortSeams: {
11625
+ getReconciliationStore: () => store,
11626
+ getTerminalTurnEpoch: () => 7,
11627
+ getActivePromptHandle: () => "unrelated-handle",
11628
+ cancelPendingPreflightForTerminalAbort: () => {},
11629
+ abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }),
11630
+ },
11631
+ });
11632
+ const ctx = { ...extensionContext(sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext;
11633
+ const target = reconciliationStorePath(sessionFile, sessionId);
11634
+ const waitFor = async (predicate: () => boolean | Promise<boolean>, label: string): Promise<void> => {
11635
+ const deadline = Date.now() + 10_000;
11636
+ while (!(await predicate())) {
11637
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
11638
+ await Bun.sleep(10);
11639
+ }
11640
+ };
11641
+ let requestId = 0;
11642
+ const request = async (frame: Record<string, unknown>): Promise<ResponseFrame> => {
11643
+ const id = `terminal-recovery-${++requestId}`;
11644
+ transport.feed("requester", { ...frame, id } as SdkFrame);
11645
+ await waitFor(() => transport.sent.some(candidate => candidate.id === id), id);
11646
+ return transport.sent.find(candidate => candidate.id === id) as ResponseFrame;
11647
+ };
11648
+ const originalRename = fsPromises.rename.bind(fsPromises);
11649
+ let commandId: string | undefined;
11650
+ let injected = false;
11651
+ const fault = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => {
11652
+ if (!injected && commandId && String(to) === target) {
11653
+ const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument;
11654
+ if (document.records.some(record => record.commandId === commandId && record.terminalAt !== undefined)) {
11655
+ injected = true;
11656
+ throw Object.assign(new Error("Injected terminal publication failure"), { code: "EIO" });
11657
+ }
11658
+ }
11659
+ return originalRename(from, to);
11660
+ });
11661
+ try {
11662
+ await handlers.get("session_start")?.({}, ctx);
11663
+ const accepted = await request({
11664
+ type: "control_request",
11665
+ operation: "turn.follow_up",
11666
+ input: { text: "queued input" },
11667
+ });
11668
+ expect(accepted.ok).toBe(true);
11669
+ commandId = accepted.result?.commandId;
11670
+ const turnId = accepted.result?.turnId;
11671
+ expect(commandId).toBeDefined();
11672
+ await waitFor(() => queued && queueSignal !== undefined, "queued admission");
11673
+ expect(await request({ type: "control_request", operation: "turn.abort", input: {} })).toMatchObject({
11674
+ ok: true,
11675
+ result: { aborted: false, reason: "queue_terminal_unconfirmed" },
11676
+ });
11677
+ expect(injected).toBe(true);
11678
+ expect(queueSignal?.aborted).toBe(true);
11679
+ expect(queued).toBe(false);
11680
+ fault.mockRestore();
11681
+ const initial = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11682
+ expect(initial.records.find(record => record.commandId === commandId)?.terminalAt).toBeUndefined();
11683
+ if (earlyShutdown)
11684
+ await expect(Promise.resolve(handlers.get("session_shutdown")?.({}, ctx))).rejects.toMatchObject({
11685
+ code: "sdk_reconciliation_teardown_failed",
11686
+ });
11687
+ await waitFor(async () => {
11688
+ const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11689
+ return durable.records.some(record => record.commandId === commandId && record.terminalAt !== undefined);
11690
+ }, "original-owner terminal recovery");
11691
+ const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument;
11692
+ expect(durable.records.find(record => record.commandId === commandId)).toMatchObject({
11693
+ status: "failed",
11694
+ error: { code: "cancelled" },
11695
+ });
11696
+ if (!earlyShutdown) await handlers.get("session_shutdown")?.({}, ctx);
11697
+ await handlers.get("session_start")?.({}, ctx);
11698
+ expect(creations).toBe(2);
11699
+ expect(
11700
+ await request({ type: "query_request", query: "turn.result", input: { kind: "prompt", commandId, turnId } }),
11701
+ ).toMatchObject({
11702
+ ok: true,
11703
+ result: { status: "failed", error: { code: "cancelled" } },
11704
+ });
11705
+ } finally {
11706
+ fault.mockRestore();
11707
+ await Promise.resolve(handlers.get("session_shutdown")?.({}, ctx)).catch(() => undefined);
11708
+ await rm(cwd, { recursive: true, force: true });
11709
+ }
11710
+ });
11711
+
9334
11712
  test("SDK-only host does not assign a follow-up requester ownership until the follow-up actually starts", async () => {
9335
11713
  // Review thread P1: a turn.follow_up accepted while ctx.isIdle() is true
9336
11714
  // but the follow-up is never promoted (compaction, transcript ending in a
@@ -9341,23 +11719,30 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
9341
11719
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-followup-stale-"));
9342
11720
  const idle = true;
9343
11721
  let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
11722
+ let queueSignal: AbortSignal | undefined;
9344
11723
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
9345
11724
  const api = {
9346
11725
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
9347
11726
  handlers.set(event, handler);
9348
11727
  },
9349
- sendUserMessage: (
9350
- _content: string,
9351
- options:
9352
- | {
9353
- onPreflightAccepted?: () => void;
9354
- onPreflightAcceptCommit?: () => void;
9355
- onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
9356
- }
9357
- | undefined,
9358
- ) =>
11728
+ sendUserMessage: (_content: string, options: PreflightHooks | undefined) =>
9359
11729
  Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
9360
- promoted = options?.onQueuedPromoted;
11730
+ let queued = true;
11731
+ queueSignal = options?.preflightSignal;
11732
+ promoted = promotion => {
11733
+ if (!queued) return;
11734
+ queued = false;
11735
+ options?.onQueuedPromoted?.(promotion);
11736
+ };
11737
+ queueSignal?.addEventListener(
11738
+ "abort",
11739
+ () => {
11740
+ if (!queued) return;
11741
+ queued = false;
11742
+ options?.onQueuedPromoted?.({ startsOwnRun: false, removed: true });
11743
+ },
11744
+ { once: true },
11745
+ );
9361
11746
  options?.onPreflightAccepted?.();
9362
11747
  return {};
9363
11748
  }),
@@ -9368,7 +11753,7 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
9368
11753
  sessionId: transport.sessionId,
9369
11754
  });
9370
11755
  const seamCalls: Array<{ handle: string; scope: string }> = [];
9371
- createSdkSessionRuntimeExtension(api, {
11756
+ createTestRuntimeExtension(api, {
9372
11757
  agentDir: cwd,
9373
11758
  createTransport: async () => transport,
9374
11759
  terminalAbortSeams: {
@@ -9424,7 +11809,16 @@ test("SDK-only host does not assign a follow-up requester ownership until the fo
9424
11809
  result: expect.objectContaining({ turn: "no_active_turn" }),
9425
11810
  });
9426
11811
  expect(seamCalls).toHaveLength(0);
9427
- // When the follow-up IS promoted, B owns its run and can abort it.
11812
+ expect(queueSignal?.aborted).toBe(true);
11813
+ // The cancelled input cannot be promoted. A fresh follow-up receives fresh authority.
11814
+ transport.feed("conn-b", {
11815
+ type: "control_request",
11816
+ id: "followup-b-next",
11817
+ operation: "turn.follow_up",
11818
+ input: { text: "fresh followup" },
11819
+ } as SdkFrame);
11820
+ await waitResponse("followup-b-next");
11821
+ expect(queueSignal?.aborted).toBe(false);
9428
11822
  promoted!({ startsOwnRun: true });
9429
11823
  await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
9430
11824
  transport.feed("conn-b", {
@@ -9481,7 +11875,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
9481
11875
  sessionId: transport.sessionId,
9482
11876
  });
9483
11877
  const seamCalls: Array<{ handle: string; scope: string }> = [];
9484
- createSdkSessionRuntimeExtension(api, {
11878
+ createTestRuntimeExtension(api, {
9485
11879
  agentDir: cwd,
9486
11880
  createTransport: async () => transport,
9487
11881
  terminalAbortSeams: {
@@ -9606,7 +12000,7 @@ test("SDK-only host rebinds the steering snapshot when the requester's turn wins
9606
12000
  let ownerReads = 0;
9607
12001
  let rebindCalls = 0;
9608
12002
  const settledOptions: Array<{ scope?: string; steeringSnapshotToken?: number }> = [];
9609
- createSdkSessionRuntimeExtension(api, {
12003
+ createTestRuntimeExtension(api, {
9610
12004
  agentDir: cwd,
9611
12005
  createTransport: async () => transport,
9612
12006
  terminalAbortSeams: {