@gajae-code/coding-agent 0.15.6 → 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 (270) hide show
  1. package/CHANGELOG.md +99 -0
  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/args.d.ts +2 -0
  5. package/dist/types/cli/root-flags.d.ts +14 -0
  6. package/dist/types/cli/update-cli.d.ts +14 -0
  7. package/dist/types/cli.d.ts +7 -0
  8. package/dist/types/commands/launch.d.ts +10 -0
  9. package/dist/types/commands/sdk.d.ts +2 -0
  10. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  11. package/dist/types/config/billing-path.d.ts +25 -0
  12. package/dist/types/config/config-schema-version.d.ts +13 -1
  13. package/dist/types/config/file-lock.d.ts +5 -0
  14. package/dist/types/config/model-registry.d.ts +22 -0
  15. package/dist/types/config/settings-schema.d.ts +27 -8
  16. package/dist/types/config/settings.d.ts +2 -0
  17. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  18. package/dist/types/crash/record-loader.d.ts +15 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  20. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  21. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  22. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  23. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  24. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  26. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  27. package/dist/types/main.d.ts +1 -0
  28. package/dist/types/master-mode/context.d.ts +21 -0
  29. package/dist/types/master-mode/first-request.d.ts +34 -0
  30. package/dist/types/master-mode/snapshot.d.ts +32 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  32. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  33. package/dist/types/modes/components/model-selector.d.ts +4 -0
  34. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  35. package/dist/types/modes/components/session-selector.d.ts +1 -1
  36. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  37. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  38. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  39. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  40. package/dist/types/modes/interactive-mode.d.ts +16 -2
  41. package/dist/types/modes/print-mode.d.ts +17 -0
  42. package/dist/types/modes/theme/theme.d.ts +2 -1
  43. package/dist/types/modes/types.d.ts +2 -0
  44. package/dist/types/modes/ui-language.d.ts +7 -1
  45. package/dist/types/sdk/broker/broker.d.ts +79 -2
  46. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  47. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  48. package/dist/types/sdk/broker/index.d.ts +3 -0
  49. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  50. package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
  51. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  52. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  53. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  54. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  55. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  56. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  57. package/dist/types/sdk/broker/state-version.d.ts +21 -1
  58. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  59. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  60. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  61. package/dist/types/sdk/bus/index.d.ts +9 -1
  62. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  63. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  64. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  65. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  67. package/dist/types/sdk/cli/index.d.ts +1 -0
  68. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  69. package/dist/types/sdk/cli/rows.d.ts +12 -5
  70. package/dist/types/sdk/cli/session-cli.d.ts +20 -3
  71. package/dist/types/sdk/client/client.d.ts +3 -2
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/events.d.ts +1 -0
  77. package/dist/types/sdk/host/host.d.ts +10 -0
  78. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  79. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  80. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +70 -17
  82. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  83. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  84. package/dist/types/sdk/lifecycle-session.d.ts +3 -1
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  86. package/dist/types/sdk/receipt-state.d.ts +2 -0
  87. package/dist/types/sdk/router/session-router.d.ts +6 -0
  88. package/dist/types/sdk/session.d.ts +16 -0
  89. package/dist/types/sdk/startup-capability.d.ts +2 -1
  90. package/dist/types/sdk/turn-result.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +52 -7
  92. package/dist/types/session/credential-store-errors.d.ts +1 -0
  93. package/dist/types/session/fold-coordinator.d.ts +29 -2
  94. package/dist/types/session/session-manager.d.ts +79 -2
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/tools/ask.d.ts +1 -0
  98. package/dist/types/tools/bash.d.ts +16 -0
  99. package/dist/types/tools/browser/launch.d.ts +10 -0
  100. package/dist/types/tools/browser/registry.d.ts +8 -0
  101. package/dist/types/tools/gh.d.ts +8 -0
  102. package/dist/types/tools/index.d.ts +10 -10
  103. package/dist/types/tools/job.d.ts +3 -1
  104. package/dist/types/utils/external-editor.d.ts +7 -0
  105. package/examples/extensions/README.md +1 -1
  106. package/examples/extensions/pirate.ts +7 -4
  107. package/package.json +7 -7
  108. package/scripts/generate-sdk-operation-inventory.ts +13 -1
  109. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  110. package/src/async/job-manager.ts +107 -9
  111. package/src/browser-backend.ts +29 -0
  112. package/src/cli/accounts-cli.ts +9 -5
  113. package/src/cli/args.ts +17 -0
  114. package/src/cli/fast-help.ts +1 -0
  115. package/src/cli/plugin-cli.ts +29 -0
  116. package/src/cli/root-flags.ts +5 -0
  117. package/src/cli/update-cli.ts +140 -23
  118. package/src/commands/launch.ts +86 -18
  119. package/src/commands/sdk.ts +135 -28
  120. package/src/config/autorouting-tier-map.ts +12 -0
  121. package/src/config/billing-path.ts +42 -0
  122. package/src/config/config-schema-version.ts +18 -1
  123. package/src/config/file-lock.ts +158 -83
  124. package/src/config/model-preset-registry.ts +178 -23
  125. package/src/config/model-profiles.ts +5 -5
  126. package/src/config/model-registry.ts +291 -42
  127. package/src/config/model-resolver.ts +5 -0
  128. package/src/config/settings-schema.ts +27 -8
  129. package/src/config/settings.ts +51 -15
  130. package/src/coordinator-mcp/projection-scan.ts +27 -6
  131. package/src/coordinator-mcp/question-state.ts +9 -11
  132. package/src/coordinator-mcp/server.ts +115 -21
  133. package/src/crash/index-store.ts +2 -0
  134. package/src/crash/record-loader.ts +10 -0
  135. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  136. package/src/extensibility/extensions/runner.ts +27 -0
  137. package/src/extensibility/extensions/types.ts +8 -3
  138. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  139. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  140. package/src/extensibility/skills.ts +22 -12
  141. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  142. package/src/gjc-runtime/launch-worktree.ts +285 -4
  143. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  144. package/src/gjc-runtime/session-state-lock.ts +213 -14
  145. package/src/gjc-runtime/session-state-sidecar.ts +938 -39
  146. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  147. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
  148. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  149. package/src/hooks/skill-state.ts +6 -0
  150. package/src/internal-urls/docs-index.generated.ts +15 -15
  151. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  152. package/src/main.ts +61 -3
  153. package/src/master-mode/context.ts +50 -0
  154. package/src/master-mode/first-request.ts +65 -0
  155. package/src/master-mode/snapshot.ts +205 -0
  156. package/src/modes/acp/acp-agent.ts +610 -161
  157. package/src/modes/components/footer.ts +4 -2
  158. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  159. package/src/modes/components/model-selector.ts +76 -8
  160. package/src/modes/components/oauth-selector.ts +6 -1
  161. package/src/modes/components/session-selector.ts +23 -15
  162. package/src/modes/components/sessions-dashboard.ts +8 -5
  163. package/src/modes/components/settings-selector.ts +1 -0
  164. package/src/modes/components/status-line/model-name.ts +53 -0
  165. package/src/modes/components/status-line/priority-row.ts +107 -0
  166. package/src/modes/components/status-line/segments.ts +23 -9
  167. package/src/modes/components/tool-status-header.ts +151 -15
  168. package/src/modes/controllers/command-controller.ts +1 -1
  169. package/src/modes/controllers/event-controller.ts +30 -4
  170. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  171. package/src/modes/controllers/input-controller.ts +30 -2
  172. package/src/modes/controllers/selector-controller.ts +200 -98
  173. package/src/modes/interactive-mode.ts +36 -12
  174. package/src/modes/print-mode.ts +64 -0
  175. package/src/modes/prompt-action-autocomplete.ts +30 -4
  176. package/src/modes/runtime-init.ts +12 -9
  177. package/src/modes/theme/theme.ts +5 -0
  178. package/src/modes/types.ts +2 -0
  179. package/src/modes/ui-language.ts +49 -2
  180. package/src/modes/utils/ui-helpers.ts +22 -3
  181. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  182. package/src/prompts/system/master-mode.md +28 -0
  183. package/src/prompts/tools/ask.md +1 -1
  184. package/src/prompts/tools/bash.md +1 -0
  185. package/src/prompts/tools/job.md +1 -1
  186. package/src/sdk/acp/adapter.ts +5 -0
  187. package/src/sdk/broker/broker.ts +1646 -27
  188. package/src/sdk/broker/endpoint-authority.ts +86 -0
  189. package/src/sdk/broker/ensure.ts +81 -3
  190. package/src/sdk/broker/index.ts +3 -0
  191. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  192. package/src/sdk/broker/lifecycle.ts +440 -75
  193. package/src/sdk/broker/master-capability.ts +232 -0
  194. package/src/sdk/broker/session-index.ts +121 -17
  195. package/src/sdk/broker/session-scope.ts +272 -0
  196. package/src/sdk/broker/spawn-authority.ts +1031 -0
  197. package/src/sdk/broker/spawn-substrate.ts +591 -0
  198. package/src/sdk/broker/startup-budget.ts +144 -2
  199. package/src/sdk/broker/state-version.ts +43 -14
  200. package/src/sdk/broker/transport.ts +1 -0
  201. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  202. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  203. package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
  204. package/src/sdk/bus/discord-daemon.ts +50 -10
  205. package/src/sdk/bus/index.ts +84 -7
  206. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  207. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  208. package/src/sdk/bus/notification-service.ts +22 -5
  209. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  210. package/src/sdk/bus/reconciliation-store.ts +55 -31
  211. package/src/sdk/bus/slack-conversation.ts +1 -1
  212. package/src/sdk/bus/slack-daemon.ts +72 -38
  213. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  214. package/src/sdk/cli/index.ts +1 -0
  215. package/src/sdk/cli/master-cli.ts +259 -0
  216. package/src/sdk/cli/rows.ts +41 -15
  217. package/src/sdk/cli/session-cli.ts +297 -26
  218. package/src/sdk/client/client.ts +14 -8
  219. package/src/sdk/host/control/dispatch.ts +101 -40
  220. package/src/sdk/host/control/index.ts +2 -0
  221. package/src/sdk/host/control/operations.ts +2 -0
  222. package/src/sdk/host/control/runtime-gate.ts +203 -11
  223. package/src/sdk/host/events.ts +1 -0
  224. package/src/sdk/host/host.ts +29 -2
  225. package/src/sdk/host/query/cursor.ts +52 -7
  226. package/src/sdk/host/query/handlers.ts +33 -12
  227. package/src/sdk/host/query/revision-store.ts +12 -0
  228. package/src/sdk/host/session-runtime.test.ts +673 -14
  229. package/src/sdk/host/session-runtime.ts +674 -103
  230. package/src/sdk/host/surface-policy.ts +3 -3
  231. package/src/sdk/lifecycle/broker-client.ts +28 -0
  232. package/src/sdk/lifecycle/service.ts +279 -20
  233. package/src/sdk/lifecycle-session.ts +17 -4
  234. package/src/sdk/prompt-deadline-manager.ts +2 -1
  235. package/src/sdk/protocol/operation-inventory.generated.json +105 -3
  236. package/src/sdk/protocol/operation-registry.ts +14 -4
  237. package/src/sdk/receipt-state.ts +10 -0
  238. package/src/sdk/router/session-router.ts +219 -118
  239. package/src/sdk/session.ts +416 -195
  240. package/src/sdk/startup-capability.ts +1 -0
  241. package/src/sdk/turn-result.ts +22 -0
  242. package/src/session/agent-session.ts +1628 -318
  243. package/src/session/blob-store.ts +15 -5
  244. package/src/session/credential-store-errors.ts +2 -0
  245. package/src/session/fold-coordinator.ts +40 -3
  246. package/src/session/internal/managed-session-storage.ts +153 -78
  247. package/src/session/session-manager.ts +850 -97
  248. package/src/session/yield-queue.ts +24 -10
  249. package/src/setup/frictionless-onboarding.ts +2 -0
  250. package/src/slash-commands/builtin-registry.ts +41 -0
  251. package/src/task/executor.ts +9 -4
  252. package/src/telemetry/events.ts +1138 -12
  253. package/src/tools/ask-contract.test.ts +64 -0
  254. package/src/tools/ask-contract.ts +36 -1
  255. package/src/tools/ask.ts +10 -8
  256. package/src/tools/bash-interactive.ts +11 -2
  257. package/src/tools/bash.ts +412 -75
  258. package/src/tools/browser/launch.ts +37 -0
  259. package/src/tools/browser/registry.ts +99 -9
  260. package/src/tools/cron.ts +6 -4
  261. package/src/tools/descriptors.test.ts +15 -0
  262. package/src/tools/descriptors.ts +5 -1
  263. package/src/tools/gh.ts +104 -48
  264. package/src/tools/github-cache.ts +2 -2
  265. package/src/tools/index.ts +10 -6
  266. package/src/tools/job.ts +4 -1
  267. package/src/tools/monitor.ts +12 -9
  268. package/src/tools/resolve.ts +30 -10
  269. package/src/tools/tool-catalog.generated.ts +2 -2
  270. package/src/utils/external-editor.ts +72 -1
