@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.
Files changed (62) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/openapi.yaml +6 -7
  3. package/package.json +1 -1
  4. package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
  5. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  6. package/src/__tests__/app-executors.test.ts +132 -0
  7. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  8. package/src/__tests__/context-overflow-reducer.test.ts +1 -1
  9. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
  10. package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
  11. package/src/__tests__/conversation-agent-loop.test.ts +48 -96
  12. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  13. package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
  14. package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
  15. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  16. package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
  17. package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
  18. package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
  19. package/src/acp/auto-install.test.ts +130 -46
  20. package/src/acp/auto-install.ts +86 -31
  21. package/src/acp/prepare-agent-env.ts +8 -9
  22. package/src/acp/resolve-agent.test.ts +26 -123
  23. package/src/acp/resolve-agent.ts +26 -85
  24. package/src/acp/resume-hint.ts +1 -3
  25. package/src/acp/session-manager.ts +32 -20
  26. package/src/acp/types.ts +0 -8
  27. package/src/agent/loop.ts +42 -26
  28. package/src/config/bundled-skills/acp/SKILL.md +14 -14
  29. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  30. package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
  31. package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
  32. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  33. package/src/config/bundled-tool-registry.ts +2 -0
  34. package/src/config/call-site-defaults.ts +0 -2
  35. package/src/config/feature-flag-registry.json +1 -1
  36. package/src/config/schemas/call-site-catalog.ts +0 -14
  37. package/src/config/schemas/llm.ts +0 -2
  38. package/src/context/compactor.ts +22 -4
  39. package/src/daemon/conversation-agent-loop.ts +15 -57
  40. package/src/daemon/conversation.ts +23 -3
  41. package/src/daemon/disk-pressure-policy.ts +0 -1
  42. package/src/daemon/lifecycle.ts +0 -7
  43. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
  44. package/src/plugins/defaults/compaction/manager-store.ts +13 -0
  45. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
  46. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
  47. package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
  48. package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
  49. package/src/runtime/routes/plugins-routes.ts +10 -9
  50. package/src/tools/acp/list-agents.test.ts +2 -2
  51. package/src/tools/acp/spawn.test.ts +103 -225
  52. package/src/tools/acp/spawn.ts +10 -120
  53. package/src/tools/apps/executors.ts +153 -42
  54. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  55. package/src/proactive-artifact/decision.test.ts +0 -226
  56. package/src/proactive-artifact/decision.ts +0 -165
  57. package/src/proactive-artifact/index.ts +0 -7
  58. package/src/proactive-artifact/job.test.ts +0 -962
  59. package/src/proactive-artifact/job.ts +0 -372
  60. package/src/proactive-artifact/message-copy.ts +0 -58
  61. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  62. 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 | Purpose |
1092
- | ---------------------------------------- | ----------------------------------------------------------------------------- |
1093
- | `src/daemon/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 |
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. Materializes the plugin under
19553
- `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's `assistant
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
- at the ref returns 404. Sibling to `POST /v1/skills/install`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.9-staging.1",
3
+ "version": "0.8.9-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- // A trust snapshot plus a context-window manager whose `maybeCompact` returns a
122
- // canned result, so the loop's compaction call runs without the real
123
- // orchestrator machinery. Both are supplied to the loop as dedicated run
124
- // options.
125
- function fakeCompaction(result: { compacted: boolean; exhausted: boolean }): {
126
- trust: TrustContext;
127
- contextWindowManager: AgentLoopRunOptions["contextWindowManager"];
128
- } {
129
- return {
130
- trust: { sourceChannel: "vellum", trustClass: "unknown" },
131
- contextWindowManager: {
132
- maybeCompact: async () => result,
133
- } as unknown as AgentLoopRunOptions["contextWindowManager"],
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 "../daemon/context-overflow-reducer.js";
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("../daemon/context-overflow-reducer.js", () => ({
106
- createInitialReducerState: () => ({
107
- appliedTiers: [],
108
- injectionMode: "full" as const,
109
- exhausted: false,
110
- }),
111
- reduceContextOverflow: async (msgs: Message[]) => ({
112
- messages: msgs,
113
- tier: "forced_compaction",
114
- state: {
115
- appliedTiers: ["forced_compaction"],
116
- injectionMode: "full",
117
- exhausted: true,
118
- },
119
- estimatedTokens: 1000,
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",