@vellumai/assistant 0.8.11 → 0.8.12-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 (244) 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 +633 -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-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  computeNextRunAt,
5
+ describeRRuleExpression,
6
+ isSingleFireRRule,
5
7
  isValidScheduleExpression,
6
8
  } from "../schedule/recurrence-engine.js";
7
9
 
@@ -117,3 +119,46 @@ describe("recurrence engine — rrule", () => {
117
119
  expect(next).toBe(jan2);
118
120
  });
119
121
  });
122
+
123
+ describe("recurrence engine — rrule display helpers", () => {
124
+ const SINGLE_FIRE = "DTSTART:20990612T080000\nRRULE:FREQ=DAILY;COUNT=1";
125
+ const WEEKLY = "DTSTART:20990612T080000\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE";
126
+
127
+ test("isSingleFireRRule detects COUNT=1 rules", () => {
128
+ expect(isSingleFireRRule(SINGLE_FIRE)).toBe(true);
129
+ expect(isSingleFireRRule(WEEKLY)).toBe(false);
130
+ expect(
131
+ isSingleFireRRule("DTSTART:20990612T080000\nRRULE:FREQ=DAILY;COUNT=2"),
132
+ ).toBe(false);
133
+ expect(isSingleFireRRule("not an rrule")).toBe(false);
134
+ });
135
+
136
+ test("isSingleFireRRule is false for set constructs", () => {
137
+ expect(
138
+ isSingleFireRRule(
139
+ "DTSTART:20990612T080000\nRRULE:FREQ=DAILY;COUNT=1\nRDATE:20990613T080000",
140
+ ),
141
+ ).toBe(false);
142
+ });
143
+
144
+ test("describeRRuleExpression humanizes rules", () => {
145
+ expect(describeRRuleExpression(SINGLE_FIRE)).toBe("One-time");
146
+ expect(describeRRuleExpression(WEEKLY)).toBe(
147
+ "Every week on Monday, Wednesday",
148
+ );
149
+ expect(
150
+ describeRRuleExpression(
151
+ "DTSTART:20990612T080000\nRRULE:FREQ=DAILY;COUNT=3",
152
+ ),
153
+ ).toBe("Every day for 3 times");
154
+ });
155
+
156
+ test("describeRRuleExpression falls back instead of leaking raw text", () => {
157
+ expect(
158
+ describeRRuleExpression(
159
+ "DTSTART:20990612T080000\nRRULE:FREQ=DAILY\nEXDATE:20990613T080000",
160
+ ),
161
+ ).toBe("Custom recurrence");
162
+ expect(describeRRuleExpression("garbage")).toBe("Custom recurrence");
163
+ });
164
+ });
@@ -492,6 +492,40 @@ describe("GET /schedules — default defer exclusion", () => {
492
492
  });
493
493
  });
494
494
 