@@ -1,11 +1,12 @@
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
- import type { ExtensionAPI, ExtensionContext } from "../../extensibility/extensions";
9
+ import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
9
10
  import {
10
11
  registerOwnedRegistration,
11
12
  resetTerminalAbortRegistriesForTests,
@@ -16,7 +17,8 @@ import { Broker } from "../broker/broker";
16
17
  import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation";
17
18
  import { createPromptReconciliation } from "../bus/prompt-reconciliation";
18
19
  import { createReconciliationStore } from "../bus/reconciliation-store";
19
- import { CursorRegistry, QueryHandlers, RevisionStore } from "./query";
20
+ import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate";
21
+ import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query";
20
22
  import {
21
23
  createInvocationReconciliation,
22
24
  createSdkSessionRuntimeExtension,
@@ -90,6 +92,7 @@ function extensionContext(
90
92
  goalState?: unknown;
91
93
  branch?: unknown[];
92
94
  liveState?: { isStreaming: boolean; steeringQueueDepth: number; followupQueueDepth: number };
95
+ transcript?: ExtensionTranscriptEntry[];
93
96
  } = {},
94
97
  ): ExtensionContext {
95
98
  return {
@@ -102,6 +105,7 @@ function extensionContext(
102
105
  getSessionName: () => undefined,
103
106
  getBranch: () => options.branch ?? [],
104
107
  },
108
+ getTranscript: () => options.transcript ?? [],
105
109
  getGoalState: () => options.goalState,
106
110
  } as unknown as ExtensionContext;
107
111
  }
@@ -148,6 +152,97 @@ async function queryGoalState(
148
152
  return response.page?.items[0];
149
153
  }
150
154
 
155
+ async function queryLastAssistant(ctx: ExtensionContext, sessionId: string): Promise<QueryResponse> {
156
+ const surface = createSdkSurfaceFactory({
157
+ ctx,
158
+ id: sessionId,
159
+ api: {} as ExtensionAPI,
160
+ }).query;
161
+ const store = new RevisionStore(sessionId);
162
+ const response = await new QueryHandlers(
163
+ surface,
164
+ sessionId,
165
+ store,
166
+ new CursorRegistry("last-assistant-test-token", store),
167
+ ).dispatch({
168
+ query: "session.last_assistant",
169
+ connectionId: "last-assistant-test-connection",
170
+ });
171
+ return response;
172
+ }
173
+
174
+ test("session.last_assistant returns the latest projected readable text past non-text assistant tails", async () => {
175
+ const sessionId = "last-assistant-readable-tail";
176
+ const transcript: ExtensionTranscriptEntry[] = [
177
+ {
178
+ id: "visible-readable",
179
+ role: "assistant",
180
+ textSummary: "first line second line",
181
+ ts: new Date(1).toISOString(),
182
+ content: [
183
+ { type: "text", text: "first line" },
184
+ { type: "thinking", thinking: "private reasoning" },
185
+ { type: "text", text: "second line" },
186
+ ],
187
+ },
188
+ ...Array.from(
189
+ { length: 2_000 },
190
+ (_, index): ExtensionTranscriptEntry => ({
191
+ id: `tool-tail-${index}`,
192
+ role: "assistant",
193
+ textSummary: "",
194
+ ts: new Date(index + 2).toISOString(),
195
+ body: index % 3 === 0 ? "" : index % 3 === 1 ? " \n\t " : undefined,
196
+ content:
197
+ index % 3 === 2
198
+ ? [
199
+ { type: "thinking", thinking: "not readable" },
200
+ { type: "toolCall", id: `call-${index}`, name: "read", arguments: {} },
201
+ ]
202
+ : undefined,
203
+ }),
204
+ ),
205
+ ];
206
+ const ctx = extensionContext(sessionId, "/tmp", {
207
+ transcript,
208
+ branch: [
209
+ {
210
+ type: "message",
211
+ message: { role: "assistant", content: [{ type: "text", text: "raw private tail" }] },
212
+ },
213
+ ],
214
+ });
215
+
216
+ expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({
217
+ ok: true,
218
+ page: { items: ["first line\nsecond line"] },
219
+ });
220
+ });
221
+
222
+ test("session.last_assistant returns resource_gone when the projected transcript has no readable assistant text", async () => {
223
+ const sessionId = "last-assistant-empty";
224
+ const ctx = extensionContext(sessionId, "/tmp", {
225
+ transcript: [
226
+ {
227
+ id: "user",
228
+ role: "user",
229
+ textSummary: "hello",
230
+ ts: new Date(1).toISOString(),
231
+ body: "hello",
232
+ },
233
+ {
234
+ id: "thinking-only",
235
+ role: "assistant",
236
+ textSummary: "",
237
+ ts: new Date(2).toISOString(),
238
+ content: [{ type: "thinking", thinking: "private reasoning" }],
239
+ },
240
+ ],
241
+ });
242
+
243
+ expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok: false, error: { code: "resource_gone" } });
244
+ });
245
+
151
246
  test("native prompt reconciliation fails closed for an explicitly empty assistant result", () => {
152
247
  const reconciliation = createPromptReconciliation();
153
248
  const correlation = { commandId: "native-empty-command", turnId: "native-empty-turn" };
@@ -160,7 +255,10 @@ test("native prompt reconciliation fails closed for an explicitly empty assistan
160
255
  receiptState: "missing",
161
256
  });
162
257
  reconciliation.noteTransition(correlation, { type: "agent_end", finalText: "late text" });
163
- 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
+ });
164
262
  });
