@gajae-code/coding-agent 0.16.0 → 0.16.1

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 (163) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +8 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +1 -0
  37. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  38. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  39. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  41. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  42. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  43. package/dist/types/sdk/receipt-state.d.ts +2 -0
  44. package/dist/types/sdk/router/session-router.d.ts +4 -0
  45. package/dist/types/sdk/turn-result.d.ts +6 -0
  46. package/dist/types/session/agent-session.d.ts +47 -7
  47. package/dist/types/session/fold-coordinator.d.ts +29 -2
  48. package/dist/types/session/session-manager.d.ts +14 -2
  49. package/dist/types/tools/ask.d.ts +1 -0
  50. package/dist/types/tools/bash.d.ts +12 -0
  51. package/dist/types/tools/browser/launch.d.ts +10 -0
  52. package/dist/types/tools/browser/registry.d.ts +8 -0
  53. package/dist/types/tools/index.d.ts +6 -10
  54. package/dist/types/tools/job.d.ts +3 -1
  55. package/dist/types/utils/external-editor.d.ts +7 -0
  56. package/examples/extensions/README.md +1 -1
  57. package/examples/extensions/pirate.ts +7 -4
  58. package/package.json +7 -7
  59. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  60. package/src/async/job-manager.ts +107 -9
  61. package/src/browser-backend.ts +29 -0
  62. package/src/cli/plugin-cli.ts +29 -0
  63. package/src/cli/update-cli.ts +63 -12
  64. package/src/commands/sdk.ts +22 -7
  65. package/src/config/billing-path.ts +42 -0
  66. package/src/config/config-schema-version.ts +18 -1
  67. package/src/config/model-preset-registry.ts +177 -18
  68. package/src/config/model-registry.ts +268 -42
  69. package/src/config/settings-schema.ts +19 -7
  70. package/src/config/settings.ts +46 -15
  71. package/src/coordinator-mcp/projection-scan.ts +6 -6
  72. package/src/coordinator-mcp/server.ts +21 -1
  73. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  74. package/src/extensibility/extensions/runner.ts +27 -0
  75. package/src/extensibility/extensions/types.ts +8 -3
  76. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  77. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  78. package/src/extensibility/skills.ts +22 -12
  79. package/src/gjc-runtime/launch-worktree.ts +285 -4
  80. package/src/gjc-runtime/session-state-lock.ts +154 -28
  81. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  82. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  83. package/src/internal-urls/docs-index.generated.ts +10 -10
  84. package/src/main.ts +4 -1
  85. package/src/modes/acp/acp-agent.ts +5 -5
  86. package/src/modes/components/model-selector.ts +76 -8
  87. package/src/modes/components/session-selector.ts +23 -15
  88. package/src/modes/components/sessions-dashboard.ts +8 -5
  89. package/src/modes/controllers/command-controller.ts +1 -1
  90. package/src/modes/controllers/event-controller.ts +30 -4
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +2 -2
  93. package/src/modes/controllers/selector-controller.ts +10 -3
  94. package/src/modes/interactive-mode.ts +36 -12
  95. package/src/modes/prompt-action-autocomplete.ts +30 -4
  96. package/src/modes/runtime-init.ts +12 -9
  97. package/src/modes/theme/theme.ts +5 -0
  98. package/src/modes/types.ts +2 -0
  99. package/src/modes/utils/ui-helpers.ts +22 -3
  100. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  101. package/src/prompts/tools/ask.md +1 -1
  102. package/src/prompts/tools/bash.md +1 -0
  103. package/src/prompts/tools/job.md +1 -1
  104. package/src/sdk/acp/adapter.ts +5 -0
  105. package/src/sdk/broker/broker.ts +10 -1
  106. package/src/sdk/broker/ensure.ts +76 -3
  107. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  108. package/src/sdk/broker/lifecycle.ts +254 -34
  109. package/src/sdk/broker/spawn-substrate.ts +45 -2
  110. package/src/sdk/broker/startup-budget.ts +144 -2
  111. package/src/sdk/broker/state-version.ts +17 -0
  112. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  113. package/src/sdk/bus/chat-daemon-runtime.ts +111 -24
  114. package/src/sdk/bus/discord-daemon.ts +50 -10
  115. package/src/sdk/bus/index.ts +33 -3
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  117. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  118. package/src/sdk/bus/notification-service.ts +22 -5
  119. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  120. package/src/sdk/bus/reconciliation-store.ts +55 -31
  121. package/src/sdk/bus/slack-conversation.ts +1 -1
  122. package/src/sdk/bus/slack-daemon.ts +72 -38
  123. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  124. package/src/sdk/cli/rows.ts +30 -7
  125. package/src/sdk/cli/session-cli.ts +33 -21
  126. package/src/sdk/host/control/dispatch.ts +2 -2
  127. package/src/sdk/host/events.ts +1 -0
  128. package/src/sdk/host/host.ts +2 -0
  129. package/src/sdk/host/query/cursor.ts +52 -7
  130. package/src/sdk/host/query/handlers.ts +33 -12
  131. package/src/sdk/host/query/revision-store.ts +12 -0
  132. package/src/sdk/host/session-runtime.test.ts +463 -8
  133. package/src/sdk/host/session-runtime.ts +383 -54
  134. package/src/sdk/host/surface-policy.ts +3 -3
  135. package/src/sdk/lifecycle-session.ts +7 -2
  136. package/src/sdk/prompt-deadline-manager.ts +2 -1
  137. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  138. package/src/sdk/protocol/operation-registry.ts +1 -1
  139. package/src/sdk/receipt-state.ts +10 -0
  140. package/src/sdk/router/session-router.ts +152 -59
  141. package/src/sdk/session.ts +60 -41
  142. package/src/sdk/turn-result.ts +22 -0
  143. package/src/session/agent-session.ts +1033 -260
  144. package/src/session/fold-coordinator.ts +40 -3
  145. package/src/session/session-manager.ts +517 -77
  146. package/src/slash-commands/builtin-registry.ts +41 -0
  147. package/src/task/executor.ts +9 -4
  148. package/src/tools/ask-contract.test.ts +64 -0
  149. package/src/tools/ask-contract.ts +36 -1
  150. package/src/tools/ask.ts +10 -8
  151. package/src/tools/bash-interactive.ts +11 -2
  152. package/src/tools/bash.ts +156 -33
  153. package/src/tools/browser/launch.ts +37 -0
  154. package/src/tools/browser/registry.ts +99 -9
  155. package/src/tools/cron.ts +6 -4
  156. package/src/tools/descriptors.test.ts +15 -0
  157. package/src/tools/descriptors.ts +5 -1
  158. package/src/tools/index.ts +6 -6
  159. package/src/tools/job.ts +4 -1
  160. package/src/tools/monitor.ts +12 -9
  161. package/src/tools/resolve.ts +30 -10
  162. package/src/tools/tool-catalog.generated.ts +2 -2
  163. package/src/utils/external-editor.ts +72 -1
