@vellumai/assistant 0.8.9-staging.1 → 0.8.9-staging.2
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/ARCHITECTURE.md +6 -6
- package/openapi.yaml +6 -7
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
- package/src/__tests__/conversation-agent-loop.test.ts +48 -96
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
- package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
- package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
- package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
- package/src/acp/auto-install.test.ts +130 -46
- package/src/acp/auto-install.ts +86 -31
- package/src/acp/prepare-agent-env.ts +8 -9
- package/src/acp/resolve-agent.test.ts +26 -123
- package/src/acp/resolve-agent.ts +26 -85
- package/src/acp/resume-hint.ts +1 -3
- package/src/acp/session-manager.ts +32 -20
- package/src/acp/types.ts +0 -8
- package/src/agent/loop.ts +42 -26
- package/src/config/bundled-skills/acp/SKILL.md +14 -14
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
- package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/schemas/call-site-catalog.ts +0 -14
- package/src/config/schemas/llm.ts +0 -2
- package/src/context/compactor.ts +22 -4
- package/src/daemon/conversation-agent-loop.ts +15 -57
- package/src/daemon/conversation.ts +23 -3
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +0 -7
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
- package/src/plugins/defaults/compaction/manager-store.ts +13 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
- package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
- package/src/runtime/routes/plugins-routes.ts +10 -9
- package/src/tools/acp/list-agents.test.ts +2 -2
- package/src/tools/acp/spawn.test.ts +103 -225
- package/src/tools/acp/spawn.ts +10 -120
- package/src/tools/apps/executors.ts +153 -42
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
package/ARCHITECTURE.md
CHANGED
|
@@ -1088,12 +1088,12 @@ All overflow recovery settings live under `contextWindow.overflowRecovery` in th
|
|
|
1088
1088
|
|
|
1089
1089
|
### Key Source Files
|
|
1090
1090
|
|
|
1091
|
-
| File
|
|
1092
|
-
|
|
|
1093
|
-
| `src/
|
|
1094
|
-
| `src/daemon/context-overflow-policy.ts`
|
|
1095
|
-
| `src/daemon/conversation-agent-loop.ts`
|
|
1096
|
-
| `src/config/core-schema.ts`
|
|
1091
|
+
| File | Purpose |
|
|
1092
|
+
| ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
1093
|
+
| `src/plugins/defaults/compaction/context-overflow-reducer.ts` | Tiered reducer: four-tier pipeline with idempotent steps and cumulative state |
|
|
1094
|
+
| `src/daemon/context-overflow-policy.ts` | Overflow policy resolver: maps config + interactivity to concrete action |
|
|
1095
|
+
| `src/daemon/conversation-agent-loop.ts` | Integration: preflight budget check, convergence loop, emergency compaction |
|
|
1096
|
+
| `src/config/core-schema.ts` | `ContextOverflowRecoveryConfigSchema` with defaults and validation |
|
|
1097
1097
|
|
|
1098
1098
|
---
|
|
1099
1099
|
|
package/openapi.yaml
CHANGED
|
@@ -19548,11 +19548,13 @@ paths:
|
|
|
19548
19548
|
operationId: plugins_install_post
|
|
19549
19549
|
summary: Install a plugin
|
|
19550
19550
|
description:
|
|
19551
|
-
Install a plugin by name from the canonical source — a whitelisted `experimental/plugins/marketplace.json`
|
|
19552
|
-
entry, else the first-party `experimental/plugins/<name>/` convention.
|
|
19553
|
-
|
|
19551
|
+
"Install a plugin by name from the canonical source — a whitelisted `experimental/plugins/marketplace.json`
|
|
19552
|
+
entry, else the first-party `experimental/plugins/<name>/` convention. Always resolves against the curated
|
|
19553
|
+
default git ref (no caller-supplied ref): installing from an unreviewed revision would bypass the
|
|
19554
|
+
marketplace/first-party curation boundary and let attacker-controlled code be loaded. Materializes the plugin
|
|
19555
|
+
under `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's `assistant
|
|
19554
19556
|
plugins install <name>`. An already-installed name without `force` returns 409; a name that resolves to nothing
|
|
19555
|
-
|
|
19557
|
+
returns 404. Sibling to `POST /v1/skills/install`."
|
|
19556
19558
|
tags:
|
|
19557
19559
|
- plugins
|
|
19558
19560
|
responses:
|
|
@@ -19601,9 +19603,6 @@ paths:
|
|
|
19601
19603
|
name:
|
|
19602
19604
|
type: string
|
|
19603
19605
|
description: Install name to resolve against the catalog (first-party directory or marketplace entry).
|
|
19604
|
-
ref:
|
|
19605
|
-
description: Optional git ref to install from. Defaults to the CLI's `DEFAULT_PLUGIN_REF`.
|
|
19606
|
-
type: string
|
|
19607
19606
|
force:
|
|
19608
19607
|
description: Overwrite an existing install in place. Defaults to false.
|
|
19609
19608
|
type: boolean
|
package/package.json
CHANGED
|
@@ -13,16 +13,21 @@
|
|
|
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 { describe, expect, mock, test } from "bun:test";
|
|
16
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
17
17
|
|
|
18
18
|
import type {
|
|
19
19
|
AgentEvent,
|
|
20
|
-
AgentLoopRunOptions,
|
|
21
20
|
CheckpointDecision,
|
|
22
21
|
CheckpointInfo,
|
|
23
22
|
} from "../agent/loop.js";
|
|
24
23
|
import { AgentLoop, isMaxTokensStopReason } from "../agent/loop.js";
|
|
24
|
+
import type { ContextWindowConfig } from "../config/types.js";
|
|
25
25
|
import type { TrustContext } from "../daemon/trust-context.js";
|
|
26
|
+
import {
|
|
27
|
+
createContextWindowManager,
|
|
28
|
+
disposeContextWindowManager,
|
|
29
|
+
getContextWindowManager,
|
|
30
|
+
} from "../plugins/defaults/compaction/manager-store.js";
|
|
26
31
|
import type { PostCompactContext } from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
|
|
27
32
|
import type {
|
|
28
33
|
Message,
|
|
@@ -118,20 +123,26 @@ const userMessage: Message = {
|
|
|
118
123
|
content: [{ type: "text", text: "Hello" }],
|
|
119
124
|
};
|
|
120
125
|
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
function fakeCompaction(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
} {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
};
|
|
126
|
+
// Register a context-window manager whose `maybeCompact` returns a canned
|
|
127
|
+
// result in the compaction store the loop resolves from, so the loop's
|
|
128
|
+
// compaction call runs without the real orchestrator machinery. Returns the
|
|
129
|
+
// trust snapshot the loop also needs as a run option.
|
|
130
|
+
function fakeCompaction(
|
|
131
|
+
conversationId: string,
|
|
132
|
+
result: { compacted: boolean; exhausted: boolean },
|
|
133
|
+
): { trust: TrustContext } {
|
|
134
|
+
createContextWindowManager({
|
|
135
|
+
provider: { name: "mock-provider" } as unknown as Provider,
|
|
136
|
+
systemPrompt: "system",
|
|
137
|
+
config: {} as unknown as ContextWindowConfig,
|
|
138
|
+
conversationId,
|
|
139
|
+
});
|
|
140
|
+
const manager = getContextWindowManager(conversationId);
|
|
141
|
+
if (manager) {
|
|
142
|
+
manager.maybeCompact = (async () =>
|
|
143
|
+
result) as unknown as typeof manager.maybeCompact;
|
|
144
|
+
}
|
|
145
|
+
return { trust: { sourceChannel: "vellum", trustClass: "unknown" } };
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
function lastExitEvent(
|
|
@@ -152,6 +163,10 @@ function countExitEvents(events: AgentEvent[]): number {
|
|
|
152
163
|
// ---------------------------------------------------------------------------
|
|
153
164
|
|
|
154
165
|
describe("AgentLoop exit-reason instrumentation", () => {
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
disposeContextWindowManager("test-conversation");
|
|
168
|
+
});
|
|
169
|
+
|
|
155
170
|
test("recognizes provider output-token stop reasons", () => {
|
|
156
171
|
expect(isMaxTokensStopReason("max_tokens")).toBe(true);
|
|
157
172
|
expect(isMaxTokensStopReason("MAX_TOKENS")).toBe(true);
|
|
@@ -457,7 +472,7 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
457
472
|
overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
|
|
458
473
|
}),
|
|
459
474
|
compactInPlace: true,
|
|
460
|
-
...fakeCompaction({
|
|
475
|
+
...fakeCompaction("test-conversation", {
|
|
461
476
|
compacted: true,
|
|
462
477
|
exhausted: false,
|
|
463
478
|
}),
|
|
@@ -502,7 +517,7 @@ describe("AgentLoop exit-reason instrumentation", () => {
|
|
|
502
517
|
overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
|
|
503
518
|
}),
|
|
504
519
|
compactInPlace: true,
|
|
505
|
-
...fakeCompaction({
|
|
520
|
+
...fakeCompaction("test-conversation", {
|
|
506
521
|
compacted: false,
|
|
507
522
|
exhausted: true,
|
|
508
523
|
}),
|
|
@@ -81,6 +81,7 @@ mock.module("../bundler/app-compiler.js", () => ({
|
|
|
81
81
|
import * as appCreateScript from "../config/bundled-skills/app-builder/tools/app-create.js";
|
|
82
82
|
import * as appDeleteScript from "../config/bundled-skills/app-builder/tools/app-delete.js";
|
|
83
83
|
import * as appRefreshScript from "../config/bundled-skills/app-builder/tools/app-refresh.js";
|
|
84
|
+
import * as appUpdateScript from "../config/bundled-skills/app-builder/tools/app-update.js";
|
|
84
85
|
|
|
85
86
|
// ---------------------------------------------------------------------------
|
|
86
87
|
// Tests
|
|
@@ -171,4 +172,24 @@ describe("app-builder skill tool scripts", () => {
|
|
|
171
172
|
expect(parsed.appId).toBe("app-1");
|
|
172
173
|
});
|
|
173
174
|
});
|
|
175
|
+
|
|
176
|
+
// ---- app-update --------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe("app-update", () => {
|
|
179
|
+
test("exports a run function", () => {
|
|
180
|
+
expect(typeof appUpdateScript.run).toBe("function");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("delegates to executeAppUpdate and returns result", async () => {
|
|
184
|
+
const result = await appUpdateScript.run(
|
|
185
|
+
{ app_id: "app-1", name: "Renamed" },
|
|
186
|
+
makeContext(),
|
|
187
|
+
);
|
|
188
|
+
expect(result.isError).toBe(false);
|
|
189
|
+
const parsed = JSON.parse(result.content);
|
|
190
|
+
expect(parsed.updated).toBe(true);
|
|
191
|
+
expect(parsed.appId).toBe("app-1");
|
|
192
|
+
expect(parsed.name).toBe("Renamed");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
174
195
|
});
|
|
@@ -32,6 +32,7 @@ import type { AppStore } from "../tools/apps/executors.js";
|
|
|
32
32
|
import {
|
|
33
33
|
executeAppCreate,
|
|
34
34
|
executeAppRefresh,
|
|
35
|
+
executeAppUpdate,
|
|
35
36
|
} from "../tools/apps/executors.js";
|
|
36
37
|
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
@@ -89,6 +90,46 @@ function mockStore(
|
|
|
89
90
|
// ---------------------------------------------------------------------------
|
|
90
91
|
|
|
91
92
|
describe("executeAppCreate", () => {
|
|
93
|
+
test("falls back to the preview title when the name is omitted or blank", async () => {
|
|
94
|
+
let createdParams: Record<string, unknown> | undefined;
|
|
95
|
+
const app = makeMultifileApp({ name: "Coffee Tracker" });
|
|
96
|
+
const store: AppStore = {
|
|
97
|
+
...mockStore(app, {}),
|
|
98
|
+
createApp: (params) => {
|
|
99
|
+
createdParams = params as unknown as Record<string, unknown>;
|
|
100
|
+
return app;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const result = await executeAppCreate(
|
|
105
|
+
{ name: " ", preview: { title: "Coffee Tracker" } },
|
|
106
|
+
store,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result.isError).toBe(false);
|
|
110
|
+
expect(createdParams?.name).toBe("Coffee Tracker");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("defaults the name to 'New App' when neither name nor preview title is given", async () => {
|
|
114
|
+
let createdParams: Record<string, unknown> | undefined;
|
|
115
|
+
const app = makeMultifileApp({ name: "New App" });
|
|
116
|
+
const store: AppStore = {
|
|
117
|
+
...mockStore(app, {}),
|
|
118
|
+
createApp: (params) => {
|
|
119
|
+
createdParams = params as unknown as Record<string, unknown>;
|
|
120
|
+
return app;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const result = await executeAppCreate(
|
|
125
|
+
{} as unknown as Parameters<typeof executeAppCreate>[0],
|
|
126
|
+
store,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(result.isError).toBe(false);
|
|
130
|
+
expect(createdParams?.name).toBe("New App");
|
|
131
|
+
});
|
|
132
|
+
|
|
92
133
|
test("creates multifile app with src/ scaffold", async () => {
|
|
93
134
|
const files: Record<string, string> = {};
|
|
94
135
|
let createdParams: Record<string, unknown> | undefined;
|
|
@@ -408,3 +449,94 @@ describe("executeAppRefresh", () => {
|
|
|
408
449
|
expect(parsed.error).toContain("not found");
|
|
409
450
|
});
|
|
410
451
|
});
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// executeAppUpdate
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
describe("executeAppUpdate", () => {
|
|
458
|
+
test("updates metadata and recompiles a multifile app", async () => {
|
|
459
|
+
const app = makeMultifileApp({ name: "Old", description: "old desc" });
|
|
460
|
+
let updateArgs: unknown;
|
|
461
|
+
const store: AppStore = {
|
|
462
|
+
...mockStore(app),
|
|
463
|
+
updateApp: (_id, updates) => {
|
|
464
|
+
updateArgs = updates;
|
|
465
|
+
return { ...app, ...updates };
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const result = await executeAppUpdate(
|
|
470
|
+
{ app_id: app.id, name: "New Name", description: "new desc" },
|
|
471
|
+
store,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
expect(result.isError).toBe(false);
|
|
475
|
+
expect(updateArgs).toEqual({ name: "New Name", description: "new desc" });
|
|
476
|
+
const parsed = JSON.parse(result.content);
|
|
477
|
+
expect(parsed.updated).toBe(true);
|
|
478
|
+
expect(parsed.name).toBe("New Name");
|
|
479
|
+
expect(parsed.description).toBe("new desc");
|
|
480
|
+
expect(parsed.compiled).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("writes source_files before recompiling", async () => {
|
|
484
|
+
const files: Record<string, string> = {};
|
|
485
|
+
const app = makeMultifileApp();
|
|
486
|
+
const store: AppStore = {
|
|
487
|
+
...mockStore(app, files),
|
|
488
|
+
updateApp: (_id, updates) => ({ ...app, ...updates }),
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const result = await executeAppUpdate(
|
|
492
|
+
{ app_id: app.id, source_files: { "src/App.tsx": "// new code" } },
|
|
493
|
+
store,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(result.isError).toBe(false);
|
|
497
|
+
expect(files["src/App.tsx"]).toBe("// new code");
|
|
498
|
+
expect(JSON.parse(result.content).compiled).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("legacy app: updates without compiling", async () => {
|
|
502
|
+
const app = makeLegacyApp({ name: "Legacy" });
|
|
503
|
+
const store: AppStore = {
|
|
504
|
+
...mockStore(app),
|
|
505
|
+
updateApp: (_id, updates) => ({ ...app, ...updates }),
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const result = await executeAppUpdate(
|
|
509
|
+
{ app_id: app.id, name: "Renamed" },
|
|
510
|
+
store,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
expect(result.isError).toBe(false);
|
|
514
|
+
const parsed = JSON.parse(result.content);
|
|
515
|
+
expect(parsed.updated).toBe(true);
|
|
516
|
+
expect(parsed.name).toBe("Renamed");
|
|
517
|
+
expect(parsed.compiled).toBeUndefined();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("returns error for unknown app", async () => {
|
|
521
|
+
const store = mockStore(makeMultifileApp());
|
|
522
|
+
const result = await executeAppUpdate({ app_id: "nope" }, store);
|
|
523
|
+
|
|
524
|
+
expect(result.isError).toBe(true);
|
|
525
|
+
expect(JSON.parse(result.content).error).toContain("not found");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("rejects invalid source_files", async () => {
|
|
529
|
+
const app = makeMultifileApp();
|
|
530
|
+
const store = mockStore(app);
|
|
531
|
+
const result = await executeAppUpdate(
|
|
532
|
+
{
|
|
533
|
+
app_id: app.id,
|
|
534
|
+
source_files: { "src/App.tsx": 123 as unknown as string },
|
|
535
|
+
},
|
|
536
|
+
store,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
expect(result.isError).toBe(true);
|
|
540
|
+
expect(JSON.parse(result.content).error).toContain("must be a string");
|
|
541
|
+
});
|
|
542
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
function makeLoggerStub(): Record<string, unknown> {
|
|
4
|
+
const stub: Record<string, unknown> = {};
|
|
5
|
+
for (const m of [
|
|
6
|
+
"info",
|
|
7
|
+
"warn",
|
|
8
|
+
"error",
|
|
9
|
+
"debug",
|
|
10
|
+
"trace",
|
|
11
|
+
"fatal",
|
|
12
|
+
"silent",
|
|
13
|
+
"child",
|
|
14
|
+
]) {
|
|
15
|
+
stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
|
|
16
|
+
}
|
|
17
|
+
return stub;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
mock.module("../util/logger.js", () => ({
|
|
21
|
+
getLogger: () => makeLoggerStub(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../memory/conversation-crud.js", () => ({
|
|
25
|
+
getMessages: () => [],
|
|
26
|
+
reserveMessage: mock(async () => ({ id: "msg-reserve" })),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module("../memory/attachments-store.js", () => ({
|
|
30
|
+
getAttachmentMetadataForMessage: () => [],
|
|
31
|
+
getAttachmentContent: () => null,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
runAssistantDrivenCompaction,
|
|
36
|
+
runEmergencyCompaction,
|
|
37
|
+
} from "../context/compactor.js";
|
|
38
|
+
import type { Message, Provider } from "../providers/types.js";
|
|
39
|
+
|
|
40
|
+
const TAIL_TIMESTAMP =
|
|
41
|
+
"2026-05-21 (Thursday) 10:00:00 -05:00 (America/Chicago)";
|
|
42
|
+
|
|
43
|
+
const compactionResponse = `
|
|
44
|
+
<compaction_result>
|
|
45
|
+
<summary>
|
|
46
|
+
Earlier turns summarized here.
|
|
47
|
+
</summary>
|
|
48
|
+
|
|
49
|
+
<key_state>
|
|
50
|
+
- Nothing critical pending.
|
|
51
|
+
</key_state>
|
|
52
|
+
|
|
53
|
+
<tail_start timestamp="${TAIL_TIMESTAMP}" preview="tail anchor message" />
|
|
54
|
+
</compaction_result>
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const ENCRYPTED_TOKEN = "expired_encrypted_token_abc123";
|
|
58
|
+
|
|
59
|
+
const userText = (text: string): Message => ({
|
|
60
|
+
role: "user",
|
|
61
|
+
content: [{ type: "text", text }],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const userTextWithTurnContext = (text: string, timestamp: string): Message => ({
|
|
65
|
+
role: "user",
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: `<turn_context>\ncurrent_time: ${timestamp}\n</turn_context>\n${text}`,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const assistantWithWebSearch = (): Message => ({
|
|
75
|
+
role: "assistant",
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: "server_tool_use",
|
|
79
|
+
id: "stu_1",
|
|
80
|
+
name: "web_search",
|
|
81
|
+
input: { query: "cats" },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
type: "web_search_tool_result",
|
|
85
|
+
tool_use_id: "stu_1",
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "web_search_result",
|
|
89
|
+
url: "https://cats.example",
|
|
90
|
+
title: "All About Cats",
|
|
91
|
+
encrypted_content: ENCRYPTED_TOKEN,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const assistantToolUse = (): Message => ({
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: [
|
|
101
|
+
{ type: "tool_use", id: "tu_1", name: "bash", input: { cmd: "ls" } },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const toolResult = (): Message => ({
|
|
106
|
+
role: "user",
|
|
107
|
+
content: [{ type: "tool_result", tool_use_id: "tu_1", content: "ok" }],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function serializeBlocks(messages: Message[]): string {
|
|
111
|
+
return JSON.stringify(messages);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Records the exact message list the provider is asked to summarize.
|
|
115
|
+
function recordingProvider(sink: { sent: Message[] }): Provider {
|
|
116
|
+
return {
|
|
117
|
+
name: "mock-provider",
|
|
118
|
+
sendMessage: async (msgs: Message[]) => {
|
|
119
|
+
sink.sent = msgs;
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: compactionResponse }],
|
|
122
|
+
model: "mock-model",
|
|
123
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
124
|
+
stopReason: "end_turn",
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Expired Anthropic web-search tokens must never reach the summarization LLM:
|
|
131
|
+
// both compaction provider calls funnel through the same request builder, so
|
|
132
|
+
// each is guarded here.
|
|
133
|
+
describe("compaction summary calls — historical web-search sanitization", () => {
|
|
134
|
+
test("runAssistantDrivenCompaction strips expired encrypted_content from the request while leaving durable history untouched", async () => {
|
|
135
|
+
// GIVEN a history whose older turn contains a native web_search_tool_result
|
|
136
|
+
// block carrying an opaque, expired `encrypted_content` token
|
|
137
|
+
const messages: Message[] = [
|
|
138
|
+
userText("old user turn"),
|
|
139
|
+
assistantWithWebSearch(),
|
|
140
|
+
userTextWithTurnContext("tail anchor message", TAIL_TIMESTAMP),
|
|
141
|
+
assistantWithWebSearch(),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// AND a snapshot of the caller's array so we can assert it is not mutated
|
|
145
|
+
const inputSnapshot = serializeBlocks(messages);
|
|
146
|
+
const sink = { sent: [] as Message[] };
|
|
147
|
+
|
|
148
|
+
// WHEN compaction runs over that history
|
|
149
|
+
const result = await runAssistantDrivenCompaction({
|
|
150
|
+
conversationId: "conv-web-search",
|
|
151
|
+
messages,
|
|
152
|
+
provider: recordingProvider(sink),
|
|
153
|
+
systemPrompt: "system",
|
|
154
|
+
compaction: { enabled: true, autoThreshold: 0.7 },
|
|
155
|
+
maxInputTokens: 1000,
|
|
156
|
+
previousEstimatedInputTokens: 900,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// THEN the summary request the provider received carries no expired token
|
|
160
|
+
// and no native web_search blocks, but keeps the search content as text
|
|
161
|
+
const sentSerialized = serializeBlocks(sink.sent);
|
|
162
|
+
expect(sentSerialized).not.toContain(ENCRYPTED_TOKEN);
|
|
163
|
+
expect(sentSerialized).not.toContain("web_search_tool_result");
|
|
164
|
+
expect(sentSerialized).not.toContain("server_tool_use");
|
|
165
|
+
expect(sentSerialized).toContain("All About Cats");
|
|
166
|
+
expect(sentSerialized).toContain("https://cats.example");
|
|
167
|
+
|
|
168
|
+
// AND the caller's durable history array is left byte-for-byte untouched,
|
|
169
|
+
// so persisted messages keep the original rich blocks
|
|
170
|
+
expect(serializeBlocks(messages)).toBe(inputSnapshot);
|
|
171
|
+
expect(result.compacted).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("runEmergencyCompaction strips expired encrypted_content from the prefix request", async () => {
|
|
175
|
+
// GIVEN a mid-turn history whose prefix (before the last tool pair) holds a
|
|
176
|
+
// web_search_tool_result block with an expired token
|
|
177
|
+
const messages: Message[] = [
|
|
178
|
+
userText("old user turn"),
|
|
179
|
+
assistantWithWebSearch(),
|
|
180
|
+
userText("keep going"),
|
|
181
|
+
assistantToolUse(),
|
|
182
|
+
toolResult(),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const inputSnapshot = serializeBlocks(messages);
|
|
186
|
+
const sink = { sent: [] as Message[] };
|
|
187
|
+
|
|
188
|
+
// WHEN emergency compaction splits at the last tool pair and summarizes the
|
|
189
|
+
// prefix
|
|
190
|
+
const result = await runEmergencyCompaction({
|
|
191
|
+
conversationId: "conv-web-search-emergency",
|
|
192
|
+
messages,
|
|
193
|
+
provider: recordingProvider(sink),
|
|
194
|
+
systemPrompt: "system",
|
|
195
|
+
compaction: { enabled: true, autoThreshold: 0.7 },
|
|
196
|
+
// Large budget so the prefix is not front-truncated — this test exercises
|
|
197
|
+
// the web-search sanitizer, not the truncation fallback.
|
|
198
|
+
maxInputTokens: 100000,
|
|
199
|
+
previousEstimatedInputTokens: 90000,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// THEN the prefix sent to the provider carries no expired token
|
|
203
|
+
const sentSerialized = serializeBlocks(sink.sent);
|
|
204
|
+
expect(sentSerialized).not.toContain(ENCRYPTED_TOKEN);
|
|
205
|
+
expect(sentSerialized).not.toContain("web_search_tool_result");
|
|
206
|
+
expect(sentSerialized).not.toContain("server_tool_use");
|
|
207
|
+
expect(sentSerialized).toContain("All About Cats");
|
|
208
|
+
|
|
209
|
+
// AND the caller's durable history is untouched
|
|
210
|
+
expect(serializeBlocks(messages)).toBe(inputSnapshot);
|
|
211
|
+
expect(result.compacted).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
reduceContextOverflow,
|
|
12
12
|
type ReducerConfig,
|
|
13
13
|
type ReducerState,
|
|
14
|
-
} from "../
|
|
14
|
+
} from "../plugins/defaults/compaction/context-overflow-reducer.js";
|
|
15
15
|
import type { Message } from "../providers/types.js";
|
|
16
16
|
|
|
17
17
|
function msg(role: "user" | "assistant", text: string): Message {
|
|
@@ -102,23 +102,26 @@ mock.module("../context/token-estimator.js", () => ({
|
|
|
102
102
|
estimateToolsTokens: () => 0,
|
|
103
103
|
}));
|
|
104
104
|
|
|
105
|
-
mock.module(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
105
|
+
mock.module(
|
|
106
|
+
"../plugins/defaults/compaction/context-overflow-reducer.js",
|
|
107
|
+
() => ({
|
|
108
|
+
createInitialReducerState: () => ({
|
|
109
|
+
appliedTiers: [],
|
|
110
|
+
injectionMode: "full" as const,
|
|
111
|
+
exhausted: false,
|
|
112
|
+
}),
|
|
113
|
+
reduceContextOverflow: async (msgs: Message[]) => ({
|
|
114
|
+
messages: msgs,
|
|
115
|
+
tier: "forced_compaction",
|
|
116
|
+
state: {
|
|
117
|
+
appliedTiers: ["forced_compaction"],
|
|
118
|
+
injectionMode: "full",
|
|
119
|
+
exhausted: true,
|
|
120
|
+
},
|
|
121
|
+
estimatedTokens: 1000,
|
|
122
|
+
}),
|
|
120
123
|
}),
|
|
121
|
-
|
|
124
|
+
);
|
|
122
125
|
|
|
123
126
|
mock.module("../daemon/context-overflow-policy.js", () => ({
|
|
124
127
|
resolveOverflowAction: () => "fail_gracefully",
|