@gajae-code/coding-agent 0.15.4 → 0.15.6

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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -13,6 +13,8 @@ import {
13
13
  unregisterOwnedRegistration,
14
14
  } from "../../session/terminal-abort";
15
15
  import { Broker } from "../broker/broker";
16
+ import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
17
+ import { createPromptReconciliation } from "../bus/prompt-reconciliation";
16
18
  import { createReconciliationStore } from "../bus/reconciliation-store";
17
19
  import { CursorRegistry, QueryHandlers, RevisionStore } from "./query";
18
20
  import {
@@ -146,6 +148,76 @@ async function queryGoalState(
146
148
  return response.page?.items[0];
147
149
  }
148
150
 
151
+ test("native prompt reconciliation fails closed for an explicitly empty assistant result", () => {
152
+ const reconciliation = createPromptReconciliation();
153
+ const correlation = { commandId: "native-empty-command", turnId: "native-empty-turn" };
154
+ reconciliation.admit("native-empty-ref");
155
+ reconciliation.noteAccepted(correlation, "native-empty-ref");
156
+ reconciliation.noteTransition(correlation, { type: "agent_end", finalText: "" });
157
+ expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({
158
+ status: "failed",
159
+ error: { code: "prompt_failed" },
160
+ receiptState: "missing",
161
+ });
162
+ reconciliation.noteTransition(correlation, { type: "agent_end", finalText: "late text" });
163
+ expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({ status: "failed" });
164
+ });
165
+
166
+ test("broker reconciliation fails closed for an empty prompt and persists the first terminal result", async () => {
167
+ let records: unknown[] = [];
168
+ const store = {
169
+ load: async () => records,
170
+ transact: async (mutator: (current: never[]) => never[]) => {
171
+ records = mutator(records as never);
172
+ },
173
+ } as never;
174
+ const reconciliation = createKindAwareReconciliation({ store });
175
+ const correlation = { commandId: "broker-empty-command", turnId: "broker-empty-turn" };
176
+ await reconciliation.noteAccepted("prompt", correlation, "broker-empty-ref");
177
+ await reconciliation.noteTransition("prompt", correlation, {
178
+ type: "agent_end",
179
+ content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
180
+ });
181
+ const settled = reconciliation.lookupResult("prompt", { clientRef: "broker-empty-ref" });
182
+ expect(settled).toMatchObject({ status: "failed", error: { code: "prompt_failed" } });
183
+ await reconciliation.noteTransition("prompt", correlation, {
184
+ type: "agent_end",
185
+ content: { version: 1, type: "text", text: "late text", byteLength: 9, truncated: false },
186
+ });
187
+ const reloaded = createKindAwareReconciliation({ store });
188
+ await reloaded.hydrateFromStore();
189
+ expect(reloaded.lookupResult("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({
190
+ status: "failed",
191
+ error: { code: "prompt_failed" },
192
+ });
193
+ const noOpCorrelation = { commandId: "broker-noop-command", turnId: "broker-noop-turn" };
194
+ await reconciliation.noteAccepted("prompt", noOpCorrelation, "broker-noop-ref");
195
+ await reconciliation.finalizeOutcome(
196
+ "prompt",
197
+ noOpCorrelation,
198
+ { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
199
+ undefined,
200
+ "",
201
+ );
202
+ expect(reconciliation.lookupResult("prompt", { clientRef: "broker-noop-ref" })).toMatchObject({
203
+ status: "terminal_ok",
204
+ });
205
+ });
206
+
207
+ test("session-host prompt reconciliation fails closed when the accepted result is empty", async () => {
208
+ const reconciliation = createInvocationReconciliation();
209
+ const correlation = { commandId: "host-empty-command", turnId: "host-empty-turn" };
210
+ await reconciliation.noteAccepted("prompt", correlation, "host-empty-ref");
211
+ await reconciliation.noteTransition("prompt", correlation, {
212
+ type: "agent_end",
213
+ content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
214
+ });
215
+ expect(reconciliation.lookup("prompt", { clientRef: "host-empty-ref" })).toMatchObject({
216
+ status: "failed",
217
+ error: { code: "prompt_failed" },
218
+ });
219
+ });
220
+
149
221
  describe("SDK goal snapshot lifecycle", () => {
150
222
  test("recovers an active nonzero-activity goal from the durable session projection", async () => {
151
223
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-goal-recovery-"));
@@ -405,7 +477,10 @@ test("durable reload keeps agent_end terminal across a paused successor transiti
405
477
  const correlation = { commandId: "durable-race-command", turnId: "durable-race-turn" };
406
478
  await reconciliation.noteAccepted("prompt", correlation, "durable-race-ref");
407
479
  pauseWrites = 2;
408
- const terminal = reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
480
+ const terminal = reconciliation.noteTransition("prompt", correlation, {
481
+ type: "agent_end",
482
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
483
+ });
409
484
  await writeStarted[0]!.promise;
410
485
  // A successor lifecycle event arrives while the terminal write is held. It
411
486
  // must observe the staged terminal record and never resurrect in_flight state.
@@ -455,7 +530,10 @@ test("agent_end is not swallowed when deadline persistence fails", async () => {
455
530
  message: "deadline",
456
531
  });
457
532
  await persistStarted.promise;
458
- const terminal = reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
533
+ const terminal = reconciliation.noteTransition("prompt", correlation, {
534
+ type: "agent_end",
535
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
536
+ });
459
537
  releasePersist.resolve();
460
538
  await expect(deadline).rejects.toThrow("held store failed");
461
539
  await terminal;
@@ -523,13 +601,86 @@ test("agent_end upgrades a durable deadline terminal when it races a successful
523
601
  code: "prompt_deadline_exceeded",
524
602
  message: "deadline",
525
603
  });
526
- await reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
604
+ await reconciliation.noteTransition("prompt", correlation, {
605
+ type: "agent_end",
606
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
607
+ });
527
608
  const reloaded = createInvocationReconciliation({ store });
528
609
  await reloaded.hydrate();
529
610
  expect(reloaded.lookup("prompt", { clientRef: "upgrade-race-ref" })).toMatchObject({ status: "terminal_ok" });
530
611
  });
531
612
 
532
- test("failed agent_end upgrade persistence retains the deadline record for retry", async () => {
613
+ test("an empty late agent_end cannot upgrade a durable prompt deadline failure", async () => {
614
+ let records: unknown[] = [];
615
+ const store = {
616
+ path: null,
617
+ load: async () => records,
618
+ transact: async (mutator: (current: never[]) => never[]) => {
619
+ records = mutator(records as never);
620
+ },
621
+ } as never;
622
+ const reconciliation = createInvocationReconciliation({ store });
623
+ const correlation = { commandId: "empty-deadline-command", turnId: "empty-deadline-turn" };
624
+ await reconciliation.noteAccepted("prompt", correlation, "empty-deadline-ref");
625
+ await reconciliation.finalizeOutcome("prompt", correlation, {
626
+ kind: "failed",
627
+ code: "prompt_deadline_exceeded",
628
+ message: "deadline",
629
+ });
630
+ await reconciliation.noteTransition("prompt", correlation, {
631
+ type: "agent_end",
632
+ content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
633
+ });
634
+ expect(reconciliation.lookup("prompt", { clientRef: "empty-deadline-ref" })).toMatchObject({
635
+ status: "failed",
636
+ error: { code: "prompt_deadline_exceeded" },
637
+ });
638
+ const reloaded = createInvocationReconciliation({ store });
639
+ await reloaded.hydrate();
640
+ expect(reloaded.lookup("prompt", { clientRef: "empty-deadline-ref" })).toMatchObject({
641
+ status: "failed",
642
+ error: { code: "prompt_deadline_exceeded" },
643
+ });
644
+ });
645
+
646
+ test("whitespace and provider failures stay actionable across a late empty agent_end", async () => {
647
+ let records: unknown[] = [];
648
+ const store = {
649
+ path: null,
650
+ load: async () => records,
651
+ transact: async (mutator: (current: never[]) => never[]) => {
652
+ records = mutator(records as never);
653
+ },
654
+ } as never;
655
+ const reconciliation = createInvocationReconciliation({ store });
656
+ const whitespace = { commandId: "whitespace-command", turnId: "whitespace-turn" };
657
+ await reconciliation.noteAccepted("prompt", whitespace, "whitespace-ref");
658
+ await reconciliation.noteTransition("prompt", whitespace, {
659
+ type: "agent_end",
660
+ content: { version: 1, type: "text", text: " \t\n ", byteLength: 4, truncated: false },
661
+ });
662
+ expect(reconciliation.lookup("prompt", { clientRef: "whitespace-ref" })).toMatchObject({
663
+ status: "failed",
664
+ error: { code: "prompt_failed", message: "Prompt submission failed." },
665
+ });
666
+
667
+ const providerFailure = { commandId: "provider-command", turnId: "provider-turn" };
668
+ await reconciliation.noteAccepted("prompt", providerFailure, "provider-ref");
669
+ await reconciliation.noteTransition("prompt", providerFailure, {
670
+ type: "agent_failed",
671
+ error: Object.assign(new Error("provider leaked detail"), { code: "provider_unavailable" }),
672
+ });
673
+ await reconciliation.noteTransition("prompt", providerFailure, {
674
+ type: "agent_end",
675
+ content: { version: 1, type: "text", text: "", byteLength: 0, truncated: false },
676
+ });
677
+ expect(reconciliation.lookup("prompt", { clientRef: "provider-ref" })).toMatchObject({
678
+ status: "failed",
679
+ error: { code: "provider_unavailable", message: "Prompt submission failed." },
680
+ });
681
+ });
682
+
683
+ test("an empty prompt terminal remains failed after a late real failure", async () => {
533
684
  let records: unknown[] = [];
534
685
  let writes = 0;
535
686
  const store = {
@@ -550,9 +701,12 @@ test("failed agent_end upgrade persistence retains the deadline record for retry
550
701
  code: "prompt_deadline_exceeded",
551
702
  message: "deadline",
552
703
  });
553
- await expect(reconciliation.noteTransition("prompt", correlation, { type: "agent_end" })).rejects.toThrow(
554
- "upgrade persist failed",
555
- );
704
+ await expect(
705
+ reconciliation.noteTransition("prompt", correlation, {
706
+ type: "agent_end",
707
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
708
+ }),
709
+ ).rejects.toThrow("upgrade persist failed");
556
710
  expect(reconciliation.lookup("prompt", { clientRef: "upgrade-failure-ref" })).toMatchObject({
557
711
  status: "failed",
558
712
  error: { code: "prompt_deadline_exceeded" },
@@ -563,17 +717,132 @@ test("failed agent_end upgrade persistence retains the deadline record for retry
563
717
  status: "failed",
564
718
  error: { code: "prompt_deadline_exceeded" },
565
719
  });
566
- await reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
720
+ await reconciliation.noteTransition("prompt", correlation, {
721
+ type: "agent_end",
722
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
723
+ });
567
724
  const reloaded = createInvocationReconciliation({ store });
568
725
  await reloaded.hydrate();
569
726
  expect(reloaded.lookup("prompt", { clientRef: "upgrade-failure-ref" })).toMatchObject({ status: "terminal_ok" });
570
727
  });
571
728
 
729
+ test("late cancellation and non-text activity upgrade deadline terminals", async () => {
730
+ const makeStore = () => {
731
+ let records: unknown[] = [];
732
+ return {
733
+ path: null,
734
+ load: async () => records,
735
+ transact: async (mutator: (current: never[]) => never[]) => {
736
+ records = mutator(records as never);
737
+ },
738
+ } as never;
739
+ };
740
+ const cancelled = createInvocationReconciliation({ store: makeStore() });
741
+ const cancelledCorrelation = { commandId: "late-cancel-command", turnId: "late-cancel-turn" };
742
+ await cancelled.noteAccepted("prompt", cancelledCorrelation, "late-cancel-ref");
743
+ await cancelled.finalizeOutcome("prompt", cancelledCorrelation, {
744
+ kind: "failed",
745
+ code: "prompt_deadline_exceeded",
746
+ message: "deadline",
747
+ });
748
+ await cancelled.noteTransition("prompt", cancelledCorrelation, {
749
+ type: "agent_end",
750
+ outcome: { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
751
+ });
752
+ expect(cancelled.lookup("prompt", { clientRef: "late-cancel-ref" })).toMatchObject({ status: "terminal_ok" });
753
+
754
+ const activity = createInvocationReconciliation({ store: makeStore() });
755
+ const activityCorrelation = { commandId: "late-activity-command", turnId: "late-activity-turn" };
756
+ await activity.noteAccepted("prompt", activityCorrelation, "late-activity-ref");
757
+ await activity.finalizeOutcome("prompt", activityCorrelation, {
758
+ kind: "failed",
759
+ code: "prompt_deadline_exceeded",
760
+ message: "deadline",
761
+ });
762
+ await activity.noteTransition("prompt", activityCorrelation, { type: "agent_end", hasActivity: true });
763
+ expect(activity.lookup("prompt", { clientRef: "late-activity-ref" })).toMatchObject({ status: "terminal_ok" });
764
+ });
765
+ test("finalization without an explicit outcome follows the noteTransition evidence predicate", async () => {
766
+ const makeLocalStore = () => {
767
+ let records: unknown[] = [];
768
+ return {
769
+ path: null,
770
+ load: async () => records,
771
+ transact: async (mutator: (current: never[]) => never[]) => {
772
+ records = mutator(records as never);
773
+ },
774
+ } as never;
775
+ };
776
+ const contentBearing = createInvocationReconciliation({ store: makeLocalStore() });
777
+ const contentCorrelation = { commandId: "final-content-command", turnId: "final-content-turn" };
778
+ await contentBearing.noteAccepted("prompt", contentCorrelation, "final-content-ref");
779
+ await contentBearing.finalizeOutcome("prompt", contentCorrelation, undefined, undefined, undefined, {
780
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
781
+ });
782
+ expect(contentBearing.lookup("prompt", { clientRef: "final-content-ref" })).toMatchObject({
783
+ status: "terminal_ok",
784
+ });
785
+
786
+ const activityBearing = createInvocationReconciliation({ store: makeLocalStore() });
787
+ const activityCorrelation = { commandId: "final-activity-command", turnId: "final-activity-turn" };
788
+ await activityBearing.noteAccepted("prompt", activityCorrelation, "final-activity-ref");
789
+ await activityBearing.finalizeOutcome("prompt", activityCorrelation, undefined, undefined, undefined, {
790
+ hasActivity: true,
791
+ });
792
+ expect(activityBearing.lookup("prompt", { clientRef: "final-activity-ref" })).toMatchObject({
793
+ status: "terminal_ok",
794
+ });
795
+
796
+ const stoppedOutcome = createInvocationReconciliation({ store: makeLocalStore() });
797
+ const stoppedCorrelation = { commandId: "final-stopped-command", turnId: "final-stopped-turn" };
798
+ await stoppedOutcome.noteAccepted("prompt", stoppedCorrelation, "final-stopped-ref");
799
+ await stoppedOutcome.finalizeOutcome("prompt", stoppedCorrelation, undefined, undefined, undefined, {
800
+ outcomeKind: "stopped",
801
+ });
802
+ expect(stoppedOutcome.lookup("prompt", { clientRef: "final-stopped-ref" })).toMatchObject({
803
+ status: "terminal_ok",
804
+ });
805
+
806
+ const evidenceFree = createInvocationReconciliation({ store: makeLocalStore() });
807
+ const emptyCorrelation = { commandId: "final-empty-command", turnId: "final-empty-turn" };
808
+ await evidenceFree.noteAccepted("prompt", emptyCorrelation, "final-empty-ref");
809
+ await evidenceFree.finalizeOutcome("prompt", emptyCorrelation);
810
+ expect(evidenceFree.lookup("prompt", { clientRef: "final-empty-ref" })).toMatchObject({
811
+ status: "failed",
812
+ error: { code: "prompt_failed" },
813
+ });
814
+ const legacyArg5 = createInvocationReconciliation({ store: makeLocalStore() });
815
+ const legacyCorrelation = { commandId: "final-legacy-command", turnId: "final-legacy-turn" };
816
+ await legacyArg5.noteAccepted("prompt", legacyCorrelation, "final-legacy-ref");
817
+ await legacyArg5.finalizeOutcome("prompt", legacyCorrelation, undefined, undefined, {
818
+ content: { version: 1, type: "text", text: "legacy", byteLength: 6, truncated: false },
819
+ });
820
+ expect(legacyArg5.lookup("prompt", { clientRef: "final-legacy-ref" })).toMatchObject({
821
+ status: "terminal_ok",
822
+ });
823
+
824
+ const explicitFailure = createInvocationReconciliation({ store: makeLocalStore() });
825
+ const failureCorrelation = { commandId: "final-failure-command", turnId: "final-failure-turn" };
826
+ await explicitFailure.noteAccepted("prompt", failureCorrelation, "final-failure-ref");
827
+ await explicitFailure.finalizeOutcome("prompt", failureCorrelation, {
828
+ kind: "failed",
829
+ code: "provider_rejected",
830
+ message: "provider rejected",
831
+ });
832
+ expect(explicitFailure.lookup("prompt", { clientRef: "final-failure-ref" })).toMatchObject({
833
+ status: "failed",
834
+ error: { code: "provider_rejected" },
835
+ });
836
+ });
837
+
572
838
  test("a reason attached after a prompt settled is never replaced by a later failure", async () => {
573
839
  const reconciliation = createInvocationReconciliation();
574
840
  const correlation = { commandId: "late-reason-command", turnId: "late-reason-turn" };
575
841
  await reconciliation.noteAccepted("prompt", correlation, "late-reason-ref");
576
- await reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
842
+ await reconciliation.noteTransition("prompt", correlation, {
843
+ type: "agent_end",
844
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
845
+ });
577
846
  const claimed = reconciliation.lookup("prompt", { clientRef: "late-reason-ref" }) as Record<string, unknown>;
578
847
  expect(claimed).toEqual({
579
848
  status: "terminal_ok",
@@ -649,7 +918,10 @@ test("retains terminal host reconciliation records past the 15-minute window", a
649
918
  reconciliation.admit("skill", "aged-skill-ref");
650
919
  await reconciliation.noteAccepted("prompt", promptCorrelation, "aged-ref");
651
920
  await reconciliation.noteAccepted("skill", skillCorrelation, "aged-skill-ref");
652
- await reconciliation.noteTransition("prompt", promptCorrelation, { type: "agent_end" });
921
+ await reconciliation.noteTransition("prompt", promptCorrelation, {
922
+ type: "agent_end",
923
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
924
+ });
653
925
  await reconciliation.noteTransition("skill", skillCorrelation, { type: "agent_end" });
654
926
 
655
927
  clock += 15 * 60_000 + 1;
@@ -679,7 +951,10 @@ test("capacity eviction still releases the clientRef and reports honest unknown"
679
951
  const correlation = { commandId: `capacity-command-${index}`, turnId: `capacity-turn-${index}` };
680
952
  reconciliation.admit("prompt", `capacity-ref-${index}`);
681
953
  await reconciliation.noteAccepted("prompt", correlation, `capacity-ref-${index}`);
682
- await reconciliation.noteTransition("prompt", correlation, { type: "agent_end" });
954
+ await reconciliation.noteTransition("prompt", correlation, {
955
+ type: "agent_end",
956
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
957
+ });
683
958
  }
684
959
  expect(reconciliation.lookup("prompt", { clientRef: "capacity-ref-1" })).toEqual({
685
960
  status: "unknown",
@@ -742,7 +1017,7 @@ describe("SessionSdkSessionRuntime", () => {
742
1017
  },
743
1018
  sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
744
1019
  await options?.onPreflightAcceptCommit?.();
745
- await new Promise<void>(() => {});
1020
+ await neverSettlingPromise();
746
1021
  },
747
1022
  } as unknown as ExtensionAPI;
748
1023
  const transport = memoryTransport();
@@ -994,7 +1269,7 @@ describe("SessionSdkSessionRuntime", () => {
994
1269
  },
995
1270
  sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
996
1271
  await options?.onPreflightAcceptCommit?.();
997
- await new Promise<void>(() => {});
1272
+ await neverSettlingPromise();
998
1273
  },
999
1274
  } as unknown as ExtensionAPI;
1000
1275
  const transport = memoryTransport();
@@ -1356,7 +1631,7 @@ describe("SessionSdkSessionRuntime", () => {
1356
1631
  },
1357
1632
  sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
1358
1633
  await options?.onPreflightAcceptCommit?.();
1359
- await new Promise<void>(() => {});
1634
+ await neverSettlingPromise();
1360
1635
  },
1361
1636
  } as unknown as ExtensionAPI;
1362
1637
  const transport = memoryTransport();
@@ -1710,7 +1985,7 @@ describe("SessionSdkSessionRuntime", () => {
1710
1985
  // Production-faithful: an accepted run stays in-flight for the whole
1711
1986
  // test; sendUserMessage resolution (turn completion) never precedes
1712
1987
  // agent_start (#4668 success-retirement).
1713
- await new Promise<void>(() => {});
1988
+ await neverSettlingPromise();
1714
1989
  },
1715
1990
  } as unknown as ExtensionAPI;
1716
1991
  const transport = memoryTransport();
@@ -1870,10 +2145,10 @@ describe("SessionSdkSessionRuntime", () => {
1870
2145
  prompt("conn-b", "stale-b");
1871
2146
  await waitResponse("stale-b");
1872
2147
  // A later AGENT-INITIATED turn starts: the pending queue is empty, so
1873
- // the owner stays conn-a (B's stale connection is not associated).
2148
+ // the predecessor's conn-a owner is cleared and no SDK client owns it.
1874
2149
  await handlers.get("agent_start")?.({ type: "agent_start" }, ctx);
1875
- // B's abort must NOT stop the agent-initiated turn.
1876
- transport.feed("conn-b", {
2150
+ // A's stale owner must NOT stop the agent-initiated turn.
2151
+ transport.feed("conn-a", {
1877
2152
  type: "control_request",
1878
2153
  id: "stale-owner-abort",
1879
2154
  operation: "turn.abort",
@@ -1987,6 +2262,40 @@ describe("SessionSdkSessionRuntime", () => {
1987
2262
  }
1988
2263
  });
1989
2264
 
2265
+ test("agent-initiated successor activity cannot renew a stale predecessor deadline", async () => {
2266
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-stale-deadline-owner-"));
2267
+ try {
2268
+ const harness = await invocationHarness("stale-deadline-owner", cwd, {
2269
+ settings: {
2270
+ get: (key: string) =>
2271
+ key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
2272
+ } as unknown as Settings,
2273
+ sendUserMessage: async (_content, options) => {
2274
+ await options?.onPreflightAcceptCommit?.();
2275
+ await neverSettlingPromise();
2276
+ },
2277
+ });
2278
+ const prompt = await harness.control("turn.prompt", { text: "predecessor" });
2279
+ expect(prompt.ok).toBe(true);
2280
+ const ids = { commandId: prompt.result?.commandId, turnId: prompt.result?.turnId };
2281
+ await harness.emit("agent_start");
2282
+ // Empty drain represents an agent-initiated successor with no SDK owner.
2283
+ await harness.emit("agent_start");
2284
+ for (let i = 0; i < 3; i += 1) {
2285
+ await harness.emit("tool_execution_start");
2286
+ await Bun.sleep(15);
2287
+ }
2288
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
2289
+ status: "failed",
2290
+ error: { code: "prompt_deadline_exceeded" },
2291
+ });
2292
+ await harness.stop();
2293
+ } finally {
2294
+ await Bun.sleep(50);
2295
+ await rm(cwd, { recursive: true, force: true });
2296
+ }
2297
+ });
2298
+
1990
2299
  test("SDK-only host retires an accepted-then-failed submission so a later agent-initiated turn is not mis-owned", async () => {
1991
2300
  // An idle submission is accepted (pending entry pushed) and then REJECTS
1992
2301
  // before agent_start (e.g. the busy retry expires). The failed entry must
@@ -2959,6 +3268,8 @@ interface PreflightHooks {
2959
3268
  interface ResponseFrame {
2960
3269
  id?: string;
2961
3270
  ok?: boolean;
3271
+ code?: string;
3272
+ error?: { code: string; message: string };
2962
3273
  result?: { status?: string; commandId?: string; turnId?: string; error?: { code: string; message: string } };
2963
3274
  }
2964
3275
 
@@ -2966,6 +3277,11 @@ interface InvocationHarness {
2966
3277
  control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
2967
3278
  query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
2968
3279
  emit(event: string, payload?: unknown): Promise<void>;
3280
+ switchSession(sessionId: string): Promise<void>;
3281
+ branch(sessionId: string): Promise<void>;
3282
+ requestOnSession(sessionId: string, frame: Record<string, unknown>): Promise<ResponseFrame>;
3283
+ sendToSession(sessionId: string, frame: Record<string, unknown>): void;
3284
+ sent(sessionId: string): SdkFrame[];
2969
3285
  readonly broadcasts: SdkFrame[];
2970
3286
  stop(): Promise<void>;
2971
3287
  }
@@ -2978,13 +3294,17 @@ async function invocationHarness(
2978
3294
  sessionId: string,
2979
3295
  cwd: string,
2980
3296
  hooks: {
2981
- sendUserMessage?: (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => Promise<void>;
3297
+ sendUserMessage?: (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => Promise<unknown>;
2982
3298
  invokeSkill?: (name: string, args?: string, options?: PreflightHooks) => Promise<unknown>;
2983
3299
  abort?: () => void;
2984
3300
  isIdle?: () => boolean;
2985
3301
  settings?: Settings;
2986
3302
  /** Throw to inject a durable-write failure for matching transitions. */
2987
3303
  persistInterceptor?: (transition: { type: string }) => void;
3304
+ /** Hold one matching durable transition until the test releases it. */
3305
+ persistHold?: { type: string; onEntered: () => void; release: Promise<void> };
3306
+ onLifecycleDrainTimeout?: () => void;
3307
+ onFailureDiagnosticKeyCount?: (count: number) => void;
2988
3308
  agentFailedWriteFailures?: number;
2989
3309
  },
2990
3310
  ): Promise<InvocationHarness> {
@@ -2992,18 +3312,28 @@ async function invocationHarness(
2992
3312
  const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
2993
3313
  const broadcasts: SdkFrame[] = [];
2994
3314
  let deliver: ((connectionId: string, frame: SdkFrame) => void) | undefined;
3315
+ const deliveries = new Map<string, (connectionId: string, frame: SdkFrame) => void>();
3316
+ const sentFrames = new Map<string, SdkFrame[]>();
2995
3317
  let nextId = 0;
2996
3318
  const api = {
2997
3319
  on(event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) {
2998
3320
  handlers.set(event, handler);
2999
3321
  },
3000
- sendUserMessage: hooks.sendUserMessage ?? (async () => {}),
3322
+ sendUserMessage: async (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => {
3323
+ const result = await hooks.sendUserMessage?.(content, options);
3324
+ return result === undefined ? "completed" : result;
3325
+ },
3001
3326
  } as unknown as ExtensionAPI;
3002
3327
  const interceptorStore = hooks.persistInterceptor
3003
- ? createInterceptorReconciliationStore(hooks.persistInterceptor, hooks.agentFailedWriteFailures)
3328
+ ? createInterceptorReconciliationStore(
3329
+ hooks.persistInterceptor,
3330
+ hooks.agentFailedWriteFailures,
3331
+ hooks.persistHold,
3332
+ )
3004
3333
  : undefined;
3005
3334
  createSdkSessionRuntimeExtension(api, {
3006
3335
  agentDir: cwd,
3336
+ ...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
3007
3337
  ...(interceptorStore
3008
3338
  ? {
3009
3339
  terminalAbortSeams: {
@@ -3015,6 +3345,9 @@ async function invocationHarness(
3015
3345
  },
3016
3346
  }
3017
3347
  : {}),
3348
+ ...(hooks.onFailureDiagnosticKeyCount
3349
+ ? { onFailureDiagnosticKeyCountForTests: hooks.onFailureDiagnosticKeyCount }
3350
+ : {}),
3018
3351
  ...(hooks.settings ? { settings: hooks.settings } : {}),
3019
3352
  createTransport: async ({ sessionId: id, stateRoot, token }) => ({
3020
3353
  sessionId: id,
@@ -3022,12 +3355,17 @@ async function invocationHarness(
3022
3355
  token,
3023
3356
  onFrame(handler) {
3024
3357
  deliver = handler;
3358
+ deliveries.set(id, handler);
3025
3359
  return () => {
3026
3360
  if (deliver === handler) deliver = undefined;
3361
+ if (deliveries.get(id) === handler) deliveries.delete(id);
3027
3362
  };
3028
3363
  },
3029
3364
  sendFrame(_connectionId, frame) {
3030
3365
  const response = frame as ResponseFrame;
3366
+ const frames = sentFrames.get(id) ?? [];
3367
+ frames.push(frame);
3368
+ sentFrames.set(id, frames);
3031
3369
  if (typeof response.id === "string") waiters.get(response.id)?.(response);
3032
3370
  },
3033
3371
  broadcastFrame(frame) {
@@ -3046,6 +3384,7 @@ async function invocationHarness(
3046
3384
  ...(hooks.invokeSkill ? { invokeSkill: hooks.invokeSkill } : {}),
3047
3385
  sessionManager: {
3048
3386
  getSessionId: () => sessionId,
3387
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3049
3388
  getSessionName: () => undefined,
3050
3389
  getBranch: () => [],
3051
3390
  },
@@ -3069,6 +3408,44 @@ async function invocationHarness(
3069
3408
  stop: async () => {
3070
3409
  await handlers.get("session_shutdown")?.({}, ctx);
3071
3410
  },
3411
+ switchSession: async sessionId => {
3412
+ await handlers.get("session_switch")?.(
3413
+ {},
3414
+ {
3415
+ ...ctx,
3416
+ sessionManager: {
3417
+ ...ctx.sessionManager,
3418
+ getSessionId: () => sessionId,
3419
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3420
+ },
3421
+ },
3422
+ );
3423
+ },
3424
+ branch: async sessionId => {
3425
+ await handlers.get("session_branch")?.(
3426
+ {},
3427
+ {
3428
+ ...ctx,
3429
+ sessionManager: {
3430
+ ...ctx.sessionManager,
3431
+ getSessionId: () => sessionId,
3432
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3433
+ },
3434
+ },
3435
+ );
3436
+ },
3437
+ requestOnSession: (sessionId, frame) => {
3438
+ const id = `frame-${nextId}`;
3439
+ nextId += 1;
3440
+ const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
3441
+ waiters.set(id, resolve);
3442
+ deliveries.get(sessionId)?.("client", { ...frame, id } as SdkFrame);
3443
+ return promise;
3444
+ },
3445
+ sendToSession: (sessionId, frame) => {
3446
+ deliveries.get(sessionId)?.("client", frame as SdkFrame);
3447
+ },
3448
+ sent: sessionId => sentFrames.get(sessionId) ?? [],
3072
3449
  };
3073
3450
  }
3074
3451
 
@@ -3078,34 +3455,46 @@ async function invocationHarness(
3078
3455
  function createInterceptorReconciliationStore(
3079
3456
  interceptor: (transition: { type: string }) => void,
3080
3457
  agentFailedWriteFailures = 1,
3458
+ persistHold?: { type: string; onEntered: () => void; release: Promise<void> },
3081
3459
  ): SdkOnlyReconciliationStore {
3082
3460
  const failureCounts = new Map<string, number>();
3461
+ let holdConsumed = false;
3083
3462
  const backing = createInterceptorBackingStore();
3084
3463
  return {
3085
3464
  path: null,
3086
3465
  load: async () => backing.records.map(record => ({ ...record })),
3087
3466
  async transact(mutator) {
3088
3467
  const records = mutator(backing.records.map(record => ({ ...record })));
3468
+ let transitionType: "agent_failed" | "agent_end" | undefined;
3089
3469
  for (const record of records as Array<{ status?: string; terminalAt?: number; commandId?: string }>) {
3090
3470
  // The intermediate durable state an agent_failed write produces:
3091
3471
  // the reason is set but the row is not terminal yet. Tests choose how
3092
3472
  // many consecutive writes fail before the recovery replay succeeds.
3093
3473
  const identity = String(record?.commandId ?? "unknown");
3094
3474
  const failureCount = failureCounts.get(identity) ?? 0;
3475
+ if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
3476
+ transitionType ??= "agent_failed";
3095
3477
  if (
3096
3478
  record?.terminalAt === undefined &&
3097
3479
  (record as { error?: unknown }).error !== undefined &&
3098
3480
  failureCount < agentFailedWriteFailures
3099
3481
  ) {
3482
+ transitionType = "agent_failed";
3100
3483
  failureCounts.set(identity, failureCount + 1);
3101
3484
  interceptor({ type: "agent_failed" });
3102
3485
  throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
3103
3486
  }
3104
3487
  const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
3105
- if (record.terminalAt !== undefined && previous?.terminalAt === undefined) {
3488
+ if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
3489
+ transitionType = "agent_end";
3106
3490
  interceptor({ type: "agent_end" });
3107
3491
  }
3108
3492
  }
3493
+ if (persistHold && !holdConsumed && persistHold.type === transitionType) {
3494
+ holdConsumed = true;
3495
+ persistHold.onEntered();
3496
+ await persistHold.release;
3497
+ }
3109
3498
  backing.records = records;
3110
3499
  },
3111
3500
  snapshotTerminalScopes: () => [],
@@ -3125,65 +3514,109 @@ async function settledStatus(
3125
3514
  name: string,
3126
3515
  input: Record<string, unknown>,
3127
3516
  ): Promise<NonNullable<ResponseFrame["result"]>> {
3128
- for (let attempt = 0; attempt < 200; attempt += 1) {
3517
+ // Wall-clock budget instead of a fixed poll count: CI runners can starve the
3518
+ // event loop long enough to exhaust 200 ~1ms polls before a 25ms deadline
3519
+ // timer is dispatched, failing a correct implementation on timing alone.
3520
+ // The contract is unchanged — the status must still reach a terminal
3521
+ // reconciliation state with the asserted shape within a bounded horizon.
3522
+ const budgetEndsAt = Date.now() + 10_000;
3523
+ for (;;) {
3129
3524
  const frame = await harness.query(name, input);
3130
3525
  const result = frame.result;
3131
3526
  if (result && (result.status === "failed" || result.status === "terminal_ok")) return result;
3527
+ if (Date.now() > budgetEndsAt) throw new Error(`${name} never reported a terminal reconciliation status`);
3132
3528
  await Bun.sleep(1);
3133
3529
  }
3134
- throw new Error(`${name} never reported a terminal reconciliation status`);
3530
+ }
3531
+
3532
+ function neverSettlingPromise(): Promise<void> {
3533
+ return Promise.withResolvers<void>().promise;
3135
3534
  }
3136
3535
 
3137
3536
  describe("post-acceptance invocation terminalization", () => {
3138
- test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
3139
- const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
3537
+ test.each([
3538
+ { status: 402, code: "provider_http_402" },
3539
+ { status: 429, code: "provider_http_429" },
3540
+ { status: 500, code: "provider_rejected" },
3541
+ ])("terminalizes a streamed provider HTTP $status rejection and permits a healthy abort_and_prompt replacement", async ({
3542
+ status,
3543
+ code,
3544
+ }) => {
3545
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-provider-http-${status}-`));
3140
3546
  try {
3141
- const harness = await invocationHarness("terminalize-prompt", cwd, {
3547
+ let prompts = 0;
3548
+ const harness = await invocationHarness(`provider-http-${status}`, cwd, {
3142
3549
  sendUserMessage: async (_content, options) => {
3550
+ prompts += 1;
3143
3551
  await options?.onPreflightAcceptCommit?.();
3144
- await new Promise<void>(() => {});
3552
+ if (prompts === 1) await neverSettlingPromise();
3145
3553
  },
3146
3554
  });
3147
- const accepted = await harness.control("turn.prompt", { text: "hello" });
3148
- expect(accepted.ok).toBe(true);
3149
- const { commandId, turnId } = accepted.result ?? {};
3555
+ const failed = await harness.control("turn.prompt", { text: "provider rejects", clientRef: `http-${status}` });
3556
+ expect(failed.ok).toBe(true);
3557
+ const failedIds = { commandId: failed.result?.commandId, turnId: failed.result?.turnId };
3150
3558
  await harness.emit("agent_start");
3151
- await harness.emit("agent_failed", {
3152
- error: Object.assign(new Error("stream interrupted"), { code: "upstream_stream_interrupted" }),
3559
+ await harness.emit("agent_end", {
3560
+ messages: [
3561
+ {
3562
+ role: "assistant",
3563
+ stopReason: "error",
3564
+ errorStatus: status,
3565
+ transportFailure: { kind: "transport", status },
3566
+ },
3567
+ ],
3153
3568
  });
3154
- await harness.emit("agent_end");
3155
- // Provider text is redacted on the wire by contract (sanitizePromptFailure);
3156
- // the failure reason survives as the safe-token code.
3157
- expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
3569
+
3570
+ const terminal = await settledStatus(harness, "turn.result", {
3571
+ kind: "prompt",
3572
+ clientRef: `http-${status}`,
3573
+ });
3574
+ expect(terminal).toMatchObject({
3158
3575
  status: "failed",
3159
- error: { code: "upstream_stream_interrupted", message: "Prompt submission failed." },
3576
+ error: { code, message: "Prompt submission failed." },
3577
+ terminalAt: expect.any(Number),
3578
+ });
3579
+ const stable = await harness.query("turn.result", { kind: "prompt", ...failedIds });
3580
+ expect(stable.result).toEqual(terminal);
3581
+
3582
+ const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
3583
+ expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
3584
+ const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
3585
+ await harness.emit("agent_start");
3586
+ await harness.emit("agent_end", {
3587
+ messages: [
3588
+ { role: "assistant", stopReason: "error", errorStatus: 429 },
3589
+ { role: "assistant", stopReason: "stop" },
3590
+ ],
3591
+ });
3592
+ expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
3593
+ status: "terminal_ok",
3160
3594
  });
3595
+ expect(prompts).toBe(2);
3161
3596
  await harness.stop();
3162
3597
  } finally {
3163
- // Let the reconciliation store finish its atomic write before the state root disappears.
3164
3598
  await Bun.sleep(50);
3165
3599
  await rm(cwd, { recursive: true, force: true });
3166
3600
  }
3167
3601
  });
3168
3602
 
3169
- test("a canceled prompt reports a terminal failed status instead of hanging", async () => {
3170
- const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-abort-"));
3603
+ test("classifies a statusless provider error completion as rejected", async () => {
3604
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-"));
3171
3605
  try {
3172
- const harness = await invocationHarness("terminalize-abort", cwd, {
3606
+ const harness = await invocationHarness("provider-statusless", cwd, {
3173
3607
  sendUserMessage: async (_content, options) => {
3174
3608
  await options?.onPreflightAcceptCommit?.();
3175
- await new Promise<void>(() => {});
3609
+ await neverSettlingPromise();
3176
3610
  },
3177
- abort: () => {},
3178
3611
  });
3179
- const accepted = await harness.control("turn.prompt", { text: "hello" });
3180
- const { commandId, turnId } = accepted.result ?? {};
3612
+ const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
3613
+ expect(accepted.ok).toBe(true);
3614
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3181
3615
  await harness.emit("agent_start");
3182
- await harness.emit("agent_failed", { error: Object.assign(new Error("turn aborted"), { code: "aborted" }) });
3183
- await harness.emit("agent_end");
3184
- expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
3616
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3617
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3185
3618
  status: "failed",
3186
- error: { code: "aborted" },
3619
+ error: { code: "provider_rejected" },
3187
3620
  });
3188
3621
  await harness.stop();
3189
3622
  } finally {
@@ -3191,46 +3624,652 @@ describe("post-acceptance invocation terminalization", () => {
3191
3624
  await rm(cwd, { recursive: true, force: true });
3192
3625
  }
3193
3626
  });
3194
- test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
3195
- const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
3627
+
3628
+ test("upgrades a generic agent_failed diagnostic when agent_end proves provider rejection", async () => {
3629
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-upgrade-"));
3196
3630
  try {
3197
- const firstInflight = Promise.withResolvers<void>();
3198
- const successorStarted = Promise.withResolvers<void>();
3199
- let prompts = 0;
3200
- const harness = await invocationHarness("abort-immediate-prompt", cwd, {
3631
+ const harness = await invocationHarness("provider-statusless-upgrade", cwd, {
3201
3632
  sendUserMessage: async (_content, options) => {
3202
- prompts += 1;
3203
3633
  await options?.onPreflightAcceptCommit?.();
3204
- if (prompts === 1) {
3205
- await firstInflight.promise;
3206
- return;
3207
- }
3208
- successorStarted.resolve();
3209
- await Promise.withResolvers<void>().promise;
3634
+ await neverSettlingPromise();
3210
3635
  },
3211
3636
  });
3212
- const first = await harness.control("turn.prompt", { text: "first" });
3213
- expect(first.ok).toBe(true);
3214
- const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3215
- await harness.emit("agent_start");
3216
- expect(await harness.control("turn.abort", {})).toMatchObject({ ok: true });
3217
- firstInflight.reject(Object.assign(new Error("turn aborted"), { code: "aborted" }));
3218
- const second = await harness.control("turn.prompt", { text: "successor" });
3219
- expect(second.ok).toBe(true);
3220
- const secondIds = { commandId: second.result?.commandId, turnId: second.result?.turnId };
3221
- expect(secondIds.commandId).not.toBe(firstIds.commandId);
3222
- expect(secondIds.turnId).not.toBe(firstIds.turnId);
3637
+ const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
3638
+ expect(accepted.ok).toBe(true);
3639
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3223
3640
  await harness.emit("agent_start");
3224
- await successorStarted.promise;
3225
- await harness.emit("agent_end");
3226
- expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
3227
- result: { status: expect.stringMatching(/accepted|in_flight/) },
3641
+ await harness.emit("agent_failed", {
3642
+ error: Object.assign(new Error("generic agent failure"), { code: "agent_failed" }),
3228
3643
  });
3229
- expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
3644
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3645
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3230
3646
  status: "failed",
3231
- error: { code: "aborted" },
3647
+ error: { code: "provider_rejected" },
3232
3648
  });
3233
- await harness.emit("agent_end");
3649
+ const failures = harness.broadcasts.filter(frame => frame.kind === "agent_failed");
3650
+ expect(failures.at(-1)).toMatchObject({ payload: { error: { code: "provider_rejected" } } });
3651
+ await harness.stop();
3652
+ } finally {
3653
+ await Bun.sleep(50);
3654
+ await rm(cwd, { recursive: true, force: true });
3655
+ }
3656
+ });
3657
+
3658
+ test("preserves a specific agent_failed diagnostic when agent_end is statusless", async () => {
3659
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-specific-"));
3660
+ try {
3661
+ const harness = await invocationHarness("provider-statusless-specific", cwd, {
3662
+ sendUserMessage: async (_content, options) => {
3663
+ await options?.onPreflightAcceptCommit?.();
3664
+ await neverSettlingPromise();
3665
+ },
3666
+ });
3667
+ const accepted = await harness.control("turn.prompt", { text: "provider unavailable" });
3668
+ expect(accepted.ok).toBe(true);
3669
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3670
+ await harness.emit("agent_start");
3671
+ await harness.emit("agent_failed", {
3672
+ error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }),
3673
+ });
3674
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3675
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3676
+ status: "failed",
3677
+ error: { code: "provider_unavailable" },
3678
+ });
3679
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_failed")).toHaveLength(1);
3680
+ await harness.stop();
3681
+ } finally {
3682
+ await Bun.sleep(50);
3683
+ await rm(cwd, { recursive: true, force: true });
3684
+ }
3685
+ });
3686
+
3687
+ test("upgrades every shared generic diagnostic on a statusless provider end", async () => {
3688
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-shared-"));
3689
+ try {
3690
+ let promoted: ((promotion: { startsOwnRun?: boolean }) => void) | undefined;
3691
+ const harness = await invocationHarness("provider-statusless-shared", cwd, {
3692
+ sendUserMessage: async (content, options) => {
3693
+ await options?.onPreflightAcceptCommit?.();
3694
+ if (content === "attached") {
3695
+ promoted = (options as { onQueuedPromoted?: (promotion: { startsOwnRun?: boolean }) => void })
3696
+ ?.onQueuedPromoted;
3697
+ return;
3698
+ }
3699
+ await neverSettlingPromise();
3700
+ },
3701
+ });
3702
+ const first = await harness.control("turn.prompt", { text: "first" });
3703
+ expect(first.ok).toBe(true);
3704
+ await harness.emit("agent_start");
3705
+ const attached = await harness.control("turn.follow_up", { text: "attached" });
3706
+ expect(attached.ok).toBe(true);
3707
+ promoted?.({ startsOwnRun: false });
3708
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3709
+ const attachedIds = { commandId: attached.result?.commandId, turnId: attached.result?.turnId };
3710
+ await harness.emit("agent_failed", { error: Object.assign(new Error("generic"), { code: "agent_failed" }) });
3711
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3712
+ expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
3713
+ status: "failed",
3714
+ error: { code: "provider_rejected" },
3715
+ });
3716
+ expect(await settledStatus(harness, "turn.prompt_status", attachedIds)).toMatchObject({
3717
+ status: "failed",
3718
+ error: { code: "provider_rejected" },
3719
+ });
3720
+ await harness.stop();
3721
+ } finally {
3722
+ await Bun.sleep(50);
3723
+ await rm(cwd, { recursive: true, force: true });
3724
+ }
3725
+ });
3726
+
3727
+ test("does not misclassify a local malformed-tool circuit breaker as provider rejection", async () => {
3728
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-malformed-tool-"));
3729
+ try {
3730
+ const harness = await invocationHarness("local-malformed-tool", cwd, {
3731
+ sendUserMessage: async (_content, options) => {
3732
+ await options?.onPreflightAcceptCommit?.();
3733
+ await neverSettlingPromise();
3734
+ },
3735
+ });
3736
+ const accepted = await harness.control("turn.prompt", { text: "local malformed tool" });
3737
+ expect(accepted.ok).toBe(true);
3738
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3739
+ await harness.emit("agent_start");
3740
+ await harness.emit("agent_end", {
3741
+ messages: [
3742
+ {
3743
+ role: "assistant",
3744
+ stopReason: "error",
3745
+ errorMessage:
3746
+ "Stopping after 3 consecutive turns of malformed tool calls; the model did not produce a usable tool call or answer.",
3747
+ },
3748
+ ],
3749
+ });
3750
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3751
+ status: "failed",
3752
+ error: { code: "prompt_failed" },
3753
+ });
3754
+ await harness.stop();
3755
+ } finally {
3756
+ await Bun.sleep(50);
3757
+ await rm(cwd, { recursive: true, force: true });
3758
+ }
3759
+ });
3760
+
3761
+ test("does not misclassify a local composer policy breaker as provider rejection", async () => {
3762
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-composer-policy-"));
3763
+ try {
3764
+ const harness = await invocationHarness("local-composer-policy", cwd, {
3765
+ sendUserMessage: async (_content, options) => {
3766
+ await options?.onPreflightAcceptCommit?.();
3767
+ await neverSettlingPromise();
3768
+ },
3769
+ });
3770
+ const accepted = await harness.control("turn.prompt", { text: "local composer policy" });
3771
+ expect(accepted.ok).toBe(true);
3772
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3773
+ await harness.emit("agent_start");
3774
+ await harness.emit("agent_end", {
3775
+ messages: [
3776
+ {
3777
+ role: "assistant",
3778
+ stopReason: "error",
3779
+ errorMessage:
3780
+ "Composer bash policy blocked repository file I/O again after its one automatic recovery turn.",
3781
+ },
3782
+ ],
3783
+ });
3784
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3785
+ status: "failed",
3786
+ error: { code: "prompt_failed" },
3787
+ });
3788
+ await harness.stop();
3789
+ } finally {
3790
+ await Bun.sleep(50);
3791
+ await rm(cwd, { recursive: true, force: true });
3792
+ }
3793
+ });
3794
+
3795
+ test("terminalizes when provider end metadata exposes a throwing accessor", async () => {
3796
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-throwing-metadata-"));
3797
+ try {
3798
+ const harness = await invocationHarness("provider-throwing-metadata", cwd, {
3799
+ sendUserMessage: async (_content, options) => {
3800
+ await options?.onPreflightAcceptCommit?.();
3801
+ await neverSettlingPromise();
3802
+ },
3803
+ });
3804
+ const accepted = await harness.control("turn.prompt", { text: "provider metadata" });
3805
+ expect(accepted.ok).toBe(true);
3806
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3807
+ await harness.emit("agent_start");
3808
+ const event = {} as Record<string, unknown>;
3809
+ Object.defineProperty(event, "messages", {
3810
+ get() {
3811
+ throw new Error("provider metadata accessor failed");
3812
+ },
3813
+ });
3814
+ await harness.emit("agent_end", event);
3815
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3816
+ status: "failed",
3817
+ error: { code: "prompt_failed" },
3818
+ });
3819
+ await harness.stop();
3820
+ } finally {
3821
+ await Bun.sleep(50);
3822
+ await rm(cwd, { recursive: true, force: true });
3823
+ }
3824
+ });
3825
+
3826
+ test("binds a delayed predecessor provider failure to its own batch after a replacement starts", async () => {
3827
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-provider-batch-"));
3828
+ try {
3829
+ const firstInflight = Promise.withResolvers<void>();
3830
+ const secondInflight = Promise.withResolvers<void>();
3831
+ let prompts = 0;
3832
+ const harness = await invocationHarness("delayed-provider-batch", cwd, {
3833
+ sendUserMessage: async (_content, options) => {
3834
+ prompts += 1;
3835
+ await options?.onPreflightAcceptCommit?.();
3836
+ if (prompts === 1) await firstInflight.promise;
3837
+ if (prompts === 2) await secondInflight.promise;
3838
+ },
3839
+ persistInterceptor: () => {},
3840
+ });
3841
+ const first = await harness.control("turn.prompt", { text: "first", clientRef: "delayed-first" });
3842
+ expect(first.ok).toBe(true);
3843
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3844
+ await harness.emit("agent_start");
3845
+ const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
3846
+ expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
3847
+ const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
3848
+ await harness.emit("agent_start");
3849
+ expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
3850
+ await harness.emit("agent_end", {
3851
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
3852
+ });
3853
+ expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
3854
+ status: "failed",
3855
+ error: { code: "provider_http_402" },
3856
+ });
3857
+ expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
3858
+ await harness.emit("agent_end", {
3859
+ messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
3860
+ });
3861
+ expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
3862
+ status: "terminal_ok",
3863
+ });
3864
+ firstInflight.resolve();
3865
+ secondInflight.resolve();
3866
+ expect(prompts).toBe(2);
3867
+ await harness.stop();
3868
+ } finally {
3869
+ await Bun.sleep(50);
3870
+ await rm(cwd, { recursive: true, force: true });
3871
+ }
3872
+ });
3873
+
3874
+ test.each([
3875
+ { operation: "switchSession" as const, label: "session switch" },
3876
+ { operation: "branch" as const, label: "session branch" },
3877
+ ])("keeps a held 402/429 predecessor terminal across $label", async ({ operation }) => {
3878
+ for (const { status, code } of [
3879
+ { status: 402, code: "provider_http_402" },
3880
+ { status: 429, code: "provider_http_429" },
3881
+ ]) {
3882
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-race-`));
3883
+ try {
3884
+ const entered = Promise.withResolvers<void>();
3885
+ const release = Promise.withResolvers<void>();
3886
+ const firstInflight = Promise.withResolvers<void>();
3887
+ const secondInflight = Promise.withResolvers<void>();
3888
+ let timeoutWarnings = 0;
3889
+ const diagnosticKeyCounts: number[] = [];
3890
+ let prompts = 0;
3891
+ const harness = await invocationHarness(`${operation}-provider-race`, cwd, {
3892
+ persistInterceptor: () => {},
3893
+ onLifecycleDrainTimeout: () => {
3894
+ timeoutWarnings += 1;
3895
+ },
3896
+ onFailureDiagnosticKeyCount: count => {
3897
+ diagnosticKeyCounts.push(count);
3898
+ },
3899
+ persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
3900
+ agentFailedWriteFailures: 0,
3901
+ sendUserMessage: async (_content, options) => {
3902
+ prompts += 1;
3903
+ await options?.onPreflightAcceptCommit?.();
3904
+ if (prompts === 1) await firstInflight.promise;
3905
+ if (prompts === 2) await secondInflight.promise;
3906
+ },
3907
+ });
3908
+ const first = await harness.control("turn.prompt", {
3909
+ text: "first",
3910
+ clientRef: `${operation}-${status}-first`,
3911
+ });
3912
+ expect(first.ok).toBe(true);
3913
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3914
+ await harness.emit("agent_start");
3915
+ const end = harness.emit("agent_end", {
3916
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
3917
+ });
3918
+ await entered.promise;
3919
+ const lifecycleChange = harness[operation](`${operation}-successor`);
3920
+ await Bun.sleep(10);
3921
+ const [oldControl, oldQuery, oldReplay, oldProvider, oldReverse] = await Promise.all([
3922
+ harness.requestOnSession(`${operation}-provider-race`, {
3923
+ type: "control_request",
3924
+ operation: "turn.prompt",
3925
+ input: {},
3926
+ }),
3927
+ harness.requestOnSession(`${operation}-provider-race`, {
3928
+ type: "query_request",
3929
+ query: "turn.prompt_status",
3930
+ input: {},
3931
+ }),
3932
+ harness.requestOnSession(`${operation}-provider-race`, {
3933
+ type: "event_replay",
3934
+ sinceGeneration: 0,
3935
+ sinceSeq: 0,
3936
+ }),
3937
+ harness.requestOnSession(`${operation}-provider-race`, {
3938
+ type: "register_provider",
3939
+ capability: "fs",
3940
+ definitions: {},
3941
+ }),
3942
+ harness.requestOnSession(`${operation}-provider-race`, {
3943
+ type: "reverse_response",
3944
+ leaseId: "old-lease",
3945
+ ok: true,
3946
+ result: {},
3947
+ }),
3948
+ ]);
3949
+ harness.sendToSession(`${operation}-provider-race`, { type: "provider_heartbeat", leaseId: "old-lease" });
3950
+ harness.sendToSession(`${operation}-provider-race`, { type: "lease_release", leaseId: "old-lease" });
3951
+ expect(
3952
+ [oldControl, oldQuery, oldReplay, oldProvider, oldReverse].map(
3953
+ response => response.error?.code ?? response.code,
3954
+ ),
3955
+ ).toEqual([
3956
+ "session_quiescing",
3957
+ "session_quiescing",
3958
+ "session_quiescing",
3959
+ "session_quiescing",
3960
+ "session_quiescing",
3961
+ ]);
3962
+ expect(
3963
+ harness.sent(`${operation}-provider-race`).filter(frame => frame.type === "transport_error"),
3964
+ ).toHaveLength(3);
3965
+ release.resolve();
3966
+ await Promise.all([end, lifecycleChange]);
3967
+ expect(timeoutWarnings).toBe(0);
3968
+ expect(diagnosticKeyCounts.at(-1)).toBe(0);
3969
+ const failure = harness.broadcasts.find(frame => {
3970
+ const payload = frame.payload;
3971
+ return (
3972
+ frame.kind === "agent_failed" &&
3973
+ typeof payload === "object" &&
3974
+ payload !== null &&
3975
+ (payload as { commandId?: unknown }).commandId === firstIds.commandId &&
3976
+ (payload as { turnId?: unknown }).turnId === firstIds.turnId
3977
+ );
3978
+ });
3979
+ expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code } } });
3980
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end").length).toBeGreaterThan(0);
3981
+
3982
+ const successor = await harness.control("turn.prompt", { text: "successor" });
3983
+ expect(successor.ok).toBe(true);
3984
+ const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
3985
+ await harness.emit("agent_start");
3986
+ await harness.emit("agent_end", {
3987
+ messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
3988
+ });
3989
+ secondInflight.resolve();
3990
+ expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
3991
+ status: "terminal_ok",
3992
+ });
3993
+ firstInflight.resolve();
3994
+ expect(prompts).toBe(2);
3995
+ await harness.stop();
3996
+ } finally {
3997
+ await Bun.sleep(50);
3998
+ await rm(cwd, { recursive: true, force: true });
3999
+ }
4000
+ }
4001
+ });
4002
+
4003
+ test.each([
4004
+ { operation: "switchSession" as const, label: "session switch" },
4005
+ { operation: "branch" as const, label: "session branch" },
4006
+ ])("bounds a stalled 402/429 lifecycle drain during $label", async ({ operation }) => {
4007
+ for (const status of [402, 429]) {
4008
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-stall-`));
4009
+ try {
4010
+ const entered = Promise.withResolvers<void>();
4011
+ const release = Promise.withResolvers<void>();
4012
+ const firstInflight = Promise.withResolvers<void>();
4013
+ let timeoutWarnings = 0;
4014
+ const harness = await invocationHarness(`${operation}-provider-stall`, cwd, {
4015
+ persistInterceptor: () => {},
4016
+ onLifecycleDrainTimeout: () => {
4017
+ timeoutWarnings += 1;
4018
+ },
4019
+ persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
4020
+ agentFailedWriteFailures: 0,
4021
+ sendUserMessage: async (_content, options) => {
4022
+ await options?.onPreflightAcceptCommit?.();
4023
+ await firstInflight.promise;
4024
+ },
4025
+ });
4026
+ const first = await harness.control("turn.prompt", { text: "first" });
4027
+ expect(first.ok).toBe(true);
4028
+ await harness.emit("agent_start");
4029
+ const end = harness.emit("agent_end", {
4030
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
4031
+ });
4032
+ await entered.promise;
4033
+ const startedAt = Date.now();
4034
+ const lifecycleChange = harness[operation](`${operation}-stall-successor`);
4035
+ await lifecycleChange;
4036
+ expect(Date.now() - startedAt).toBeLessThan(1_500);
4037
+ expect(timeoutWarnings).toBe(1);
4038
+ release.resolve();
4039
+ firstInflight.resolve();
4040
+ await end;
4041
+ await harness.stop();
4042
+ } finally {
4043
+ await Bun.sleep(50);
4044
+ await rm(cwd, { recursive: true, force: true });
4045
+ }
4046
+ }
4047
+ });
4048
+
4049
+ test("deduplicates repeated agent_failed diagnostics for one invocation", async () => {
4050
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-agent-failed-dedupe-"));
4051
+ try {
4052
+ const inflight = Promise.withResolvers<void>();
4053
+ const harness = await invocationHarness("agent-failed-dedupe", cwd, {
4054
+ sendUserMessage: async (_content, options) => {
4055
+ await options?.onPreflightAcceptCommit?.();
4056
+ await inflight.promise;
4057
+ },
4058
+ });
4059
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
4060
+ expect(accepted.ok).toBe(true);
4061
+ const correlation = accepted.result;
4062
+ await harness.emit("agent_start");
4063
+ const failure = { error: Object.assign(new Error("provider failed"), { code: "provider_rejected" }) };
4064
+ await harness.emit("agent_failed", failure);
4065
+ await harness.emit("agent_failed", failure);
4066
+ expect(
4067
+ harness.broadcasts.filter(
4068
+ frame =>
4069
+ frame.kind === "agent_failed" &&
4070
+ (frame.payload as { commandId?: string }).commandId === correlation?.commandId &&
4071
+ (frame.payload as { turnId?: string }).turnId === correlation?.turnId,
4072
+ ),
4073
+ ).toHaveLength(1);
4074
+ await harness.emit("agent_end");
4075
+ inflight.resolve();
4076
+ await harness.stop();
4077
+ } finally {
4078
+ await Bun.sleep(50);
4079
+ await rm(cwd, { recursive: true, force: true });
4080
+ }
4081
+ });
4082
+
4083
+ test("routes a delayed predecessor agent_end after replacement completion to the retired owner", async () => {
4084
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-retired-owner-"));
4085
+ try {
4086
+ const firstInflight = Promise.withResolvers<void>();
4087
+ const secondInflight = Promise.withResolvers<void>();
4088
+ let prompts = 0;
4089
+ const harness = await invocationHarness("retired-owner-a", cwd, {
4090
+ persistInterceptor: () => {},
4091
+ sendUserMessage: async (_content, options) => {
4092
+ prompts += 1;
4093
+ await options?.onPreflightAcceptCommit?.();
4094
+ if (prompts === 1) await firstInflight.promise;
4095
+ if (prompts === 2) await secondInflight.promise;
4096
+ },
4097
+ });
4098
+ const first = await harness.control("turn.prompt", { text: "first" });
4099
+ expect(first.ok).toBe(true);
4100
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
4101
+ await harness.emit("agent_start");
4102
+ await harness.switchSession("retired-owner-b");
4103
+ await harness.emit("agent_end", {
4104
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
4105
+ });
4106
+ const failure = harness.broadcasts.find(frame => {
4107
+ const payload = frame.payload;
4108
+ return (
4109
+ frame.kind === "agent_failed" &&
4110
+ typeof payload === "object" &&
4111
+ payload !== null &&
4112
+ (payload as { commandId?: unknown }).commandId === firstIds.commandId &&
4113
+ (payload as { turnId?: unknown }).turnId === firstIds.turnId
4114
+ );
4115
+ });
4116
+ expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code: "provider_http_402" } } });
4117
+ const successor = await harness.control("turn.prompt", { text: "successor" });
4118
+ expect(successor.ok).toBe(true);
4119
+ const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
4120
+ await harness.emit("agent_start");
4121
+ await harness.emit("agent_end", {
4122
+ messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
4123
+ });
4124
+ secondInflight.resolve();
4125
+ expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
4126
+ status: "terminal_ok",
4127
+ });
4128
+ firstInflight.resolve();
4129
+ expect(prompts).toBe(2);
4130
+ await harness.stop();
4131
+ } finally {
4132
+ await Bun.sleep(50);
4133
+ await rm(cwd, { recursive: true, force: true });
4134
+ }
4135
+ });
4136
+
4137
+ test("fails closed for tokenless delayed events when a session id is reused", async () => {
4138
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-session-retired-owner-"));
4139
+ try {
4140
+ const firstInflight = Promise.withResolvers<void>();
4141
+ const harness = await invocationHarness("reused-session", cwd, {
4142
+ persistInterceptor: () => {},
4143
+ sendUserMessage: async (_content, options) => {
4144
+ await options?.onPreflightAcceptCommit?.();
4145
+ await firstInflight.promise;
4146
+ },
4147
+ });
4148
+ const first = await harness.control("turn.prompt", { text: "first" });
4149
+ expect(first.ok).toBe(true);
4150
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
4151
+ await harness.emit("agent_start");
4152
+ await harness.switchSession("different-session");
4153
+ await harness.switchSession("reused-session");
4154
+ await harness.emit("agent_start");
4155
+ await harness.emit("agent_end", {
4156
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
4157
+ });
4158
+ const failure = harness.broadcasts.find(frame => {
4159
+ const payload = frame.payload;
4160
+ return (
4161
+ frame.kind === "agent_failed" &&
4162
+ typeof payload === "object" &&
4163
+ payload !== null &&
4164
+ (payload as { commandId?: unknown }).commandId === firstIds.commandId &&
4165
+ (payload as { turnId?: unknown }).turnId === firstIds.turnId
4166
+ );
4167
+ });
4168
+ expect(failure).toBeUndefined();
4169
+ firstInflight.resolve();
4170
+ await harness.stop();
4171
+ } finally {
4172
+ await Bun.sleep(50);
4173
+ await rm(cwd, { recursive: true, force: true });
4174
+ }
4175
+ });
4176
+
4177
+ test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
4178
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
4179
+ try {
4180
+ const harness = await invocationHarness("terminalize-prompt", cwd, {
4181
+ sendUserMessage: async (_content, options) => {
4182
+ await options?.onPreflightAcceptCommit?.();
4183
+ await neverSettlingPromise();
4184
+ },
4185
+ });
4186
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
4187
+ expect(accepted.ok).toBe(true);
4188
+ const { commandId, turnId } = accepted.result ?? {};
4189
+ await harness.emit("agent_start");
4190
+ await harness.emit("agent_failed", {
4191
+ error: Object.assign(new Error("stream interrupted"), { code: "upstream_stream_interrupted" }),
4192
+ });
4193
+ await harness.emit("agent_end");
4194
+ // Provider text is redacted on the wire by contract (sanitizePromptFailure);
4195
+ // the failure reason survives as the safe-token code.
4196
+ expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
4197
+ status: "failed",
4198
+ error: { code: "upstream_stream_interrupted", message: "Prompt submission failed." },
4199
+ });
4200
+ await harness.stop();
4201
+ } finally {
4202
+ // Let the reconciliation store finish its atomic write before the state root disappears.
4203
+ await Bun.sleep(50);
4204
+ await rm(cwd, { recursive: true, force: true });
4205
+ }
4206
+ });
4207
+
4208
+ test("a canceled prompt reports a terminal failed status instead of hanging", async () => {
4209
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-abort-"));
4210
+ try {
4211
+ const harness = await invocationHarness("terminalize-abort", cwd, {
4212
+ sendUserMessage: async (_content, options) => {
4213
+ await options?.onPreflightAcceptCommit?.();
4214
+ await neverSettlingPromise();
4215
+ },
4216
+ abort: () => {},
4217
+ });
4218
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
4219
+ const { commandId, turnId } = accepted.result ?? {};
4220
+ await harness.emit("agent_start");
4221
+ await harness.emit("agent_failed", { error: Object.assign(new Error("turn aborted"), { code: "aborted" }) });
4222
+ await harness.emit("agent_end");
4223
+ expect(await settledStatus(harness, "turn.prompt_status", { commandId, turnId })).toMatchObject({
4224
+ status: "failed",
4225
+ error: { code: "aborted" },
4226
+ });
4227
+ await harness.stop();
4228
+ } finally {
4229
+ await Bun.sleep(50);
4230
+ await rm(cwd, { recursive: true, force: true });
4231
+ }
4232
+ });
4233
+ test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
4234
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
4235
+ try {
4236
+ const firstInflight = Promise.withResolvers<void>();
4237
+ const successorStarted = Promise.withResolvers<void>();
4238
+ let prompts = 0;
4239
+ const harness = await invocationHarness("abort-immediate-prompt", cwd, {
4240
+ sendUserMessage: async (_content, options) => {
4241
+ prompts += 1;
4242
+ await options?.onPreflightAcceptCommit?.();
4243
+ if (prompts === 1) {
4244
+ await firstInflight.promise;
4245
+ return;
4246
+ }
4247
+ successorStarted.resolve();
4248
+ await Promise.withResolvers<void>().promise;
4249
+ },
4250
+ });
4251
+ const first = await harness.control("turn.prompt", { text: "first" });
4252
+ expect(first.ok).toBe(true);
4253
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
4254
+ await harness.emit("agent_start");
4255
+ expect(await harness.control("turn.abort", {})).toMatchObject({ ok: true });
4256
+ firstInflight.reject(Object.assign(new Error("turn aborted"), { code: "aborted" }));
4257
+ const second = await harness.control("turn.prompt", { text: "successor" });
4258
+ expect(second.ok).toBe(true);
4259
+ const secondIds = { commandId: second.result?.commandId, turnId: second.result?.turnId };
4260
+ expect(secondIds.commandId).not.toBe(firstIds.commandId);
4261
+ expect(secondIds.turnId).not.toBe(firstIds.turnId);
4262
+ await harness.emit("agent_start");
4263
+ await successorStarted.promise;
4264
+ await harness.emit("agent_end");
4265
+ expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
4266
+ result: { status: expect.stringMatching(/accepted|in_flight/) },
4267
+ });
4268
+ expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
4269
+ status: "failed",
4270
+ error: { code: "aborted" },
4271
+ });
4272
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
3234
4273
  expect(await settledStatus(harness, "turn.prompt_status", secondIds)).toMatchObject({
3235
4274
  status: "terminal_ok",
3236
4275
  });
@@ -3281,8 +4320,8 @@ describe("post-acceptance invocation terminalization", () => {
3281
4320
  expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
3282
4321
  result: { status: expect.stringMatching(/accepted|in_flight/) },
3283
4322
  });
3284
- await harness.emit("agent_end");
3285
- await harness.emit("agent_end");
4323
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
4324
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
3286
4325
  expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
3287
4326
  status: "terminal_ok",
3288
4327
  });
@@ -3293,6 +4332,55 @@ describe("post-acceptance invocation terminalization", () => {
3293
4332
  }
3294
4333
  });
3295
4334
 
4335
+ test("reused-session successor tool progress renews its active deadline", async () => {
4336
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-successor-progress-"));
4337
+ try {
4338
+ const firstInflight = Promise.withResolvers<void>();
4339
+ const successorStarted = Promise.withResolvers<void>();
4340
+ let prompts = 0;
4341
+ const harness = await invocationHarness("reused-successor-progress", cwd, {
4342
+ settings: {
4343
+ get: (key: string) =>
4344
+ key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
4345
+ } as unknown as Settings,
4346
+ sendUserMessage: async (_content, options) => {
4347
+ prompts += 1;
4348
+ await options?.onPreflightAcceptCommit?.();
4349
+ if (prompts === 1) {
4350
+ await firstInflight.promise;
4351
+ return;
4352
+ }
4353
+ successorStarted.resolve();
4354
+ await Promise.withResolvers<void>().promise;
4355
+ },
4356
+ });
4357
+ const first = await harness.control("turn.prompt", { text: "first" });
4358
+ expect(first.ok).toBe(true);
4359
+ await harness.emit("agent_start");
4360
+ await harness.switchSession("reused-successor-progress-other");
4361
+ await harness.switchSession("reused-successor-progress");
4362
+ const successor = await harness.control("turn.prompt", { text: "successor" });
4363
+ expect(successor.ok).toBe(true);
4364
+ const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
4365
+ await harness.emit("agent_start");
4366
+ await successorStarted.promise;
4367
+ for (let index = 0; index < 4; index += 1) {
4368
+ await harness.emit("tool_execution_start");
4369
+ await Bun.sleep(15);
4370
+ }
4371
+ expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
4372
+ result: { status: expect.stringMatching(/accepted|in_flight/) },
4373
+ });
4374
+ await harness.emit("agent_end");
4375
+ firstInflight.resolve();
4376
+ await harness.stop();
4377
+ expect(prompts).toBe(2);
4378
+ } finally {
4379
+ await Bun.sleep(50);
4380
+ await rm(cwd, { recursive: true, force: true });
4381
+ }
4382
+ });
4383
+
3296
4384
  test("abort_and_prompt starts exactly one successor and is not duplicated by delayed abort teardown", async () => {
3297
4385
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-and-prompt-once-"));
3298
4386
  try {
@@ -3321,7 +4409,7 @@ describe("post-acceptance invocation terminalization", () => {
3321
4409
  expect(accepted.ok).toBe(true);
3322
4410
  const successorIds = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3323
4411
  await harness.emit("agent_start");
3324
- await harness.emit("agent_end");
4412
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
3325
4413
  expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
3326
4414
  status: "terminal_ok",
3327
4415
  });
@@ -3339,7 +4427,7 @@ describe("post-acceptance invocation terminalization", () => {
3339
4427
  const harness = await invocationHarness("terminalize-skill", cwd, {
3340
4428
  invokeSkill: async (_name, _args, options) => {
3341
4429
  await options?.onPreflightAcceptCommit?.();
3342
- await new Promise<void>(() => {});
4430
+ await neverSettlingPromise();
3343
4431
  },
3344
4432
  });
3345
4433
  const accepted = await harness.control("skill.invoke", { name: "ralplan" });
@@ -3520,7 +4608,7 @@ describe("post-acceptance invocation terminalization", () => {
3520
4608
  const accepted = await harness.control("turn.prompt", { text: "hello" });
3521
4609
  const { commandId, turnId } = accepted.result ?? {};
3522
4610
  await harness.emit("agent_start");
3523
- await harness.emit("agent_end");
4611
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
3524
4612
  const claimed = await settledStatus(harness, "turn.prompt_status", { commandId, turnId });
3525
4613
  expect(claimed).toMatchObject({ status: "terminal_ok" });
3526
4614
  inflight.reject(Object.assign(new Error("late provider failure"), { code: "upstream_error" }));
@@ -3563,7 +4651,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3563
4651
  sendUserMessage: async (_content, options) => {
3564
4652
  await options?.onPreflightAcceptCommit?.();
3565
4653
  // Accepted, then permanently no execution progress and no agent_start.
3566
- await new Promise<void>(() => {});
4654
+ await neverSettlingPromise();
3567
4655
  },
3568
4656
  });
3569
4657
  const accepted = await harness.control("turn.prompt", { text: "hello" });
@@ -3597,7 +4685,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3597
4685
  settings: zeroProgressSettings,
3598
4686
  sendUserMessage: async (_content, options) => {
3599
4687
  await options?.onPreflightAcceptCommit?.();
3600
- await new Promise<void>(() => {});
4688
+ await neverSettlingPromise();
3601
4689
  },
3602
4690
  });
3603
4691
  const original = await harness.control("turn.prompt", { text: "original" });
@@ -3688,7 +4776,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3688
4776
  const first = await harness.control("turn.prompt", { text: "first" });
3689
4777
  expect(first.ok).toBe(true);
3690
4778
  await harness.emit("agent_start");
3691
- await harness.emit("agent_end");
4779
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
3692
4780
  const followUpB = await harness.control("turn.follow_up", { text: "b" });
3693
4781
  const followUpC = await harness.control("turn.follow_up", { text: "c" });
3694
4782
  promoted[0]?.({ startsOwnRun: true });
@@ -3731,7 +4819,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3731
4819
  return;
3732
4820
  }
3733
4821
  // The first turn accepts and then never makes progress.
3734
- await new Promise<void>(() => {});
4822
+ await neverSettlingPromise();
3735
4823
  },
3736
4824
  });
3737
4825
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -3817,7 +4905,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3817
4905
  )?.onQueuedPromoted;
3818
4906
  return;
3819
4907
  }
3820
- await new Promise<void>(() => {});
4908
+ await neverSettlingPromise();
3821
4909
  },
3822
4910
  });
3823
4911
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -3836,7 +4924,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3836
4924
  }
3837
4925
  const midRun = await harness.query("turn.prompt_status", ids);
3838
4926
  expect(midRun.result?.status).not.toBe("failed");
3839
- await harness.emit("agent_end");
4927
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
3840
4928
  const final = await harness.query("turn.prompt_status", ids);
3841
4929
  expect(final.result?.status).toBe("terminal_ok");
3842
4930
  await harness.stop();
@@ -3873,7 +4961,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3873
4961
  promoted = options?.onQueuedPromoted;
3874
4962
  return;
3875
4963
  }
3876
- await new Promise<void>(() => {});
4964
+ await neverSettlingPromise();
3877
4965
  },
3878
4966
  } as unknown as ExtensionAPI;
3879
4967
  const transport = memoryTransport();
@@ -3931,7 +5019,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3931
5019
  // The consuming run ends. The consumed submission must terminalize
3932
5020
  // WITH it (terminal_ok, never a fabricated prompt_deadline_exceeded):
3933
5021
  // with the bug it would still be parked in pending as merely accepted.
3934
- await handlers.get("agent_end")?.({}, ctx);
5022
+ await handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "completed" }] }, ctx);
3935
5023
  const statusOf = async (ids: { commandId?: string; turnId?: string }, frameId: string) => {
3936
5024
  transport.feed("conn-a", {
3937
5025
  type: "query_request",
@@ -3994,7 +5082,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3994
5082
  return;
3995
5083
  }
3996
5084
  // The first turn accepts and then keeps streaming.
3997
- await new Promise<void>(() => {});
5085
+ await neverSettlingPromise();
3998
5086
  },
3999
5087
  });
4000
5088
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -4009,7 +5097,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4009
5097
  expect(midRun.result?.status).not.toBe("terminal_ok");
4010
5098
  expect(midRun.result?.status).not.toBe("failed");
4011
5099
  // The in-flight run ends: the diverted correlation terminalizes with it.
4012
- await harness.emit("agent_end");
5100
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
4013
5101
  expect(await settledStatus(harness, "turn.prompt_status", idsRaced)).toMatchObject({
4014
5102
  status: "terminal_ok",
4015
5103
  });
@@ -4052,7 +5140,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4052
5140
  return;
4053
5141
  }
4054
5142
  // The first turn accepts and then keeps streaming.
4055
- await new Promise<void>(() => {});
5143
+ await neverSettlingPromise();
4056
5144
  },
4057
5145
  });
4058
5146
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -4068,7 +5156,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4068
5156
  expect(queued.result?.status).not.toBe("failed");
4069
5157
  // Real consumption re-leases and attaches to the in-flight run.
4070
5158
  consumed?.({ startsOwnRun: false });
4071
- await harness.emit("agent_end");
5159
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
4072
5160
  expect(await settledStatus(harness, "turn.prompt_status", idsRaced)).toMatchObject({
4073
5161
  status: "terminal_ok",
4074
5162
  });
@@ -4128,7 +5216,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4128
5216
  } as unknown as Settings,
4129
5217
  sendUserMessage: async (_content, options) => {
4130
5218
  await options?.onPreflightAcceptCommit?.();
4131
- await new Promise<void>(() => {});
5219
+ await neverSettlingPromise();
4132
5220
  },
4133
5221
  persistInterceptor: transition => {
4134
5222
  if (transition.type === "agent_failed") {
@@ -4212,7 +5300,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4212
5300
  const harness = await invocationHarness("skill-terminal-retry", cwd, {
4213
5301
  invokeSkill: async (_name, _args, options) => {
4214
5302
  await options?.onPreflightAcceptCommit?.();
4215
- await new Promise<void>(() => {});
5303
+ await neverSettlingPromise();
4216
5304
  },
4217
5305
  persistInterceptor: transition => {
4218
5306
  if (transition.type === "agent_end" && failedWrites < 2) {
@@ -4245,6 +5333,48 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4245
5333
  }
4246
5334
  });
4247
5335
 
5336
+ test("retains skill terminal recovery across session replacement", async () => {
5337
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-skill-terminal-replacement-"));
5338
+ let failFirstTerminalWrite = true;
5339
+ try {
5340
+ const harness = await invocationHarness("skill-terminal-replacement", cwd, {
5341
+ invokeSkill: async (_name, _args, options) => {
5342
+ await options?.onPreflightAcceptCommit?.();
5343
+ await neverSettlingPromise();
5344
+ },
5345
+ persistInterceptor: transition => {
5346
+ if (transition.type === "agent_end" && failFirstTerminalWrite) {
5347
+ failFirstTerminalWrite = false;
5348
+ throw Object.assign(new Error("injected terminal persistence failure"), { code: "io_error" });
5349
+ }
5350
+ },
5351
+ });
5352
+ const submitted = await harness.control("skill.invoke", {
5353
+ name: "replacement-hang",
5354
+ args: "",
5355
+ clientRef: "skill-terminal-replacement-ref",
5356
+ });
5357
+ expect(submitted.ok).toBe(true);
5358
+ await harness.emit("agent_start");
5359
+ await harness.emit("agent_end");
5360
+ await Bun.sleep(50);
5361
+ await harness.switchSession("skill-terminal-replacement-next");
5362
+ await Bun.sleep(1_100);
5363
+ let settled: { status?: string; error?: { code?: string } } | undefined;
5364
+ for (let attempt = 0; attempt < 500 && settled === undefined; attempt += 1) {
5365
+ const frame = await harness.query("skill.invoke_status", { clientRef: "skill-terminal-replacement-ref" });
5366
+ const result = frame.result as { status?: string; error?: { code?: string } } | undefined;
5367
+ if (result?.status === "terminal_ok" || result?.status === "failed") settled = result;
5368
+ else await Bun.sleep(10);
5369
+ }
5370
+ expect(settled?.status).toBe("terminal_ok");
5371
+ await harness.stop();
5372
+ } finally {
5373
+ await Bun.sleep(50);
5374
+ await rm(cwd, { recursive: true, force: true });
5375
+ }
5376
+ });
5377
+
4248
5378
  test("a rejected prompt replays its failure reason, never a bare terminal_ok", async () => {
4249
5379
  // Exact-head review HIGH: after failed agent_failed persistence, recovery
4250
5380
  // replayed only agent_end, so an abandoned/rejected prompt became durable
@@ -4335,7 +5465,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4335
5465
  )?.onQueuedPromoted;
4336
5466
  return;
4337
5467
  }
4338
- await new Promise<void>(() => {});
5468
+ await neverSettlingPromise();
4339
5469
  },
4340
5470
  });
4341
5471
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -4369,7 +5499,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4369
5499
  sendUserMessage: async (_content, options) => {
4370
5500
  await options?.onPreflightAcceptCommit?.();
4371
5501
  // The failing turn accepts and then never makes progress on its own.
4372
- await new Promise<void>(() => {});
5502
+ await neverSettlingPromise();
4373
5503
  },
4374
5504
  });
4375
5505
  const failing = await harness.control("turn.prompt", { text: "failing" });
@@ -4420,7 +5550,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4420
5550
  options: { onPreflightAcceptCommit?: () => Promise<void> } | undefined,
4421
5551
  ) => {
4422
5552
  await options?.onPreflightAcceptCommit?.();
4423
- if (content === "hangs") await new Promise<void>(() => {});
5553
+ if (content === "hangs") await neverSettlingPromise();
4424
5554
  },
4425
5555
  } as unknown as ExtensionAPI;
4426
5556
  const transport = memoryTransport();
@@ -4466,6 +5596,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4466
5596
  const acceptedA = (await waitFrame("settle-early-a")) as { result?: { commandId?: string; turnId?: string } };
4467
5597
  const idsA = { commandId: acceptedA.result?.commandId, turnId: acceptedA.result?.turnId };
4468
5598
  const statusDeadline = Date.now() + 15_000;
5599
+ let settledStatus: string | undefined;
4469
5600
  for (;;) {
4470
5601
  transport.feed("conn-a", {
4471
5602
  type: "query_request",
@@ -4477,10 +5608,12 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4477
5608
  result?: { status?: string };
4478
5609
  };
4479
5610
  transport.sent.splice(transport.sent.indexOf(statusFrame), 1);
4480
- if (statusFrame.result?.status === "terminal_ok") break;
5611
+ settledStatus = statusFrame.result?.status;
5612
+ if (statusFrame.result?.status === "terminal_ok" || statusFrame.result?.status === "failed") break;
4481
5613
  if (Date.now() > statusDeadline) throw new Error("settled prompt never reported terminal_ok");
4482
5614
  await Bun.sleep(20);
4483
5615
  }
5616
+ expect(settledStatus).toBe("failed");
4484
5617
  // conn-b's prompt is accepted and hangs; its run then starts.
4485
5618
  transport.feed("conn-b", {
4486
5619
  type: "control_request",
@@ -4818,7 +5951,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
4818
5951
  // Production-faithful: the accepted run stays in-flight; sendUserMessage
4819
5952
  // resolution (turn completion) never precedes agent_start (#4668
4820
5953
  // success-retirement).
4821
- await new Promise<void>(() => {});
5954
+ await neverSettlingPromise();
4822
5955
  },
4823
5956
  } as unknown as ExtensionAPI;
4824
5957
  const transport = memoryTransport();
@@ -5155,7 +6288,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
5155
6288
  Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => {
5156
6289
  if (options?.onQueuedPromoted) promoted.push(options.onQueuedPromoted);
5157
6290
  options?.onPreflightAccepted?.();
5158
- return {};
6291
+ return "completed";
5159
6292
  }),
5160
6293
  } as unknown as ExtensionAPI;
5161
6294
  const transport = memoryTransport();
@@ -5243,7 +6376,7 @@ test("SDK-only host lets every connection whose follow-up was promoted abort the
5243
6376
  // invocation left the remaining follow-ups durably accepted — their
5244
6377
  // result lookups would never complete and a restart would report them
5245
6378
  // failed even though they ran in the shared turn.
5246
- await handlers.get("agent_end")?.({ type: "agent_end" }, ctx);
6379
+ await handlers.get("agent_end")?.({ type: "agent_end", stopReason: "cancelled", messages: [] }, ctx);
5247
6380
  {
5248
6381
  const terminalDeadline = Date.now() + 15_000;
5249
6382
  const promptStatuses = () =>