@@ -1,8 +1,9 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { createHash } from "node:crypto";
3
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
+ import { logger } from "@gajae-code/utils";
6
7
  import { AsyncJobManager } from "../../async";
7
8
  import type { Settings } from "../../config/settings";
8
9
  import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
@@ -254,7 +255,10 @@ test("native prompt reconciliation fails closed for an explicitly empty assistan
254
255
  receiptState: "missing",
255
256
  });
256
257
  reconciliation.noteTransition(correlation, { type: "agent_end", finalText: "late text" });
257
- expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({ status: "failed" });
258
+ expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({
259
+ status: "failed",
260
+ receiptState: "present",
261
+ });
258
262
  });
259
263
 
260
264
  test("broker reconciliation fails closed for an empty prompt and persists the first terminal result", async () => {
@@ -283,7 +287,9 @@ test("broker reconciliation fails closed for an empty prompt and persists the fi
283
287
  expect(reloaded.lookupResult("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({
284
288
  status: "failed",
285
289
  error: { code: "prompt_failed" },
290
+ content: { text: "late text", byteLength: 9, truncated: false },
286
291
  });
292
+ expect(reloaded.lookup("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({ receiptState: "present" });
287
293
  const noOpCorrelation = { commandId: "broker-noop-command", turnId: "broker-noop-turn" };
288
294
  await reconciliation.noteAccepted("prompt", noOpCorrelation, "broker-noop-ref");
289
295
  await reconciliation.finalizeOutcome(
@@ -446,6 +452,42 @@ test("SDK-only finalizeOutcome keeps legacy recordError positional compatibility
446
452
  });
447
453
  });
448
454
 
455
+ test("SDK-only terminal outcomes survive result projection and durable reload", async () => {
456
+ let records: unknown[] = [];
457
+ const store = {
458
+ path: null,
459
+ load: async () => records,
460
+ transact: async (mutator: (current: never[]) => never[]) => {
461
+ records = mutator(records as never);
462
+ },
463
+ } as never;
464
+ const stopped = { kind: "stopped", reason: "cancelled", provenance: "client_cancel" } as const;
465
+ const reconciliation = createInvocationReconciliation({ store });
466
+ const correlation = { commandId: "outcome-command", turnId: "outcome-turn" };
467
+ await reconciliation.noteAccepted("prompt", correlation, "outcome-ref");
468
+ await reconciliation.claimPendingOutcome("prompt", correlation, stopped);
469
+ await reconciliation.finalizeOutcome("prompt", correlation);
470
+ const projected = reconciliation.lookupResult("prompt", { clientRef: "outcome-ref" });
471
+ expect(projected).toMatchObject({ status: "terminal_ok", outcome: stopped, receiptState: "unknown" });
472
+ expect(reconciliation.lookup("prompt", { clientRef: "outcome-ref" })).toMatchObject({ outcome: stopped });
473
+
474
+ const reopened = createInvocationReconciliation({ store });
475
+ await reopened.hydrate();
476
+ expect(reopened.lookupResult("prompt", { clientRef: "outcome-ref" })).toMatchObject({
477
+ status: "terminal_ok",
478
+ outcome: stopped,
479
+ });
480
+
481
+ const direct = createInvocationReconciliation();
482
+ const directCorrelation = { commandId: "direct-outcome-command", turnId: "direct-outcome-turn" };
483
+ await direct.noteAccepted("prompt", directCorrelation, "direct-outcome-ref");
484
+ await direct.noteTransition("prompt", directCorrelation, { type: "agent_end", outcome: stopped });
485
+ expect(direct.lookupResult("prompt", { clientRef: "direct-outcome-ref" })).toMatchObject({
486
+ status: "terminal_ok",
487
+ outcome: stopped,
488
+ });
489
+ });
490
+
449
491
  test("a late agent failure never overwrites the reason an already terminal record carries", async () => {
450
492
  const reconciliation = createInvocationReconciliation();
451
493
  const correlation = { commandId: "first-reason-command", turnId: "first-reason-turn" };
@@ -493,7 +535,7 @@ test("a failed acceptance persist rolls back the provisional record and reservat
493
535
  "accept persist failed",
494
536
  );
495
537
  reconciliation.release("prompt", "accept-failed-ref");
496
- expect(reconciliation.lookup("prompt", first)).toEqual({ status: "unknown" });
538
+ expect(reconciliation.lookup("prompt", first)).toEqual({ status: "unknown", receiptState: "unknown" });
497
539
 
498
540
  const retry = { commandId: "accept-retry-command", turnId: "accept-retry-turn" };
499
541
  reconciliation.admit("prompt", "accept-failed-ref");
@@ -548,6 +590,54 @@ test("a failed uncertainty persist restores the durable deadline terminal until
548
590
  ]);
549
591
  });
550
592
 
593
+ test("SDK-only markUncertain persists an active-schema record without quarantining siblings", async () => {
594
+ const root = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-uncertain-reload-"));
595
+ try {
596
+ const sessionFile = path.join(root, "session.jsonl");
597
+ await Bun.write(sessionFile, "");
598
+ const store = createReconciliationStore({ sessionFile, sessionId: "uncertain-reload" });
599
+ const reconciliation = createInvocationReconciliation({ store });
600
+ const correlation = { commandId: "uncertain-reload-command", turnId: "uncertain-reload-turn" };
601
+ const sibling = { commandId: "uncertain-sibling-command", turnId: "uncertain-sibling-turn" };
602
+ await reconciliation.noteAccepted("prompt", correlation, "uncertain-reload-ref");
603
+ await reconciliation.finalizeOutcome("prompt", correlation, {
604
+ kind: "failed",
605
+ code: "prompt_deadline_exceeded",
606
+ message: "deadline",
607
+ });
608
+ await reconciliation.noteAccepted("skill", sibling, "uncertain-sibling-ref");
609
+ await reconciliation.finalizeOutcome("skill", sibling, {
610
+ kind: "stopped",
611
+ code: "cancelled",
612
+ message: "cancelled",
613
+ });
614
+
615
+ await reconciliation.markUncertain("prompt", correlation, undefined, 5_000);
616
+ const active = store
617
+ .snapshot()
618
+ .find(record => record.kind === "prompt" && record.commandId === correlation.commandId) as
619
+ | Record<string, unknown>
620
+ | undefined;
621
+ expect(active).toMatchObject({ status: "accepted", deadlineRecoveryPending: true, deadlineMaxAt: 5_000 });
622
+ expect(active?.terminalAt).toBeUndefined();
623
+ expect(active?.receiptState).toBeUndefined();
624
+ expect(active?.outcome).toBeUndefined();
625
+ expect(active?.pendingOutcome).toBeUndefined();
626
+ expect(active?.pendingReceiptState).toBeUndefined();
627
+
628
+ const reopenedStore = createReconciliationStore({ sessionFile, sessionId: "uncertain-reload" });
629
+ const reopened = createInvocationReconciliation({ store: reopenedStore });
630
+ await reopened.hydrate();
631
+ expect(reopened.lookup("prompt", { clientRef: "uncertain-reload-ref" })).toMatchObject({ status: "accepted" });
632
+ expect(reopened.lookup("skill", { clientRef: "uncertain-sibling-ref" })).toMatchObject({
633
+ status: "terminal_ok",
634
+ });
635
+ expect((await readdir(root)).some(name => name.includes("corrupt"))).toBe(false);
636
+ } finally {
637
+ await rm(root, { recursive: true, force: true });
638
+ }
639
+ });
640
+
551
641
  test("durable reload keeps agent_end terminal across a paused successor transition", async () => {
552
642
  let records: unknown[] = [];
553
643
  let pauseWrites = 0;
@@ -911,8 +1001,13 @@ test("finalization without an explicit outcome follows the noteTransition eviden
911
1001
  await legacyArg5.finalizeOutcome("prompt", legacyCorrelation, undefined, undefined, {
912
1002
  content: { version: 1, type: "text", text: "legacy", byteLength: 6, truncated: false },
913
1003
  });
914
- expect(legacyArg5.lookup("prompt", { clientRef: "final-legacy-ref" })).toMatchObject({
1004
+ await legacyArg5.noteTransition("prompt", legacyCorrelation, {
1005
+ type: "agent_end",
1006
+ content: { version: 1, type: "text", text: "later", byteLength: 5, truncated: false },
1007
+ });
1008
+ expect(legacyArg5.lookupResult("prompt", { clientRef: "final-legacy-ref" })).toMatchObject({
915
1009
  status: "terminal_ok",
1010
+ content: { text: "legacy", byteLength: 6, truncated: false },
916
1011
  });
917
1012
 
918
1013
  const explicitFailure = createInvocationReconciliation({ store: makeLocalStore() });
@@ -924,9 +1019,58 @@ test("finalization without an explicit outcome follows the noteTransition eviden
924
1019
  message: "provider rejected",
925
1020
  });
926
1021
  expect(explicitFailure.lookup("prompt", { clientRef: "final-failure-ref" })).toMatchObject({
1022
+ status: "failed",
1023
+ receiptState: "unknown",
1024
+ outcome: { kind: "failed", code: "prompt_failed", provenance: "agent_failed" },
1025
+ error: { code: "prompt_failed" },
1026
+ });
1027
+ });
1028
+
1029
+ test("provider failures outrank later stopped outcomes in both SDK-only terminal paths", async () => {
1030
+ const providerError = Object.assign(new Error("provider rejected"), { code: "provider_rejected" });
1031
+ const transition = createInvocationReconciliation();
1032
+ const transitionCorrelation = { commandId: "precedence-transition-command", turnId: "precedence-transition-turn" };
1033
+ await transition.noteAccepted("prompt", transitionCorrelation, "precedence-transition-ref");
1034
+ await transition.noteTransition("prompt", transitionCorrelation, { type: "agent_failed", error: providerError });
1035
+ await transition.noteTransition("prompt", transitionCorrelation, {
1036
+ type: "agent_end",
1037
+ outcome: { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
1038
+ });
1039
+ expect(transition.lookupResult("prompt", { clientRef: "precedence-transition-ref" })).toMatchObject({
1040
+ status: "failed",
1041
+ error: { code: "provider_rejected" },
1042
+ });
1043
+
1044
+ const finalized = createInvocationReconciliation();
1045
+ const finalizedCorrelation = { commandId: "precedence-finalize-command", turnId: "precedence-finalize-turn" };
1046
+ await finalized.noteAccepted("prompt", finalizedCorrelation, "precedence-finalize-ref");
1047
+ await finalized.noteTransition("prompt", finalizedCorrelation, { type: "agent_failed", error: providerError });
1048
+ await finalized.claimPendingOutcome("prompt", finalizedCorrelation, {
1049
+ kind: "failed",
1050
+ code: "prompt_deadline_exceeded",
1051
+ message: "deadline",
1052
+ });
1053
+ await finalized.finalizeOutcome("prompt", finalizedCorrelation);
1054
+ expect(finalized.lookupResult("prompt", { clientRef: "precedence-finalize-ref" })).toMatchObject({
927
1055
  status: "failed",
928
1056
  error: { code: "provider_rejected" },
929
1057
  });
1058
+
1059
+ const generic = createInvocationReconciliation();
1060
+ const genericCorrelation = { commandId: "precedence-generic-command", turnId: "precedence-generic-turn" };
1061
+ await generic.noteAccepted("prompt", genericCorrelation, "precedence-generic-ref");
1062
+ await generic.noteTransition("prompt", genericCorrelation, {
1063
+ type: "agent_failed",
1064
+ error: Object.assign(new Error("generic failure"), { code: "agent_failed" }),
1065
+ });
1066
+ await generic.noteTransition("prompt", genericCorrelation, {
1067
+ type: "agent_end",
1068
+ outcome: { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
1069
+ });
1070
+ expect(generic.lookupResult("prompt", { clientRef: "precedence-generic-ref" })).toMatchObject({
1071
+ status: "failed",
1072
+ error: { code: "agent_failed", message: "Prompt submission failed." },
1073
+ });
930
1074
  });
931
1075
 
932
1076
  test("a reason attached after a prompt settled is never replaced by a later failure", async () => {
@@ -945,6 +1089,8 @@ test("a reason attached after a prompt settled is never replaced by a later fail
945
1089
  clientRef: "late-reason-ref",
946
1090
  acceptedAt: expect.any(Number),
947
1091
  terminalAt: expect.any(Number),
1092
+ receiptState: "present",
1093
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
948
1094
  });
949
1095
  // A failure delivered after the record settled enriches it with the sanitized reason and
950
1096
  // leaves the terminal claim itself (status, terminalAt, identity) untouched.
@@ -1052,6 +1198,7 @@ test("capacity eviction still releases the clientRef and reports honest unknown"
1052
1198
  }
1053
1199
  expect(reconciliation.lookup("prompt", { clientRef: "capacity-ref-1" })).toEqual({
1054
1200
  status: "unknown",
1201
+ receiptState: "unknown",
1055
1202
  });
1056
1203
  expect(() => reconciliation.admit("prompt", "capacity-ref-1")).not.toThrow();
1057
1204
  const retained = {
@@ -1102,6 +1249,110 @@ describe("SessionSdkSessionRuntime", () => {
1102
1249
  );
1103
1250
  await runtime.stop();
1104
1251
  });
1252
+ test("SDK-only host publishes one replayable bash_folded frame per fold and drops the subscription on shutdown", async () => {
1253
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-bash-folded-"));
1254
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1255
+ const api = {
1256
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1257
+ handlers.set(event, handler);
1258
+ },
1259
+ sendUserMessage: async () => {},
1260
+ } as unknown as ExtensionAPI;
1261
+ const transport = memoryTransport();
1262
+ createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1263
+ const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
1264
+ const ctx = {
1265
+ ...extensionContext(transport.sessionId, cwd),
1266
+ onJobFold: (listener: (event: { jobId: string; generation: string; reason: string }) => void) => {
1267
+ listeners.add(listener);
1268
+ return () => listeners.delete(listener);
1269
+ },
1270
+ } as unknown as ExtensionContext;
1271
+ try {
1272
+ await handlers.get("session_start")?.({}, ctx);
1273
+ expect(listeners.size).toBe(1);
1274
+ for (const listener of listeners) listener({ jobId: "bg_7", generation: "bg_7:1", reason: "steer" });
1275
+ transport.feed("client", { type: "event_replay", id: "fold-replay", sinceSeq: 0 });
1276
+ await Bun.sleep(0);
1277
+ const replay = transport.sent.find(
1278
+ frame => frame.type === "event_replay_result" && frame.id === "fold-replay",
1279
+ ) as { events?: Array<{ kind?: unknown; payload?: Record<string, unknown> }> } | undefined;
1280
+ const folded = replay?.events?.filter(event => event.kind === "bash_folded") ?? [];
1281
+ expect(folded).toEqual([
1282
+ expect.objectContaining({
1283
+ kind: "bash_folded",
1284
+ payload: {
1285
+ type: "bash_folded",
1286
+ sessionId: transport.sessionId,
1287
+ jobId: "bg_7",
1288
+ generation: "bg_7:1",
1289
+ reason: "steer",
1290
+ },
1291
+ }),
1292
+ ]);
1293
+ } finally {
1294
+ await handlers.get("session_shutdown")?.({}, ctx);
1295
+ }
1296
+ expect(listeners.size).toBe(0);
1297
+ });
1298
+
1299
+ test("SDK-only host logs a bash_folded publication failure and keeps the fold replayable", async () => {
1300
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-bash-folded-fail-"));
1301
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1302
+ const api = {
1303
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1304
+ handlers.set(event, handler);
1305
+ },
1306
+ sendUserMessage: async () => {},
1307
+ } as unknown as ExtensionAPI;
1308
+ const transport = memoryTransport();
1309
+ const broadcastFrame = transport.broadcastFrame?.bind(transport);
1310
+ let failNextBroadcast = false;
1311
+ transport.broadcastFrame = frame => {
1312
+ if (failNextBroadcast && frame.kind === "bash_folded") {
1313
+ failNextBroadcast = false;
1314
+ throw new Error("broadcast socket closed");
1315
+ }
1316
+ broadcastFrame?.(frame);
1317
+ };
1318
+ createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
1319
+ const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
1320
+ const ctx = {
1321
+ ...extensionContext(transport.sessionId, cwd),
1322
+ onJobFold: (listener: (event: { jobId: string; generation: string; reason: string }) => void) => {
1323
+ listeners.add(listener);
1324
+ return () => listeners.delete(listener);
1325
+ },
1326
+ } as unknown as ExtensionContext;
1327
+ const warnings: Array<{ message: string; data?: Record<string, unknown> }> = [];
1328
+ const warn = spyOn(logger, "warn").mockImplementation((message, data) => {
1329
+ warnings.push({ message: String(message), data: data as Record<string, unknown> | undefined });
1330
+ });
1331
+ try {
1332
+ await handlers.get("session_start")?.({}, ctx);
1333
+ failNextBroadcast = true;
1334
+ // The listener call must not throw back into the manager: publication failure is evidence, not a fold error.
1335
+ for (const listener of listeners) listener({ jobId: "bg_9", generation: "bg_9:1", reason: "chord" });
1336
+ expect(warnings.filter(entry => entry.message === "sdk: bash_folded publication failed")).toEqual([
1337
+ expect.objectContaining({
1338
+ data: expect.objectContaining({ jobId: "bg_9", reason: "chord", error: "broadcast socket closed" }),
1339
+ }),
1340
+ ]);
1341
+ // The frame was appended to the ring before the broadcast failed, so it still replays.
1342
+ transport.feed("client", { type: "event_replay", id: "fold-replay-fail", sinceSeq: 0 });
1343
+ await Bun.sleep(0);
1344
+ const replay = transport.sent.find(
1345
+ frame => frame.type === "event_replay_result" && frame.id === "fold-replay-fail",
1346
+ ) as { events?: Array<{ kind?: unknown; payload?: Record<string, unknown> }> } | undefined;
1347
+ expect(replay?.events?.filter(event => event.kind === "bash_folded")).toEqual([
1348
+ expect.objectContaining({ payload: expect.objectContaining({ jobId: "bg_9", reason: "chord" }) }),
1349
+ ]);
1350
+ } finally {
1351
+ warn.mockRestore();
1352
+ await handlers.get("session_shutdown")?.({}, ctx);
1353
+ }
1354
+ });
1355
+
1105
1356
  test("SDK-only host admits, replays, and conflicts terminal abort requests durably", async () => {
1106
1357
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-abort-"));
1107
1358
  const operatorCapability = "runtime-terminal-abort-capability";
@@ -3510,6 +3761,8 @@ async function invocationHarness(
3510
3761
  onLifecycleDrainTimeout?: () => void;
3511
3762
  onFailureDiagnosticKeyCount?: (count: number) => void;
3512
3763
  agentFailedWriteFailures?: number;
3764
+ branch?: unknown[];
3765
+ onInvocationCompletionReconciled?: (kind: string, correlation: { commandId: string; turnId: string }) => void;
3513
3766
  },
3514
3767
  ): Promise<InvocationHarness> {
3515
3768
  const waiters = new Map<string, (frame: ResponseFrame) => void>();
@@ -3552,6 +3805,9 @@ async function invocationHarness(
3552
3805
  ...(hooks.onFailureDiagnosticKeyCount
3553
3806
  ? { onFailureDiagnosticKeyCountForTests: hooks.onFailureDiagnosticKeyCount }
3554
3807
  : {}),
3808
+ ...(hooks.onInvocationCompletionReconciled
3809
+ ? { onInvocationCompletionReconciledForTests: hooks.onInvocationCompletionReconciled }
3810
+ : {}),
3555
3811
  ...(hooks.settings ? { settings: hooks.settings } : {}),
3556
3812
  createTransport: async ({ sessionId: id, stateRoot, token }) => ({
3557
3813
  sessionId: id,
@@ -3590,7 +3846,7 @@ async function invocationHarness(
3590
3846
  getSessionId: () => sessionId,
3591
3847
  getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3592
3848
  getSessionName: () => undefined,
3593
- getBranch: () => [],
3849
+ getBranch: () => hooks.branch ?? [],
3594
3850
  },
3595
3851
  };
3596
3852
  await handlers.get("session_start")?.({}, ctx);
@@ -4465,7 +4721,9 @@ describe("post-acceptance invocation terminalization", () => {
4465
4721
  expect(secondIds.turnId).not.toBe(firstIds.turnId);
4466
4722
  await harness.emit("agent_start");
4467
4723
  await successorStarted.promise;
4468
- await harness.emit("agent_end");
4724
+ await harness.emit("agent_end", {
4725
+ messages: [{ role: "assistant", content: "predecessor delayed" }],
4726
+ });
4469
4727
  expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
4470
4728
  result: { status: expect.stringMatching(/accepted|in_flight/) },
4471
4729
  });
@@ -4473,10 +4731,23 @@ describe("post-acceptance invocation terminalization", () => {
4473
4731
  status: "failed",
4474
4732
  error: { code: "aborted" },
4475
4733
  });
4734
+ const firstResult = await harness.query("turn.result", { kind: "prompt", ...firstIds });
4735
+ expect(firstResult).toMatchObject({
4736
+ result: { status: "failed", content: { text: "predecessor delayed" } },
4737
+ });
4476
4738
  await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
4477
4739
  expect(await settledStatus(harness, "turn.prompt_status", secondIds)).toMatchObject({
4478
4740
  status: "terminal_ok",
4479
4741
  });
4742
+ expect(await harness.query("turn.result", { kind: "prompt", ...secondIds })).toMatchObject({
4743
+ result: {
4744
+ status: "terminal_ok",
4745
+ content: { text: "completed", byteLength: 9, truncated: false },
4746
+ },
4747
+ });
4748
+ expect(await harness.query("turn.result", { kind: "prompt", ...firstIds })).toMatchObject({
4749
+ result: { content: { text: "predecessor delayed" } },
4750
+ });
4480
4751
  expect(prompts).toBe(2);
4481
4752
  await harness.stop();
4482
4753
  } finally {
@@ -4673,6 +4944,190 @@ describe("post-acceptance invocation terminalization", () => {
4673
4944
  await rm(cwd, { recursive: true, force: true });
4674
4945
  }
4675
4946
  });
4947
+ test("captures exact bounded content from actual prompt agent_end messages", async () => {
4948
+ const cases = [
4949
+ { name: "short", agent: "SDK_OK", expected: "SDK_OK", bytes: 6, truncated: false },
4950
+ { name: "max", agent: "x".repeat(16_384), expected: "x".repeat(16_384), bytes: 16_384, truncated: false },
4951
+ { name: "blank", agent: " ", expected: undefined, bytes: 0, truncated: false },
4952
+ {
4953
+ name: "overflow",
4954
+ agent: `${"😀".repeat(4_096)}tail`,
4955
+ expected: "😀".repeat(4_096),
4956
+ bytes: 16_384,
4957
+ truncated: true,
4958
+ },
4959
+ ] as const;
4960
+ for (const testCase of cases) {
4961
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-content-${testCase.name}-`));
4962
+ try {
4963
+ const harness = await invocationHarness(`terminal-content-${testCase.name}`, cwd, {
4964
+ sendUserMessage: async (_content, options) => {
4965
+ await options?.onPreflightAcceptCommit?.();
4966
+ await Promise.withResolvers<void>().promise;
4967
+ },
4968
+ });
4969
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
4970
+ await harness.emit("agent_start");
4971
+ await harness.emit("agent_end", { messages: [{ role: "assistant", content: testCase.agent }] });
4972
+ const result = await settledStatus(harness, "turn.result", {
4973
+ kind: "prompt",
4974
+ commandId: accepted.result?.commandId,
4975
+ turnId: accepted.result?.turnId,
4976
+ });
4977
+ if (testCase.expected === undefined) {
4978
+ expect(result).toMatchObject({ status: "failed" });
4979
+ expect((result as Record<string, unknown>).content).toBeUndefined();
4980
+ } else {
4981
+ expect(result).toMatchObject({
4982
+ status: "terminal_ok",
4983
+ content: {
4984
+ text: testCase.expected,
4985
+ byteLength: testCase.bytes,
4986
+ truncated: testCase.truncated,
4987
+ },
4988
+ });
4989
+ }
4990
+ await harness.stop();
4991
+ } finally {
4992
+ await rm(cwd, { recursive: true, force: true });
4993
+ }
4994
+ }
4995
+ });
4996
+ test("does not reuse a previous branch assistant for a new textless prompt", async () => {
4997
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminal-order-previous-assistant-"));
4998
+ try {
4999
+ const harness = await invocationHarness("terminal-order-previous-assistant", cwd, {
5000
+ branch: [{ type: "message", message: { role: "assistant", content: "PREVIOUS" } }],
5001
+ sendUserMessage: async (_content, options) => {
5002
+ await options?.onPreflightAcceptCommit?.();
5003
+ await Promise.withResolvers<void>().promise;
5004
+ },
5005
+ });
5006
+ const accepted = await harness.control("turn.prompt", { text: "textless" });
5007
+ const selector = {
5008
+ kind: "prompt" as const,
5009
+ commandId: accepted.result?.commandId,
5010
+ turnId: accepted.result?.turnId,
5011
+ };
5012
+ await harness.emit("agent_start");
5013
+ await harness.emit("agent_end", { messages: [] });
5014
+ const result = await settledStatus(harness, "turn.result", selector);
5015
+ expect((result as Record<string, unknown>).content).toBeUndefined();
5016
+ await harness.stop();
5017
+ } finally {
5018
+ await rm(cwd, { recursive: true, force: true });
5019
+ }
5020
+ });
5021
+ test("reconciles contract-valid skill completion and agent_end ordering", async () => {
5022
+ const cases = [
5023
+ {
5024
+ name: "completion-real-agent-real",
5025
+ order: "completion-first",
5026
+ completion: "completion",
5027
+ agent: "agent",
5028
+ expected: "completion",
5029
+ },
5030
+ {
5031
+ name: "completion-blank-agent-real",
5032
+ order: "completion-first",
5033
+ completion: " ",
5034
+ agent: "agent",
5035
+ expected: "agent",
5036
+ },
5037
+ {
5038
+ name: "completion-none-agent-real",
5039
+ order: "completion-first",
5040
+ completion: null,
5041
+ agent: "agent",
5042
+ expected: "agent",
5043
+ },
5044
+ {
5045
+ name: "agent-real-completion-real",
5046
+ order: "agent-first",
5047
+ completion: "completion",
5048
+ agent: "agent",
5049
+ expected: "agent",
5050
+ },
5051
+ {
5052
+ name: "agent-real-completion-blank",
5053
+ order: "agent-first",
5054
+ completion: " ",
5055
+ agent: "agent",
5056
+ expected: "agent",
5057
+ },
5058
+ {
5059
+ name: "agent-blank-completion-real",
5060
+ order: "agent-first",
5061
+ completion: "completion",
5062
+ agent: " ",
5063
+ expected: "completion",
5064
+ },
5065
+ {
5066
+ name: "agent-blank-completion-none",
5067
+ order: "agent-first",
5068
+ completion: null,
5069
+ agent: " ",
5070
+ expected: undefined,
5071
+ },
5072
+ {
5073
+ name: "agent-none-completion-real",
5074
+ order: "agent-first",
5075
+ completion: "completion",
5076
+ agent: null,
5077
+ expected: "completion",
5078
+ },
5079
+ {
5080
+ name: "agent-none-completion-blank",
5081
+ order: "agent-first",
5082
+ completion: " ",
5083
+ agent: null,
5084
+ expected: undefined,
5085
+ },
5086
+ ] as const;
5087
+ for (const testCase of cases) {
5088
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`));
5089
+ const completion = Promise.withResolvers<unknown>();
5090
+ const completionReconciled = Promise.withResolvers<void>();
5091
+ try {
5092
+ const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, {
5093
+ onInvocationCompletionReconciled: kind => {
5094
+ if (kind === "skill") completionReconciled.resolve();
5095
+ },
5096
+ invokeSkill: async (_name, _args, options) => {
5097
+ await options?.onPreflightAcceptCommit?.();
5098
+ return await completion.promise;
5099
+ },
5100
+ });
5101
+ const accepted = await harness.control("skill.invoke", { name: "ralplan" });
5102
+ const selector = {
5103
+ kind: "skill" as const,
5104
+ commandId: accepted.result?.commandId,
5105
+ turnId: accepted.result?.turnId,
5106
+ };
5107
+ await harness.emit("agent_start");
5108
+ const emitAgentEnd = () =>
5109
+ harness.emit("agent_end", {
5110
+ messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
5111
+ });
5112
+ if (testCase.order === "completion-first") {
5113
+ completion.resolve(testCase.completion);
5114
+ await completionReconciled.promise;
5115
+ await emitAgentEnd();
5116
+ } else {
5117
+ await emitAgentEnd();
5118
+ completion.resolve(testCase.completion);
5119
+ await completionReconciled.promise;
5120
+ }
5121
+ const result = await harness.query("turn.result", selector);
5122
+ const content = (result.result as { content?: { text?: string } } | undefined)?.content;
5123
+ expect(content?.text).toBe(testCase.expected);
5124
+ await harness.stop();
5125
+ } finally {
5126
+ completion.resolve(undefined);
5127
+ await rm(cwd, { recursive: true, force: true });
5128
+ }
5129
+ }
5130
+ }, 30_000);
4676
5131
  test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
4677
5132
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-followup-"));
4678
5133
  try {
@@ -4791,7 +5246,7 @@ describe("post-acceptance invocation terminalization", () => {
4791
5246
  const rejected = await harness.control("turn.prompt", { text: "hello", clientRef: "preflight-ref" });
4792
5247
  expect(rejected.ok).toBe(false);
4793
5248
  const status = await harness.query("turn.prompt_status", { clientRef: "preflight-ref" });
4794
- expect(status.result).toEqual({ status: "unknown" });
5249
+ expect(status.result as Record<string, unknown>).toEqual({ status: "unknown", receiptState: "unknown" });
4795
5250
  await harness.stop();
4796
5251
  } finally {
4797
5252
  await Bun.sleep(50);