@vellumai/assistant 0.8.10-dev.202606101436.d73da44 → 0.8.10-dev.202606101714.d4b22de

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 (46) hide show
  1. package/openapi.yaml +35 -53
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop.test.ts +214 -0
  4. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +0 -1
  5. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  6. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -4
  7. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -5
  8. package/src/__tests__/conversation-agent-loop.test.ts +5 -43
  9. package/src/__tests__/conversation-provider-retry-repair.test.ts +26 -40
  10. package/src/__tests__/conversation-queue.test.ts +0 -39
  11. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  12. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  13. package/src/__tests__/empty-response-hook.test.ts +19 -0
  14. package/src/__tests__/external-plugin-loader.test.ts +0 -30
  15. package/src/__tests__/history-repair-hook.test.ts +156 -3
  16. package/src/agent/loop.ts +46 -13
  17. package/src/cli/commands/plugins.ts +2 -2
  18. package/src/cli/lib/__tests__/install-from-github.test.ts +89 -317
  19. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +6 -2
  20. package/src/cli/lib/__tests__/plugin-details.test.ts +9 -15
  21. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +1 -1
  22. package/src/cli/lib/__tests__/search-plugins.test.ts +145 -240
  23. package/src/cli/lib/install-from-github.ts +63 -104
  24. package/src/cli/lib/plugin-catalog-cache.ts +9 -9
  25. package/src/cli/lib/plugin-details.ts +37 -67
  26. package/src/cli/lib/plugin-marketplace.ts +40 -12
  27. package/src/cli/lib/search-plugins.ts +29 -129
  28. package/src/config/bundled-skills/skill-management/SKILL.md +13 -2
  29. package/src/daemon/conversation-agent-loop-handlers.ts +30 -48
  30. package/src/daemon/conversation-agent-loop.ts +15 -60
  31. package/src/daemon/conversation-error.ts +4 -15
  32. package/src/daemon/conversation-lifecycle.ts +1 -1
  33. package/src/daemon/conversation-slash.ts +0 -14
  34. package/src/plugin-api/types.ts +33 -12
  35. package/src/plugins/defaults/empty-response/hooks/stop.ts +6 -0
  36. package/src/plugins/defaults/history-repair/hooks/stop.ts +58 -0
  37. package/src/plugins/defaults/history-repair/repair-state-store.ts +47 -0
  38. package/src/plugins/defaults/history-repair/terminal.ts +39 -2
  39. package/src/plugins/defaults/index.ts +9 -3
  40. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +95 -0
  41. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +65 -8
  42. package/src/plugins/defaults/title-generate/hooks/stop.ts +6 -3
  43. package/src/providers/model-catalog.ts +13 -0
  44. package/src/runtime/__tests__/agent-wake.test.ts +0 -1
  45. package/src/runtime/routes/__tests__/plugins-routes.test.ts +30 -10
  46. package/src/runtime/routes/plugins-routes.ts +34 -45
package/openapi.yaml CHANGED
@@ -19406,15 +19406,7 @@ paths:
19406
19406
  - type: "null"
19407
19407
  description: Resolved version (installed copy first, then repo `package.json`).
19408
19408
  source:
19409
- oneOf:
19410
- - type: object
19411
- properties:
19412
- kind:
19413
- type: string
19414
- const: first-party
19415
- required:
19416
- - kind
19417
- additionalProperties: false
19409
+ anyOf:
19418
19410
  - type: object
19419
19411
  properties:
19420
19412
  kind:
@@ -19434,7 +19426,9 @@ paths:
19434
19426
  - repo
19435
19427
  - ref
19436
19428
  additionalProperties: false
19437
- description: "Origin of the match: a first-party in-repo plugin or a whitelisted external one."
19429
+ description: "Origin of the match: a whitelisted external plugin repository."
19430
+ - type: "null"
19431
+ description: Pinned origin from the marketplace entry, or null when an installed copy has no catalog entry.
19438
19432
  readme:
19439
19433
  anyOf:
19440
19434
  - type: string
@@ -19475,13 +19469,12 @@ paths:
19475
19469
  operationId: plugins_install_post
19476
19470
  summary: Install a plugin
19477
19471
  description:
