@vellumai/assistant 0.8.11 → 0.8.12-staging.1

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 (219) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +126 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-usage-store.test.ts +11 -0
  37. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  38. package/src/__tests__/model-intents.test.ts +1 -1
  39. package/src/__tests__/oauth-cli.test.ts +19 -8
  40. package/src/__tests__/openai-provider.test.ts +34 -0
  41. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  42. package/src/__tests__/recurrence-engine.test.ts +45 -0
  43. package/src/__tests__/schedule-routes.test.ts +34 -0
  44. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  45. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  46. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  47. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  48. package/src/__tests__/subagent-tools.test.ts +1 -0
  49. package/src/__tests__/system-prompt.test.ts +109 -11
  50. package/src/__tests__/tool-error-hook.test.ts +1 -0
  51. package/src/__tests__/tool-result-spool.test.ts +337 -0
  52. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  53. package/src/__tests__/validate-input.test.ts +95 -1
  54. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  55. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  56. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  57. package/src/agent/loop.ts +81 -24
  58. package/src/api/events/usage-progress.ts +28 -0
  59. package/src/api/index.ts +6 -0
  60. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  61. package/src/bundler/app-bundler.ts +25 -42
  62. package/src/calls/call-controller.ts +1 -1
  63. package/src/cli/commands/plugins.ts +248 -15
  64. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  66. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  68. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  69. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
  70. package/src/cli/lib/inspect-plugin.ts +252 -0
  71. package/src/cli/lib/install-from-github.ts +214 -21
  72. package/src/cli/lib/list-installed-plugins.ts +17 -6
  73. package/src/cli/lib/plugin-artifact.ts +103 -0
  74. package/src/cli/lib/plugin-details.ts +18 -1
  75. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  76. package/src/cli/lib/upgrade-plugin.ts +219 -0
  77. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  78. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  79. package/src/config/call-site-defaults.ts +13 -2
  80. package/src/config/feature-flag-registry.json +8 -16
  81. package/src/config/loader.ts +52 -59
  82. package/src/config/schema.ts +0 -2
  83. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  84. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  85. package/src/config/schemas/llm.ts +10 -0
  86. package/src/config/schemas/memory-v2.ts +13 -0
  87. package/src/config/schemas/memory-v3.ts +92 -0
  88. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  89. package/src/context/tool-result-spool.ts +104 -0
  90. package/src/credential-execution/feature-gates.ts +0 -1
  91. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  92. package/src/daemon/conversation-error.ts +6 -15
  93. package/src/daemon/conversation.ts +9 -0
  94. package/src/daemon/disk-pressure-policy.ts +0 -1
  95. package/src/daemon/lifecycle.ts +1 -20
  96. package/src/daemon/message-types/conversations.ts +2 -15
  97. package/src/daemon/trust-context.ts +1 -1
  98. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  99. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  100. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  101. package/src/home/home-content-refresh.ts +43 -31
  102. package/src/home/home-greeting-cache.ts +8 -1
  103. package/src/home/home-greeting.ts +13 -9
  104. package/src/home/suggested-prompts.ts +77 -24
  105. package/src/ipc/routes/trust-rules.test.ts +66 -72
  106. package/src/media/image-credentials.ts +2 -2
  107. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  108. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  109. package/src/memory/conversation-attention-store.ts +1 -0
  110. package/src/memory/conversation-bootstrap.ts +18 -9
  111. package/src/memory/conversation-crud.ts +12 -2
  112. package/src/memory/conversation-title-service.ts +53 -9
  113. package/src/memory/delivery-channels.ts +0 -69
  114. package/src/memory/graph/extraction-job.ts +0 -15
  115. package/src/memory/guardian-action-store.ts +1 -376
  116. package/src/memory/llm-usage-store.ts +5 -1
  117. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  119. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  120. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  121. package/src/memory/v2/consolidation-job.ts +93 -9
  122. package/src/memory/v2/injection.ts +53 -0
  123. package/src/memory/v2/prompts/consolidation.ts +1 -0
  124. package/src/memory/v2/static-context.ts +13 -1
  125. package/src/memory/v2/sweep-job.ts +1 -1
  126. package/src/memory/v2/types.ts +5 -0
  127. package/src/plugin-api/types.ts +7 -0
  128. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  129. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  130. package/src/plugins/defaults/index.ts +25 -0
  131. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  132. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  133. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  134. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  135. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  136. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  137. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  138. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  139. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  140. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  141. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  142. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  143. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  144. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  145. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  146. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  147. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  148. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  149. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  150. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  151. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  152. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  153. package/src/prompts/cache-boundary.ts +17 -0
  154. package/src/prompts/sections.ts +50 -17
  155. package/src/prompts/system-prompt.ts +12 -4
  156. package/src/prompts/templates/system-sections.ts +22 -0
  157. package/src/providers/anthropic/client.ts +74 -28
  158. package/src/providers/gemini/client.ts +5 -1
  159. package/src/providers/minimax/client.ts +9 -0
  160. package/src/providers/model-intents.ts +2 -2
  161. package/src/providers/openai/chat-completions-provider.ts +2 -1
  162. package/src/providers/openai/responses-provider.ts +5 -1
  163. package/src/providers/retry.ts +8 -0
  164. package/src/providers/types.ts +11 -0
  165. package/src/runtime/AGENTS.md +6 -0
  166. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  167. package/src/runtime/agent-wake.ts +5 -5
  168. package/src/runtime/background-job-runner.ts +2 -2
  169. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  170. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  171. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  172. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  173. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  174. package/src/runtime/pre-first-message-gate.ts +1 -1
  175. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  176. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  177. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  178. package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
  179. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  180. package/src/runtime/routes/btw-routes.ts +0 -14
  181. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  182. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  183. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  184. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  185. package/src/runtime/routes/home-feed-routes.ts +10 -0
  186. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  187. package/src/runtime/routes/identity-routes.ts +76 -20
  188. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  189. package/src/runtime/routes/plugins-routes.ts +21 -0
  190. package/src/runtime/routes/schedule-routes.ts +19 -2
  191. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  192. package/src/schedule/recurrence-engine.ts +34 -0
  193. package/src/schedule/scheduler.ts +1 -0
  194. package/src/skills/validate-input.ts +41 -1
  195. package/src/subagent/types.ts +26 -1
  196. package/src/telemetry/types.ts +15 -1
  197. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  198. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  199. package/src/tools/apps/executors.ts +1 -1
  200. package/src/tools/skills/skill-tool-factory.ts +19 -8
  201. package/src/usage/types.ts +8 -1
  202. package/src/util/platform.ts +16 -0
  203. package/src/watcher/engine.ts +1 -0
  204. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  205. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  206. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  207. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  210. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  211. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  212. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  213. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  214. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  215. package/src/config/schemas/updates.ts +0 -14
  216. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  217. package/src/memory/conversation-starters-cadence.ts +0 -78
  218. package/src/prompts/update-bulletin-job.ts +0 -180
  219. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -0,0 +1,337 @@
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import type { AgentEvent } from "../agent/loop.js";
13
+ import { AgentLoop } from "../agent/loop.js";
14
+ import {
15
+ getToolResultFilePath,
16
+ postTurnTruncateToolResults,
17
+ THRESHOLD_CHARS,
18
+ TOOL_RESULT_DIR,
19
+ TRUNCATION_MARKER,
20
+ } from "../context/post-turn-tool-result-truncation.js";
21
+ import { spoolAndStubOversizedToolResults } from "../context/tool-result-spool.js";
22
+ import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
23
+ import type {
24
+ ContentBlock,
25
+ Message,
26
+ ToolDefinition,
27
+ } from "../providers/types.js";
28
+ import {
29
+ createMockProvider,
30
+ textResponse,
31
+ toolUseResponse,
32
+ } from "./helpers/mock-provider.js";
33
+
34
+ function makeToolResult(
35
+ content: string,
36
+ toolUseId = "tool_use_1",
37
+ is_error = false,
38
+ ): ContentBlock {
39
+ return {
40
+ type: "tool_result" as const,
41
+ tool_use_id: toolUseId,
42
+ content,
43
+ ...(is_error ? { is_error: true } : {}),
44
+ };
45
+ }
46
+
47
+ const LONG = "x".repeat(THRESHOLD_CHARS + 100);
48
+ const SHORT = "y".repeat(100);
49
+
50
+ describe("spoolAndStubOversizedToolResults", () => {
51
+ let convDir: string;
52
+ const noName = () => undefined;
53
+
54
+ beforeEach(() => {
55
+ convDir = mkdtempSync(join(tmpdir(), "tool-result-spool-"));
56
+ });
57
+
58
+ afterEach(() => {
59
+ rmSync(convDir, { recursive: true, force: true });
60
+ });
61
+
62
+ test("spools an oversized result and swaps in the stub", () => {
63
+ const blocks = [makeToolResult(LONG, "tu_big")];
64
+
65
+ const count = spoolAndStubOversizedToolResults(blocks, {
66
+ conversationDir: convDir,
67
+ toolNameById: noName,
68
+ });
69
+
70
+ expect(count).toBe(1);
71
+ const filePath = getToolResultFilePath(convDir, "tu_big");
72
+ expect(existsSync(filePath)).toBe(true);
73
+ expect(readFileSync(filePath, "utf-8")).toBe(LONG);
74
+ const stubbed = blocks[0] as { content: string };
75
+ expect(stubbed.content).toContain(TRUNCATION_MARKER);
76
+ expect(stubbed.content).toContain(filePath);
77
+ expect(stubbed.content.length).toBeLessThan(LONG.length);
78
+ });
79
+
80
+ test("stub is byte-identical to the post-turn truncation stub", () => {
81
+ const blocks = [makeToolResult(LONG, "tu_big")];
82
+ spoolAndStubOversizedToolResults(blocks, {
83
+ conversationDir: convDir,
84
+ toolNameById: noName,
85
+ });
86
+
87
+ const { messages: postTurn } = postTurnTruncateToolResults(
88
+ [{ role: "user", content: [makeToolResult(LONG, "tu_big")] }],
89
+ { conversationDir: convDir },
90
+ );
91
+
92
+ expect((blocks[0] as { content: string }).content).toBe(
93
+ (postTurn[0].content[0] as { content: string }).content,
94
+ );
95
+ });
96
+
97
+ test("post-turn pass is a no-op on result-time stubs", () => {
98
+ const blocks = [makeToolResult(LONG, "tu_big")];
99
+ spoolAndStubOversizedToolResults(blocks, {
100
+ conversationDir: convDir,
101
+ toolNameById: noName,
102
+ });
103
+
104
+ const history: Message[] = [{ role: "user", content: [...blocks] }];
105
+ const { messages, truncatedCount } = postTurnTruncateToolResults(history, {
106
+ conversationDir: convDir,
107
+ });
108
+
109
+ expect(truncatedCount).toBe(0);
110
+ expect(messages).toBe(history);
111
+ });
112
+
113
+ test("skips below-threshold, error, exempt, file_read, marked, and ax-tree results", () => {
114
+ const blocks = [
115
+ makeToolResult(SHORT, "tu_short"),
116
+ makeToolResult(LONG, "tu_err", true),
117
+ makeToolResult(LONG, "tu_skill"),
118
+ makeToolResult(LONG, "tu_read"),
119
+ makeToolResult(`${LONG}${TRUNCATION_MARKER}`, "tu_marked"),
120
+ makeToolResult(`<ax-tree>${LONG}</ax-tree>`, "tu_ax"),
121
+ { type: "text" as const, text: LONG },
122
+ ];
123
+ const originals = blocks.map((b) => b);
124
+
125
+ const count = spoolAndStubOversizedToolResults(blocks, {
126
+ conversationDir: convDir,
127
+ toolNameById: (id) => {
128
+ if (id === "tu_skill") return "skill_load";
129
+ if (id === "tu_read") return "file_read";
130
+ return undefined;
131
+ },
132
+ });
133
+
134
+ expect(count).toBe(0);
135
+ expect(blocks).toEqual(originals);
136
+ expect(existsSync(join(convDir, TOOL_RESULT_DIR))).toBe(false);
137
+ });
138
+
139
+ test("spools each eligible block in a mixed batch", () => {
140
+ const blocks = [
141
+ makeToolResult(LONG, "tu_a"),
142
+ makeToolResult(SHORT, "tu_b"),
143
+ makeToolResult(LONG, "tu_c"),
144
+ ];
145
+
146
+ const count = spoolAndStubOversizedToolResults(blocks, {
147
+ conversationDir: convDir,
148
+ toolNameById: noName,
149
+ });
150
+
151
+ expect(count).toBe(2);
152
+ expect(existsSync(getToolResultFilePath(convDir, "tu_a"))).toBe(true);
153
+ expect(existsSync(getToolResultFilePath(convDir, "tu_b"))).toBe(false);
154
+ expect(existsSync(getToolResultFilePath(convDir, "tu_c"))).toBe(true);
155
+ expect((blocks[0] as { content: string }).content).toContain(
156
+ TRUNCATION_MARKER,
157
+ );
158
+ expect((blocks[1] as { content: string }).content).toBe(SHORT);
159
+ expect((blocks[2] as { content: string }).content).toContain(
160
+ TRUNCATION_MARKER,
161
+ );
162
+ });
163
+
164
+ test("keeps full content when the spool directory cannot be created", () => {
165
+ // A regular file at the conversation-dir path makes mkdir throw, so no
166
+ // stub can be written; the block must keep its full content (a stub must
167
+ // never exist without its file on disk).
168
+ const fileAsDir = join(convDir, "not-a-dir");
169
+ writeFileSync(fileAsDir, "occupied", "utf-8");
170
+ const blocks = [makeToolResult(LONG, "tu_big")];
171
+
172
+ expect(() =>
173
+ spoolAndStubOversizedToolResults(blocks, {
174
+ conversationDir: fileAsDir,
175
+ toolNameById: noName,
176
+ }),
177
+ ).toThrow();
178
+ expect((blocks[0] as { content: string }).content).toBe(LONG);
179
+ });
180
+ });
181
+
182
+ describe("AgentLoop result-time spooling", () => {
183
+ let convDir: string;
184
+
185
+ const dummyTools: ToolDefinition[] = [
186
+ {
187
+ name: "fetch_transcript",
188
+ description: "Fetch a transcript",
189
+ input_schema: { type: "object", properties: {} },
190
+ },
191
+ ];
192
+
193
+ const userMessage: Message = {
194
+ role: "user",
195
+ content: [{ type: "text", text: "Import the transcripts" }],
196
+ };
197
+
198
+ beforeEach(() => {
199
+ resetPluginRegistryAndRegisterDefaults();
200
+ convDir = mkdtempSync(join(tmpdir(), "tool-result-spool-loop-"));
201
+ });
202
+
203
+ afterEach(() => {
204
+ rmSync(convDir, { recursive: true, force: true });
205
+ });
206
+
207
+ test("oversized result enters history as the stub; full content is never sent to the provider", async () => {
208
+ const { provider, calls } = createMockProvider([
209
+ toolUseResponse("tu_1", "fetch_transcript", {}),
210
+ textResponse("Done."),
211
+ ]);
212
+ const loop = new AgentLoop({
213
+ provider,
214
+ systemPrompt: "system",
215
+ conversationId: "test-conversation",
216
+ tools: dummyTools,
217
+ toolExecutor: async () => ({ content: LONG, isError: false }),
218
+ resolveConversationDir: () => convDir,
219
+ });
220
+
221
+ const events: AgentEvent[] = [];
222
+ const { history } = await loop.run({
223
+ requestId: "test-request",
224
+ messages: [userMessage],
225
+ onEvent: (e) => {
226
+ events.push(e);
227
+ },
228
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
229
+ });
230
+
231
+ const filePath = getToolResultFilePath(convDir, "tu_1");
232
+ expect(readFileSync(filePath, "utf-8")).toBe(LONG);
233
+
234
+ // The second provider call carries the stub, not the full content.
235
+ const sentResult = calls[1].messages[2].content[0] as { content: string };
236
+ expect(sentResult.content).toContain(TRUNCATION_MARKER);
237
+ expect(sentResult.content).toContain(filePath);
238
+ expect(JSON.stringify(calls.map((c) => c.messages))).not.toContain(LONG);
239
+
240
+ // Durable history and the emitted tool_result event match what was sent.
241
+ const historyResult = history[2].content[0] as { content: string };
242
+ expect(historyResult.content).toBe(sentResult.content);
243
+ const resultEvent = events.find((e) => e.type === "tool_result") as {
244
+ content: string;
245
+ };
246
+ expect(resultEvent.content).toBe(sentResult.content);
247
+ });
248
+
249
+ test("outbound payloads stay append-only across calls (prompt-cache prefix invariant)", async () => {
250
+ const { provider, calls } = createMockProvider([
251
+ toolUseResponse("tu_1", "fetch_transcript", {}),
252
+ toolUseResponse("tu_2", "fetch_transcript", {}),
253
+ textResponse("Done."),
254
+ ]);
255
+ const loop = new AgentLoop({
256
+ provider,
257
+ systemPrompt: "system",
258
+ conversationId: "test-conversation",
259
+ tools: dummyTools,
260
+ toolExecutor: async () => ({ content: LONG, isError: false }),
261
+ resolveConversationDir: () => convDir,
262
+ });
263
+
264
+ await loop.run({
265
+ requestId: "test-request",
266
+ messages: [userMessage],
267
+ onEvent: () => {},
268
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
269
+ });
270
+
271
+ expect(calls).toHaveLength(3);
272
+ // Each call's payload extends the previous call's payload without
273
+ // rewriting it — the property that keeps the provider's prompt-cache
274
+ // prefix valid across the turn's iterations.
275
+ for (let i = 1; i < calls.length; i++) {
276
+ expect(calls[i].messages.slice(0, calls[i - 1].messages.length)).toEqual(
277
+ calls[i - 1].messages,
278
+ );
279
+ }
280
+ });
281
+
282
+ test("spools the raw output even when it exceeds the post-tool-use truncation budget", async () => {
283
+ // maxInputTokens of 10k gives the default truncate plugin a 12k-char
284
+ // budget (0.3 share × 4 chars/token). The spool must run before that
285
+ // hook, so the file holds all 20k chars of raw output rather than the
286
+ // hook's tail-dropped copy — otherwise the omitted tail is unrecoverable.
287
+ const HUGE = "z".repeat(20_000);
288
+ const { provider } = createMockProvider([
289
+ toolUseResponse("tu_1", "fetch_transcript", {}),
290
+ textResponse("Done."),
291
+ ]);
292
+ const loop = new AgentLoop({
293
+ provider,
294
+ systemPrompt: "system",
295
+ conversationId: "test-conversation",
296
+ config: { maxInputTokens: 10_000 },
297
+ tools: dummyTools,
298
+ toolExecutor: async () => ({ content: HUGE, isError: false }),
299
+ resolveConversationDir: () => convDir,
300
+ });
301
+
302
+ await loop.run({
303
+ requestId: "test-request",
304
+ messages: [userMessage],
305
+ onEvent: () => {},
306
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
307
+ });
308
+
309
+ const filePath = getToolResultFilePath(convDir, "tu_1");
310
+ expect(readFileSync(filePath, "utf-8")).toBe(HUGE);
311
+ });
312
+
313
+ test("without resolveConversationDir the full content is sent and post-turn handles it", async () => {
314
+ const { provider, calls } = createMockProvider([
315
+ toolUseResponse("tu_1", "fetch_transcript", {}),
316
+ textResponse("Done."),
317
+ ]);
318
+ const loop = new AgentLoop({
319
+ provider,
320
+ systemPrompt: "system",
321
+ conversationId: "test-conversation",
322
+ tools: dummyTools,
323
+ toolExecutor: async () => ({ content: LONG, isError: false }),
324
+ });
325
+
326
+ await loop.run({
327
+ requestId: "test-request",
328
+ messages: [userMessage],
329
+ onEvent: () => {},
330
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
331
+ });
332
+
333
+ const sentResult = calls[1].messages[2].content[0] as { content: string };
334
+ expect(sentResult.content).toBe(LONG);
335
+ expect(existsSync(join(convDir, TOOL_RESULT_DIR))).toBe(false);
336
+ });
337
+ });
@@ -48,6 +48,7 @@ function makeCtx(content: string): PostToolUseContext {
48
48
  toolResponse: makeToolResponse(content),
49
49
  messages: [],
50
50
  additionalContext: null,
51
+ model: "claude-test-model",
51
52
  maxInputTokens: MAX_INPUT_TOKENS,
52
53
  logger: noopLogger,
53
54
  };
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { validateInputAgainstSchema } from "../skills/validate-input.js";
3
+ import {
4
+ coerceStringBooleans,
5
+ validateInputAgainstSchema,
6
+ } from "../skills/validate-input.js";
4
7
 
