@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.
- package/openapi.yaml +35 -53
- package/package.json +1 -1
- package/src/__tests__/agent-loop.test.ts +214 -0
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +0 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -4
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -5
- package/src/__tests__/conversation-agent-loop.test.ts +5 -43
- package/src/__tests__/conversation-provider-retry-repair.test.ts +26 -40
- package/src/__tests__/conversation-queue.test.ts +0 -39
- package/src/__tests__/conversation-slash-queue.test.ts +0 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
- package/src/__tests__/empty-response-hook.test.ts +19 -0
- package/src/__tests__/external-plugin-loader.test.ts +0 -30
- package/src/__tests__/history-repair-hook.test.ts +156 -3
- package/src/agent/loop.ts +46 -13
- package/src/cli/commands/plugins.ts +2 -2
- package/src/cli/lib/__tests__/install-from-github.test.ts +89 -317
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +6 -2
- package/src/cli/lib/__tests__/plugin-details.test.ts +9 -15
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +1 -1
- package/src/cli/lib/__tests__/search-plugins.test.ts +145 -240
- package/src/cli/lib/install-from-github.ts +63 -104
- package/src/cli/lib/plugin-catalog-cache.ts +9 -9
- package/src/cli/lib/plugin-details.ts +37 -67
- package/src/cli/lib/plugin-marketplace.ts +40 -12
- package/src/cli/lib/search-plugins.ts +29 -129
- package/src/config/bundled-skills/skill-management/SKILL.md +13 -2
- package/src/daemon/conversation-agent-loop-handlers.ts +30 -48
- package/src/daemon/conversation-agent-loop.ts +15 -60
- package/src/daemon/conversation-error.ts +4 -15
- package/src/daemon/conversation-lifecycle.ts +1 -1
- package/src/daemon/conversation-slash.ts +0 -14
- package/src/plugin-api/types.ts +33 -12
- package/src/plugins/defaults/empty-response/hooks/stop.ts +6 -0
- package/src/plugins/defaults/history-repair/hooks/stop.ts +58 -0
- package/src/plugins/defaults/history-repair/repair-state-store.ts +47 -0
- package/src/plugins/defaults/history-repair/terminal.ts +39 -2
- package/src/plugins/defaults/index.ts +9 -3
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +95 -0
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +65 -8
- package/src/plugins/defaults/title-generate/hooks/stop.ts +6 -3
- package/src/providers/model-catalog.ts +13 -0
- package/src/runtime/__tests__/agent-wake.test.ts +0 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +30 -10
- 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
|
-
|
|
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
|
-
|
|
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
|
|
19479
|
-
|
|
19480
|
-
|
|
19481
|
-
|
|
19482
|
-
|
|
19483
|
-
|
|
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
|
|
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
|
|
19545
|
-
|
|
19546
|
-
|
|
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
|
-
|
|
19581
|
-
|
|
19582
|
-
|
|
19583
|
-
|
|
19584
|
-
|
|
19585
|
-
|
|
19586
|
-
|
|
19587
|
-
|
|
19588
|
-
|
|
19589
|
-
|
|
19590
|
-
|
|
19591
|
-
|
|
19592
|
-
|
|
19593
|
-
|
|
19594
|
-
|
|
19595
|
-
|
|
19596
|
-
|
|
19597
|
-
|
|
19598
|
-
|
|
19599
|
-
|
|
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
|
@@ -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();
|
|
@@ -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
|
|
3198
|
-
//
|
|
3199
|
-
//
|
|
3200
|
-
//
|
|
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
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
345
|
-
|
|
346
|
-
expect(
|
|
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
|
-
//
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
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("
|
|
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
|
-
//
|
|
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
|
});
|