495
+ test("humanizes rrule cadences and flags single-fire rrules as one-shot", () => {
496
+ createSchedule({
497
+ name: "RRule single fire",
498
+ cronExpression: "DTSTART:20990612T080000\nRRULE:FREQ=DAILY;COUNT=1",
499
+ syntax: "rrule",
500
+ message: "brush teeth",
501
+ });
502
+ createSchedule({
503
+ name: "RRule weekly",
504
+ cronExpression: "DTSTART:20990612T080000\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE",
505
+ syntax: "rrule",
506
+ message: "standup",
507
+ });
508
+
509
+ const route = findRoute("schedules", "GET");
510
+ const result = route.handler({}) as {
511
+ schedules: Array<{
512
+ name: string;
513
+ cadenceDescription: string;
514
+ isOneShot: boolean;
515
+ }>;
516
+ };
517
+ const byName = new Map(result.schedules.map((s) => [s.name, s]));
518
+
519
+ expect(byName.get("RRule single fire")).toMatchObject({
520
+ cadenceDescription: "One-time",
521
+ isOneShot: true,
522
+ });
523
+ expect(byName.get("RRule weekly")).toMatchObject({
524
+ cadenceDescription: "Every week on Monday, Wednesday",
525
+ isOneShot: false,
526
+ });
527
+ });
528
+
495
529
  test("mutation responses also exclude deferred wakes", () => {
496
530
  createSchedule({
497
531
  name: "Agent schedule",
@@ -28,7 +28,7 @@ mock.module("../config/loader.js", () => ({
28
28
  getNestedValue: () => undefined,
29
29
  setNestedValue: () => {},
30
30
  API_KEY_PROVIDERS: [],
31
- _appendQuarantineBulletin: () => {},
31
+ _writeQuarantineNotice: () => {},
32
32
  invalidateConfigCache: () => {},
33
33
  }));
34
34
 
@@ -247,13 +247,18 @@ describe("session-manager", () => {
247
247
  await startSession(session.id);
248
248
  expect(getActiveSession(CONV_ID)).toBeDefined();
249
249
 
250
- // Wait for the idle timeout to fire
251
- await new Promise((r) => setTimeout(r, 200));
250
+ // Wait for the idle timeout to fire and the async stop to finish.
251
+ // The session passes through "stopping" while the server closes, so
252
+ // poll for the terminal state rather than sleeping a fixed interval.
253
+ const deadline = Date.now() + 5000;
254
+ const findSession = () =>
255
+ getSessionsForConversation(CONV_ID).find((x) => x.id === session.id);
256
+ while (findSession()?.status !== "stopped" && Date.now() < deadline) {
257
+ await new Promise((r) => setTimeout(r, 20));
258
+ }
252
259
 
253
260
  expect(getActiveSession(CONV_ID)).toBeUndefined();
254
- const all = getSessionsForConversation(CONV_ID);
255
- const s = all.find((x) => x.id === session.id);
256
- expect(s?.status).toBe("stopped");
261
+ expect(findSession()?.status).toBe("stopped");
257
262
  });
258
263
  });
259
264
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared synthetic-secret fixtures for redaction tests.
3
+ *
4
+ * Constants only — per the test machinery isolation rule, shared helpers in
5
+ * `src/__tests__/` must not import from `src/`.
6
+ */
7
+
8
+ /**
9
+ * A synthetic OpenAI project key that matches the scanner's
10
+ * `sk-proj-[A-Za-z0-9\-_]{40,}` pattern while deliberately dodging its
11
+ * placeholder filtering: no "test"/"example"/"xxxx"-style segments, not a
12
+ * repeated character, and it ends with an alphanumeric so the trailing `\b`
13
+ * boundary holds.
14
+ */
15
+ export const SYNTHETIC_OPENAI_PROJECT_KEY =
16
+ "sk-proj-Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8Qr9St0Uv1Wx2Yz3Ab4Cd5Ef6Gh";
17
+
18
+ /** The marker `redactSecrets()` substitutes for the key above. */
19
+ export const OPENAI_PROJECT_KEY_REDACTION_MARKER =
20
+ '<redacted type="OpenAI Project Key" />';
@@ -347,6 +347,55 @@ describe("createSkillTool — required/type/enum validation", () => {
347
347
  expect(result.content).toContain('mode must be one of "a", "b"');
348
348
  });
349
349
 
350
+ test("coerces string booleans before validation and passes the coerced value to the executor", async () => {
351
+ const hash = computeSkillVersionHash(tempDir);
352
+ const tool = createSkillTool(
353
+ makeEntry({
354
+ executor: "echo.ts",
355
+ input_schema: {
356
+ type: "object",
357
+ properties: {
358
+ auto_open: { type: "boolean" },
359
+ name: { type: "string" },
360
+ },
361
+ },
362
+ }),
363
+ tempDir,
364
+ hash,
365
+ );
366
+
367
+ const result = await tool.execute(
368
+ { auto_open: "false", name: "x" },
369
+ makeContext(),
370
+ );
371
+
372
+ expect(result.isError).toBe(false);
373
+ const parsed = JSON.parse(result.content);
374
+ expect(parsed.input).toEqual({ auto_open: false, name: "x" });
375
+ });
376
+
377
+ test("rejects non-coercible boolean strings with a self-correcting message", async () => {
378
+ const hash = computeSkillVersionHash(tempDir);
379
+ const tool = createSkillTool(
380
+ makeEntry({
381
+ executor: "echo.ts",
382
+ input_schema: {
383
+ type: "object",
384
+ properties: { auto_open: { type: "boolean" } },
385
+ },
386
+ }),
387
+ tempDir,
388
+ hash,
389
+ );
390
+
391
+ const result = await tool.execute({ auto_open: "yes" }, makeContext());
392
+
393
+ expect(result.isError).toBe(true);
394
+ expect(result.content).toContain(
395
+ "auto_open must be a boolean — pass true or false as a JSON boolean, not a string",
396
+ );
397
+ });
398
+
350
399
  test("passes valid input through to the executor unchanged", async () => {
351
400
  const hash = computeSkillVersionHash(tempDir);
352
401
  const tool = createSkillTool(
@@ -7,7 +7,13 @@ import {
7
7
  } from "../subagent/index.js";
8
8
 
9
9
  /** All roles defined in the SubagentRole union. */
10
- const ALL_ROLES: SubagentRole[] = ["general", "researcher", "coder", "planner"];
10
+ const ALL_ROLES: SubagentRole[] = [
11
+ "general",
12
+ "researcher",
13
+ "coder",
14
+ "planner",
15
+ "investigator",
16
+ ];
11
17
 
12
18
  describe("SUBAGENT_ROLE_REGISTRY", () => {
13
19
  test("covers all values in the SubagentRole union", () => {
@@ -64,6 +70,23 @@ describe("SUBAGENT_ROLE_REGISTRY", () => {
64
70
  expect(SUBAGENT_ROLE_REGISTRY.planner.allowedTools!).toContain("recall");
65
71
  });
66
72
 
73
+ test('investigator includes "bash" for grep/find-based code and log investigation', () => {
74
+ expect(SUBAGENT_ROLE_REGISTRY.investigator.allowedTools!).toContain("bash");
75
+ });
76
+
77
+ test("investigator excludes file write tools (read-only investigation)", () => {
78
+ const tools = SUBAGENT_ROLE_REGISTRY.investigator.allowedTools!;
79
+ expect(tools).not.toContain("file_write");
80
+ expect(tools).not.toContain("file_edit");
81
+ });
82
+
83
+ test("investigator preamble defines the root-cause report contract", () => {
84
+ const preamble = SUBAGENT_ROLE_REGISTRY.investigator.systemPromptPreamble;
85
+ expect(preamble).toContain("Root cause");
86
+ expect(preamble).toContain("Evidence");
87
+ expect(preamble).toContain("notify_parent");
88
+ });
89
+
67
90
  test("no role references the old memory_recall tool name", () => {
68
91
  for (const [_role, config] of Object.entries(SUBAGENT_ROLE_REGISTRY)) {
69
92
  if (config.allowedTools !== undefined) {
@@ -1325,6 +1325,7 @@ describe("Subagent role-based spawn", () => {
1325
1325
  "researcher",
1326
1326
  "coder",
1327
1327
  "planner",
1328
+ "investigator",
1328
1329
  ]);
1329
1330
  // role is not required
1330
1331
  expect(def.input_schema.required).not.toContain("role");
@@ -110,6 +110,8 @@ mock.module("../credential-execution/managed-catalog.js", () => ({
110
110
  // Import after mock
111
111
  const { buildSystemPrompt, ensurePromptFiles, stripCommentLines } =
112
112
  await import("../prompts/system-prompt.js");
113
+ const { SYSTEM_PROMPT_CACHE_BOUNDARY } =
114
+ await import("../prompts/cache-boundary.js");
113
115
 
114
116
  describe("buildSystemPrompt", () => {
115
117
  beforeEach(() => {
@@ -128,7 +130,6 @@ describe("buildSystemPrompt", () => {
128
130
  "SOUL.md",
129
131
  "USER.md",
130
132
  "BOOTSTRAP.md",
131
- "UPDATES.md",
132
133
  "VOICE.md",
133
134
  "skills",
134
135
  "users",
@@ -603,16 +604,6 @@ describe("buildSystemPrompt", () => {
603
604
  });
604
605
  });
605
606
 
606
- test("never includes UPDATES.md content in system prompt", () => {
607
- const updatesBody = "# v1.2\n\nNew feature added. UNIQUE_UPDATES_MARKER.";
608
- writeFileSync(join(TEST_DIR, "UPDATES.md"), updatesBody);
609
- const result = buildSystemPrompt();
610
- expect(result).not.toContain("## Recent Updates");
611
- expect(result).not.toContain(updatesBody);
612
- expect(result).not.toContain("UNIQUE_UPDATES_MARKER");
613
- expect(result).not.toContain("Update Handling");
614
- });
615
-
616
607
  test("strips comment lines starting with _ from prompt files", () => {
617
608
  writeFileSync(
618
609
  join(TEST_DIR, "IDENTITY.md"),
@@ -879,6 +870,113 @@ describe("buildSystemPrompt", () => {
879
870
  expect(orgIdx).toBeGreaterThan(parallelIdx);
880
871
  });
881
872
 
873
+ describe("cache breakpoints", () => {
874
+ test("default breakpoint splits the prompt after 11-channel-persona", () => {
875
+ /**
876
+ * Tests that the bundled `cacheBreakpoint` on 11-channel-persona
877
+ * places the boundary marker between the stable sections and the
878
+ * volatile 12-voice section.
879
+ */
880
+ // GIVEN a workspace where a volatile section (12-voice) renders
881
+ writeFileSync(join(TEST_DIR, "VOICE.md"), "- Prefers lowercase.");
882
+
883
+ // WHEN the system prompt is built
884
+ const result = buildSystemPrompt();
885
+
886
+ // THEN the boundary marker sits before the voice content and
887
+ // after the stable instruction sections
888
+ const boundaryIdx = result.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
889
+ expect(boundaryIdx).toBeGreaterThan(-1);
890
+ expect(result.indexOf("## Communication")).toBeLessThan(boundaryIdx);
891
+ expect(result.indexOf("# Voice Profile")).toBeGreaterThan(boundaryIdx);
892
+ });
893
+
894
+ test("breakpoint splits even when the declaring section gates off", () => {
895
+ /**
896
+ * Tests that the split is resolved against the section ordering,
897
+ * not the rendered output — an absent channel persona must not
898
+ * merge the blocks around it.
899
+ */
900
+ // GIVEN no channel persona file exists but a volatile section renders
901
+ writeFileSync(join(TEST_DIR, "VOICE.md"), "- Prefers lowercase.");
902
+
903
+ // WHEN the system prompt is built
904
+ const result = buildSystemPrompt();
905
+
906
+ // THEN the boundary marker is still present
907
+ expect(result).toContain(SYSTEM_PROMPT_CACHE_BOUNDARY);
908
+ });
909
+
910
+ test("no boundary marker when nothing renders after the breakpoint", () => {
911
+ /**
912
+ * Tests that empty trailing blocks are dropped so the marker
913
+ * never dangles at the end of the prompt.
914
+ */
915
+ // GIVEN a fresh workspace with no volatile sections (no VOICE.md,
916
+ // no BOOTSTRAP.md, no connections)
917
+ // WHEN the system prompt is built
918
+ const result = buildSystemPrompt();
919
+
920
+ // THEN no boundary marker appears
921
+ expect(result).not.toContain(SYSTEM_PROMPT_CACHE_BOUNDARY);
922
+ });
923
+
924
+ test("workspace override of 11-channel-persona without cache_breakpoint clears the default", () => {
925
+ /**
926
+ * Tests that a workspace override takes full control of the
927
+ * section, including its breakpoint declaration.
928
+ */
929
+ // GIVEN a workspace override of 11-channel-persona with no
930
+ // cache_breakpoint frontmatter, and a volatile section that renders
931
+ mkdirSync(SYSTEM_PROMPTS_DIR, { recursive: true });
932
+ writeFileSync(
933
+ join(SYSTEM_PROMPTS_DIR, "11-channel-persona.md"),
934
+ "Override channel persona.\n",
935
+ );
936
+ writeFileSync(join(TEST_DIR, "VOICE.md"), "- Prefers lowercase.");
937
+
938
+ // WHEN the system prompt is built
939
+ const result = buildSystemPrompt();
940
+
941
+ // THEN the override body renders but no boundary marker appears
942
+ expect(result).toContain("Override channel persona.");
943
+ expect(result).not.toContain(SYSTEM_PROMPT_CACHE_BOUNDARY);
944
+ });
945
+
946
+ test("frontmatter cache_breakpoint on a workspace section places the boundary there", () => {
947
+ /**
948
+ * Tests that users can declare the breakpoint position via
949
+ * `cache_breakpoint: true` frontmatter, and that only the first
950
+ * declaration (in id-sort order) is honored.
951
+ */
952
+ // GIVEN a custom workspace section declaring a cache breakpoint
953
+ // ahead of the bundled default on 11-channel-persona
954
+ mkdirSync(SYSTEM_PROMPTS_DIR, { recursive: true });
955
+ writeFileSync(
956
+ join(SYSTEM_PROMPTS_DIR, "00-aaa-custom.md"),
957
+ "---\ncache_breakpoint: true\n---\nCustom head section.\n",
958
+ );
959
+ writeFileSync(join(TEST_DIR, "VOICE.md"), "- Prefers lowercase.");
960
+
961
+ // WHEN the system prompt is built
962
+ const result = buildSystemPrompt();
963
+
964
+ // THEN exactly one boundary appears, right after the custom section
965
+ const boundaryIdx = result.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
966
+ expect(boundaryIdx).toBeGreaterThan(-1);
967
+ expect(result.indexOf("Custom head section.")).toBeLessThan(
968
+ boundaryIdx,
969
+ );
970
+ expect(result.indexOf("## Communication")).toBeGreaterThan(boundaryIdx);
971
+
972
+ // AND the bundled default declaration on 11-channel-persona is
973
+ // ignored — no second marker
974
+ expect(
975
+ result.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY, boundaryIdx + 1),
976
+ ).toBe(-1);
977
+ });
978
+ });
979
+
882
980
  describe("containerized section (slot 02)", () => {
883
981
  const CONTAINERIZED_FILE = join(
884
982
  SYSTEM_PROMPTS_DIR,
@@ -532,6 +532,91 @@ describe("ToolApprovalHandler / pre-exec gate grant check", () => {
532
532
  });
533
533
  });
534
534
 
535
+ describe("ToolApprovalHandler / unparseable tool args gate", () => {
536
+ const handler = new ToolApprovalHandler();
537
+ const events: ToolLifecycleEvent[] = [];
538
+ const emitLifecycleEvent = (event: ToolLifecycleEvent) => {
539
+ events.push(event);
540
+ };
541
+
542
+ beforeEach(() => {
543
+ clearTables();
544
+ events.length = 0;
545
+ });
546
+
547
+ test("input wrapped as { _raw } is rejected without executing", async () => {
548
+ const result = await handler.checkPreExecutionGates(
549
+ "bash",
550
+ { _raw: '{"command": "ls -' },
551
+ makeContext({ trustClass: "guardian" }),
552
+ "sandbox",
553
+ "low",
554
+ Date.now(),
555
+ emitLifecycleEvent,
556
+ );
557
+
558
+ expect(result.allowed).toBe(false);
559
+ if (result.allowed) return;
560
+ expect(result.result.isError).toBe(true);
561
+ expect(result.result.content).toContain("were not valid JSON");
562
+ expect(result.result.content).toContain('{"command": "ls -');
563
+ expect(result.result.content).toContain("Retry");
564
+
565
+ const errorEvents = events.filter((e) => e.type === "error");
566
+ expect(errorEvents).toHaveLength(1);
567
+ if (errorEvents[0].type === "error") {
568
+ expect(errorEvents[0].isExpected).toBe(true);
569
+ expect(errorEvents[0].errorCategory).toBe("tool_failure");
570
+ }
571
+ });
572
+
573
+ test("long raw args are truncated in the error message", async () => {
574
+ const raw = `{"data": "${"x".repeat(500)}`;
575
+ const result = await handler.checkPreExecutionGates(
576
+ "bash",
577
+ { _raw: raw },
578
+ makeContext({ trustClass: "guardian" }),
579
+ "sandbox",
580
+ "low",
581
+ Date.now(),
582
+ emitLifecycleEvent,
583
+ );
584
+
585
+ expect(result.allowed).toBe(false);
586
+ if (result.allowed) return;
587
+ expect(result.result.content).not.toContain(raw);
588
+ expect(result.result.content).toContain("…");
589
+ });
590
+
591
+ test("legitimate input containing a _raw field among others is not rejected", async () => {
592
+ const result = await handler.checkPreExecutionGates(
593
+ "bash",
594
+ { _raw: "something", command: "ls" },
595
+ makeContext({ trustClass: "guardian" }),
596
+ "sandbox",
597
+ "low",
598
+ Date.now(),
599
+ emitLifecycleEvent,
600
+ );
601
+
602
+ expect(result.allowed).toBe(true);
603
+ });
604
+
605
+ test("non-string _raw value is not treated as the marker", async () => {
606
+ const result = await handler.checkPreExecutionGates(
607
+ "bash",
608
+ { _raw: 42 },
609
+ makeContext({ trustClass: "guardian" }),
610
+ "sandbox",
611
+ "low",
612
+ Date.now(),
613
+ emitLifecycleEvent,
614
+ );
615
+
616
+ expect(result.allowed).toBe(true);
617
+ });
618
+ });
619
+
535
620
  afterAll(() => {
536
621
  mock.restore();
537
622
  });
@@ -10,6 +10,10 @@ mock.module("../config/loader.js", () => ({
10
10
 
11
11
  import { createToolAuditListener } from "../events/tool-audit-listener.js";
12
12
  import type { ToolInvocationRecord } from "../memory/tool-usage-store.js";
13
+ import {
14
+ OPENAI_PROJECT_KEY_REDACTION_MARKER,
15
+ SYNTHETIC_OPENAI_PROJECT_KEY,
16
+ } from "./secret-fixtures.js";
13
17
 
14
18
  describe("tool audit listener", () => {
15
19
  beforeEach(() => {
@@ -101,6 +105,88 @@ describe("tool audit listener", () => {
101
105
  expect(records[0].result).toContain("<redacted");
102
106
  });
103
107
 
108
+ test("redacts known-pattern secrets in tool inputs before recording", () => {
109
+ const records: ToolInvocationRecord[] = [];
110
+ const listener = createToolAuditListener((record) => records.push(record));
111
+
112
+ const input = {
113
+ command: `export OPENAI_API_KEY="${SYNTHETIC_OPENAI_PROJECT_KEY}"`,
114
+ };
115
+
116
+ listener({
117
+ type: "executed",
118
+ toolName: "bash",
119
+ input,
120
+ workingDir: "/tmp",
121
+ conversationId: "conv-input-redact",
122
+ riskLevel: "low",
123
+ decision: "allow",
124
+ durationMs: 5,
125
+ result: { content: "ok", isError: false },
126
+ });
127
+ listener({
128
+ type: "error",
129
+ toolName: "bash",
130
+ input,
131
+ workingDir: "/tmp",
132
+ conversationId: "conv-input-redact",
133
+ riskLevel: "low",
134
+ decision: "error",
135
+ durationMs: 5,
136
+ errorMessage: "boom",
137
+ isExpected: false,
138
+ errorCategory: "tool_failure",
139
+ });
140
+ listener({
141
+ type: "permission_denied",
142
+ toolName: "bash",
143
+ input,
144
+ workingDir: "/tmp",
145
+ conversationId: "conv-input-redact",
146
+ riskLevel: "high",
147
+ decision: "deny",
148
+ reason: "Permission denied by user",
149
+ durationMs: 5,
150
+ });
151
+
152
+ expect(records).toHaveLength(3);
153
+ for (const record of records) {
154
+ expect(record.input).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
155
+ // The stored input must remain parseable JSON: string leaves are
156
+ // redacted before stringification, so the marker lands inside a JSON
157
+ // string value (its quotes escaped) instead of corrupting the
158
+ // serialized form.
159
+ const parsed = JSON.parse(record.input) as { command: string };
160
+ expect(parsed.command).toContain(OPENAI_PROJECT_KEY_REDACTION_MARKER);
161
+ }
162
+ });
163
+
164
+ test("benign inputs round-trip byte-identical (no false-positive mangling)", () => {
165
+ const records: ToolInvocationRecord[] = [];
166
+ const listener = createToolAuditListener((record) => records.push(record));
167
+
168
+ const input = {
169
+ command: "git log --oneline -5 && echo done",
170
+ cwd: "/tmp/project",
171
+ env: { LANG: "en_US.UTF-8" },
172
+ };
173
+
174
+ listener({
175
+ type: "executed",
176
+ toolName: "bash",
177
+ input,
178
+ workingDir: "/tmp",
179
+ conversationId: "conv-benign-input",
180
+ riskLevel: "low",
181
+ decision: "allow",
182
+ durationMs: 2,
183
+ result: { content: "ok", isError: false },
184
+ });
185
+
186
+ expect(records).toHaveLength(1);
187
+ expect(records[0].input).toBe(JSON.stringify(input));
188
+ });
189
+
104
190
  test("does not redact non-secret content like UUIDs or hashes", () => {
105
191
  const records: ToolInvocationRecord[] = [];
106
192
  const listener = createToolAuditListener((record) => records.push(record));
@@ -79,6 +79,7 @@ function makeCtx(
79
79
  toolResponse,
80
80
  messages,
81
81
  additionalContext: null,
82
+ model: "claude-test-model",
82
83
  maxInputTokens: 10_000,
83
84
  logger: noopLogger,
84
85
  };