5
8
  // ---------------------------------------------------------------------------
6
9
  // required
@@ -355,6 +358,97 @@ describe("validateInputAgainstSchema — permissive paths", () => {
355
358
  });
356
359
  });
357
360
 
361
+ // ---------------------------------------------------------------------------
362
+ // string-boolean coercion (pre-validation pass)
363
+ // ---------------------------------------------------------------------------
364
+
365
+ describe("coerceStringBooleans", () => {
366
+ const schema = {
367
+ type: "object",
368
+ properties: {
369
+ auto_open: { type: "boolean" },
370
+ name: { type: "string" },
371
+ flags: { type: ["boolean", "null"] },
372
+ },
373
+ };
374
+
375
+ test.each([
376
+ ["false", false],
377
+ ["true", true],
378
+ ["False", false],
379
+ ["TRUE", true],
380
+ [" true ", true],
381
+ ] as const)("coerces %p to %p for boolean-typed properties", (raw, want) => {
382
+ const result = coerceStringBooleans({ auto_open: raw }, schema);
383
+ expect(result.auto_open).toBe(want);
384
+ });
385
+
386
+ test("coerced input passes validation and preserves intent", () => {
387
+ const coerced = coerceStringBooleans(
388
+ { auto_open: "false", name: "x" },
389
+ schema,
390
+ );
391
+ expect(validateInputAgainstSchema("app_create", coerced, schema)).toEqual({
392
+ ok: true,
393
+ });
394
+ expect(coerced.auto_open).toBe(false);
395
+ });
396
+
397
+ test("leaves non-coercible strings alone (validation still rejects)", () => {
398
+ const input = { auto_open: "yes" };
399
+ const result = coerceStringBooleans(input, schema);
400
+ expect(result).toBe(input);
401
+ const validation = validateInputAgainstSchema("app_create", result, schema);
402
+ expect(validation.ok).toBe(false);
403
+ });
404
+
405
+ test("does not touch string-typed or union-typed properties", () => {
406
+ const input = { name: "true", flags: "false" };
407
+ const result = coerceStringBooleans(input, schema);
408
+ expect(result).toBe(input);
409
+ expect(result.name).toBe("true");
410
+ expect(result.flags).toBe("false");
411
+ });
412
+
413
+ test("does not touch real booleans or other types", () => {
414
+ const input = { auto_open: true };
415
+ expect(coerceStringBooleans(input, schema)).toBe(input);
416
+ const inputNum = { auto_open: 1 };
417
+ expect(coerceStringBooleans(inputNum, schema)).toBe(inputNum);
418
+ });
419
+
420
+ test("returns the same object when there is nothing to coerce", () => {
421
+ const input = { name: "x" };
422
+ expect(coerceStringBooleans(input, schema)).toBe(input);
423
+ expect(coerceStringBooleans(input, undefined)).toBe(input);
424
+ expect(coerceStringBooleans(input, { type: "object" })).toBe(input);
425
+ });
426
+
427
+ test("returns a new object and never mutates the original input", () => {
428
+ const input = { auto_open: "false", name: "x" };
429
+ const snapshot = JSON.parse(JSON.stringify(input));
430
+ const result = coerceStringBooleans(input, schema);
431
+ expect(result).not.toBe(input);
432
+ expect(input).toEqual(snapshot);
433
+ expect(result.name).toBe("x");
434
+ });
435
+ });
436
+
437
+ describe("validateInputAgainstSchema — boolean string error message", () => {
438
+ test("points at the JSON boolean form when a string is passed", () => {
439
+ const result = validateInputAgainstSchema(
440
+ "app_create",
441
+ { auto_open: "yes" },
442
+ { type: "object", properties: { auto_open: { type: "boolean" } } },
443
+ );
444
+ expect(result.ok).toBe(false);
445
+ if (result.ok) return;
446
+ expect(result.errors).toContain(
447
+ "auto_open must be a boolean — pass true or false as a JSON boolean, not a string",
448
+ );
449
+ });
450
+ });
451
+
358
452
  // ---------------------------------------------------------------------------