19478
- "Install a plugin by name from the canonical source — a whitelisted `plugins/marketplace.json` entry, else
19479
- the first-party `plugins/<name>/` convention. Always resolves against the curated default git ref (no
19480
- caller-supplied ref): installing from an unreviewed revision would bypass the marketplace/first-party curation
19481
- boundary and let attacker-controlled code be loaded. Materializes the plugin under
19482
- `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's `assistant
19483
- plugins install <name>`. An already-installed name without `force` returns 409; a name that resolves to nothing
19484
- returns 404. Sibling to `POST /v1/skills/install`."
19472
+ "Install a plugin by name from the canonical source — a whitelisted `plugins/marketplace.json` entry.
19473
+ Always resolves against the curated default git ref (no caller-supplied ref): installing from an unreviewed
19474
+ revision would bypass the marketplace curation boundary and let attacker-controlled code be loaded. Materializes
19475
+ the plugin under `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's
19476
+ `assistant plugins install <name>`. An already-installed name without `force` returns 409; a name that resolves
19477
+ to nothing returns 404. Sibling to `POST /v1/skills/install`."
19485
19478
  tags:
19486
19479
  - plugins
19487
19480
  responses:
@@ -19529,7 +19522,7 @@ paths:
19529
19522
  properties:
19530
19523
  name:
19531
19524
  type: string
19532
- description: Install name to resolve against the catalog (first-party directory or marketplace entry).
19525
+ description: Install name to resolve against the marketplace catalog.
19533
19526
  force:
19534
19527
  description: Overwrite an existing install in place. Defaults to false.
19535
19528
  type: boolean
@@ -19541,9 +19534,9 @@ paths:
19541
19534
  operationId: plugins_search_get
19542
19535
  summary: Search the plugin catalog
19543
19536
  description:
19544
- List installable plugins from the canonical `vellum-ai/vellum-assistant` catalog at `plugins/`. The query
19545
- is an ECMAScript regex matched case-insensitively against the directory name (e.g. `memory`, `^simple`). Empty
19546
- query returns every entry. Mirrors the CLI's `assistant plugins search`.
19537
+ List installable plugins from the curated `plugins/marketplace.json` catalog. The query is an ECMAScript
19538
+ regex matched case-insensitively against the plugin name (e.g. `memory`, `^simple`). Empty query returns every
19539
+ entry. Mirrors the CLI's `assistant plugins search`.
19547
19540
  tags:
19548
19541
  - plugins
19549
19542
  responses:
@@ -19570,42 +19563,31 @@ paths:
19570
19563
  description: Install name. Matches `assistant plugins install <name>`.
19571
19564
  path:
19572
19565
  type: string
19573
- description:
19574
- "Human-readable origin: a repo-relative path (`plugins/<name>`) for first-party plugins or a
19575
- `github:owner/repo@ref` locator for external ones."
19566
+ description: "Human-readable origin: a `github:owner/repo@ref` locator for the external plugin."
19576
19567
  description:
19577
19568
  description: Short description, when known (external entries only today).
19578
19569
  type: string
19579
19570
  source:
19580
- oneOf:
19581
- - type: object
19582
- properties:
19583
- kind:
19584
- type: string
19585
- const: first-party
19586
- required:
19587
- - kind
19588
- additionalProperties: false
19589
- - type: object
19590
- properties:
19591
- kind:
19592
- type: string
19593
- const: github
19594
- repo:
19595
- type: string
19596
- description: "`owner/repo` of the external plugin repository."
19597
- path:
19598
- description: Directory within the repo, when the plugin is not at the root.
19599
- type: string
19600
- ref:
19601
- type: string
19602
- description: Pinned git ref the plugin is fetched from.
19603
- required:
19604
- - kind
19605
- - repo
19606
- - ref
19607
- additionalProperties: false
19608
- description: "Origin of the match: a first-party in-repo plugin or a whitelisted external one."
19571
+ type: object
19572
+ properties:
19573
+ kind:
19574
+ type: string
19575
+ const: github
19576
+ repo:
19577
+ type: string
19578
+ description: "`owner/repo` of the external plugin repository."
19579
+ path:
19580
+ description: Directory within the repo, when the plugin is not at the root.
19581
+ type: string
19582
+ ref:
19583
+ type: string
19584
+ description: Pinned git ref the plugin is fetched from.
19585
+ required:
19586
+ - kind
19587
+ - repo
19588
+ - ref
19589
+ additionalProperties: false
19590
+ description: "Origin of the match: a whitelisted external plugin repository."
19609
19591
  required:
