@vellumai/assistant 0.8.10-dev.202606110941.6cb149d → 0.8.10-dev.202606111140.6a5e88c

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.10-dev.202606110941.6cb149d",
3
+ "version": "0.8.10-dev.202606111140.6a5e88c",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  * Sites not exercised here (`aborted_via_error`) require deeper provider
14
14
  * fakery and are best covered by integration tests.
15
15
  */
16
- import { afterEach, describe, expect, test } from "bun:test";
16
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
17
17
 
18
18
  import type { PostCompactContext } from "@vellumai/plugin-api";
19
19
 
@@ -31,7 +31,10 @@ import {
31
31
  disposeContextWindowManager,
32
32
  getContextWindowManager,
33
33
  } from "../plugins/defaults/compaction/manager-store.js";
34
- import { registerPlugin } from "../plugins/registry.js";
34
+ import {
35
+ registerPlugin,
36
+ resetPluginRegistryForTests,
37
+ } from "../plugins/registry.js";
35
38
  import type {
36
39
  Message,
37
40
  Provider,
@@ -49,7 +52,7 @@ import type {
49
52
  let postCompactImpl:
50
53
  | ((input: PostCompactContext) => Promise<Message[]>)
51
54
  | null = null;
52
- registerPlugin({
55
+ const testPostCompactPlugin = {
53
56
  manifest: { name: "test-post-compact", version: "0.0.0" },
54
57
  hooks: {
55
58
  [HOOKS.POST_COMPACT]: async (input: PostCompactContext): Promise<void> => {
@@ -58,7 +61,7 @@ registerPlugin({
58
61
  : input.history;
59
62
  },
60
63
  },
61
- });
64
+ };
62
65
 
63
66
  // ---------------------------------------------------------------------------
64
67
  // Helpers (mirrored from agent-loop.test.ts so this file is self-contained)
@@ -169,6 +172,14 @@ function countExitEvents(events: AgentEvent[]): number {
169
172
  // ---------------------------------------------------------------------------
170
173
 
171
174
  describe("AgentLoop exit-reason instrumentation", () => {
175
+ // Reset the plugin registry to a known state so ambient registrations from
176
+ // other test files (e.g. the default plugins) cannot leak into the
177
+ // post-compact hook chain these tests drive.
178
+ beforeEach(() => {
179
+ resetPluginRegistryForTests();
180
+ registerPlugin(testPostCompactPlugin);
181
+ });
182
+
172
183
  afterEach(() => {
173
184
  disposeContextWindowManager("test-conversation");
174
185
  });
@@ -227,6 +238,41 @@ describe("AgentLoop exit-reason instrumentation", () => {
227
238
  expect(events[events.length - 1].type).toBe("agent_loop_exit");
228
239
  });
229
240
 
241
+ test("emits agent_loop_exit even when a stop hook throws", async () => {
242
+ // A third-party teardown hook that rejects must not suppress the terminal
243
+ // exit: the loop isolates the stop chain, logs the failure, and still emits
244
+ // `agent_loop_exit` exactly once with the real reason.
245
+ registerPlugin({
246
+ manifest: { name: "throwing-stop", version: "0.0.0" },
247
+ hooks: {
248
+ [HOOKS.STOP]: async (): Promise<void> => {
249
+ throw new Error("teardown boom");
250
+ },
251
+ },
252
+ });
253
+
254
+ const { provider } = createMockProvider([textResponse("Hi there!")]);
255
+ const loop = new AgentLoop({
256
+ provider: provider,
257
+ systemPrompt: "system prompt",
258
+ conversationId: "test-conversation",
259
+ });
260
+
261
+ const events: AgentEvent[] = [];
262
+ await loop.run({
263
+ requestId: "test-request",
264
+ messages: [userMessage],
265
+ onEvent: (e) => {
266
+ events.push(e);
267
+ },
268
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
269
+ });
270
+
271
+ expect(countExitEvents(events)).toBe(1);
272
+ expect(lastExitEvent(events)?.reason).toBe("no_tool_calls");
273
+ expect(events[events.length - 1].type).toBe("agent_loop_exit");
274
+ });
275
+
230
276
  test("emits continuation surface event and exits on max_tokens", async () => {
231
277
  const { provider } = createMockProvider([
232
278
  maxTokensResponse("Partial answer"),
@@ -6,6 +6,7 @@ import type {
6
6
  CheckpointInfo,
7
7
  } from "../agent/loop.js";
8
8
  import { AgentLoop } from "../agent/loop.js";
9
+ import type { StopContext } from "../plugin-api/types.js";
9
10
  import { REFUSAL_FALLBACK_TEXT } from "../plugins/defaults/empty-response/hooks/post-model-call.js";
10
11
  import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
11
12
  import { registerPlugin } from "../plugins/registry.js";
@@ -544,11 +545,12 @@ describe("AgentLoop", () => {
544
545
  });
545
546
  });
546
547
 
547
- test("a stop-hook throw on a successful stop surfaces through the error path without re-entering the stop chain", async () => {
548
- // The error-stop recovery is confined to genuine provider rejections. A
549
- // throw from the success-path stop chain must not re-enter that chain (the
550
- // same hook would throw again and escape the loop, bypassing the generic
551
- // error handling) — it must fall through to the standard error path.
548
+ test("isolates a throwing stop hook on a successful stop and still emits the terminal exit", async () => {
549
+ // The `stop` chain is the loop's terminal teardown: a throwing teardown
550
+ // hook (e.g. a third-party plugin) is logged and contained, never escalated
551
+ // into a turn error. The turn's real outcome stands — a successful no-tool
552
+ // stop — and the terminal `agent_loop_exit` still fires so the run stays
553
+ // observable.
552
554
 
553
555
  // GIVEN a registered stop hook that always throws, and a provider that
554
556
  // returns a successful no-tool response (a successful stop)
@@ -581,11 +583,68 @@ describe("AgentLoop", () => {
581
583
  rejected = true;
582
584
  });
583
585
 
584
- // THEN the loop handled the throw via the error path (it did not reject),
585
- // surfaced exactly one error, and did not re-issue the call
586
+ // THEN the loop did not reject, did not re-issue the call, surfaced no
587
+ // error event, and still emitted the terminal exit with the real reason
586
588
  expect(rejected).toBe(false);
587
589
  expect(calls).toHaveLength(1);
588
- expect(events.filter((e) => e.type === "error")).toHaveLength(1);
590
+ expect(events.filter((e) => e.type === "error")).toHaveLength(0);
591
+ const exitEvents = events.filter((e) => e.type === "agent_loop_exit");
592
+ expect(exitEvents).toHaveLength(1);
593
+ expect(exitEvents[0]).toMatchObject({ reason: "no_tool_calls" });
594
+ });
595
+
596
+ test("fires the stop teardown chain on a checkpoint handoff without emitting agent_loop_exit", async () => {
597
+ // A handoff pauses this run so the orchestrator can drain a queued message,
598
+ // then re-enters with a fresh run. It still ends *this* turn, so the
599
+ // terminal `stop` chain must fire — that is what runs per-turn teardown
600
+ // (e.g. clearing the recovery bounds the post-model-call hooks set) before
601
+ // the queued message is processed. But `agent_loop_exit` must NOT be
602
+ // emitted, because the handoff is a control transfer, not a terminal exit.
603
+
604
+ // GIVEN a stop hook recording every exit reason it observes, and a provider
605
+ // whose first reply requests a tool so the loop reaches a checkpoint
606
+ const stopReasons: string[] = [];
607
+ registerPlugin({
608
+ manifest: { name: "recording-stop", version: "0.0.1" },
609
+ hooks: {
610
+ stop: async (ctx: StopContext) => {
611
+ stopReasons.push(ctx.exitReason);
612
+ },
613
+ },
614
+ });
615
+ const { provider } = createMockProvider([
616
+ toolUseResponse("t1", "read_file", { path: "/a.txt" }),
617
+ textResponse("never reached"),
618
+ ]);
619
+ const toolExecutor = async () => ({ content: "ok", isError: false });
620
+ const loop = new AgentLoop({
621
+ provider,
622
+ systemPrompt: "system",
623
+ conversationId: "test-conversation",
624
+ tools: dummyTools,
625
+ toolExecutor,
626
+ });
627
+ const events: AgentEvent[] = [];
628
+
629
+ // AND a checkpoint callback that hands off at the first opportunity
630
+ const onCheckpoint = (_info: CheckpointInfo): CheckpointDecision =>
631
+ "handoff";
632
+
633
+ // WHEN the loop runs and yields control at the checkpoint
634
+ const { exitReason } = await loop.run({
635
+ requestId: "test-request",
636
+ messages: [userMessage],
637
+ onEvent: collectEvents(events),
638
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
639
+ onCheckpoint,
640
+ });
641
+
642
+ // THEN the terminal stop chain fired exactly once with the handoff reason
643
+ // (teardown ran), the run reported the handoff, and no agent_loop_exit was
644
+ // emitted
645
+ expect(stopReasons).toEqual(["checkpoint_handoff"]);
646
+ expect(exitReason).toBe("handoff");
647
+ expect(events.filter((e) => e.type === "agent_loop_exit")).toHaveLength(0);
589
648
  });
590
649
 
591
650
  // 6. Abort signal — verify the loop respects AbortSignal
@@ -1,21 +1,21 @@
1
1
  /**
2
- * Tests for the default `empty-response` plugin's `post-model-call` hook.
2
+ * Tests for the default `empty-response` plugin's hooks.
3
3
  *
4
4
  * Covers:
5
- * - The default hook's decision for the canonical cases: empty turn after a
6
- * prior tool-use turn → continue (with the canonical nudge text); visible
7
- * text → stop; prior turn already delivered visible text → stop; first model
8
- * call with no prior turn → stop; provider refusal → stop with the turn
9
- * rewritten to the user-facing fallback; refusal-but-recovered → stop;
10
- * refusal after a visible reply this run → stop with no rewrite.
5
+ * - The `post-model-call` hook's decision for the canonical cases: empty turn
6
+ * after a prior tool-use turn → continue (with the canonical nudge text);
7
+ * visible text → stop; prior turn already delivered visible text → stop;
8
+ * first model call with no prior turn → stop; provider refusal → stop with
9
+ * the turn rewritten to the user-facing fallback; refusal-but-recovered →
10
+ * stop; refusal after a visible reply this run → stop with no rewrite.
11
11
  * - The hook scopes its prior-turn signals to the current response cycle (the
12
12
  * messages after the last genuine user prompt), so visible text from the
13
13
  * inbound conversation does not suppress the nudge.
14
14
  * - When the hook continues, it appends the nudge as a `user` message to
15
15
  * `messages`.
16
- * - The hook owns its retry bound: it nudges at most once per run for a
17
- * conversation, and a terminal outcome clears the bound so the next run
18
- * nudges afresh.
16
+ * - The retry bound is split across the two hooks: `post-model-call` marks the
17
+ * bound (nudging at most once per run) and the `stop` hook clears it on the
18
+ * definitive terminal so the next run nudges afresh.
19
19
  * - The hook ignores outcomes it does not own: a provider rejection (it carries
20
20
  * an `error`) and a tool-bearing turn (the loop continues on its own).
21
21
  * - End-to-end through `runHook` + the registry: registering the default
@@ -34,13 +34,19 @@ import { HOOKS } from "../plugin-api/constants.js";
34
34
  import type {
35
35
  PluginLogger,
36
36
  PostModelCallContext,
37
+ StopContext,
37
38
  } from "../plugin-api/types.js";
38
39
  import {
39
40
  NUDGE_TEXT,
40
41
  REFUSAL_FALLBACK_TEXT,
41
42
  } from "../plugins/defaults/empty-response/hooks/post-model-call.js";
42
43
  import postModelCall from "../plugins/defaults/empty-response/hooks/post-model-call.js";
43
- import { resetEmptyResponseNudgeStoreForTests } from "../plugins/defaults/empty-response/nudge-state-store.js";
44
+ import stop from "../plugins/defaults/empty-response/hooks/stop.js";
45
+ import {
46
+ isEmptyResponseNudged,
47
+ markEmptyResponseNudged,
48
+ resetEmptyResponseNudgeStoreForTests,
49
+ } from "../plugins/defaults/empty-response/nudge-state-store.js";
44
50
  import { defaultEmptyResponsePlugin } from "../plugins/defaults/index.js";
45
51
  import { runHook } from "../plugins/pipeline.js";
46
52
  import {
@@ -348,69 +354,76 @@ describe("empty-response post-model-call hook — one-shot bound", () => {
348
354
  expect(second.messages).toEqual([priorToolUseTurn]);
349
355
  });
350
356
 
351
- test("a provider rejection clears the bound so the next run nudges afresh", async () => {
352
- // GIVEN the hook nudged once, then the re-queried call threw a provider
353
- // rejection (which the hook does not own, but which ends this turn).
357
+ test("the hook never clears its own bound — clearing is the stop hook's job", async () => {
358
+ // GIVEN the hook nudged once this run for the conversation.
354
359
  const nudged = makeCtx({
355
- conversationId: "conv-err-reset",
360
+ conversationId: "conv-no-self-clear",
356
361
  messages: [priorToolUseTurn],
357
362
  content: [],
358
363
  });
359
364
  await postModelCall(nudged);
360
- const rejected = makeCtx({
361
- conversationId: "conv-err-reset",
362
- messages: [priorToolUseTurn],
363
- content: [],
364
- error: new Error("provider rejected the request"),
365
- });
366
- await postModelCall(rejected);
367
365
 
368
- // WHEN a fresh empty-after-tools turn arrives for the same conversation.
369
- const next = makeCtx({
370
- conversationId: "conv-err-reset",
371
- messages: [priorToolUseTurn],
372
- content: [],
373
- });
374
- await postModelCall(next);
366
+ // WHEN the re-queried call comes back as a provider rejection, then as a
367
+ // recovered visible-text reply — outcomes that end the turn.
368
+ await postModelCall(
369
+ makeCtx({
370
+ conversationId: "conv-no-self-clear",
371
+ messages: [priorToolUseTurn],
372
+ content: [],
373
+ error: new Error("provider rejected the request"),
374
+ }),
375
+ );
376
+ await postModelCall(
377
+ makeCtx({
378
+ conversationId: "conv-no-self-clear",
379
+ messages: [priorToolUseTurn],
380
+ content: [{ type: "text", text: "here is the summary" }],
381
+ }),
382
+ );
375
383
 
376
- // THEN the bound was cleared on the rejection, so the hook nudges again.
377
- expect(next.decision).toBe("continue");
378
- expect(next.messages.at(-1)).toEqual({
379
- role: "user",
380
- content: [{ type: "text", text: NUDGE_TEXT }],
381
- });
384
+ // THEN the bound stays marked — `post-model-call` only ever marks; the
385
+ // sibling `stop` hook is what clears it at the turn boundary.
386
+ expect(isEmptyResponseNudged("conv-no-self-clear")).toBe(true);
382
387
  });
388
+ });
383
389
 
384
- test("a recovered turn clears the bound so the next run nudges afresh", async () => {
385
- // GIVEN the hook nudged once, then the run recovered with visible text
386
- // (which clears the bound).
387
- const nudged = makeCtx({
388
- conversationId: "conv-reset",
389
- messages: [priorToolUseTurn],
390
- content: [],
391
- });
392
- await postModelCall(nudged);
393
- const recovered = makeCtx({
394
- conversationId: "conv-reset",
395
- messages: [priorToolUseTurn],
396
- content: [{ type: "text", text: "here is the summary" }],
397
- });
398
- await postModelCall(recovered);
390
+ // ─── stop hook (terminal cleanup) ────────────────────────────────────────────
399
391
 
400
- // WHEN a fresh empty-after-tools turn arrives for the same conversation.
401
- const next = makeCtx({
402
- conversationId: "conv-reset",
403
- messages: [priorToolUseTurn],
404
- content: [],
405
- });
406
- await postModelCall(next);
392
+ function makeStopCtx(overrides: Partial<StopContext> = {}): StopContext {
393
+ return {
394
+ conversationId: "conv-stop",
395
+ messages: [],
396
+ exitReason: "no_tool_calls",
397
+ logger: noopLogger,
398
+ ...overrides,
399
+ };
400
+ }
407
401
 
408
- // THEN the bound was cleared, so the hook nudges again.
409
- expect(next.decision).toBe("continue");
410
- expect(next.messages.at(-1)).toEqual({
411
- role: "user",
412
- content: [{ type: "text", text: NUDGE_TEXT }],
413
- });
402
+ describe("empty-response stop hook — terminal cleanup", () => {
403
+ test("a terminal stop clears the nudge bound", async () => {
404
+ // GIVEN a conversation that nudged this run.
405
+ const conversationId = "conv-stop-clear";
406
+ markEmptyResponseNudged(conversationId);
407
+
408
+ // WHEN the terminal stop hook runs.
409
+ await stop(makeStopCtx({ conversationId }));
410
+
411
+ // THEN the bound is cleared so the next run nudges afresh.
412
+ expect(isEmptyResponseNudged(conversationId)).toBe(false);
413
+ });
414
+
415
+ test("clears the bound regardless of how the turn ended", async () => {
416
+ // GIVEN a conversation that nudged this run, ending on an abort rather than
417
+ // a finalized reply.
418
+ const conversationId = "conv-stop-abort";
419
+ markEmptyResponseNudged(conversationId);
420
+
421
+ // WHEN the terminal stop hook runs for that exit.
422
+ await stop(makeStopCtx({ conversationId, exitReason: "aborted_pre_call" }));
423
+
424
+ // THEN the bound is still cleared — `stop` is the definitive terminal, so
425
+ // the next run always nudges afresh.
426
+ expect(isEmptyResponseNudged(conversationId)).toBe(false);
414
427
  });
415
428
  });
416
429
 
@@ -14,20 +14,17 @@
14
14
  *
15
15
  * `post-model-call` (error-recovery):
16
16
  * - On a provider rejection carrying a repairable ordering error, the hook
17
- * deep-repairs the messages and asks the loop to continue.
18
- * - Bounded to one pass per turn via the per-conversation repair state: a
19
- * second consecutive ordering rejection is left to surface; the bound clears
20
- * at the turn boundary.
21
- * - Leaves the bound intact across a mid-turn tool-bearing turn so a later
22
- * ordering rejection is still the exhausted second attempt.
23
- * - Ignores non-ordering errors and finalized (non-error) replies.
17
+ * deep-repairs the messages, marks the per-conversation bound, and asks the
18
+ * loop to continue.
19
+ * - Bounded to one pass per turn via that bound: a second consecutive ordering
20
+ * rejection is left to surface (the bound is cleared by the `stop` hook, not
21
+ * here).
22
+ * - Ignores non-ordering errors, tool-bearing turns, and finalized (non-error)
23
+ * replies — none of which it ever touches the bound for.
24
24
  *
25
25
  * `stop` (terminal cleanup):
26
- * - Clears the repair bound on a terminal stop, covering the retry the loop's
27
- * per-run backstop overrides — the one terminal `post-model-call` cannot
28
- * resolve — so the next turn repairs afresh.
29
- * - Leaves the bound intact on a `"continue"` stop, where a hook is re-querying
30
- * the model this turn.
26
+ * - Clears the repair bound unconditionally on the definitive terminal stop, so
27
+ * the next turn always repairs afresh no matter how the turn ended.
31
28
  */
32
29
 
33
30
  import { beforeEach, describe, expect, test } from "bun:test";
@@ -266,39 +263,12 @@ describe("history-repair post-model-call hook — direct", () => {
266
263
  expect(ctx.decision).toBe("stop");
267
264
  expect(ctx.messages).toEqual(messages);
268
265
 
269
- // AND the exhausted bound is cleared so the next turn repairs afresh.
270
- expect(isOrderingRepairAttempted(conversationId)).toBe(false);
271
- });
272
-
273
- test("a finalized reply clears the bound so a later turn repairs again", async () => {
274
- // GIVEN a conversation that already attempted a repair this turn.
275
- const conversationId = "conv-reset";
276
- markOrderingRepairAttempted(conversationId);
277
-
278
- // AND a finalized no-tool reply ends that turn, which the hook treats as
279
- // terminal.
280
- await postModelCall(
281
- makePostModelCallCtx({
282
- conversationId,
283
- content: [{ type: "text", text: "done" }],
284
- stopReason: "end_turn",
285
- }),
286
- );
287
- expect(isOrderingRepairAttempted(conversationId)).toBe(false);
288
-
289
- // WHEN a later turn hits a repairable ordering rejection.
290
- const ctx = makePostModelCallCtx({
291
- conversationId,
292
- messages: orderingViolatingHistory(),
293
- error: new Error(ORDERING_ERROR_MESSAGE),
294
- });
295
- await postModelCall(ctx);
296
-
297
- // THEN the hook repairs independently of the prior turn.
298
- expect(ctx.decision).toBe("continue");
266
+ // AND the bound stays marked — the hook never clears it; the `stop` hook
267
+ // clears it at the turn boundary.
268
+ expect(isOrderingRepairAttempted(conversationId)).toBe(true);
299
269
  });
300
270
 
301
- test("a non-ordering outcome already continuing this turn keeps the bound", async () => {
271
+ test("a non-ordering error while continuing is a no-op", async () => {
302
272
  // GIVEN this turn already repaired an ordering rejection (bound marked),
303
273
  // then an earlier hook recovered a different error and set the decision to
304
274
  // continue.
@@ -313,16 +283,13 @@ describe("history-repair post-model-call hook — direct", () => {
313
283
  // WHEN the history-repair hook runs after that earlier hook.
314
284
  await postModelCall(ctx);
315
285
 
316
- // THEN it leaves the in-flight continue alone and keeps its bound, so a
317
- // later ordering rejection in the same turn is recognized as the exhausted
318
- // second attempt rather than a fresh first one. The error retry shares the
319
- // loop's per-run backstop, but clearing here could let alternating
320
- // recoverable rejections reissue the provider call within that cap.
286
+ // THEN it leaves the in-flight continue and the bound alone — it only acts
287
+ // on a repairable ordering rejection.
321
288
  expect(ctx.decision).toBe("continue");
322
289
  expect(isOrderingRepairAttempted(conversationId)).toBe(true);
323
290
  });
324
291
 
325
- test("a mid-turn tool-bearing turn keeps the bound", async () => {
292
+ test("a mid-turn tool-bearing turn is a no-op", async () => {
326
293
  // GIVEN this turn already repaired an ordering rejection (bound marked),
327
294
  // then the model returned a tool-bearing turn the loop continues on its
328
295
  // own.
@@ -336,9 +303,8 @@ describe("history-repair post-model-call hook — direct", () => {
336
303
  // WHEN the hook runs over the tool-bearing turn.
337
304
  await postModelCall(ctx);
338
305
 
339
- // THEN it leaves the decision alone and keeps its bound, so a later
340
- // ordering rejection in the same turn is still the exhausted second
341
- // attempt.
306
+ // THEN it leaves the decision and the bound alone — there is no provider
307
+ // rejection to act on.
342
308
  expect(ctx.decision).toBe("stop");
343
309
  expect(isOrderingRepairAttempted(conversationId)).toBe(true);
344
310
  });
@@ -384,9 +350,7 @@ function makeStopCtx(overrides: Partial<StopContext> = {}): StopContext {
384
350
  return {
385
351
  conversationId: "conv-stop",
386
352
  messages: [],
387
- responseContent: [],
388
- stopReason: null,
389
- decision: "stop",
353
+ exitReason: "no_tool_calls",
390
354
  logger: noopLogger,
391
355
  ...overrides,
392
356
  };
@@ -397,31 +361,29 @@ describe("history-repair stop hook — direct", () => {
397
361
  resetRepairStateStoreForTests();
398
362
  });
399
363
 
400
- test("a terminal stop clears a bound the backstop stranded", async () => {
401
- // GIVEN a turn marked a repair-retry, but the loop's per-run backstop
402
- // refused the continue and surfaced the rejection through the terminal
403
- // stop chain without re-running post-model-call — so the bound is stranded.
364
+ test("a terminal stop clears the repair bound", async () => {
365
+ // GIVEN a turn marked a repair-retry.
404
366
  const conversationId = "conv-backstop";
405
367
  markOrderingRepairAttempted(conversationId);
406
368
 
407
369
  // WHEN the terminal stop hook runs.
408
370
  await stop(makeStopCtx({ conversationId }));
409
371
 
410
- // THEN the stranded bound is cleared so the next turn repairs afresh.
372
+ // THEN the bound is cleared so the next turn repairs afresh.
411
373
  expect(isOrderingRepairAttempted(conversationId)).toBe(false);
412
374
  });
413
375
 
414
- test("a continuing stop keeps the bound for the in-flight retry", async () => {
415
- // GIVEN a turn marked a repair-retry, and a stop hook is re-querying the
416
- // model this turn (decision is continue).
417
- const conversationId = "conv-continue";
376
+ test("clears the bound regardless of how the turn ended", async () => {
377
+ // GIVEN a turn marked a repair-retry that ends on an abort rather than a
378
+ // finalized reply.
379
+ const conversationId = "conv-abort";
418
380
  markOrderingRepairAttempted(conversationId);
419
381
 
420
- // WHEN the stop hook runs on that continuing stop.
421
- await stop(makeStopCtx({ conversationId, decision: "continue" }));
382
+ // WHEN the terminal stop hook runs for that exit.
383
+ await stop(makeStopCtx({ conversationId, exitReason: "aborted_pre_call" }));
422
384
 
423
- // THEN the bound survives so a later ordering rejection this turn is still
424
- // recognized as the exhausted second attempt.
425
- expect(isOrderingRepairAttempted(conversationId)).toBe(true);
385
+ // THEN the bound is still cleared — `stop` is the definitive terminal, so
386
+ // the next turn always repairs afresh.
387
+ expect(isOrderingRepairAttempted(conversationId)).toBe(false);
426
388
  });
427
389
  });