359
453
  // purity
360
454
  // ---------------------------------------------------------------------------
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ test,
12
+ } from "bun:test";
13
+
14
+ import { releaseNotesTavilyWebSearchMigration } from "../workspace/migrations/078-release-notes-tavily-web-search.js";
15
+ import { removeStaleUpdatesBulletinFileMigration } from "../workspace/migrations/098-remove-stale-updates-bulletin-file.js";
16
+
17
+ let testRoot: string;
18
+ let workspaceDir: string;
19
+
20
+ beforeAll(() => {
21
+ testRoot = mkdtempSync(join(tmpdir(), "migration-098-remove-updates-"));
22
+ });
23
+
24
+ afterAll(() => {
25
+ rmSync(testRoot, { recursive: true, force: true });
26
+ });
27
+
28
+ beforeEach(() => {
29
+ workspaceDir = mkdtempSync(join(testRoot, "ws-"));
30
+ });
31
+
32
+ afterEach(() => {
33
+ rmSync(workspaceDir, { recursive: true, force: true });
34
+ });
35
+
36
+ function updatesPath(): string {
37
+ return join(workspaceDir, "UPDATES.md");
38
+ }
39
+
40
+ describe("098-remove-stale-updates-bulletin-file", () => {
41
+ test("deletes a leftover UPDATES.md", () => {
42
+ releaseNotesTavilyWebSearchMigration.run(workspaceDir);
43
+ expect(existsSync(updatesPath())).toBe(true);
44
+
45
+ removeStaleUpdatesBulletinFileMigration.run(workspaceDir);
46
+
47
+ expect(existsSync(updatesPath())).toBe(false);
48
+ });
49
+
50
+ test("no-ops when UPDATES.md is absent", () => {
51
+ expect(() =>
52
+ removeStaleUpdatesBulletinFileMigration.run(workspaceDir),
53
+ ).not.toThrow();
54
+ expect(existsSync(updatesPath())).toBe(false);
55
+ });
56
+
57
+ test("is idempotent", () => {
58
+ writeFileSync(updatesPath(), "anything\n", "utf-8");
59
+
60
+ removeStaleUpdatesBulletinFileMigration.run(workspaceDir);
61
+ removeStaleUpdatesBulletinFileMigration.run(workspaceDir);
62
+
63
+ expect(existsSync(updatesPath())).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,139 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import { disableCacheOneShotCallsitesMigration } from "../workspace/migrations/099-disable-cache-one-shot-callsites.js";
13
+
14
+ let workspaceDir: string;
15
+
16
+ function freshWorkspace(): void {
17
+ workspaceDir = join(
18
+ tmpdir(),
19
+ `vellum-migration-099-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
20
+ );
21
+ mkdirSync(workspaceDir, { recursive: true });
22
+ }
23
+
24
+ function writeConfig(data: Record<string, unknown>): void {
25
+ writeFileSync(
26
+ join(workspaceDir, "config.json"),
27
+ JSON.stringify(data, null, 2) + "\n",
28
+ );
29
+ }
30
+
31
+ function readConfig(): Record<string, unknown> {
32
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
33
+ }
34
+
35
+ beforeEach(() => {
36
+ freshWorkspace();
37
+ delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
38
+ });
39
+
40
+ afterEach(() => {
41
+ delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
42
+ if (existsSync(workspaceDir)) {
43
+ rmSync(workspaceDir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ type CallSites = Record<string, Record<string, unknown>>;
48
+
49
+ function callSites(config: Record<string, unknown>): CallSites {
50
+ return (config.llm as { callSites: CallSites }).callSites;
51
+ }
52
+
53
+ describe("099-disable-cache-one-shot-callsites migration", () => {
54
+ test("has correct migration id", () => {
55
+ expect(disableCacheOneShotCallsitesMigration.id).toBe(
56
+ "099-disable-cache-one-shot-callsites",
57
+ );
58
+ });
59
+
60
+ test("adds disableCache to existing one-shot entries, preserving other keys", () => {
61
+ writeConfig({
62
+ llm: {
63
+ callSites: {
64
+ recall: { profile: "cost-optimized", maxTokens: 4096 },
65
+ replySuggestion: { profile: "cost-optimized" },
66
+ },
67
+ },
68
+ });
69
+
70
+ disableCacheOneShotCallsitesMigration.run(workspaceDir);
71
+
72
+ const sites = callSites(readConfig());
73
+ expect(sites.recall).toEqual({
74
+ profile: "cost-optimized",
75
+ maxTokens: 4096,
76
+ disableCache: true,
77
+ });
78
+ expect(sites.replySuggestion).toEqual({
79
+ profile: "cost-optimized",
80
+ disableCache: true,
81
+ });
82
+ });
83
+
84
+ test("leaves an explicit disableCache value untouched (user opted back in)", () => {
85
+ writeConfig({
86
+ llm: {
87
+ callSites: {
88
+ recall: { profile: "cost-optimized", disableCache: false },
89
+ },
90
+ },
91
+ });
92
+
93
+ disableCacheOneShotCallsitesMigration.run(workspaceDir);
94
+
95
+ expect(callSites(readConfig()).recall.disableCache).toBe(false);
96
+ });
97
+
98
+ test("does not create entries for call sites absent from disk", () => {
99
+ writeConfig({
100
+ llm: {
101
+ callSites: {
102
+ mainAgent: { profile: "balanced" },
103
+ },
104
+ },
105
+ });
106
+
107
+ disableCacheOneShotCallsitesMigration.run(workspaceDir);
108
+
109
+ const sites = callSites(readConfig());
110
+ expect(sites.recall).toBeUndefined();
111
+ expect(sites.homeGreeting).toBeUndefined();
112
+ expect(sites.mainAgent).toEqual({ profile: "balanced" });
113
+ });
114
+
115
+ test("is idempotent across repeated runs", () => {
116
+ writeConfig({
117
+ llm: {
118
+ callSites: {
119
+ conversationTitle: { profile: "cost-optimized" },
120
+ },
121
+ },
122
+ });
123
+
124
+ disableCacheOneShotCallsitesMigration.run(workspaceDir);
125
+ const afterFirst = readConfig();
126
+ disableCacheOneShotCallsitesMigration.run(workspaceDir);
127
+
128
+ expect(readConfig()).toEqual(afterFirst);
129
+ });
130
+
131
+ test("no-ops when config.json is missing or has no callSites", () => {
132
+ disableCacheOneShotCallsitesMigration.run(workspaceDir);
133
+ expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
134
+
135
+ writeConfig({ llm: {} });
136
+ disableCacheOneShotCallsitesMigration.run(workspaceDir);
137
+ expect(readConfig()).toEqual({ llm: {} });
138
+ });
139
+ });