@gajae-code/coding-agent 0.15.4 → 0.15.5

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 (42) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/dist/types/cli/update-cli.d.ts +25 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  4. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  5. package/dist/types/extensibility/shared-events.d.ts +2 -0
  6. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  7. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  8. package/dist/types/sdk/host/host.d.ts +2 -0
  9. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  10. package/dist/types/session/agent-session.d.ts +4 -2
  11. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  12. package/dist/types/tools/path-utils.d.ts +5 -6
  13. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  14. package/package.json +7 -7
  15. package/src/cli/update-cli.ts +102 -1
  16. package/src/commands/sdk.ts +5 -0
  17. package/src/config/autorouting-tier-map.ts +1 -0
  18. package/src/config/model-profiles.ts +5 -5
  19. package/src/config/model-registry.ts +10 -4
  20. package/src/coordinator-mcp/policy.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -0
  22. package/src/extensibility/shared-events.ts +2 -0
  23. package/src/gjc-runtime/launch-worktree.ts +44 -7
  24. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  25. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  26. package/src/internal-urls/docs-index.generated.ts +7 -7
  27. package/src/modes/prompt-action-autocomplete.ts +26 -47
  28. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  29. package/src/sdk/bus/kind-aware-reconciliation.ts +10 -2
  30. package/src/sdk/bus/prompt-reconciliation.ts +5 -2
  31. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  32. package/src/sdk/cli/session-cli.ts +149 -4
  33. package/src/sdk/host/host.ts +7 -0
  34. package/src/sdk/host/session-runtime.test.ts +821 -22
  35. package/src/sdk/host/session-runtime.ts +669 -67
  36. package/src/sdk/router/session-router.ts +69 -15
  37. package/src/session/agent-session.ts +232 -15
  38. package/src/session/internal/managed-session-storage.ts +64 -26
  39. package/src/session/session-manager.ts +26 -8
  40. package/src/tools/bash.ts +14 -0
  41. package/src/tools/path-utils.ts +77 -29
  42. package/src/tools/tmux-self-injection-guard.ts +359 -0
@@ -742,7 +742,7 @@ describe("SessionSdkSessionRuntime", () => {
742
742
  },
743
743
  sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
744
744
  await options?.onPreflightAcceptCommit?.();
745
- await new Promise<void>(() => {});
745
+ await neverSettlingPromise();
746
746
  },
747
747
  } as unknown as ExtensionAPI;
748
748
  const transport = memoryTransport();
@@ -994,7 +994,7 @@ describe("SessionSdkSessionRuntime", () => {
994
994
  },
995
995
  sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
996
996
  await options?.onPreflightAcceptCommit?.();
997
- await new Promise<void>(() => {});
997
+ await neverSettlingPromise();
998
998
  },
999
999
  } as unknown as ExtensionAPI;
1000
1000
  const transport = memoryTransport();
@@ -1356,7 +1356,7 @@ describe("SessionSdkSessionRuntime", () => {
1356
1356
  },
1357
1357
  sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
1358
1358
  await options?.onPreflightAcceptCommit?.();
1359
- await new Promise<void>(() => {});
1359
+ await neverSettlingPromise();
1360
1360
  },
1361
1361
  } as unknown as ExtensionAPI;
1362
1362
  const transport = memoryTransport();
@@ -1710,7 +1710,7 @@ describe("SessionSdkSessionRuntime", () => {
1710
1710
  // Production-faithful: an accepted run stays in-flight for the whole
1711
1711
  // test; sendUserMessage resolution (turn completion) never precedes
1712
1712
  // agent_start (#4668 success-retirement).
1713
- await new Promise<void>(() => {});
1713
+ await neverSettlingPromise();
1714
1714
  },
1715
1715
  } as unknown as ExtensionAPI;
1716
1716
  const transport = memoryTransport();
