@vellumai/assistant 0.8.9 → 0.8.10-dev.202606092334.09948c8

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 (161) hide show
  1. package/AGENTS.md +1 -1
  2. package/examples/plugins/echo/README.md +3 -3
  3. package/node_modules/@vellumai/skill-host-contracts/src/tool-types.ts +2 -0
  4. package/openapi.yaml +38 -12
  5. package/package.json +1 -1
  6. package/scripts/sync-llm-catalog.ts +2 -0
  7. package/src/__tests__/agent-loop-exit-reason.test.ts +21 -15
  8. package/src/__tests__/approval-cascade.test.ts +1 -0
  9. package/src/__tests__/build-persisted-content.test.ts +75 -1
  10. package/src/__tests__/compaction-events.test.ts +1 -0
  11. package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
  12. package/src/__tests__/context-overflow-reducer.test.ts +264 -124
  13. package/src/__tests__/context-window-manager-overflow-rung.test.ts +351 -0
  14. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -0
  15. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +11 -0
  16. package/src/__tests__/conversation-agent-loop-overflow.test.ts +90 -49
  17. package/src/__tests__/conversation-agent-loop.test.ts +113 -64
  18. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -0
  19. package/src/__tests__/conversation-fork-crud.test.ts +110 -0
  20. package/src/__tests__/conversation-process-callsite.test.ts +1 -0
  21. package/src/__tests__/conversation-provider-retry-repair.test.ts +48 -48
  22. package/src/__tests__/conversation-queue.test.ts +1 -0
  23. package/src/__tests__/conversation-runtime-assembly.test.ts +119 -8
  24. package/src/__tests__/conversation-slash-queue.test.ts +1 -0
  25. package/src/__tests__/conversation-slash-unknown.test.ts +1 -0
  26. package/src/__tests__/conversation-speed-override.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +4 -1
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +91 -142
  29. package/src/__tests__/conversation-tool-setup-skill-attribution.test.ts +116 -0
  30. package/src/__tests__/conversation-tool-setup-test-helpers.ts +98 -0
  31. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -0
  32. package/src/__tests__/conversation-workspace-injection.test.ts +1 -0
  33. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -0
  34. package/src/__tests__/corrected-target.test.ts +93 -0
  35. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -5
  36. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  37. package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
  38. package/src/__tests__/external-plugin-loader.test.ts +0 -1
  39. package/src/__tests__/guardian-routing-invariants.test.ts +91 -0
  40. package/src/__tests__/history-repair-hook.test.ts +2 -0
  41. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  42. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  43. package/src/__tests__/memory-retrieval-hook.test.ts +29 -15
  44. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  45. package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
  46. package/src/__tests__/plugin-bootstrap.test.ts +82 -73
  47. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  48. package/src/__tests__/plugin-types.test.ts +0 -8
  49. package/src/__tests__/provider-catalog-visibility.test.ts +1 -9
  50. package/src/__tests__/retry-thinking-adaptive-only.test.ts +245 -0
  51. package/src/__tests__/server-history-render.test.ts +39 -0
  52. package/src/__tests__/skills.test.ts +123 -0
  53. package/src/__tests__/title-generate-hook.test.ts +2 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +51 -0
  55. package/src/__tests__/tool-executor-lifecycle-events.test.ts +32 -0
  56. package/src/__tests__/tool-start-timestamp.test.ts +218 -0
  57. package/src/agent/loop.ts +26 -39
  58. package/src/api/events/tool-result.ts +9 -0
  59. package/src/api/events/tool-use-start.ts +7 -0
  60. package/src/api/responses/conversation-message.ts +91 -14
  61. package/src/approvals/guardian-request-resolvers.ts +26 -0
  62. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  63. package/src/cli/commands/inference-providers.ts +0 -8
  64. package/src/cli/commands/plugins.ts +2 -2
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +174 -18
  66. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +1 -1
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +1 -1
  68. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +1 -1
  69. package/src/cli/lib/__tests__/search-plugins.test.ts +11 -11
  70. package/src/cli/lib/install-from-github.ts +130 -19
  71. package/src/cli/lib/plugin-catalog-cache.ts +1 -1
  72. package/src/cli/lib/plugin-details.ts +4 -4
  73. package/src/cli/lib/plugin-marketplace.ts +2 -2
  74. package/src/cli/lib/search-plugins.ts +6 -6
  75. package/src/config/bundled-skills/skill-management/SKILL.md +89 -1
  76. package/src/config/env-registry.ts +12 -0
  77. package/src/config/feature-flag-registry.json +0 -80
  78. package/src/config/loader.ts +5 -22
  79. package/src/config/seed-inference-profiles.ts +1 -1
  80. package/src/config/skills.ts +101 -47
  81. package/src/context/strip-injections.ts +7 -4
  82. package/src/daemon/conversation-agent-loop-handlers.ts +102 -6
  83. package/src/daemon/conversation-agent-loop.ts +126 -270
  84. package/src/daemon/conversation-runtime-assembly.ts +44 -8
  85. package/src/daemon/conversation-tool-setup.ts +9 -1
  86. package/src/daemon/conversation.ts +13 -0
  87. package/src/daemon/external-plugins-bootstrap.ts +33 -87
  88. package/src/daemon/handlers/config-channels.ts +11 -2
  89. package/src/daemon/handlers/shared.ts +9 -1
  90. package/src/daemon/handlers/skills.ts +6 -2
  91. package/src/daemon/host-app-control-proxy.ts +72 -57
  92. package/src/daemon/message-protocol.ts +0 -7
  93. package/src/daemon/message-types/skills.ts +4 -0
  94. package/src/events/tool-audit-listener.ts +7 -1
  95. package/src/memory/__tests__/tool-invocation-test-helpers.ts +69 -0
  96. package/src/memory/conversation-crud.ts +44 -1
  97. package/src/memory/db-init.ts +4 -0
  98. package/src/memory/embedding-backend.ts +5 -22
  99. package/src/memory/memory-retrospective-startup-cleanup.ts +1 -1
  100. package/src/memory/migrations/275-tool-invocations-add-skill-id.test.ts +81 -0
  101. package/src/memory/migrations/275-tool-invocations-add-skill-id.ts +20 -0
  102. package/src/memory/migrations/276-tool-invocations-created-at-id-index.test.ts +68 -0
  103. package/src/memory/migrations/276-tool-invocations-created-at-id-index.ts +20 -0
  104. package/src/memory/migrations/index.ts +2 -0
  105. package/src/memory/schema/conversations.ts +3 -0
  106. package/src/memory/tool-execution-events-store.test.ts +162 -0
  107. package/src/memory/tool-execution-events-store.ts +73 -0
  108. package/src/memory/tool-usage-store.ts +4 -3
  109. package/src/memory/v2/__tests__/injected-block-slugs.test.ts +51 -0
  110. package/src/memory/v2/activation-store.ts +50 -1
  111. package/src/memory/v2/injected-block-slugs.ts +30 -0
  112. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  113. package/src/notifications/access-request-copy.ts +298 -0
  114. package/src/notifications/adapters/slack.ts +3 -3
  115. package/src/notifications/adapters/telegram.ts +2 -1
  116. package/src/notifications/copy-composer.ts +49 -267
  117. package/src/notifications/decision-engine.ts +16 -35
  118. package/src/notifications/home-feed-side-effect.ts +1 -6
  119. package/src/platform/client.ts +1 -3
  120. package/src/platform/feature-gate.ts +3 -12
  121. package/src/plugin-api/constants.ts +2 -0
  122. package/src/plugin-api/index.ts +37 -6
  123. package/src/plugin-api/types.ts +75 -0
  124. package/src/plugins/defaults/compaction/compact.ts +39 -1
  125. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +240 -32
  126. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  127. package/src/plugins/defaults/compaction/window-manager.ts +195 -1
  128. package/src/plugins/defaults/index.ts +24 -0
  129. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +76 -112
  130. package/src/plugins/defaults/memory-retrieval/hooks/{user-prompt-submit-temp.ts → user-prompt-submit.ts} +36 -67
  131. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  132. package/src/plugins/external-api.ts +1 -1
  133. package/src/plugins/types.ts +5 -51
  134. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -2
  135. package/src/providers/model-catalog.ts +57 -1
  136. package/src/providers/registry.ts +0 -8
  137. package/src/providers/retry.ts +35 -7
  138. package/src/runtime/routes/__tests__/acp-routes.test.ts +151 -0
  139. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +0 -62
  140. package/src/runtime/routes/__tests__/plugins-routes.test.ts +4 -4
  141. package/src/runtime/routes/acp-routes.test.ts +106 -0
  142. package/src/runtime/routes/acp-routes.ts +248 -2
  143. package/src/runtime/routes/browser-tabs-routes.ts +1 -1
  144. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  145. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +0 -14
  146. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  147. package/src/runtime/routes/inference-provider-connection-routes.ts +0 -45
  148. package/src/runtime/routes/plugins-routes.ts +3 -3
  149. package/src/runtime/verification-outbound-actions.ts +147 -2
  150. package/src/runtime/verification-templates.ts +29 -3
  151. package/src/telemetry/types.ts +25 -1
  152. package/src/telemetry/usage-telemetry-reporter.test.ts +141 -7
  153. package/src/telemetry/usage-telemetry-reporter.ts +63 -7
  154. package/src/tools/executor.ts +4 -1
  155. package/src/tools/skills/load.ts +11 -6
  156. package/src/tools/terminal/safe-env.ts +1 -0
  157. package/src/tools/types.ts +10 -0
  158. package/src/__tests__/plugin-skill-contribution.test.ts +0 -355
  159. package/src/daemon/message-types/browser.ts +0 -10
  160. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  161. package/src/plugins/plugin-skill-contributions.ts +0 -292
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Coverage for the turn-scoped overflow-recovery state machine that lives on
3
+ * `ContextWindowManager.reduceOverflowOneRung`. The manager holds the reducer
4
+ * state across the successive rung calls of a single turn, derives the
5
+ * compaction target from its own preflight budget corrected by the provider's
6
+ * actual token count, and starts a fresh ladder once the turn boundary resets
7
+ * it. `reduceContextOverflow` is mocked so these tests pin the manager's own
8
+ * orchestration without exercising the full reduction ladder.
9
+ */
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ function makeLoggerStub(): Record<string, unknown> {
13
+ const stub: Record<string, unknown> = {};
14
+ for (const m of [
15
+ "info",
16
+ "warn",
17
+ "error",
18
+ "debug",
19
+ "trace",
20
+ "fatal",
21
+ "silent",
22
+ "child",
23
+ ]) {
24
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
25
+ }
26
+ return stub;
27
+ }
28
+
29
+ mock.module("../util/logger.js", () => ({
30
+ getLogger: () => makeLoggerStub(),
31
+ }));
32
+
33
+ // ── Module-level mock state ───────────────────────────────────────────
34
+ // The reducer call is captured so tests can assert which state and config the
35
+ // manager fed in; the returned step is seeded per call so tests can drive the
36
+ // ladder forward across rungs.
37
+
38
+ interface ReducerState {
39
+ appliedTiers: string[];
40
+ injectionMode: string;
41
+ exhausted: boolean;
42
+ }
43
+
44
+ interface ReducerStepResult {
45
+ messages: unknown;
46
+ tier: string;
47
+ state: ReducerState;
48
+ estimatedTokens: number;
49
+ }
50
+
51
+ let reduceCalls: Array<{
52
+ messages: unknown;
53
+ config: Record<string, unknown>;
54
+ state: ReducerState | undefined;
55
+ }> = [];
56
+ let reduceSteps: ReducerStepResult[] = [];
57
+
58
+ const estimateReturns: number[] = [];
59
+
60
+ mock.module("../context/token-estimator.js", () => ({
61
+ estimatePromptTokens: (): number => {
62
+ const next = estimateReturns.shift();
63
+ if (next === undefined) {
64
+ throw new Error(
65
+ "estimatePromptTokens called more times than estimateReturns seeded",
66
+ );
67
+ }
68
+ return next;
69
+ },
70
+ // Deterministic per-tool budget so tests can assert the manager fed the
71
+ // turn's resolved tool set (not the constructor-time snapshot) into the
72
+ // overflow estimate.
73
+ estimateToolsTokens: (tools: unknown[]): number => tools.length * 1_000,
74
+ }));
75
+
76
+ mock.module(
77
+ "../plugins/defaults/compaction/context-overflow-reducer.js",
78
+ () => ({
79
+ createInitialReducerState: (): ReducerState => ({
80
+ appliedTiers: [],
81
+ injectionMode: "full",
82
+ exhausted: false,
83
+ }),
84
+ reduceContextOverflow: async (
85
+ messages: unknown,
86
+ config: Record<string, unknown>,
87
+ state: ReducerState | undefined,
88
+ ): Promise<ReducerStepResult> => {
89
+ reduceCalls.push({ messages, config, state });
90
+ const idx = reduceCalls.length - 1;
91
+ const step = reduceSteps[idx];
92
+ if (!step) {
93
+ throw new Error(
94
+ `Mock reducer called ${reduceCalls.length} time(s) but only ${reduceSteps.length} step(s) seeded`,
95
+ );
96
+ }
97
+ return step;
98
+ },
99
+ }),
100
+ );
101
+
102
+ import { ContextWindowManager } from "../plugins/defaults/compaction/window-manager.js";
103
+ import type { Message, Provider, ToolDefinition } from "../providers/types.js";
104
+
105
+ function makeProvider(): Provider {
106
+ return {
107
+ name: "mock-provider",
108
+ sendMessage: async () => ({
109
+ content: [],
110
+ model: "mock-model",
111
+ usage: { inputTokens: 0, outputTokens: 0 },
112
+ stopReason: "end_turn",
113
+ }),
114
+ };
115
+ }
116
+
117
+ function makeMessages(count: number): Message[] {
118
+ const messages: Message[] = [];
119
+ for (let i = 0; i < count; i++) {
120
+ messages.push({
121
+ role: i % 2 === 0 ? "user" : "assistant",
122
+ content: [{ type: "text", text: `msg ${i}` }],
123
+ });
124
+ }
125
+ return messages;
126
+ }
127
+
128
+ function makeConfig(maxAttempts: number = 3) {
129
+ return {
130
+ enabled: true,
131
+ maxInputTokens: 200_000,
132
+ targetBudgetRatio: 0.3,
133
+ compactThreshold: 0.8,
134
+ summaryBudgetRatio: 0.05,
135
+ overflowRecovery: {
136
+ enabled: true,
137
+ safetyMarginRatio: 0.05,
138
+ maxAttempts,
139
+ interactiveLatestTurnCompression: "summarize" as const,
140
+ nonInteractiveLatestTurnCompression: "summarize" as const,
141
+ },
142
+ };
143
+ }
144
+
145
+ function buildManager(maxAttempts: number = 3): ContextWindowManager {
146
+ return new ContextWindowManager({
147
+ provider: makeProvider(),
148
+ systemPrompt: "you are a test assistant",
149
+ config: makeConfig(maxAttempts),
150
+ conversationId: "conv-test",
151
+ });
152
+ }
153
+
154
+ function makeStep(
155
+ appliedTiers: string[],
156
+ exhausted: boolean = false,
157
+ ): ReducerStepResult {
158
+ return {
159
+ messages: makeMessages(2),
160
+ tier: appliedTiers[appliedTiers.length - 1] ?? "emergency_compaction",
161
+ state: { appliedTiers, injectionMode: "full", exhausted },
162
+ estimatedTokens: 100_000,
163
+ };
164
+ }
165
+
166
+ describe("ContextWindowManager.reduceOverflowOneRung", () => {
167
+ beforeEach(() => {
168
+ reduceCalls = [];
169
+ reduceSteps = [];
170
+ estimateReturns.length = 0;
171
+ });
172
+
173
+ test("holds reducer state across rungs within a turn", async () => {
174
+ // GIVEN a manager and a reducer that advances the ladder one rung per call
175
+ estimateReturns.push(240_000, 240_000);
176
+ reduceSteps = [
177
+ makeStep(["emergency_compaction"]),
178
+ makeStep(["emergency_compaction", "forced_compaction"]),
179
+ ];
180
+ const manager = buildManager();
181
+
182
+ // WHEN two rungs run within the same turn
183
+ await manager.reduceOverflowOneRung(makeMessages(10), {
184
+ actualTokens: null,
185
+ allowAutoCompressLatestTurn: false,
186
+ });
187
+ await manager.reduceOverflowOneRung(makeMessages(10), {
188
+ actualTokens: null,
189
+ allowAutoCompressLatestTurn: false,
190
+ });
191
+
192
+ // THEN the first call seeds an empty ladder
193
+ expect(reduceCalls).toHaveLength(2);
194
+ expect(reduceCalls[0]?.state?.appliedTiers).toEqual([]);
195
+ // AND the second call carries the first rung's state forward
196
+ expect(reduceCalls[1]?.state?.appliedTiers).toEqual([
197
+ "emergency_compaction",
198
+ ]);
199
+ });
200
+
201
+ test("reuses the first rung's corrected target across the turn's later rungs", async () => {
202
+ // GIVEN the provider rejected at 480k while the estimator counted 240k, and
203
+ // only one estimate is seeded so a second estimate call would throw
204
+ estimateReturns.push(240_000);
205
+ reduceSteps = [
206
+ makeStep(["emergency_compaction"]),
207
+ makeStep(["emergency_compaction", "forced_compaction"]),
208
+ ];
209
+ const manager = buildManager();
210
+
211
+ // WHEN two rungs run within the same turn with the provider's actual count
212
+ await manager.reduceOverflowOneRung(makeMessages(10), {
213
+ actualTokens: 480_000,
214
+ allowAutoCompressLatestTurn: false,
215
+ });
216
+ await manager.reduceOverflowOneRung(makeMessages(4), {
217
+ actualTokens: 480_000,
218
+ allowAutoCompressLatestTurn: false,
219
+ });
220
+
221
+ // THEN the corrected target (190k preflight / 2x error ratio) is computed
222
+ // once and reused, rather than re-derived against the shrunk prompt
223
+ expect(reduceCalls[0]?.config.targetTokens).toBe(95_000);
224
+ expect(reduceCalls[1]?.config.targetTokens).toBe(95_000);
225
+ expect(reduceCalls[0]?.config.previousEstimatedInputTokens).toBe(240_000);
226
+ expect(reduceCalls[1]?.config.previousEstimatedInputTokens).toBe(240_000);
227
+ });
228
+
229
+ test("resetOverflowRecovery starts a fresh ladder", async () => {
230
+ // GIVEN a manager whose ladder has already advanced one rung
231
+ estimateReturns.push(240_000, 240_000);
232
+ reduceSteps = [
233
+ makeStep(["emergency_compaction"]),
234
+ makeStep(["emergency_compaction"]),
235
+ ];
236
+ const manager = buildManager();
237
+ await manager.reduceOverflowOneRung(makeMessages(10), {
238
+ actualTokens: null,
239
+ allowAutoCompressLatestTurn: false,
240
+ });
241
+
242
+ // WHEN the turn boundary resets recovery and a new rung runs
243
+ manager.resetOverflowRecovery();
244
+ await manager.reduceOverflowOneRung(makeMessages(10), {
245
+ actualTokens: null,
246
+ allowAutoCompressLatestTurn: false,
247
+ });
248
+
249
+ // THEN the post-reset call seeds an empty ladder again
250
+ expect(reduceCalls[1]?.state?.appliedTiers).toEqual([]);
251
+ });
252
+
253
+ test("corrected target lowers below preflight when the provider under-counted", async () => {
254
+ // GIVEN the provider rejected at 480k while the estimator counted 240k
255
+ estimateReturns.push(240_000);
256
+ reduceSteps = [makeStep(["emergency_compaction"])];
257
+ const manager = buildManager();
258
+
259
+ // WHEN a rung runs with the provider's actual token count
260
+ await manager.reduceOverflowOneRung(makeMessages(10), {
261
+ actualTokens: 480_000,
262
+ allowAutoCompressLatestTurn: false,
263
+ });
264
+
265
+ // THEN the target is the preflight budget (200k * 0.95 = 190k) divided by
266
+ // the 2x estimation-error ratio
267
+ expect(reduceCalls[0]?.config.targetTokens).toBe(95_000);
268
+ });
269
+
270
+ test("estimates against the turn's resolved tool set, not the constructor snapshot", async () => {
271
+ // GIVEN a manager whose live tool set (3 tools) differs from the stale
272
+ // constructor-time budget snapshot
273
+ estimateReturns.push(240_000);
274
+ reduceSteps = [makeStep(["emergency_compaction"])];
275
+ const resolvedTools = [
276
+ { name: "a" },
277
+ { name: "b" },
278
+ { name: "c" },
279
+ ] as unknown as ToolDefinition[];
280
+ const manager = new ContextWindowManager({
281
+ provider: makeProvider(),
282
+ systemPrompt: "you are a test assistant",
283
+ config: makeConfig(),
284
+ conversationId: "conv-test",
285
+ toolTokenBudget: 50,
286
+ resolveTools: () => resolvedTools,
287
+ });
288
+
289
+ // WHEN a rung runs
290
+ await manager.reduceOverflowOneRung(makeMessages(10), {
291
+ actualTokens: null,
292
+ allowAutoCompressLatestTurn: false,
293
+ });
294
+
295
+ // THEN the reducer is fed the resolved tool budget (3 tools * 1k) rather
296
+ // than the constructor snapshot (50)
297
+ expect(reduceCalls[0]?.config.toolTokenBudget).toBe(3_000);
298
+ });
299
+
300
+ test("target is the full preflight budget when the actual count is unknown", async () => {
301
+ // GIVEN no provider-reported actual token count
302
+ estimateReturns.push(240_000);
303
+ reduceSteps = [makeStep(["emergency_compaction"])];
304
+ const manager = buildManager();
305
+
306
+ // WHEN a rung runs
307
+ await manager.reduceOverflowOneRung(makeMessages(10), {
308
+ actualTokens: null,
309
+ allowAutoCompressLatestTurn: false,
310
+ });
311
+
312
+ // THEN the target is the uncorrected preflight budget (200k * 0.95)
313
+ expect(reduceCalls[0]?.config.targetTokens).toBe(190_000);
314
+ });
315
+
316
+ test("long histories widen the safety margin", async () => {
317
+ // GIVEN a history longer than the 50-message threshold
318
+ estimateReturns.push(240_000);
319
+ reduceSteps = [makeStep(["emergency_compaction"])];
320
+ const manager = buildManager();
321
+
322
+ // WHEN a rung runs over 60 messages with no actual-token correction
323
+ await manager.reduceOverflowOneRung(makeMessages(60), {
324
+ actualTokens: null,
325
+ allowAutoCompressLatestTurn: false,
326
+ });
327
+
328
+ // THEN the safety margin bumps to 0.15 → preflight budget 200k * 0.85
329
+ expect(reduceCalls[0]?.config.targetTokens).toBe(170_000);
330
+ });
331
+
332
+ test("forwards the policy auto-compress permission to the ladder", async () => {
333
+ // GIVEN the overflow policy permits the terminal auto-compress rung
334
+ estimateReturns.push(240_000);
335
+ reduceSteps = [makeStep(["emergency_compaction"])];
336
+ const manager = buildManager();
337
+
338
+ // WHEN a rung runs
339
+ await manager.reduceOverflowOneRung(makeMessages(10), {
340
+ actualTokens: null,
341
+ allowAutoCompressLatestTurn: true,
342
+ overrideProfile: "fast",
343
+ });
344
+
345
+ // THEN the manager passes the policy decision and profile through to the
346
+ // reducer config alongside the budget it owns
347
+ expect(reduceCalls[0]?.config.allowAutoCompressLatestTurn).toBe(true);
348
+ expect(reduceCalls[0]?.config.overrideProfile).toBe("fast");
349
+ expect(reduceCalls[0]?.config.maxMiddleTierAttempts).toBe(3);
350
+ });
351
+ });
@@ -177,6 +177,7 @@ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
177
177
  async maybeCompact() {
178
178
  return { compacted: false };
179
179
  }