165
263
 
166
264
  test("broker reconciliation fails closed for an empty prompt and persists the first terminal result", async () => {
@@ -189,7 +287,9 @@ test("broker reconciliation fails closed for an empty prompt and persists the fi
189
287
  expect(reloaded.lookupResult("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({
190
288
  status: "failed",
191
289
  error: { code: "prompt_failed" },
290
+ content: { text: "late text", byteLength: 9, truncated: false },
192
291
  });
292
+ expect(reloaded.lookup("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({ receiptState: "present" });
193
293
  const noOpCorrelation = { commandId: "broker-noop-command", turnId: "broker-noop-turn" };
194
294
  await reconciliation.noteAccepted("prompt", noOpCorrelation, "broker-noop-ref");
195
295
  await reconciliation.finalizeOutcome(
@@ -352,6 +452,42 @@ test("SDK-only finalizeOutcome keeps legacy recordError positional compatibility
352
452
  });
353
453
  });
354
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
+
355
491
  test("a late agent failure never overwrites the reason an already terminal record carries", async () => {
356
492
  const reconciliation = createInvocationReconciliation();
357
493
  const correlation = { commandId: "first-reason-command", turnId: "first-reason-turn" };
@@ -399,7 +535,7 @@ test("a failed acceptance persist rolls back the provisional record and reservat
399
535
  "accept persist failed",
400
536
  );
401
537
  reconciliation.release("prompt", "accept-failed-ref");
402
- expect(reconciliation.lookup("prompt", first)).toEqual({ status: "unknown" });
538
+ expect(reconciliation.lookup("prompt", first)).toEqual({ status: "unknown", receiptState: "unknown" });
403
539
 
404
540
  const retry = { commandId: "accept-retry-command", turnId: "accept-retry-turn" };
405
541
  reconciliation.admit("prompt", "accept-failed-ref");
@@ -454,6 +590,54 @@ test("a failed uncertainty persist restores the durable deadline terminal until
454
590
  ]);
455
591
  });
456
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
+
457
641
  test("durable reload keeps agent_end terminal across a paused successor transition", async () => {
458
642
  let records: unknown[] = [];
459
643
  let pauseWrites = 0;
@@ -817,8 +1001,13 @@ test("finalization without an explicit outcome follows the noteTransition eviden
817
1001
  await legacyArg5.finalizeOutcome("prompt", legacyCorrelation, undefined, undefined, {
818
1002
  content: { version: 1, type: "text", text: "legacy", byteLength: 6, truncated: false },
819
1003
  });
820
- 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({
821
1009
  status: "terminal_ok",
1010
+ content: { text: "legacy", byteLength: 6, truncated: false },
822
1011
  });
823
1012
 
824
1013
  const explicitFailure = createInvocationReconciliation({ store: makeLocalStore() });
@@ -830,9 +1019,58 @@ test("finalization without an explicit outcome follows the noteTransition eviden
830
1019
  message: "provider rejected",
831
1020
  });
832
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({
833
1055
  status: "failed",
834
1056
  error: { code: "provider_rejected" },
835
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
+ });
836
1074
  });
837
1075
 
838
1076
  test("a reason attached after a prompt settled is never replaced by a later failure", async () => {
@@ -851,6 +1089,8 @@ test("a reason attached after a prompt settled is never replaced by a later fail
851
1089
  clientRef: "late-reason-ref",
852
1090
  acceptedAt: expect.any(Number),
853
1091
  terminalAt: expect.any(Number),
1092
+ receiptState: "present",
1093
+ content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
854
1094
  });
855
1095
  // A failure delivered after the record settled enriches it with the sanitized reason and
856
1096
  // leaves the terminal claim itself (status, terminalAt, identity) untouched.
@@ -958,6 +1198,7 @@ test("capacity eviction still releases the clientRef and reports honest unknown"
958
1198
  }
959
1199
  expect(reconciliation.lookup("prompt", { clientRef: "capacity-ref-1" })).toEqual({
960
1200
  status: "unknown",
1201
+ receiptState: "unknown",
961
1202
  });
962
1203
  expect(() => reconciliation.admit("prompt", "capacity-ref-1")).not.toThrow();
963
1204
  const retained = {
@@ -1008,8 +1249,114 @@ describe("SessionSdkSessionRuntime", () => {
1008
1249
  );
1009
1250
  await runtime.stop();
1010
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
+
1011
1356
  test("SDK-only host admits, replays, and conflicts terminal abort requests durably", async () => {
1012
1357
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-abort-"));
1358
+ const operatorCapability = "runtime-terminal-abort-capability";
1359
+ setBrokerRuntimeAbortCapabilityForTest(operatorCapability);
1013
1360
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1014
1361
  const api = {
1015
1362
  on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
@@ -1045,7 +1392,13 @@ describe("SessionSdkSessionRuntime", () => {
1045
1392
  },
1046
1393
  abortPromptAndWaitWithTerminal: async (handle, options) => {
1047
1394
  seamCalls.push({ handle, scope: options.terminal?.scope ?? "none" });
1048
- return { status: "settled", terminalScope: {} };
1395
+ return {
1396
+ status: "settled",
1397
+ terminalScope: {
1398
+ abortedAttemptEpoch: activeEpoch,
1399
+ lineageIdHash: "runtime-test-lineage",
1400
+ },
1401
+ };
1049
1402
  },
1050
1403
  },
1051
1404
  });
@@ -1140,7 +1493,43 @@ describe("SessionSdkSessionRuntime", () => {
1140
1493
  }),
1141
1494
  ]),
1142
1495
  );
1496
+
1497
+ // A separately connected, explicitly confirmed local operator may stop
1498
+ // the active turn and its exact owned work without weakening ordinary
1499
+ // connection ownership checks.
1500
+ transport.feed("local-operator", {
1501
+ type: "control_request",
1502
+ id: "terminal-operator-abort",
1503
+ operation: "turn.abort",
1504
+ input: {
1505
+ mode: "terminal",
1506
+ scope: "owned",
1507
+ operator: true,
1508
+ [BROKER_RUNTIME_ABORT_CAPABILITY_FIELD]: operatorCapability,
1509
+ },
1510
+ confirm: true,
1511
+ idempotencyKey: "terminal-operator-key",
1512
+ } as SdkFrame);
1513
+ await waitForFrame("terminal-operator-abort");
1514
+ expect(seamCalls).toEqual([
1515
+ { handle: "exact-run-handle", scope: "turn" },
1516
+ { handle: "later-run-handle", scope: "owned" },
1517
+ ]);
1518
+ expect(transport.sent.find(frame => frame.id === "terminal-operator-abort")).toMatchObject({
1519
+ ok: true,
1520
+ result: expect.objectContaining({ turn: "stopped", ownedWork: "stopped" }),
1521
+ });
1522
+ const operatorKeyHash = createHash("sha256").update("terminal-operator-key").digest("hex");
1523
+ const deliveryDeadline = Date.now() + 15_000;
1524
+ while (
1525
+ reconciliationStore.snapshotTerminalScopes().find(record => record.idempotencyKeyHash === operatorKeyHash)
1526
+ ?.responseState !== "sent"
1527
+ ) {
1528
+ if (Date.now() > deliveryDeadline) throw new Error("Timed out waiting for operator delivery receipt");
1529
+ await Bun.sleep(20);
1530
+ }
1143
1531
  } finally {
1532
+ setBrokerRuntimeAbortCapabilityForTest(undefined);
1144
1533
  await handlers.get("session_shutdown")?.({}, ctx);
1145
1534
  await rm(cwd, { recursive: true, force: true });
1146
1535
  }
@@ -1351,7 +1740,71 @@ describe("SessionSdkSessionRuntime", () => {
1351
1740
  }
1352
1741
  });
1353
1742
 
1354
- test("SDK-only host re-reads the active prompt AND its owner after an owner-mismatch reservation wins the race", async () => {
1743
+ test("SDK-only operator abort never adopts a turn that starts after idle admission", async () => {
1744
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-operator-idle-race-"));
1745
+ const capability = "operator-idle-race-capability";
1746
+ setBrokerRuntimeAbortCapabilityForTest(capability);
1747
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1748
+ const api = {
1749
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
1750
+ handlers.set(event, handler);
1751
+ },
1752
+ } as unknown as ExtensionAPI;
1753
+ const transport = memoryTransport();
1754
+ const reconciliationStore = createReconciliationStore({
1755
+ sessionFile: path.join(cwd, "session.json"),
1756
+ sessionId: transport.sessionId,
1757
+ });
1758
+ const seamCalls: string[] = [];
1759
+ let promptReads = 0;
1760
+ createSdkSessionRuntimeExtension(api, {
1761
+ agentDir: cwd,
1762
+ createTransport: async () => transport,
1763
+ terminalAbortSeams: {
1764
+ getReconciliationStore: () => reconciliationStore,
1765
+ getTerminalTurnEpoch: () => (promptReads++ === 0 ? undefined : 9),
1766
+ getActivePromptHandle: () => (promptReads === 0 ? undefined : "successor-handle"),
1767
+ getActivePromptOwnerConnectionId: () => "successor-owner",
1768
+ cancelPendingPreflightForTerminalAbort: () => {},
1769
+ abortPromptAndWaitWithTerminal: async handle => {
1770
+ seamCalls.push(handle);
1771
+ return { status: "settled", terminalScope: {} };
1772
+ },
1773
+ },
1774
+ });
1775
+ const ctx = extensionContext(transport.sessionId, cwd);
1776
+ try {
1777
+ await handlers.get("session_start")?.({}, ctx);
1778
+ transport.feed("local-operator", {
1779
+ type: "control_request",
1780
+ id: "operator-idle-race",
1781
+ operation: "turn.abort",
1782
+ input: {
1783
+ mode: "terminal",
1784
+ operator: true,
1785
+ [BROKER_RUNTIME_ABORT_CAPABILITY_FIELD]: capability,
1786
+ },
1787
+ confirm: true,
1788
+ idempotencyKey: "operator-idle-race-key",
1789
+ } as SdkFrame);
1790
+ const deadline = Date.now() + 15_000;
1791
+ while (!transport.sent.some(frame => frame.id === "operator-idle-race")) {
1792
+ if (Date.now() > deadline) throw new Error("Timed out waiting for operator idle-race response");
1793
+ await Bun.sleep(20);
1794
+ }
1795
+ expect(transport.sent.find(frame => frame.id === "operator-idle-race")).toMatchObject({
1796
+ ok: true,
1797
+ result: expect.objectContaining({ turn: "no_active_turn", terminal: "terminal_no_effect" }),
1798
+ });
1799
+ expect(seamCalls).toHaveLength(0);
1800
+ } finally {
1801
+ setBrokerRuntimeAbortCapabilityForTest(undefined);
1802
+ await handlers.get("session_shutdown")?.({}, ctx);
1803
+ await rm(cwd, { recursive: true, force: true });
1804
+ }
1805
+ });
1806
+
1807
+ test("SDK-only host re-reads the active prompt AND its owner after an owner-mismatch reservation race", async () => {
1355
1808
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-owner-race-"));
1356
1809
  const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
1357
1810
  const api = {
@@ -3172,6 +3625,7 @@ describe("SessionSdkSessionRuntime", () => {
3172
3625
  },
3173
3626
  } as any;
3174
3627
  const sessionId = "broker-recovery";
3628
+ const endpointUrl = "ws://127.0.0.1:1";
3175
3629
  createSdkSessionRuntimeExtension(api, {
3176
3630
  agentDir,
3177
3631
  createTransport: async ({ stateRoot, token }) => ({
@@ -3183,8 +3637,8 @@ describe("SessionSdkSessionRuntime", () => {
3183
3637
  start: async () => {
3184
3638
  const endpoint = path.join(stateRoot, "sdk", `${sessionId}.json`);
3185
3639
  await mkdir(path.dirname(endpoint), { recursive: true });
3186
- await writeFile(endpoint, JSON.stringify({ sessionId, token, pid: process.pid }));
3187
- return { url: "ws://127.0.0.1:1" };
3640
+ await writeFile(endpoint, JSON.stringify({ sessionId, token, pid: process.pid, url: endpointUrl }));
3641
+ return { url: endpointUrl };
3188
3642
  },
3189
3643
  stop: async () => {},
3190
3644
  }),
@@ -3241,6 +3695,7 @@ describe("SessionSdkSessionRuntime", () => {
3241
3695
  createSdkSessionRuntimeExtension(api, {
3242
3696
  agentDir,
3243
3697
  brokerRegistrationRequired: true,
3698
+ lifecycleRequestId: "broker-required-marker",
3244
3699
  createTransport: async ({ sessionId, stateRoot, token }) => ({
3245
3700
  sessionId,
3246
3701
  stateRoot,
@@ -3306,6 +3761,8 @@ async function invocationHarness(
3306
3761
  onLifecycleDrainTimeout?: () => void;
3307
3762
  onFailureDiagnosticKeyCount?: (count: number) => void;
3308
3763
  agentFailedWriteFailures?: number;
3764
+ branch?: unknown[];
3765
+ onInvocationCompletionReconciled?: (kind: string, correlation: { commandId: string; turnId: string }) => void;
3309
3766
  },
3310
3767
  ): Promise<InvocationHarness> {
3311
3768
  const waiters = new Map<string, (frame: ResponseFrame) => void>();
@@ -3348,6 +3805,9 @@ async function invocationHarness(
3348
3805
  ...(hooks.onFailureDiagnosticKeyCount
3349
3806
  ? { onFailureDiagnosticKeyCountForTests: hooks.onFailureDiagnosticKeyCount }
3350
3807
  : {}),
3808
+ ...(hooks.onInvocationCompletionReconciled
3809
+ ? { onInvocationCompletionReconciledForTests: hooks.onInvocationCompletionReconciled }
3810
+ : {}),
3351
3811
  ...(hooks.settings ? { settings: hooks.settings } : {}),
3352
3812
  createTransport: async ({ sessionId: id, stateRoot, token }) => ({
3353
3813
  sessionId: id,
@@ -3386,7 +3846,7 @@ async function invocationHarness(
3386
3846
  getSessionId: () => sessionId,
3387
3847
  getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3388
3848
  getSessionName: () => undefined,
3389
- getBranch: () => [],
3849
+ getBranch: () => hooks.branch ?? [],
3390
3850
  },
3391
3851
  };
3392
3852
  await handlers.get("session_start")?.({}, ctx);
@@ -4261,7 +4721,9 @@ describe("post-acceptance invocation terminalization", () => {
4261
4721
  expect(secondIds.turnId).not.toBe(firstIds.turnId);
4262
4722
  await harness.emit("agent_start");
4263
4723
  await successorStarted.promise;
4264
- await harness.emit("agent_end");
4724
+ await harness.emit("agent_end", {
4725
+ messages: [{ role: "assistant", content: "predecessor delayed" }],
4726
+ });
4265
4727
  expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
4266
4728
  result: { status: expect.stringMatching(/accepted|in_flight/) },
4267
4729
  });
@@ -4269,10 +4731,23 @@ describe("post-acceptance invocation terminalization", () => {
4269
4731
  status: "failed",
4270
4732
  error: { code: "aborted" },
4271
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
+ });
4272
4738
  await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
4273
4739
  expect(await settledStatus(harness, "turn.prompt_status", secondIds)).toMatchObject({
4274
4740
  status: "terminal_ok",
4275
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
+ });
4276
4751
  expect(prompts).toBe(2);
4277
4752
  await harness.stop();
4278
4753
  } finally {
@@ -4469,6 +4944,190 @@ describe("post-acceptance invocation terminalization", () => {
4469
4944
  await rm(cwd, { recursive: true, force: true });
4470
4945
  }
4471
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);
4472
5131
  test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
4473
5132
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-followup-"));
4474
5133
  try {
@@ -4587,7 +5246,7 @@ describe("post-acceptance invocation terminalization", () => {
4587
5246
  const rejected = await harness.control("turn.prompt", { text: "hello", clientRef: "preflight-ref" });
4588
5247
  expect(rejected.ok).toBe(false);
4589
5248
  const status = await harness.query("turn.prompt_status", { clientRef: "preflight-ref" });
4590
- expect(status.result).toEqual({ status: "unknown" });
5249
+ expect(status.result as Record<string, unknown>).toEqual({ status: "unknown", receiptState: "unknown" });
4591
5250
  await harness.stop();
4592
5251
  } finally {
4593
5252
  await Bun.sleep(50);