@@ -2959,6 +2959,8 @@ interface PreflightHooks {
2959
2959
  interface ResponseFrame {
2960
2960
  id?: string;
2961
2961
  ok?: boolean;
2962
+ code?: string;
2963
+ error?: { code: string; message: string };
2962
2964
  result?: { status?: string; commandId?: string; turnId?: string; error?: { code: string; message: string } };
2963
2965
  }
2964
2966
 
@@ -2966,6 +2968,11 @@ interface InvocationHarness {
2966
2968
  control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
2967
2969
  query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
2968
2970
  emit(event: string, payload?: unknown): Promise<void>;
2971
+ switchSession(sessionId: string): Promise<void>;
2972
+ branch(sessionId: string): Promise<void>;
2973
+ requestOnSession(sessionId: string, frame: Record<string, unknown>): Promise<ResponseFrame>;
2974
+ sendToSession(sessionId: string, frame: Record<string, unknown>): void;
2975
+ sent(sessionId: string): SdkFrame[];
2969
2976
  readonly broadcasts: SdkFrame[];
2970
2977
  stop(): Promise<void>;
2971
2978
  }
@@ -2985,6 +2992,10 @@ async function invocationHarness(
2985
2992
  settings?: Settings;
2986
2993
  /** Throw to inject a durable-write failure for matching transitions. */
2987
2994
  persistInterceptor?: (transition: { type: string }) => void;
2995
+ /** Hold one matching durable transition until the test releases it. */
2996
+ persistHold?: { type: string; onEntered: () => void; release: Promise<void> };
2997
+ onLifecycleDrainTimeout?: () => void;
2998
+ onFailureDiagnosticKeyCount?: (count: number) => void;
2988
2999
  agentFailedWriteFailures?: number;
2989
3000
  },
2990
3001
  ): Promise<InvocationHarness> {
@@ -2992,6 +3003,8 @@ async function invocationHarness(
2992
3003
  const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
2993
3004
  const broadcasts: SdkFrame[] = [];
2994
3005
  let deliver: ((connectionId: string, frame: SdkFrame) => void) | undefined;
3006
+ const deliveries = new Map<string, (connectionId: string, frame: SdkFrame) => void>();
3007
+ const sentFrames = new Map<string, SdkFrame[]>();
2995
3008
  let nextId = 0;
2996
3009
  const api = {
2997
3010
  on(event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) {
@@ -3000,10 +3013,15 @@ async function invocationHarness(
3000
3013
  sendUserMessage: hooks.sendUserMessage ?? (async () => {}),
3001
3014
  } as unknown as ExtensionAPI;
3002
3015
  const interceptorStore = hooks.persistInterceptor
3003
- ? createInterceptorReconciliationStore(hooks.persistInterceptor, hooks.agentFailedWriteFailures)
3016
+ ? createInterceptorReconciliationStore(
3017
+ hooks.persistInterceptor,
3018
+ hooks.agentFailedWriteFailures,
3019
+ hooks.persistHold,
3020
+ )
3004
3021
  : undefined;
3005
3022
  createSdkSessionRuntimeExtension(api, {
3006
3023
  agentDir: cwd,
3024
+ ...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
3007
3025
  ...(interceptorStore
3008
3026
  ? {
3009
3027
  terminalAbortSeams: {
@@ -3015,6 +3033,9 @@ async function invocationHarness(
3015
3033
  },
3016
3034
  }
3017
3035
  : {}),
3036
+ ...(hooks.onFailureDiagnosticKeyCount
3037
+ ? { onFailureDiagnosticKeyCountForTests: hooks.onFailureDiagnosticKeyCount }
3038
+ : {}),
3018
3039
  ...(hooks.settings ? { settings: hooks.settings } : {}),
3019
3040
  createTransport: async ({ sessionId: id, stateRoot, token }) => ({
3020
3041
  sessionId: id,
@@ -3022,12 +3043,17 @@ async function invocationHarness(
3022
3043
  token,
3023
3044
  onFrame(handler) {
3024
3045
  deliver = handler;
3046
+ deliveries.set(id, handler);
3025
3047
  return () => {
3026
3048
  if (deliver === handler) deliver = undefined;
3049
+ if (deliveries.get(id) === handler) deliveries.delete(id);
3027
3050
  };
3028
3051
  },
3029
3052
  sendFrame(_connectionId, frame) {
3030
3053
  const response = frame as ResponseFrame;
3054
+ const frames = sentFrames.get(id) ?? [];
3055
+ frames.push(frame);
3056
+ sentFrames.set(id, frames);
3031
3057
  if (typeof response.id === "string") waiters.get(response.id)?.(response);
3032
3058
  },
3033
3059
  broadcastFrame(frame) {
@@ -3046,6 +3072,7 @@ async function invocationHarness(
3046
3072
  ...(hooks.invokeSkill ? { invokeSkill: hooks.invokeSkill } : {}),
3047
3073
  sessionManager: {
3048
3074
  getSessionId: () => sessionId,
3075
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3049
3076
  getSessionName: () => undefined,
3050
3077
  getBranch: () => [],
3051
3078
  },
@@ -3069,6 +3096,44 @@ async function invocationHarness(
3069
3096
  stop: async () => {
3070
3097
  await handlers.get("session_shutdown")?.({}, ctx);
3071
3098
  },
3099
+ switchSession: async sessionId => {
3100
+ await handlers.get("session_switch")?.(
3101
+ {},
3102
+ {
3103
+ ...ctx,
3104
+ sessionManager: {
3105
+ ...ctx.sessionManager,
3106
+ getSessionId: () => sessionId,
3107
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3108
+ },
3109
+ },
3110
+ );
3111
+ },
3112
+ branch: async sessionId => {
3113
+ await handlers.get("session_branch")?.(
3114
+ {},
3115
+ {
3116
+ ...ctx,
3117
+ sessionManager: {
3118
+ ...ctx.sessionManager,
3119
+ getSessionId: () => sessionId,
3120
+ getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
3121
+ },
3122
+ },
3123
+ );
3124
+ },
3125
+ requestOnSession: (sessionId, frame) => {
3126
+ const id = `frame-${nextId}`;
3127
+ nextId += 1;
3128
+ const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
3129
+ waiters.set(id, resolve);
3130
+ deliveries.get(sessionId)?.("client", { ...frame, id } as SdkFrame);
3131
+ return promise;
3132
+ },
3133
+ sendToSession: (sessionId, frame) => {
3134
+ deliveries.get(sessionId)?.("client", frame as SdkFrame);
3135
+ },
3136
+ sent: sessionId => sentFrames.get(sessionId) ?? [],
3072
3137
  };
3073
3138
  }
3074
3139
 
@@ -3078,34 +3143,46 @@ async function invocationHarness(
3078
3143
  function createInterceptorReconciliationStore(
3079
3144
  interceptor: (transition: { type: string }) => void,
3080
3145
  agentFailedWriteFailures = 1,
3146
+ persistHold?: { type: string; onEntered: () => void; release: Promise<void> },
3081
3147
  ): SdkOnlyReconciliationStore {
3082
3148
  const failureCounts = new Map<string, number>();
3149
+ let holdConsumed = false;
3083
3150
  const backing = createInterceptorBackingStore();
3084
3151
  return {
3085
3152
  path: null,
3086
3153
  load: async () => backing.records.map(record => ({ ...record })),
3087
3154
  async transact(mutator) {
3088
3155
  const records = mutator(backing.records.map(record => ({ ...record })));
3156
+ let transitionType: "agent_failed" | "agent_end" | undefined;
3089
3157
  for (const record of records as Array<{ status?: string; terminalAt?: number; commandId?: string }>) {
3090
3158
  // The intermediate durable state an agent_failed write produces:
3091
3159
  // the reason is set but the row is not terminal yet. Tests choose how
3092
3160
  // many consecutive writes fail before the recovery replay succeeds.
3093
3161
  const identity = String(record?.commandId ?? "unknown");
3094
3162
  const failureCount = failureCounts.get(identity) ?? 0;
3163
+ if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
3164
+ transitionType ??= "agent_failed";
3095
3165
  if (
3096
3166
  record?.terminalAt === undefined &&
3097
3167
  (record as { error?: unknown }).error !== undefined &&
3098
3168
  failureCount < agentFailedWriteFailures
3099
3169
  ) {
3170
+ transitionType = "agent_failed";
3100
3171
  failureCounts.set(identity, failureCount + 1);
3101
3172
  interceptor({ type: "agent_failed" });
3102
3173
  throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
3103
3174
  }
3104
3175
  const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
3105
- if (record.terminalAt !== undefined && previous?.terminalAt === undefined) {
3176
+ if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
3177
+ transitionType = "agent_end";
3106
3178
  interceptor({ type: "agent_end" });
3107
3179
  }
3108
3180
  }
3181
+ if (persistHold && !holdConsumed && persistHold.type === transitionType) {
3182
+ holdConsumed = true;
3183
+ persistHold.onEntered();
3184
+ await persistHold.release;
3185
+ }
3109
3186
  backing.records = records;
3110
3187
  },
3111
3188
  snapshotTerminalScopes: () => [],
@@ -3134,14 +3211,645 @@ async function settledStatus(
3134
3211
  throw new Error(`${name} never reported a terminal reconciliation status`);
3135
3212
  }
3136
3213
 
3214
+ function neverSettlingPromise(): Promise<void> {
3215
+ return Promise.withResolvers<void>().promise;
3216
+ }
3217
+
3137
3218
  describe("post-acceptance invocation terminalization", () => {
3219
+ test.each([
3220
+ { status: 402, code: "provider_http_402" },
3221
+ { status: 429, code: "provider_http_429" },
3222
+ { status: 500, code: "provider_rejected" },
3223
+ ])("terminalizes a streamed provider HTTP $status rejection and permits a healthy abort_and_prompt replacement", async ({
3224
+ status,
3225
+ code,
3226
+ }) => {
3227
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-provider-http-${status}-`));
3228
+ try {
3229
+ let prompts = 0;
3230
+ const harness = await invocationHarness(`provider-http-${status}`, cwd, {
3231
+ sendUserMessage: async (_content, options) => {
3232
+ prompts += 1;
3233
+ await options?.onPreflightAcceptCommit?.();
3234
+ if (prompts === 1) await neverSettlingPromise();
3235
+ },
3236
+ });
3237
+ const failed = await harness.control("turn.prompt", { text: "provider rejects", clientRef: `http-${status}` });
3238
+ expect(failed.ok).toBe(true);
3239
+ const failedIds = { commandId: failed.result?.commandId, turnId: failed.result?.turnId };
3240
+ await harness.emit("agent_start");
3241
+ await harness.emit("agent_end", {
3242
+ messages: [
3243
+ {
3244
+ role: "assistant",
3245
+ stopReason: "error",
3246
+ errorStatus: status,
3247
+ transportFailure: { kind: "transport", status },
3248
+ },
3249
+ ],
3250
+ });
3251
+
3252
+ const terminal = await settledStatus(harness, "turn.result", {
3253
+ kind: "prompt",
3254
+ clientRef: `http-${status}`,
3255
+ });
3256
+ expect(terminal).toMatchObject({
3257
+ status: "failed",
3258
+ error: { code, message: "Prompt submission failed." },
3259
+ terminalAt: expect.any(Number),
3260
+ });
3261
+ const stable = await harness.query("turn.result", { kind: "prompt", ...failedIds });
3262
+ expect(stable.result).toEqual(terminal);
3263
+
3264
+ const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
3265
+ expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
3266
+ const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
3267
+ await harness.emit("agent_start");
3268
+ await harness.emit("agent_end", {
3269
+ messages: [
3270
+ { role: "assistant", stopReason: "error", errorStatus: 429 },
3271
+ { role: "assistant", stopReason: "stop" },
3272
+ ],
3273
+ });
3274
+ expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
3275
+ status: "terminal_ok",
3276
+ });
3277
+ expect(prompts).toBe(2);
3278
+ await harness.stop();
3279
+ } finally {
3280
+ await Bun.sleep(50);
3281
+ await rm(cwd, { recursive: true, force: true });
3282
+ }
3283
+ });
3284
+
3285
+ test("classifies a statusless provider error completion as rejected", async () => {
3286
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-"));
3287
+ try {
3288
+ const harness = await invocationHarness("provider-statusless", cwd, {
3289
+ sendUserMessage: async (_content, options) => {
3290
+ await options?.onPreflightAcceptCommit?.();
3291
+ await neverSettlingPromise();
3292
+ },
3293
+ });
3294
+ const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
3295
+ expect(accepted.ok).toBe(true);
3296
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3297
+ await harness.emit("agent_start");
3298
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3299
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3300
+ status: "failed",
3301
+ error: { code: "provider_rejected" },
3302
+ });
3303
+ await harness.stop();
3304
+ } finally {
3305
+ await Bun.sleep(50);
3306
+ await rm(cwd, { recursive: true, force: true });
3307
+ }
3308
+ });
3309
+
3310
+ test("upgrades a generic agent_failed diagnostic when agent_end proves provider rejection", async () => {
3311
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-upgrade-"));
3312
+ try {
3313
+ const harness = await invocationHarness("provider-statusless-upgrade", cwd, {
3314
+ sendUserMessage: async (_content, options) => {
3315
+ await options?.onPreflightAcceptCommit?.();
3316
+ await neverSettlingPromise();
3317
+ },
3318
+ });
3319
+ const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
3320
+ expect(accepted.ok).toBe(true);
3321
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3322
+ await harness.emit("agent_start");
3323
+ await harness.emit("agent_failed", {
3324
+ error: Object.assign(new Error("generic agent failure"), { code: "agent_failed" }),
3325
+ });
3326
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3327
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3328
+ status: "failed",
3329
+ error: { code: "provider_rejected" },
3330
+ });
3331
+ const failures = harness.broadcasts.filter(frame => frame.kind === "agent_failed");
3332
+ expect(failures.at(-1)).toMatchObject({ payload: { error: { code: "provider_rejected" } } });
3333
+ await harness.stop();
3334
+ } finally {
3335
+ await Bun.sleep(50);
3336
+ await rm(cwd, { recursive: true, force: true });
3337
+ }
3338
+ });
3339
+
3340
+ test("preserves a specific agent_failed diagnostic when agent_end is statusless", async () => {
3341
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-specific-"));
3342
+ try {
3343
+ const harness = await invocationHarness("provider-statusless-specific", cwd, {
3344
+ sendUserMessage: async (_content, options) => {
3345
+ await options?.onPreflightAcceptCommit?.();
3346
+ await neverSettlingPromise();
3347
+ },
3348
+ });
3349
+ const accepted = await harness.control("turn.prompt", { text: "provider unavailable" });
3350
+ expect(accepted.ok).toBe(true);
3351
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3352
+ await harness.emit("agent_start");
3353
+ await harness.emit("agent_failed", {
3354
+ error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }),
3355
+ });
3356
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3357
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3358
+ status: "failed",
3359
+ error: { code: "provider_unavailable" },
3360
+ });
3361
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_failed")).toHaveLength(1);
3362
+ await harness.stop();
3363
+ } finally {
3364
+ await Bun.sleep(50);
3365
+ await rm(cwd, { recursive: true, force: true });
3366
+ }
3367
+ });
3368
+
3369
+ test("upgrades every shared generic diagnostic on a statusless provider end", async () => {
3370
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-shared-"));
3371
+ try {
3372
+ let promoted: ((promotion: { startsOwnRun?: boolean }) => void) | undefined;
3373
+ const harness = await invocationHarness("provider-statusless-shared", cwd, {
3374
+ sendUserMessage: async (content, options) => {
3375
+ await options?.onPreflightAcceptCommit?.();
3376
+ if (content === "attached") {
3377
+ promoted = (options as { onQueuedPromoted?: (promotion: { startsOwnRun?: boolean }) => void })
3378
+ ?.onQueuedPromoted;
3379
+ return;
3380
+ }
3381
+ await neverSettlingPromise();
3382
+ },
3383
+ });
3384
+ const first = await harness.control("turn.prompt", { text: "first" });
3385
+ expect(first.ok).toBe(true);
3386
+ await harness.emit("agent_start");
3387
+ const attached = await harness.control("turn.follow_up", { text: "attached" });
3388
+ expect(attached.ok).toBe(true);
3389
+ promoted?.({ startsOwnRun: false });
3390
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3391
+ const attachedIds = { commandId: attached.result?.commandId, turnId: attached.result?.turnId };
3392
+ await harness.emit("agent_failed", { error: Object.assign(new Error("generic"), { code: "agent_failed" }) });
3393
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
3394
+ expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
3395
+ status: "failed",
3396
+ error: { code: "provider_rejected" },
3397
+ });
3398
+ expect(await settledStatus(harness, "turn.prompt_status", attachedIds)).toMatchObject({
3399
+ status: "failed",
3400
+ error: { code: "provider_rejected" },
3401
+ });
3402
+ await harness.stop();
3403
+ } finally {
3404
+ await Bun.sleep(50);
3405
+ await rm(cwd, { recursive: true, force: true });
3406
+ }
3407
+ });
3408
+
3409
+ test("does not misclassify a local malformed-tool circuit breaker as provider rejection", async () => {
3410
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-malformed-tool-"));
3411
+ try {
3412
+ const harness = await invocationHarness("local-malformed-tool", cwd, {
3413
+ sendUserMessage: async (_content, options) => {
3414
+ await options?.onPreflightAcceptCommit?.();
3415
+ await neverSettlingPromise();
3416
+ },
3417
+ });
3418
+ const accepted = await harness.control("turn.prompt", { text: "local malformed tool" });
3419
+ expect(accepted.ok).toBe(true);
3420
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3421
+ await harness.emit("agent_start");
3422
+ await harness.emit("agent_end", {
3423
+ messages: [
3424
+ {
3425
+ role: "assistant",
3426
+ stopReason: "error",
3427
+ errorMessage:
3428
+ "Stopping after 3 consecutive turns of malformed tool calls; the model did not produce a usable tool call or answer.",
3429
+ },
3430
+ ],
3431
+ });
3432
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({ status: "terminal_ok" });
3433
+ await harness.stop();
3434
+ } finally {
3435
+ await Bun.sleep(50);
3436
+ await rm(cwd, { recursive: true, force: true });
3437
+ }
3438
+ });
3439
+
3440
+ test("does not misclassify a local composer policy breaker as provider rejection", async () => {
3441
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-composer-policy-"));
3442
+ try {
3443
+ const harness = await invocationHarness("local-composer-policy", cwd, {
3444
+ sendUserMessage: async (_content, options) => {
3445
+ await options?.onPreflightAcceptCommit?.();
3446
+ await neverSettlingPromise();
3447
+ },
3448
+ });
3449
+ const accepted = await harness.control("turn.prompt", { text: "local composer policy" });
3450
+ expect(accepted.ok).toBe(true);
3451
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3452
+ await harness.emit("agent_start");
3453
+ await harness.emit("agent_end", {
3454
+ messages: [
3455
+ {
3456
+ role: "assistant",
3457
+ stopReason: "error",
3458
+ errorMessage:
3459
+ "Composer bash policy blocked repository file I/O again after its one automatic recovery turn.",
3460
+ },
3461
+ ],
3462
+ });
3463
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({ status: "terminal_ok" });
3464
+ await harness.stop();
3465
+ } finally {
3466
+ await Bun.sleep(50);
3467
+ await rm(cwd, { recursive: true, force: true });
3468
+ }
3469
+ });
3470
+
3471
+ test("terminalizes when provider end metadata exposes a throwing accessor", async () => {
3472
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-throwing-metadata-"));
3473
+ try {
3474
+ const harness = await invocationHarness("provider-throwing-metadata", cwd, {
3475
+ sendUserMessage: async (_content, options) => {
3476
+ await options?.onPreflightAcceptCommit?.();
3477
+ await neverSettlingPromise();
3478
+ },
3479
+ });
3480
+ const accepted = await harness.control("turn.prompt", { text: "provider metadata" });
3481
+ expect(accepted.ok).toBe(true);
3482
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
3483
+ await harness.emit("agent_start");
3484
+ const event = {} as Record<string, unknown>;
3485
+ Object.defineProperty(event, "messages", {
3486
+ get() {
3487
+ throw new Error("provider metadata accessor failed");
3488
+ },
3489
+ });
3490
+ await harness.emit("agent_end", event);
3491
+ expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
3492
+ status: "terminal_ok",
3493
+ });
3494
+ await harness.stop();
3495
+ } finally {
3496
+ await Bun.sleep(50);
3497
+ await rm(cwd, { recursive: true, force: true });
3498
+ }
3499
+ });
3500
+
3501
+ test("binds a delayed predecessor provider failure to its own batch after a replacement starts", async () => {
3502
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-provider-batch-"));
3503
+ try {
3504
+ const firstInflight = Promise.withResolvers<void>();
3505
+ const secondInflight = Promise.withResolvers<void>();
3506
+ let prompts = 0;
3507
+ const harness = await invocationHarness("delayed-provider-batch", cwd, {
3508
+ sendUserMessage: async (_content, options) => {
3509
+ prompts += 1;
3510
+ await options?.onPreflightAcceptCommit?.();
3511
+ if (prompts === 1) await firstInflight.promise;
3512
+ if (prompts === 2) await secondInflight.promise;
3513
+ },
3514
+ persistInterceptor: () => {},
3515
+ });
3516
+ const first = await harness.control("turn.prompt", { text: "first", clientRef: "delayed-first" });
3517
+ expect(first.ok).toBe(true);
3518
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3519
+ await harness.emit("agent_start");
3520
+ const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
3521
+ expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
3522
+ const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
3523
+ await harness.emit("agent_start");
3524
+ expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
3525
+ await harness.emit("agent_end", {
3526
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
3527
+ });
3528
+ expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
3529
+ status: "failed",
3530
+ error: { code: "provider_http_402" },
3531
+ });
3532
+ expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
3533
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop" }] });
3534
+ expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
3535
+ status: "terminal_ok",
3536
+ });
3537
+ firstInflight.resolve();
3538
+ secondInflight.resolve();
3539
+ expect(prompts).toBe(2);
3540
+ await harness.stop();
3541
+ } finally {
3542
+ await Bun.sleep(50);
3543
+ await rm(cwd, { recursive: true, force: true });
3544
+ }
3545
+ });
3546
+
3547
+ test.each([
3548
+ { operation: "switchSession" as const, label: "session switch" },
3549
+ { operation: "branch" as const, label: "session branch" },
3550
+ ])("keeps a held 402/429 predecessor terminal across $label", async ({ operation }) => {
3551
+ for (const { status, code } of [
3552
+ { status: 402, code: "provider_http_402" },
3553
+ { status: 429, code: "provider_http_429" },
3554
+ ]) {
3555
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-race-`));
3556
+ try {
3557
+ const entered = Promise.withResolvers<void>();
3558
+ const release = Promise.withResolvers<void>();
3559
+ const firstInflight = Promise.withResolvers<void>();
3560
+ const secondInflight = Promise.withResolvers<void>();
3561
+ let timeoutWarnings = 0;
3562
+ const diagnosticKeyCounts: number[] = [];
3563
+ let prompts = 0;
3564
+ const harness = await invocationHarness(`${operation}-provider-race`, cwd, {
3565
+ persistInterceptor: () => {},
3566
+ onLifecycleDrainTimeout: () => {
3567
+ timeoutWarnings += 1;
3568
+ },
3569
+ onFailureDiagnosticKeyCount: count => {
3570
+ diagnosticKeyCounts.push(count);
3571
+ },
3572
+ persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
3573
+ agentFailedWriteFailures: 0,
3574
+ sendUserMessage: async (_content, options) => {
3575
+ prompts += 1;
3576
+ await options?.onPreflightAcceptCommit?.();
3577
+ if (prompts === 1) await firstInflight.promise;
3578
+ if (prompts === 2) await secondInflight.promise;
3579
+ },
3580
+ });
3581
+ const first = await harness.control("turn.prompt", {
3582
+ text: "first",
3583
+ clientRef: `${operation}-${status}-first`,
3584
+ });
3585
+ expect(first.ok).toBe(true);
3586
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3587
+ await harness.emit("agent_start");
3588
+ const end = harness.emit("agent_end", {
3589
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
3590
+ });
3591
+ await entered.promise;
3592
+ const lifecycleChange = harness[operation](`${operation}-successor`);
3593
+ await Bun.sleep(10);
3594
+ const [oldControl, oldQuery, oldReplay, oldProvider, oldReverse] = await Promise.all([
3595
+ harness.requestOnSession(`${operation}-provider-race`, {
3596
+ type: "control_request",
3597
+ operation: "turn.prompt",
3598
+ input: {},
3599
+ }),
3600
+ harness.requestOnSession(`${operation}-provider-race`, {
3601
+ type: "query_request",
3602
+ query: "turn.prompt_status",
3603
+ input: {},
3604
+ }),
3605
+ harness.requestOnSession(`${operation}-provider-race`, {
3606
+ type: "event_replay",
3607
+ sinceGeneration: 0,
3608
+ sinceSeq: 0,
3609
+ }),
3610
+ harness.requestOnSession(`${operation}-provider-race`, {
3611
+ type: "register_provider",
3612
+ capability: "fs",
3613
+ definitions: {},
3614
+ }),
3615
+ harness.requestOnSession(`${operation}-provider-race`, {
3616
+ type: "reverse_response",
3617
+ leaseId: "old-lease",
3618
+ ok: true,
3619
+ result: {},
3620
+ }),
3621
+ ]);
3622
+ harness.sendToSession(`${operation}-provider-race`, { type: "provider_heartbeat", leaseId: "old-lease" });
3623
+ harness.sendToSession(`${operation}-provider-race`, { type: "lease_release", leaseId: "old-lease" });
3624
+ expect(
3625
+ [oldControl, oldQuery, oldReplay, oldProvider, oldReverse].map(
3626
+ response => response.error?.code ?? response.code,
3627
+ ),
3628
+ ).toEqual([
3629
+ "session_quiescing",
3630
+ "session_quiescing",
3631
+ "session_quiescing",
3632
+ "session_quiescing",
3633
+ "session_quiescing",
3634
+ ]);
3635
+ expect(
3636
+ harness.sent(`${operation}-provider-race`).filter(frame => frame.type === "transport_error"),
3637
+ ).toHaveLength(3);
3638
+ release.resolve();
3639
+ await Promise.all([end, lifecycleChange]);
3640
+ expect(timeoutWarnings).toBe(0);
3641
+ expect(diagnosticKeyCounts.at(-1)).toBe(0);
3642
+ const failure = harness.broadcasts.find(frame => {
3643
+ const payload = frame.payload;
3644
+ return (
3645
+ frame.kind === "agent_failed" &&
3646
+ typeof payload === "object" &&
3647
+ payload !== null &&
3648
+ (payload as { commandId?: unknown }).commandId === firstIds.commandId &&
3649
+ (payload as { turnId?: unknown }).turnId === firstIds.turnId
3650
+ );
3651
+ });
3652
+ expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code } } });
3653
+ expect(harness.broadcasts.filter(frame => frame.kind === "agent_end").length).toBeGreaterThan(0);
3654
+
3655
+ const successor = await harness.control("turn.prompt", { text: "successor" });
3656
+ expect(successor.ok).toBe(true);
3657
+ const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
3658
+ await harness.emit("agent_start");
3659
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop" }] });
3660
+ secondInflight.resolve();
3661
+ expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
3662
+ status: "terminal_ok",
3663
+ });
3664
+ firstInflight.resolve();
3665
+ expect(prompts).toBe(2);
3666
+ await harness.stop();
3667
+ } finally {
3668
+ await Bun.sleep(50);
3669
+ await rm(cwd, { recursive: true, force: true });
3670
+ }
3671
+ }
3672
+ });
3673
+
3674
+ test.each([
3675
+ { operation: "switchSession" as const, label: "session switch" },
3676
+ { operation: "branch" as const, label: "session branch" },
3677
+ ])("bounds a stalled 402/429 lifecycle drain during $label", async ({ operation }) => {
3678
+ for (const status of [402, 429]) {
3679
+ const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-stall-`));
3680
+ try {
3681
+ const entered = Promise.withResolvers<void>();
3682
+ const release = Promise.withResolvers<void>();
3683
+ const firstInflight = Promise.withResolvers<void>();
3684
+ let timeoutWarnings = 0;
3685
+ const harness = await invocationHarness(`${operation}-provider-stall`, cwd, {
3686
+ persistInterceptor: () => {},
3687
+ onLifecycleDrainTimeout: () => {
3688
+ timeoutWarnings += 1;
3689
+ },
3690
+ persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
3691
+ agentFailedWriteFailures: 0,
3692
+ sendUserMessage: async (_content, options) => {
3693
+ await options?.onPreflightAcceptCommit?.();
3694
+ await firstInflight.promise;
3695
+ },
3696
+ });
3697
+ const first = await harness.control("turn.prompt", { text: "first" });
3698
+ expect(first.ok).toBe(true);
3699
+ await harness.emit("agent_start");
3700
+ const end = harness.emit("agent_end", {
3701
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
3702
+ });
3703
+ await entered.promise;
3704
+ const startedAt = Date.now();
3705
+ const lifecycleChange = harness[operation](`${operation}-stall-successor`);
3706
+ await lifecycleChange;
3707
+ expect(Date.now() - startedAt).toBeLessThan(1_500);
3708
+ expect(timeoutWarnings).toBe(1);
3709
+ release.resolve();
3710
+ firstInflight.resolve();
3711
+ await end;
3712
+ await harness.stop();
3713
+ } finally {
3714
+ await Bun.sleep(50);
3715
+ await rm(cwd, { recursive: true, force: true });
3716
+ }
3717
+ }
3718
+ });
3719
+
3720
+ test("deduplicates repeated agent_failed diagnostics for one invocation", async () => {
3721
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-agent-failed-dedupe-"));
3722
+ try {
3723
+ const inflight = Promise.withResolvers<void>();
3724
+ const harness = await invocationHarness("agent-failed-dedupe", cwd, {
3725
+ sendUserMessage: async (_content, options) => {
3726
+ await options?.onPreflightAcceptCommit?.();
3727
+ await inflight.promise;
3728
+ },
3729
+ });
3730
+ const accepted = await harness.control("turn.prompt", { text: "hello" });
3731
+ expect(accepted.ok).toBe(true);
3732
+ const correlation = accepted.result;
3733
+ await harness.emit("agent_start");
3734
+ const failure = { error: Object.assign(new Error("provider failed"), { code: "provider_rejected" }) };
3735
+ await harness.emit("agent_failed", failure);
3736
+ await harness.emit("agent_failed", failure);
3737
+ expect(
3738
+ harness.broadcasts.filter(
3739
+ frame =>
3740
+ frame.kind === "agent_failed" &&
3741
+ (frame.payload as { commandId?: string }).commandId === correlation?.commandId &&
3742
+ (frame.payload as { turnId?: string }).turnId === correlation?.turnId,
3743
+ ),
3744
+ ).toHaveLength(1);
3745
+ await harness.emit("agent_end");
3746
+ inflight.resolve();
3747
+ await harness.stop();
3748
+ } finally {
3749
+ await Bun.sleep(50);
3750
+ await rm(cwd, { recursive: true, force: true });
3751
+ }
3752
+ });
3753
+
3754
+ test("routes a delayed predecessor agent_end after replacement completion to the retired owner", async () => {
3755
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-retired-owner-"));
3756
+ try {
3757
+ const firstInflight = Promise.withResolvers<void>();
3758
+ const secondInflight = Promise.withResolvers<void>();
3759
+ let prompts = 0;
3760
+ const harness = await invocationHarness("retired-owner-a", cwd, {
3761
+ persistInterceptor: () => {},
3762
+ sendUserMessage: async (_content, options) => {
3763
+ prompts += 1;
3764
+ await options?.onPreflightAcceptCommit?.();
3765
+ if (prompts === 1) await firstInflight.promise;
3766
+ if (prompts === 2) await secondInflight.promise;
3767
+ },
3768
+ });
3769
+ const first = await harness.control("turn.prompt", { text: "first" });
3770
+ expect(first.ok).toBe(true);
3771
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3772
+ await harness.emit("agent_start");
3773
+ await harness.switchSession("retired-owner-b");
3774
+ await harness.emit("agent_end", {
3775
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
3776
+ });
3777
+ const failure = harness.broadcasts.find(frame => {
3778
+ const payload = frame.payload;
3779
+ return (
3780
+ frame.kind === "agent_failed" &&
3781
+ typeof payload === "object" &&
3782
+ payload !== null &&
3783
+ (payload as { commandId?: unknown }).commandId === firstIds.commandId &&
3784
+ (payload as { turnId?: unknown }).turnId === firstIds.turnId
3785
+ );
3786
+ });
3787
+ expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code: "provider_http_402" } } });
3788
+ const successor = await harness.control("turn.prompt", { text: "successor" });
3789
+ expect(successor.ok).toBe(true);
3790
+ const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
3791
+ await harness.emit("agent_start");
3792
+ await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop" }] });
3793
+ secondInflight.resolve();
3794
+ expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
3795
+ status: "terminal_ok",
3796
+ });
3797
+ firstInflight.resolve();
3798
+ expect(prompts).toBe(2);
3799
+ await harness.stop();
3800
+ } finally {
3801
+ await Bun.sleep(50);
3802
+ await rm(cwd, { recursive: true, force: true });
3803
+ }
3804
+ });
3805
+
3806
+ test("fails closed for tokenless delayed events when a session id is reused", async () => {
3807
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-session-retired-owner-"));
3808
+ try {
3809
+ const firstInflight = Promise.withResolvers<void>();
3810
+ const harness = await invocationHarness("reused-session", cwd, {
3811
+ persistInterceptor: () => {},
3812
+ sendUserMessage: async (_content, options) => {
3813
+ await options?.onPreflightAcceptCommit?.();
3814
+ await firstInflight.promise;
3815
+ },
3816
+ });
3817
+ const first = await harness.control("turn.prompt", { text: "first" });
3818
+ expect(first.ok).toBe(true);
3819
+ const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
3820
+ await harness.emit("agent_start");
3821
+ await harness.switchSession("different-session");
3822
+ await harness.switchSession("reused-session");
3823
+ await harness.emit("agent_start");
3824
+ await harness.emit("agent_end", {
3825
+ messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
3826
+ });
3827
+ const failure = harness.broadcasts.find(frame => {
3828
+ const payload = frame.payload;
3829
+ return (
3830
+ frame.kind === "agent_failed" &&
3831
+ typeof payload === "object" &&
3832
+ payload !== null &&
3833
+ (payload as { commandId?: unknown }).commandId === firstIds.commandId &&
3834
+ (payload as { turnId?: unknown }).turnId === firstIds.turnId
3835
+ );
3836
+ });
3837
+ expect(failure).toBeUndefined();
3838
+ firstInflight.resolve();
3839
+ await harness.stop();
3840
+ } finally {
3841
+ await Bun.sleep(50);
3842
+ await rm(cwd, { recursive: true, force: true });
3843
+ }
3844
+ });
3845
+
3138
3846
  test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
3139
3847
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
3140
3848
  try {
3141
3849
  const harness = await invocationHarness("terminalize-prompt", cwd, {
3142
3850
  sendUserMessage: async (_content, options) => {
3143
3851
  await options?.onPreflightAcceptCommit?.();
3144
- await new Promise<void>(() => {});
3852
+ await neverSettlingPromise();
3145
3853
  },
3146
3854
  });
3147
3855
  const accepted = await harness.control("turn.prompt", { text: "hello" });
@@ -3172,7 +3880,7 @@ describe("post-acceptance invocation terminalization", () => {
3172
3880
  const harness = await invocationHarness("terminalize-abort", cwd, {
3173
3881
  sendUserMessage: async (_content, options) => {
3174
3882
  await options?.onPreflightAcceptCommit?.();
3175
- await new Promise<void>(() => {});
3883
+ await neverSettlingPromise();
3176
3884
  },
3177
3885
  abort: () => {},
3178
3886
  });
@@ -3293,6 +4001,55 @@ describe("post-acceptance invocation terminalization", () => {
3293
4001
  }
3294
4002
  });
3295
4003
 
4004
+ test("reused-session successor tool progress renews its active deadline", async () => {
4005
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-successor-progress-"));
4006
+ try {
4007
+ const firstInflight = Promise.withResolvers<void>();
4008
+ const successorStarted = Promise.withResolvers<void>();
4009
+ let prompts = 0;
4010
+ const harness = await invocationHarness("reused-successor-progress", cwd, {
4011
+ settings: {
4012
+ get: (key: string) =>
4013
+ key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
4014
+ } as unknown as Settings,
4015
+ sendUserMessage: async (_content, options) => {
4016
+ prompts += 1;
4017
+ await options?.onPreflightAcceptCommit?.();
4018
+ if (prompts === 1) {
4019
+ await firstInflight.promise;
4020
+ return;
4021
+ }
4022
+ successorStarted.resolve();
4023
+ await Promise.withResolvers<void>().promise;
4024
+ },
4025
+ });
4026
+ const first = await harness.control("turn.prompt", { text: "first" });
4027
+ expect(first.ok).toBe(true);
4028
+ await harness.emit("agent_start");
4029
+ await harness.switchSession("reused-successor-progress-other");
4030
+ await harness.switchSession("reused-successor-progress");
4031
+ const successor = await harness.control("turn.prompt", { text: "successor" });
4032
+ expect(successor.ok).toBe(true);
4033
+ const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
4034
+ await harness.emit("agent_start");
4035
+ await successorStarted.promise;
4036
+ for (let index = 0; index < 4; index += 1) {
4037
+ await harness.emit("tool_execution_start");
4038
+ await Bun.sleep(15);
4039
+ }
4040
+ expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
4041
+ result: { status: expect.stringMatching(/accepted|in_flight/) },
4042
+ });
4043
+ await harness.emit("agent_end");
4044
+ firstInflight.resolve();
4045
+ await harness.stop();
4046
+ expect(prompts).toBe(2);
4047
+ } finally {
4048
+ await Bun.sleep(50);
4049
+ await rm(cwd, { recursive: true, force: true });
4050
+ }
4051
+ });
4052
+
3296
4053
  test("abort_and_prompt starts exactly one successor and is not duplicated by delayed abort teardown", async () => {
3297
4054
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-and-prompt-once-"));
3298
4055
  try {
@@ -3339,7 +4096,7 @@ describe("post-acceptance invocation terminalization", () => {
3339
4096
  const harness = await invocationHarness("terminalize-skill", cwd, {
3340
4097
  invokeSkill: async (_name, _args, options) => {
3341
4098
  await options?.onPreflightAcceptCommit?.();
3342
- await new Promise<void>(() => {});
4099
+ await neverSettlingPromise();
3343
4100
  },
3344
4101
  });
3345
4102
  const accepted = await harness.control("skill.invoke", { name: "ralplan" });
@@ -3563,7 +4320,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3563
4320
  sendUserMessage: async (_content, options) => {
3564
4321
  await options?.onPreflightAcceptCommit?.();
3565
4322
  // Accepted, then permanently no execution progress and no agent_start.
3566
- await new Promise<void>(() => {});
4323
+ await neverSettlingPromise();
3567
4324
  },
3568
4325
  });
3569
4326
  const accepted = await harness.control("turn.prompt", { text: "hello" });
@@ -3597,7 +4354,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3597
4354
  settings: zeroProgressSettings,
3598
4355
  sendUserMessage: async (_content, options) => {
3599
4356
  await options?.onPreflightAcceptCommit?.();
3600
- await new Promise<void>(() => {});
4357
+ await neverSettlingPromise();
3601
4358
  },
3602
4359
  });
3603
4360
  const original = await harness.control("turn.prompt", { text: "original" });
@@ -3731,7 +4488,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3731
4488
  return;
3732
4489
  }
3733
4490
  // The first turn accepts and then never makes progress.
3734
- await new Promise<void>(() => {});
4491
+ await neverSettlingPromise();
3735
4492
  },
3736
4493
  });
3737
4494
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -3817,7 +4574,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3817
4574
  )?.onQueuedPromoted;
3818
4575
  return;
3819
4576
  }
3820
- await new Promise<void>(() => {});
4577
+ await neverSettlingPromise();
3821
4578
  },
3822
4579
  });
3823
4580
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -3873,7 +4630,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3873
4630
  promoted = options?.onQueuedPromoted;
3874
4631
  return;
3875
4632
  }
3876
- await new Promise<void>(() => {});
4633
+ await neverSettlingPromise();
3877
4634
  },
3878
4635
  } as unknown as ExtensionAPI;
3879
4636
  const transport = memoryTransport();
@@ -3994,7 +4751,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
3994
4751
  return;
3995
4752
  }
3996
4753
  // The first turn accepts and then keeps streaming.
3997
- await new Promise<void>(() => {});
4754
+ await neverSettlingPromise();
3998
4755
  },
3999
4756
  });
4000
4757
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -4052,7 +4809,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4052
4809
  return;
4053
4810
  }
4054
4811
  // The first turn accepts and then keeps streaming.
4055
- await new Promise<void>(() => {});
4812
+ await neverSettlingPromise();
4056
4813
  },
4057
4814
  });
4058
4815
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -4128,7 +4885,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4128
4885
  } as unknown as Settings,
4129
4886
  sendUserMessage: async (_content, options) => {
4130
4887
  await options?.onPreflightAcceptCommit?.();
4131
- await new Promise<void>(() => {});
4888
+ await neverSettlingPromise();
4132
4889
  },
4133
4890
  persistInterceptor: transition => {
4134
4891
  if (transition.type === "agent_failed") {
@@ -4212,7 +4969,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4212
4969
  const harness = await invocationHarness("skill-terminal-retry", cwd, {
4213
4970
  invokeSkill: async (_name, _args, options) => {
4214
4971
  await options?.onPreflightAcceptCommit?.();
4215
- await new Promise<void>(() => {});
4972
+ await neverSettlingPromise();
4216
4973
  },
4217
4974
  persistInterceptor: transition => {
4218
4975
  if (transition.type === "agent_end" && failedWrites < 2) {
@@ -4245,6 +5002,48 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4245
5002
  }
4246
5003
  });
4247
5004
 
5005
+ test("retains skill terminal recovery across session replacement", async () => {
5006
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-skill-terminal-replacement-"));
5007
+ let failFirstTerminalWrite = true;
5008
+ try {
5009
+ const harness = await invocationHarness("skill-terminal-replacement", cwd, {
5010
+ invokeSkill: async (_name, _args, options) => {
5011
+ await options?.onPreflightAcceptCommit?.();
5012
+ await neverSettlingPromise();
5013
+ },
5014
+ persistInterceptor: transition => {
5015
+ if (transition.type === "agent_end" && failFirstTerminalWrite) {
5016
+ failFirstTerminalWrite = false;
5017
+ throw Object.assign(new Error("injected terminal persistence failure"), { code: "io_error" });
5018
+ }
5019
+ },
5020
+ });
5021
+ const submitted = await harness.control("skill.invoke", {
5022
+ name: "replacement-hang",
5023
+ args: "",
5024
+ clientRef: "skill-terminal-replacement-ref",
5025
+ });
5026
+ expect(submitted.ok).toBe(true);
5027
+ await harness.emit("agent_start");
5028
+ await harness.emit("agent_end");
5029
+ await Bun.sleep(50);
5030
+ await harness.switchSession("skill-terminal-replacement-next");
5031
+ await Bun.sleep(1_100);
5032
+ let settled: { status?: string; error?: { code?: string } } | undefined;
5033
+ for (let attempt = 0; attempt < 500 && settled === undefined; attempt += 1) {
5034
+ const frame = await harness.query("skill.invoke_status", { clientRef: "skill-terminal-replacement-ref" });
5035
+ const result = frame.result as { status?: string; error?: { code?: string } } | undefined;
5036
+ if (result?.status === "terminal_ok" || result?.status === "failed") settled = result;
5037
+ else await Bun.sleep(10);
5038
+ }
5039
+ expect(settled?.status).toBe("terminal_ok");
5040
+ await harness.stop();
5041
+ } finally {
5042
+ await Bun.sleep(50);
5043
+ await rm(cwd, { recursive: true, force: true });
5044
+ }
5045
+ });
5046
+
4248
5047
  test("a rejected prompt replays its failure reason, never a bare terminal_ok", async () => {
4249
5048
  // Exact-head review HIGH: after failed agent_failed persistence, recovery
4250
5049
  // replayed only agent_end, so an abandoned/rejected prompt became durable
@@ -4335,7 +5134,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4335
5134
  )?.onQueuedPromoted;
4336
5135
  return;
4337
5136
  }
4338
- await new Promise<void>(() => {});
5137
+ await neverSettlingPromise();
4339
5138
  },
4340
5139
  });
4341
5140
  const first = await harness.control("turn.prompt", { text: "first" });
@@ -4369,7 +5168,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4369
5168
  sendUserMessage: async (_content, options) => {
4370
5169
  await options?.onPreflightAcceptCommit?.();
4371
5170
  // The failing turn accepts and then never makes progress on its own.
4372
- await new Promise<void>(() => {});
5171
+ await neverSettlingPromise();
4373
5172
  },
4374
5173
  });
4375
5174
  const failing = await harness.control("turn.prompt", { text: "failing" });
@@ -4420,7 +5219,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
4420
5219
  options: { onPreflightAcceptCommit?: () => Promise<void> } | undefined,
4421
5220
  ) => {
4422
5221
  await options?.onPreflightAcceptCommit?.();
4423
- if (content === "hangs") await new Promise<void>(() => {});
5222
+ if (content === "hangs") await neverSettlingPromise();
4424
5223
  },
4425
5224
  } as unknown as ExtensionAPI;
4426
5225
  const transport = memoryTransport();
@@ -4818,7 +5617,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
4818
5617
  // Production-faithful: the accepted run stays in-flight; sendUserMessage
4819
5618
  // resolution (turn completion) never precedes agent_start (#4668
4820
5619
  // success-retirement).
4821
- await new Promise<void>(() => {});
5620
+ await neverSettlingPromise();
4822
5621
  },
4823
5622
  } as unknown as ExtensionAPI;
4824
5623
  const transport = memoryTransport();