180
+ resetOverflowRecovery() {}
180
181
  },
181
182
  createContextSummaryMessage: () => ({
182
183
  role: "user",
@@ -435,6 +435,17 @@ function makeCtx(
435
435
  updateConfig: () => {},
436
436
  shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
437
437
  maybeCompact: async () => ({ compacted: false }),
438
+ resetOverflowRecovery: () => {},
439
+ reduceOverflowOneRung: async (msgs: Message[]) => ({
440
+ messages: msgs,
441
+ tier: "forced_compaction",
442
+ state: {
443
+ appliedTiers: ["forced_compaction"],
444
+ injectionMode: "full",
445
+ exhausted: true,
446
+ },
447
+ estimatedTokens: 1000,
448
+ }),
438
449
  } as unknown as Conversation["contextWindowManager"],
439
450
  contextCompactedMessageCount: 0,
440
451
  contextCompactedAt: null,
@@ -151,39 +151,70 @@ mock.module("../context/token-estimator.js", () => ({
151
151
  let mockReducerStepFn:
152
152
  | ((msgs: Message[], cfg: unknown, state: unknown) => unknown)
153
153
  | null = null;
154
+ const makeInitialReducerState = () => ({
155
+ appliedTiers: [] as string[],
156
+ injectionMode: "full" as const,
157
+ exhausted: false,
158
+ });
159
+ const runMockReducer = async (
160
+ msgs: Message[],
161
+ cfg: unknown,
162
+ state: unknown,
163
+ ) => {
164
+ if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
165
+ return {
166
+ messages: msgs,
167
+ tier: "forced_compaction",
168
+ state: {
169
+ appliedTiers: [
170
+ "forced_compaction",
171
+ "tool_result_truncation",
172
+ "media_stubbing",
173
+ "injection_downgrade",
174
+ ],
175
+ injectionMode: "full",
176
+ exhausted: true,
177
+ },
178
+ estimatedTokens: 1000,
179
+ };
180
+ };
154
181
  mock.module(
155
182
  "../plugins/defaults/compaction/context-overflow-reducer.js",
156
183
  () => ({
157
- createInitialReducerState: () => ({
158
- appliedTiers: [],
159
- injectionMode: "full" as const,
160
- exhausted: false,
161
- }),
162
- reduceContextOverflow: async (
163
- msgs: Message[],
164
- cfg: unknown,
165
- state: unknown,
166
- ) => {
167
- if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
168
- return {
169
- messages: msgs,
170
- tier: "forced_compaction",
171
- state: {
172
- appliedTiers: [
173
- "forced_compaction",
174
- "tool_result_truncation",
175
- "media_stubbing",
176
- "injection_downgrade",
177
- ],
178
- injectionMode: "full",
179
- exhausted: true,
180
- },
181
- estimatedTokens: 1000,
182
- };
183
- },
184
+ createInitialReducerState: makeInitialReducerState,
185
+ reduceContextOverflow: runMockReducer,
184
186
  }),
185
187
  );
186
188
 
189
+ // Stand-in for `ContextWindowManager`'s turn-scoped overflow ladder. Threads
190
+ // reducer state across a turn's rungs and delegates each rung to the mocked
191
+ // reducer, mirroring `reduceOverflowOneRung` / `resetOverflowRecovery` so the
192
+ // convergence driver exercises the same per-rung escalation the real manager
193
+ // drives.
194
+ function makeOverflowLadderStub(): {
195
+ resetOverflowRecovery: () => void;
196
+ reduceOverflowOneRung: (
197
+ msgs: Message[],
198
+ opts: unknown,
199
+ signal?: AbortSignal,
200
+ ) => Promise<unknown>;
201
+ } {
202
+ let state: unknown;
203
+ return {
204
+ resetOverflowRecovery: () => {
205
+ state = undefined;
206
+ },
207
+ reduceOverflowOneRung: async (msgs: Message[], opts: unknown) => {
208
+ if (!state) state = makeInitialReducerState();
209
+ const step = (await runMockReducer(msgs, opts, state)) as {
210
+ state: unknown;
211
+ };
212
+ state = step.state;
213
+ return step;
214
+ },
215
+ };
216
+ }
217
+
187
218
  // Policy: default to fail_gracefully
188
219
  let mockOverflowAction: string = "fail_gracefully";
189
220
  mock.module("../daemon/context-overflow-policy.js", () => ({
@@ -599,6 +630,16 @@ function makeCtx(
599
630
 
600
631
  ...ctxOverrides,
601
632
  } as unknown as Conversation;
633
+ // The convergence driver resolves the turn-scoped overflow ladder off the
634
+ // manager; give every fake manager the ladder methods unless a test supplied
635
+ // its own.
636
+ const manager = ctx.contextWindowManager as unknown as Record<
637
+ string,
638
+ unknown
639
+ >;
640
+ if (typeof manager.reduceOverflowOneRung !== "function") {
641
+ Object.assign(manager, makeOverflowLadderStub());
642
+ }
602
643
  fakeContextWindowManagers.set(conversationId, ctx.contextWindowManager);
603
644
  return ctx;
604
645
  }
@@ -1721,28 +1762,30 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
1721
1762
  return 170_000;
1722
1763
  };
1723
1764
 
1724
- // Convergence reducer becomes exhausted on the second tier so the
1725
- // loop escalates from convergence to the action-resolution block.
1765
+ // The reduction ladder applies a middle tier first, then escalates to its
1766
+ // terminal auto-compress-latest-turn rung and reports exhaustion. The
1767
+ // terminal rung is what produces a `budget_yield_unrecovered` outcome when
1768
+ // its rerun still yields at the mid-loop checkpoint.
1726
1769
  let reducerCallCount = 0;
1727
1770
  mockReducerStepFn = (msgs: Message[]) => {
1728
1771
  reducerCallCount++;
1729
- const exhausted = reducerCallCount >= 2;
1772
+ const terminal = reducerCallCount >= 2;
1730
1773
  return {
1731
1774
  messages: msgs,
1732
- tier: exhausted ? "tool_result_truncation" : "forced_compaction",
1775
+ tier: terminal ? "auto_compress_latest_turn" : "forced_compaction",
1733
1776
  state: {
1734
- appliedTiers: exhausted
1735
- ? ["forced_compaction", "tool_result_truncation"]
1777
+ appliedTiers: terminal
1778
+ ? ["forced_compaction", "auto_compress_latest_turn"]
1736
1779
  : ["forced_compaction"],
1737
1780
  injectionMode: "full" as const,
1738
- exhausted,
1781
+ exhausted: terminal,
1739
1782
  },
1740
- estimatedTokens: exhausted ? 60_000 : 80_000,
1783
+ estimatedTokens: terminal ? 60_000 : 80_000,
1741
1784
  };
1742
1785
  };
1743
1786
 
1744
- // The overflow policy directs us into auto_compress_latest_turn so the
1745
- // emergency compaction + final agentLoop.run path executes.
1787
+ // The overflow policy permits the terminal auto-compress-latest-turn rung,
1788
+ // so the ladder runs it as its final step.
1746
1789
  mockOverflowAction = "auto_compress_latest_turn";
1747
1790
 
1748
1791
  // Every provider call returns a tool_use, so each loop run does a tool
@@ -1752,19 +1795,16 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
1752
1795
  toolUseResponse("tu-1", "bash", { command: "ls" }),
1753
1796
  ]);
1754
1797
 
1755
- // Every forced `maybeCompact` is invoked through three distinct call
1756
- // sites, all owned by the loop's pre-call budget gate / recovery ladder:
1757
- // 1. First-call (turn-start) gate (`force: true`) — the loop now owns
1758
- // the turn-start compaction. It succeeds, but the mocked estimate
1759
- // stays above the mid-loop threshold, so the turn proceeds and the
1760
- // mid-loop gate still trips on the next iteration.
1798
+ // Forced `maybeCompact` is invoked through the loop's own pre-call budget
1799
+ // gate at two call sites (the reduction-ladder rungs are mocked out, so
1800
+ // their summary calls don't reach this manager):
1801
+ // 1. First-call (turn-start) gate (`force: true`) — the loop owns the
1802
+ // turn-start compaction. It succeeds, but the mocked estimate stays
1803
+ // above the mid-loop threshold, so the turn proceeds and the mid-loop
1804
+ // gate still trips on the next iteration.
1761
1805
  // 2. Mid-loop after the first tool turn (`force: true`) — must signal
1762
1806
  // `exhausted: true` so the daemon escalates to the convergence
1763
1807
  // reducer instead of looping forever.
1764
- // 3. auto_compress_latest_turn emergency compaction (`force: true`,
1765
- // `minKeepRecentUserTurns: 0`) — succeeds and drops tokens below
1766
- // threshold; the subsequent rerun yields again and is classified
1767
- // as BUDGET_YIELD_UNRECOVERED.
1768
1808
  let forcedMaybeCompactCallCount = 0;
1769
1809
  const ctx = makeCtx({
1770
1810
  loopProvider: provider,
@@ -1843,7 +1883,8 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
1843
1883
  exhausted: true,
1844
1884
  };
1845
1885
  }
1846
- // Emergency compaction call from auto_compress_latest_turn.
1886
+ // Defensive fallback for any additional forced probe; the two gate
1887
+ // call sites above are the only ones this test exercises.
1847
1888
  return {
1848
1889
  compacted: true,
1849
1890
  messages: [
@@ -1853,7 +1894,7 @@ describe("session-agent-loop overflow recovery (JARVIS-110)", () => {
1853
1894
  },
1854
1895
  ] as Message[],
1855
1896
  compactedPersistedMessages: 5,
1856
- summaryText: "Emergency summary",
1897
+ summaryText: "Fallback summary",
1857
1898
  previousEstimatedInputTokens: 170_000,
1858
1899
  estimatedInputTokens: 90_000,
1859
1900
  maxInputTokens: 200_000,