@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 +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +50 -4
- package/src/__tests__/agent-loop.test.ts +67 -8
- package/src/__tests__/empty-response-hook.test.ts +76 -63
- package/src/__tests__/history-repair-hook.test.ts +31 -69
- package/src/__tests__/image-recovery-hook.test.ts +29 -68
- package/src/__tests__/title-generate-hook.test.ts +7 -7
- package/src/agent/loop.ts +73 -110
- package/src/plugin-api/index.ts +5 -3
- package/src/plugin-api/types.ts +65 -69
- package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +7 -14
- package/src/plugins/defaults/empty-response/hooks/stop.ts +22 -0
- package/src/plugins/defaults/empty-response/nudge-state-store.ts +5 -7
- package/src/plugins/defaults/history-repair/hooks/post-model-call.ts +15 -45
- package/src/plugins/defaults/history-repair/hooks/stop.ts +7 -14
- package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +28 -58
- package/src/plugins/defaults/image-recovery/hooks/stop.ts +7 -14
- package/src/plugins/defaults/index.ts +4 -1
- package/src/plugins/defaults/title-generate/hooks/stop.ts +9 -14
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
548
|
-
// The
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
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
|
|
585
|
-
//
|
|
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(
|
|
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
|
|
2
|
+
* Tests for the default `empty-response` plugin's hooks.
|
|
3
3
|
*
|
|
4
4
|
* Covers:
|
|
5
|
-
* - The
|
|
6
|
-
* prior tool-use turn → continue (with the canonical nudge text);
|
|
7
|
-
* text → stop; prior turn already delivered visible text → stop;
|
|
8
|
-
* call with no prior turn → stop; provider refusal → stop with
|
|
9
|
-
* rewritten to the user-facing fallback; refusal-but-recovered →
|
|
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
|
|
17
|
-
*
|
|
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
|
|
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("
|
|
352
|
-
// GIVEN the hook nudged once
|
|
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-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
377
|
-
|
|
378
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
27
|
-
*
|
|
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
|
|
270
|
-
|
|
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
|
|
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
|
|
317
|
-
//
|
|
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
|
|
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
|
|
340
|
-
//
|
|
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
|
-
|
|
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
|
|
401
|
-
// GIVEN a turn marked a repair-retry
|
|
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
|
|
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("
|
|
415
|
-
// GIVEN a turn marked a repair-retry
|
|
416
|
-
//
|
|
417
|
-
const conversationId = "conv-
|
|
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
|
|
421
|
-
await stop(makeStopCtx({ conversationId,
|
|
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
|
|
424
|
-
//
|
|
425
|
-
expect(isOrderingRepairAttempted(conversationId)).toBe(
|
|
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
|
});
|