19610
19592
  - name
19611
19593
  - path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.10-dev.202606101436.d73da44",
3
+ "version": "0.8.10-dev.202606101714.d4b22de",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,7 @@ import type {
8
8
  import { AgentLoop } from "../agent/loop.js";
9
9
  import { REFUSAL_FALLBACK_TEXT } from "../plugins/defaults/empty-response/hooks/stop.js";
10
10
  import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
11
+ import { registerPlugin } from "../plugins/registry.js";
11
12
  import type {
12
13
  ContentBlock,
13
14
  Message,
@@ -362,6 +363,219 @@ describe("AgentLoop", () => {
362
363
  expect(events.filter((e) => e.type === "error")).toHaveLength(1);
363
364
  });
364
365
 
366
+ // 5c. Reactive ordering-error recovery
367
+ //
368
+ // When the provider rejects a call because the history violates
369
+ // tool-use/tool-result pairing or role-alternation rules, the loop runs
370
+ // `deepRepairHistory` directly and re-issues the call. It is bounded to a
371
+ // single repair pass per provider call, so a second consecutive ordering
372
+ // rejection falls through to the error path instead of looping forever.
373
+ test("repairs the history and re-issues on a provider ordering rejection", async () => {
374
+ // GIVEN a provider that rejects the first call with an ordering error and
375
+ // succeeds on the retry
376
+ const { provider, calls } = createMockProvider([
377
+ new Error(
378
+ "tool_result blocks that are not immediately after a tool_use block",
379
+ ),
380
+ textResponse("recovered"),
381
+ ]);
382
+ const loop = new AgentLoop({
383
+ provider,
384
+ systemPrompt: "system",
385
+ conversationId: "test-conversation",
386
+ });
387
+ const events: AgentEvent[] = [];
388
+
389
+ // WHEN the loop runs
390
+ const { history } = await loop.run({
391
+ messages: [userMessage],
392
+ onEvent: collectEvents(events),
393
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
394
+ });
395
+
396
+ // THEN it re-issued the call after repairing, surfaced no error, and
397
+ // appended the recovered assistant response
398
+ expect(calls).toHaveLength(2);
399
+ expect(events.filter((e) => e.type === "error")).toHaveLength(0);
400
+ expect(history[history.length - 1]).toEqual({
401
+ role: "assistant",
402
+ content: [{ type: "text", text: "recovered" }],
403
+ });
404
+ });
405
+
406
+ test("surfaces an ordering error after a single repair fails to recover", async () => {
407
+ // GIVEN a provider that rejects every call with an ordering error
408
+ const { provider, calls } = createMockProvider([
409
+ new Error("tool_use_id provided without a matching tool_result"),
410
+ ]);
411
+ const loop = new AgentLoop({
412
+ provider,
413
+ systemPrompt: "system",
414
+ conversationId: "test-conversation",
415
+ });
416
+ const events: AgentEvent[] = [];
417
+
418
+ // WHEN the loop runs
419
+ await loop.run({
420
+ messages: [userMessage],
421
+ onEvent: collectEvents(events),
422
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
423
+ });
424
+
425
+ // THEN it repaired once, re-issued once, then surfaced the error rather
426
+ // than retrying again
427
+ expect(calls).toHaveLength(2);
428
+ expect(events.filter((e) => e.type === "error")).toHaveLength(1);
429
+ });
430
+
431
+ test("reports only the retry's output as new messages when deep-repair shrinks the base", async () => {
432
+ // The wrapper reconstructs the persisted transcript from the loop's
433
+ // `newMessages` tail, so after `deepRepairHistory` removes a base message
434
+ // the new-message boundary must track the re-normalized history — otherwise
435
+ // the recovered assistant response is sliced off and lost.
436
+
437
+ // GIVEN an input whose leading assistant message deep-repair strips, a
438
+ // provider that rejects the first call on ordering grounds, then succeeds
439
+ const leadingAssistantMessage: Message = {
440
+ role: "assistant",
441
+ content: [{ type: "text", text: "stale leading turn" }],
442
+ };
443
+ const { provider } = createMockProvider([
444
+ new Error(
445
+ "tool_result blocks that are not immediately after a tool_use block",
446
+ ),
447
+ textResponse("recovered"),
448
+ ]);
449
+ const loop = new AgentLoop({
450
+ provider,
451
+ systemPrompt: "system",
452
+ conversationId: "test-conversation",
453
+ });
454
+
455
+ // WHEN the loop runs
456
+ const { history, newMessages } = await loop.run({
457
+ messages: [leadingAssistantMessage, userMessage],
458
+ onEvent: () => {},
459
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
460
+ });
461
+
462
+ // THEN deep-repair dropped the leading assistant from the base, and
463
+ // `newMessages` is exactly the retry's appended response
464
+ expect(history).toEqual([
465
+ userMessage,
466
+ { role: "assistant", content: [{ type: "text", text: "recovered" }] },
467
+ ]);
468
+ expect(newMessages).toEqual([
469
+ { role: "assistant", content: [{ type: "text", text: "recovered" }] },
470
+ ]);
471
+ });
472
+
473
+ test("repairs a fresh ordering rejection on a later run after a prior run left the bound spent", async () => {
474
+ // The ordering-repair bound is owned by the history-repair plugin's
475
+ // per-conversation state. A run scopes that bound, so the loop clears it on
476
+ // entry — otherwise a direct `run` caller that bypasses the daemon
477
+ // orchestrator (e.g. an agent wake) inherits a spent bound from a prior run
478
+ // on the same conversation and surfaces a repairable rejection instead of
479
+ // repairing it.
480
+
481
+ // GIVEN a first run that rejects on ordering grounds, repairs once, then
482
+ // rejects again — ending with the bound spent and the error surfaced
483
+ const { provider: firstProvider, calls: firstCalls } = createMockProvider([
484
+ new Error("tool_use_id provided without a matching tool_result"),
485
+ new Error("tool_use_id provided without a matching tool_result"),
486
+ ]);
487
+ const conversationId = "ordering-bound-across-runs";
488
+ const firstLoop = new AgentLoop({
489
+ provider: firstProvider,
490
+ systemPrompt: "system",
491
+ conversationId,
492
+ });
493
+ const firstEvents: AgentEvent[] = [];
494
+ await firstLoop.run({
495
+ messages: [userMessage],
496
+ onEvent: collectEvents(firstEvents),
497
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
498
+ });
499
+ expect(firstCalls).toHaveLength(2);
500
+ expect(firstEvents.filter((e) => e.type === "error")).toHaveLength(1);
501
+
502
+ // AND a second run on the same conversation whose first call rejects on
503
+ // ordering grounds, then succeeds on the retry
504
+ const { provider: secondProvider, calls: secondCalls } = createMockProvider(
505
+ [
506
+ new Error(
507
+ "tool_result blocks that are not immediately after a tool_use block",
508
+ ),
509
+ textResponse("recovered"),
510
+ ],
511
+ );
512
+ const secondLoop = new AgentLoop({
513
+ provider: secondProvider,
514
+ systemPrompt: "system",
515
+ conversationId,
516
+ });
517
+ const secondEvents: AgentEvent[] = [];
518
+
519
+ // WHEN the second run executes
520
+ const { history } = await secondLoop.run({
521
+ messages: [userMessage],
522
+ onEvent: collectEvents(secondEvents),
523
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
524
+ });
525
+
526
+ // THEN it repaired and re-issued (the bound did not leak across runs),
527
+ // surfaced no error, and appended the recovered response
528
+ expect(secondCalls).toHaveLength(2);
529
+ expect(secondEvents.filter((e) => e.type === "error")).toHaveLength(0);
530
+ expect(history[history.length - 1]).toEqual({
531
+ role: "assistant",
532
+ content: [{ type: "text", text: "recovered" }],
533
+ });
534
+ });
535
+
536
+ test("a stop-hook throw on a successful stop surfaces through the error path without re-entering the stop chain", async () => {
537
+ // The error-stop recovery is confined to genuine provider rejections. A
538
+ // throw from the success-path stop chain must not re-enter that chain (the
539
+ // same hook would throw again and escape the loop, bypassing the generic
540
+ // error handling) — it must fall through to the standard error path.
541
+
542
+ // GIVEN a registered stop hook that always throws, and a provider that
543
+ // returns a successful no-tool response (a successful stop)
544
+ registerPlugin({
545
+ manifest: { name: "throwing-stop", version: "0.0.1" },
546
+ hooks: {
547
+ stop: async () => {
548
+ throw new Error("stop hook boom");
549
+ },
550
+ },
551
+ });
552
+ const { provider, calls } = createMockProvider([textResponse("done")]);
553
+ const loop = new AgentLoop({
554
+ provider,
555
+ systemPrompt: "system",
556
+ conversationId: "test-conversation",
557
+ });
558
+ const events: AgentEvent[] = [];
559
+
560
+ // WHEN the loop runs
561
+ let rejected = false;
562
+ await loop
563
+ .run({
564
+ messages: [userMessage],
565
+ onEvent: collectEvents(events),
566
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
567
+ })
568
+ .catch(() => {
569
+ rejected = true;
570
+ });
571
+
572
+ // THEN the loop handled the throw via the error path (it did not reject),
573
+ // surfaced exactly one error, and did not re-issue the call
574
+ expect(rejected).toBe(false);
575
+ expect(calls).toHaveLength(1);
576
+ expect(events.filter((e) => e.type === "error")).toHaveLength(1);
577
+ });
578
+
365
579
  // 6. Abort signal — verify the loop respects AbortSignal
366
580
  test("stops when abort signal is triggered before provider call", async () => {
367
581
  const controller = new AbortController();
@@ -76,7 +76,6 @@ function makeTarget(): Conversation {
76
76
  run: async ({ messages }: { messages: Message[] }) => ({
77
77
  history: messages,
78
78
  exitReason: null,
79
- appendedNewMessages: false,
80
79
  newMessages: [],
81
80
  }),
82
81
  },
@@ -274,7 +274,6 @@ mock.module("../agent/loop.js", () => ({
274
274
  return {
275
275
  history,
276
276
  exitReason: null,
277
- appendedNewMessages: true,
278
277
  newMessages: history.slice(messages.length),
279
278
  };
280
279
  }
@@ -264,6 +264,7 @@ mock.module("../plugins/defaults/history-repair/terminal.js", () => ({
264
264
  },
265
265
  }),
266
266
  deepRepairHistory: (msgs: Message[]) => ({ messages: msgs, stats: {} }),
267
+ isRepairableOrderingError: () => false,
267
268
  }));
268
269
 
269
270
  mock.module("../daemon/conversation-usage.js", () => ({
@@ -331,10 +332,6 @@ mock.module("../daemon/conversation-error.js", () => ({
331
332
  isContextTooLarge: () => false,
332
333
  }));
333
334
 
334
- mock.module("../daemon/conversation-slash.js", () => ({
335
- isProviderOrderingError: () => false,
336
- }));
337
-
338
335
  mock.module("../util/truncate.js", () => ({
339
336
  truncate: (s: string) => s,
340
337
  }));
@@ -380,6 +380,7 @@ mock.module("../plugins/defaults/history-repair/terminal.js", () => ({
380
380
  },
381
381
  }),
382
382
  deepRepairHistory: (msgs: Message[]) => ({ messages: msgs, stats: {} }),
383
+ isRepairableOrderingError: () => false,
383
384
  }));
384
385
 
385
386
  const recordUsageMock = mock((..._args: unknown[]) => {});
@@ -479,11 +480,6 @@ mock.module("../daemon/conversation-error.js", () => ({
479
480
  }),
480
481
  }));
481
482
 
482
- mock.module("../daemon/conversation-slash.js", () => ({
483
- isProviderOrderingError: (msg: string) =>
484
- /ordering|before.*after|messages.*order/i.test(msg),
485
- }));
486
-
487
483
  mock.module("../util/truncate.js", () => ({
488
484
  truncate: (s: string) => s,
489
485
  }));
@@ -496,6 +496,7 @@ mock.module("../plugins/defaults/history-repair/terminal.js", () => ({
496
496
  },
497
497
  }),
498
498
  deepRepairHistory: (msgs: Message[]) => ({ messages: msgs, stats: {} }),
499
+ isRepairableOrderingError: () => false,
499
500
  }));
500
501
 
501
502
  const recordUsageMock = mock(() => {});
@@ -573,11 +574,6 @@ mock.module("../daemon/conversation-error.js", () => ({
573
574
  isContextTooLarge: (msg: string) => /context.?length.?exceeded/i.test(msg),
574
575
  }));
575
576
 
576
- mock.module("../daemon/conversation-slash.js", () => ({
577
- isProviderOrderingError: (msg: string) =>
578
- /ordering|before.*after|messages.*order/i.test(msg),
579
- }));
580
-
581
577
  mock.module("../util/truncate.js", () => ({
582
578
  truncate: (s: string) => s,
583
579
  }));
@@ -1545,40 +1541,6 @@ describe("session-agent-loop", () => {
1545
1541
  });
1546
1542
  });
1547
1543
 
1548
- describe("provider ordering error retry", () => {
1549
- test("retries with deep repair when ordering error is detected", async () => {
1550
- const events: ServerMessage[] = [];
1551
-
1552
- // The provider rejects the first call with an ordering error, then
1553
- // succeeds once the orchestrator's deep repair re-sends the turn.
1554
- const { provider, calls } = createMockProvider([
1555
- new Error("messages ordering error"),
1556
- textResponse("fixed"),
1557
- ]);
1558
-
1559
- const ctx = makeCtx({ loopProvider: provider });
1560
- await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1561
-
1562
- expect(calls.length).toBe(2);
1563
- });
1564
-
1565
- test("emits deferred ordering error when retry also fails", async () => {
1566
- const events: ServerMessage[] = [];
1567
-
1568
- // The provider rejects every call with an ordering error, so even the
1569
- // deep-repair retry fails and the orchestrator surfaces the error.
1570
- const ctx = makeCtx({
1571
- providerResponses: [new Error("messages ordering error")],
1572
- });
1573
- await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1574
-
1575
- const conversationError = events.find(
1576
- (e) => e.type === "conversation_error",
1577
- );
1578
- expect(conversationError).toBeDefined();
1579
- });
1580
- });
1581
-
1582
1544
  describe("checkpoint handoff (infinite loop prevention)", () => {
1583
1545
  test("yields at checkpoint when canHandoffAtCheckpoint returns true", async () => {
1584
1546
  const events: ServerMessage[] = [];
@@ -3194,10 +3156,10 @@ describe("session-agent-loop", () => {
3194
3156
  estimatedTokens: 5000,
3195
3157
  });
3196
3158
 
3197
- // GIVEN a real loop that appends a tool turn (so the run reports
3198
- // `appendedNewMessages`) and then rejects with a context-too-large
3199
- // error on the following call — reactive overflow recovery strips that
3200
- // appended history when it compacts before a final call recovers.
3159
+ // GIVEN a real loop that appends a tool turn and then rejects with a
3160
+ // context-too-large error on the following call — reactive overflow
3161
+ // recovery strips that appended history when it compacts before a final
3162
+ // call recovers.
3201
3163
  const ctx = makeCtx({
3202
3164
  providerResponses: [
3203
3165
  toolUseResponse("t1", "file_read", {}),
@@ -199,10 +199,11 @@ mock.module("../agent/loop.js", () => ({
199
199
  await onEvent({ type: "llm_call_started" });
200
200
  agentLoopRunCount++;
201
201
 
202
- // The ordering error only fires on the first call; the wrapper's
203
- // deep-repair retry then re-runs the loop, which succeeds.
204
- const shouldError =
205
- firstRunErrorMode === "ordering" && agentLoopRunCount === 1;
202
+ // Ordering-error recovery lives inside `AgentLoop.run`; with the loop
203
+ // mocked here, an unrecovered ordering error surfaces as a terminal
204
+ // `error` event so we can assert the wrapper's surface behavior — it
205
+ // routes the error to a durable conversation_error and does not re-run.
206
+ const shouldError = firstRunErrorMode === "ordering";
206
207
 
207
208
  if (shouldError) {
208
209
  onEvent({
@@ -220,7 +221,6 @@ mock.module("../agent/loop.js", () => ({
220
221
  return {
221
222
  history: [...messages],
222
223
  exitReason: null,
223
- appendedNewMessages: false,
224
224
  newMessages: [],
225
225
  };
226
226
  }
@@ -243,7 +243,6 @@ mock.module("../agent/loop.js", () => ({
243
243
  return {
244
244
  history,
245
245
  exitReason: null,
246
- appendedNewMessages: true,
247
246
  newMessages: history.slice(messages.length),
248
247
  };
249
248
  }
@@ -300,7 +299,7 @@ function makeConversation(): Conversation {
300
299
  );
301
300
  }
302
301
 
303
- describe("provider ordering error retry", () => {
302
+ describe("terminal provider ordering error", () => {
304
303
  beforeEach(() => {
305
304
  agentLoopRunCount = 0;
306
305
  firstRunErrorMode = "ordering";
@@ -311,29 +310,13 @@ describe("provider ordering error retry", () => {
311
310
  resetPluginRegistryAndRegisterDefaults();
312
311
  });
313
312
 
314
- test("simulated strict provider error triggers exactly one retry", async () => {
313
+ test("surfaces a durable conversation error without re-running the loop", async () => {
314
+ // GIVEN the agent loop reports an unrecovered ordering error
315
315
  firstRunErrorMode = "ordering";
316
-
317
- const conversation = makeConversation();
318
- await conversation.loadFromDb();
319
-
320
- const events: Array<Record<string, unknown>> = [];
321
- await conversation.processMessage({
322
- content: "Hello",
323
- attachments: [],
324
- onEvent: (msg) => events.push(msg as unknown as Record<string, unknown>),
325
- });
326
-
327
- // Should have been called exactly 2 times: original + one retry
328
- expect(agentLoopRunCount).toBe(2);
329
- });
330
-
331
- test("[experimental] retry succeeds with repaired history and no spurious error event", async () => {
332
- firstRunErrorMode = "ordering";
333
-
334
316
  const conversation = makeConversation();
335
317
  await conversation.loadFromDb();
336
318
 
319
+ // WHEN the conversation processes a message
337
320
  const events: Array<Record<string, unknown>> = [];
338
321
  await conversation.processMessage({
339
322
  content: "Hello",
@@ -341,26 +324,26 @@ describe("provider ordering error retry", () => {
341
324
  onEvent: (msg) => events.push(msg as unknown as Record<string, unknown>),
342
325
  });
343
326
 
344
- // Should have a message_complete event (from successful retry)
345
- const messageComplete = events.find((e) => e.type === "message_complete");
346
- expect(messageComplete).toBeDefined();
347
-
348
- // Ordering error should be suppressed when retry succeeds — no error events
349
- const errorEvents = events.filter((e) => e.type === "error");
350
- expect(errorEvents.length).toBe(0);
327
+ // THEN the wrapper invokes the loop exactly once — ordering recovery is
328
+ // the loop's responsibility, not a wrapper-level re-run
329
+ expect(agentLoopRunCount).toBe(1);
351
330
 
352
- // Should also have the assistant response in memory
353
- const messages = conversation.getMessages();
354
- const lastMsg = messages[messages.length - 1];
355
- expect(lastMsg.role).toBe("assistant");
331
+ // AND the error is surfaced as a durable conversation_error consistent
332
+ // with every other terminal provider error
333
+ const conversationError = events.find(
334
+ (e) => e.type === "conversation_error",
335
+ );
336
+ expect(conversationError).toBeDefined();
337
+ expect(conversationError?.code).toBe("PROVIDER_ORDERING");
356
338
  });
357
339
 
358
- test("non-ordering errors do not trigger retry", async () => {
340
+ test("runs the loop once and emits no error on a normal turn", async () => {
341
+ // GIVEN the agent loop completes the turn without error
359
342
  firstRunErrorMode = "none";
360
-
361
343
  const conversation = makeConversation();
362
344
  await conversation.loadFromDb();
363
345
 
346
+ // WHEN the conversation processes a message
364
347
  const events: Array<Record<string, unknown>> = [];
365
348
  await conversation.processMessage({
366
349
  content: "Hello",
@@ -368,7 +351,10 @@ describe("provider ordering error retry", () => {
368
351
  onEvent: (msg) => events.push(msg as unknown as Record<string, unknown>),
369
352
  });
370
353
 
371
- // Should have been called exactly 1 time (no retry for non-ordering errors)
354
+ // THEN the loop ran once and the assistant response was persisted
372
355
  expect(agentLoopRunCount).toBe(1);
356
+ expect(events.some((e) => e.type === "conversation_error")).toBe(false);
357
+ const messages = conversation.getMessages();
358
+ expect(messages[messages.length - 1].role).toBe("assistant");
373
359
  });
374
360
  });