@vellumai/assistant 0.8.2 → 0.8.3

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 (231) hide show
  1. package/ARCHITECTURE.md +11 -12
  2. package/docker-entrypoint.sh +13 -1
  3. package/docker-init-apt-root.sh +79 -6
  4. package/openapi.yaml +336 -21
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  7. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  8. package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
  9. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  10. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  11. package/src/__tests__/context-token-estimator.test.ts +30 -65
  12. package/src/__tests__/conversation-agent-loop.test.ts +57 -1
  13. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
  15. package/src/__tests__/date-context.test.ts +45 -0
  16. package/src/__tests__/external-plugin-loader.test.ts +91 -19
  17. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  18. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  19. package/src/__tests__/heartbeat-service.test.ts +24 -164
  20. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  21. package/src/__tests__/host-app-control-proxy.test.ts +241 -0
  22. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  23. package/src/__tests__/injector-background-turn.test.ts +153 -0
  24. package/src/__tests__/injector-chain.test.ts +5 -0
  25. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
  26. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  27. package/src/__tests__/llm-catalog-parity.test.ts +3 -0
  28. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  29. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  30. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
  31. package/src/__tests__/llm-resolver.test.ts +255 -2
  32. package/src/__tests__/managed-profile-guard.test.ts +10 -0
  33. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  34. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  35. package/src/__tests__/notification-deep-link.test.ts +15 -0
  36. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  37. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  38. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  40. package/src/__tests__/openai-provider.test.ts +218 -3
  41. package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
  42. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  43. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  44. package/src/__tests__/platform-proxy-context.test.ts +6 -1
  45. package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
  46. package/src/__tests__/plugin-types.test.ts +2 -2
  47. package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
  48. package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
  49. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  50. package/src/__tests__/system-prompt.test.ts +6 -73
  51. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  52. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  53. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  54. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  55. package/src/a2a/__tests__/task-store.test.ts +246 -0
  56. package/src/a2a/agent-card.ts +58 -0
  57. package/src/a2a/feature-gate.ts +8 -0
  58. package/src/a2a/protocol-constants.ts +21 -0
  59. package/src/a2a/protocol-errors.ts +50 -0
  60. package/src/a2a/protocol-types.ts +162 -0
  61. package/src/a2a/task-store.ts +168 -0
  62. package/src/agent/loop.ts +167 -18
  63. package/src/channels/config.ts +9 -0
  64. package/src/channels/types.ts +14 -0
  65. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  66. package/src/cli/commands/__tests__/schedules.test.ts +469 -0
  67. package/src/cli/commands/notifications.ts +65 -35
  68. package/src/cli/commands/plugins.ts +67 -0
  69. package/src/cli/commands/schedules.ts +297 -5
  70. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  71. package/src/cli/lib/install-from-github.ts +8 -9
  72. package/src/cli/lib/search-plugins.ts +163 -0
  73. package/src/cli/program.ts +14 -0
  74. package/src/config/assistant-feature-flags.ts +24 -54
  75. package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
  76. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  77. package/src/config/call-site-defaults.ts +105 -0
  78. package/src/config/feature-flag-registry.json +21 -29
  79. package/src/config/llm-resolver.ts +52 -1
  80. package/src/config/schema.ts +2 -0
  81. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  82. package/src/config/schemas/channels.ts +9 -0
  83. package/src/config/schemas/conversations.ts +10 -0
  84. package/src/config/schemas/heartbeat.ts +14 -0
  85. package/src/config/schemas/llm.ts +1 -3
  86. package/src/config/schemas/memory-retrospective.ts +1 -1
  87. package/src/config/schemas/memory-v2.ts +4 -4
  88. package/src/config/schemas/memory.ts +3 -1
  89. package/src/config/seed-inference-profiles.ts +99 -29
  90. package/src/context/compactor.ts +72 -12
  91. package/src/context/token-estimator.ts +32 -34
  92. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
  93. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  94. package/src/daemon/conversation-agent-loop.ts +29 -2
  95. package/src/daemon/conversation-runtime-assembly.ts +9 -0
  96. package/src/daemon/conversation.ts +0 -7
  97. package/src/daemon/date-context.ts +40 -0
  98. package/src/daemon/guardian-action-generators.ts +1 -125
  99. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  100. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  101. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  102. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  103. package/src/daemon/handlers/config-a2a.ts +289 -0
  104. package/src/daemon/handlers/conversations.ts +1 -0
  105. package/src/daemon/host-app-control-proxy.ts +69 -18
  106. package/src/daemon/host-proxy-preactivation.ts +85 -18
  107. package/src/daemon/lifecycle.ts +49 -61
  108. package/src/daemon/memory-v2-startup.ts +49 -13
  109. package/src/daemon/message-types/notifications.ts +21 -0
  110. package/src/daemon/pkb-reminder-builder.test.ts +10 -53
  111. package/src/daemon/pkb-reminder-builder.ts +4 -19
  112. package/src/daemon/process-message.ts +3 -0
  113. package/src/daemon/skill-memory-refresh.ts +5 -1
  114. package/src/daemon/wake-target-adapter.ts +2 -0
  115. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  116. package/src/export/transcript-formatter.ts +54 -20
  117. package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
  118. package/src/heartbeat/heartbeat-service.ts +34 -191
  119. package/src/home/__tests__/feed-types.test.ts +40 -0
  120. package/src/home/feed-types.ts +14 -2
  121. package/src/ipc/cli-client.ts +147 -45
  122. package/src/memory/__tests__/conversation-queries.test.ts +220 -0
  123. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  124. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  125. package/src/memory/conversation-queries.ts +87 -1
  126. package/src/memory/conversation-title-service.ts +26 -4
  127. package/src/memory/db-init.ts +6 -0
  128. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
  129. package/src/memory/graph/conversation-graph-memory.ts +18 -6
  130. package/src/memory/graph/tools.ts +6 -37
  131. package/src/memory/invite-store.ts +53 -0
  132. package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
  133. package/src/memory/llm-request-log-store.ts +92 -1
  134. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  135. package/src/memory/memory-retrospective-job.ts +33 -6
  136. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  137. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  138. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  139. package/src/memory/migrations/index.ts +3 -0
  140. package/src/memory/migrations/registry.ts +8 -0
  141. package/src/memory/schema/a2a.ts +15 -0
  142. package/src/memory/schema/index.ts +1 -0
  143. package/src/memory/schema/inference.ts +2 -0
  144. package/src/memory/schema/infrastructure.ts +1 -0
  145. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  146. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  147. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  148. package/src/memory/v2/__tests__/injection.test.ts +190 -3
  149. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  150. package/src/memory/v2/activation-store.ts +14 -16
  151. package/src/memory/v2/cli-command-content.ts +19 -0
  152. package/src/memory/v2/cli-command-store.ts +304 -0
  153. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  154. package/src/memory/v2/injection.ts +49 -20
  155. package/src/memory/v2/page-index.ts +38 -13
  156. package/src/memory/v2/static-context.ts +4 -4
  157. package/src/memory/v2/types.ts +23 -0
  158. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  159. package/src/messaging/providers/a2a/deliver.ts +156 -0
  160. package/src/messaging/providers/gmail/client.ts +9 -2
  161. package/src/messaging/providers/index.ts +11 -2
  162. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  163. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  164. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  165. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  166. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  167. package/src/notifications/adapters/macos.ts +12 -2
  168. package/src/notifications/broadcaster.ts +29 -4
  169. package/src/notifications/copy-composer.ts +17 -64
  170. package/src/notifications/decision-engine.ts +111 -44
  171. package/src/notifications/deterministic-checks.ts +96 -0
  172. package/src/notifications/emit-signal.ts +1 -0
  173. package/src/notifications/home-feed-side-effect.ts +85 -6
  174. package/src/notifications/signal.ts +0 -4
  175. package/src/notifications/types.ts +8 -0
  176. package/src/oauth/platform-connection.test.ts +43 -3
  177. package/src/oauth/platform-connection.ts +13 -4
  178. package/src/plugins/defaults/injectors.ts +38 -19
  179. package/src/plugins/external-plugin-loader.ts +82 -10
  180. package/src/plugins/types.ts +16 -7
  181. package/src/prompts/__tests__/system-prompt.test.ts +6 -51
  182. package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
  183. package/src/prompts/system-prompt.ts +0 -8
  184. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  185. package/src/prompts/templates/system-sections.ts +0 -9
  186. package/src/providers/__tests__/inference.test.ts +2 -0
  187. package/src/providers/call-site-routing.ts +24 -6
  188. package/src/providers/connection-resolution.ts +63 -13
  189. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  190. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  191. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  192. package/src/providers/inference/adapter-factory.ts +9 -20
  193. package/src/providers/inference/auth.ts +12 -0
  194. package/src/providers/inference/backfill.ts +14 -1
  195. package/src/providers/inference/connections.ts +85 -5
  196. package/src/providers/inference/resolve-auth.ts +2 -0
  197. package/src/providers/model-catalog.ts +199 -244
  198. package/src/providers/model-intents.ts +3 -3
  199. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  200. package/src/providers/openai/chat-completions-provider.ts +159 -6
  201. package/src/providers/openrouter/client.ts +42 -4
  202. package/src/providers/platform-proxy/constants.ts +3 -4
  203. package/src/providers/provider-catalog-visibility.ts +3 -1
  204. package/src/providers/provider-send-message.ts +27 -12
  205. package/src/providers/registry.ts +30 -1
  206. package/src/runtime/agent-wake.ts +61 -1
  207. package/src/runtime/auth/route-policy.ts +13 -0
  208. package/src/runtime/http-server.ts +7 -16
  209. package/src/runtime/http-types.ts +0 -47
  210. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  211. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
  212. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  213. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  214. package/src/runtime/routes/channel-availability-routes.ts +5 -0
  215. package/src/runtime/routes/consolidation-routes.ts +100 -0
  216. package/src/runtime/routes/conversation-query-routes.ts +70 -11
  217. package/src/runtime/routes/conversation-routes.ts +7 -0
  218. package/src/runtime/routes/index.ts +2 -0
  219. package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
  220. package/src/runtime/routes/integrations/a2a.ts +235 -0
  221. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  222. package/src/runtime/routes/subagents-routes.ts +41 -0
  223. package/src/subagent/manager.ts +2 -0
  224. package/src/tools/memory/register.ts +1 -9
  225. package/src/tools/registry.ts +2 -2
  226. package/src/tools/types.ts +37 -2
  227. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  228. package/src/workspace/migrations/registry.ts +2 -0
  229. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  230. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  231. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Verifies that `GET /v1/config` enriches each profile in `llm.profiles`
3
+ * with `supportsVision` resolved from the model catalog.
4
+ */
5
+
6
+ import { describe, expect, mock, test } from "bun:test";
7
+
8
+ import { makeMockLogger } from "./helpers/mock-logger.js";
9
+
10
+ mock.module("../util/logger.js", () => ({
11
+ getLogger: () => makeMockLogger(),
12
+ }));
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mocks for handleGetConfig's transitive deps
16
+ // ---------------------------------------------------------------------------
17
+
18
+ let rawConfig: Record<string, unknown> = {};
19
+
20
+ mock.module("../config/loader.js", () => ({
21
+ loadRawConfig: () => structuredClone(rawConfig),
22
+ saveRawConfig: () => {},
23
+ deepMergeOverwrite: () => {},
24
+ getConfig: () => rawConfig,
25
+ getDeploymentContextDefaults: () => ({}),
26
+ fillContextDefaultsForMissingKeys: () => {},
27
+ invalidateConfigCache: () => {},
28
+ setNestedValue: () => {},
29
+ }));
30
+
31
+ mock.module("../providers/registry.js", () => ({
32
+ initializeProviders: async () => {},
33
+ }));
34
+
35
+ mock.module("../memory/embedding-backend.js", () => ({
36
+ clearEmbeddingBackendCache: () => {},
37
+ }));
38
+
39
+ mock.module("../security/secret-allowlist.js", () => ({
40
+ validateAllowlistFile: () => null,
41
+ }));
42
+
43
+ import { ROUTES } from "../runtime/routes/conversation-query-routes.js";
44
+
45
+ const configGetRoute = ROUTES.find((r) => r.operationId === "config_get")!;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Tests
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe("GET /v1/config profile vision enrichment", () => {
52
+ test("profile with a non-vision model gets supportsVision: false", () => {
53
+ rawConfig = {
54
+ llm: {
55
+ profiles: {
56
+ "test-no-vision": {
57
+ provider: "fireworks",
58
+ model: "accounts/fireworks/models/kimi-k2p5",
59
+ },
60
+ },
61
+ },
62
+ };
63
+
64
+ const result = configGetRoute.handler({}) as {
65
+ llm?: {
66
+ profiles?: Record<string, { supportsVision?: boolean }>;
67
+ };
68
+ };
69
+
70
+ expect(result?.llm?.profiles?.["test-no-vision"]?.supportsVision).toBe(
71
+ false,
72
+ );
73
+ });
74
+
75
+ test("profile with a vision-capable model gets supportsVision: true", () => {
76
+ rawConfig = {
77
+ llm: {
78
+ profiles: {
79
+ "test-vision": {
80
+ provider: "anthropic",
81
+ model: "claude-opus-4-6",
82
+ },
83
+ },
84
+ },
85
+ };
86
+
87
+ const result = configGetRoute.handler({}) as {
88
+ llm?: {
89
+ profiles?: Record<string, { supportsVision?: boolean }>;
90
+ };
91
+ };
92
+
93
+ expect(result?.llm?.profiles?.["test-vision"]?.supportsVision).toBe(true);
94
+ });
95
+
96
+ test("profile with an unknown model defaults supportsVision to true (fail-open)", () => {
97
+ rawConfig = {
98
+ llm: {
99
+ profiles: {
100
+ "test-unknown": {
101
+ provider: "anthropic",
102
+ model: "some-unknown-model-xyz",
103
+ },
104
+ },
105
+ },
106
+ };
107
+
108
+ const result = configGetRoute.handler({}) as {
109
+ llm?: {
110
+ profiles?: Record<string, { supportsVision?: boolean }>;
111
+ };
112
+ };
113
+
114
+ expect(result?.llm?.profiles?.["test-unknown"]?.supportsVision).toBe(true);
115
+ });
116
+
117
+ test("profile without provider/model is left without supportsVision", () => {
118
+ rawConfig = {
119
+ llm: {
120
+ profiles: {
121
+ "test-empty": {},
122
+ },
123
+ },
124
+ };
125
+
126
+ const result = configGetRoute.handler({}) as {
127
+ llm?: {
128
+ profiles?: Record<string, { supportsVision?: boolean }>;
129
+ };
130
+ };
131
+
132
+ expect(
133
+ result?.llm?.profiles?.["test-empty"]?.supportsVision,
134
+ ).toBeUndefined();
135
+ });
136
+ });
@@ -7,6 +7,7 @@ import {
7
7
  writeFileSync,
8
8
  } from "node:fs";
9
9
  import { join } from "node:path";
10
+ import { Database } from "bun:sqlite";
10
11
  import {
11
12
  afterAll,
12
13
  afterEach,
@@ -17,6 +18,8 @@ import {
17
18
  test,
18
19
  } from "bun:test";
19
20
 
21
+ import { drizzle } from "drizzle-orm/bun-sqlite";
22
+
20
23
  // ---------------------------------------------------------------------------
21
24
  // Mocks — declared before imports that depend on platform/logger
22
25
  // ---------------------------------------------------------------------------
@@ -73,6 +76,12 @@ import {
73
76
  mergeDefaultWorkspaceConfig,
74
77
  } from "../config/loader.js";
75
78
  import { seedInferenceProfiles } from "../config/seed-inference-profiles.js";
79
+ import type { DrizzleDb } from "../memory/db-connection.js";
80
+ import { migrateCreateProviderConnections } from "../memory/migrations/243-provider-connections.js";
81
+ import { migrateProviderConnectionStatusLabel } from "../memory/migrations/244-provider-connection-status-label.js";
82
+ import { migrateProviderConnectionBaseUrlAndModels } from "../memory/migrations/250-provider-connection-base-url-and-models.js";
83
+ import * as schema from "../memory/schema.js";
84
+ import { getConnection } from "../providers/inference/connections.js";
76
85
  import { _setStorePath } from "../security/encrypted-store.js";
77
86
 
78
87
  // ---------------------------------------------------------------------------
@@ -83,15 +92,26 @@ function writeConfig(obj: unknown): void {
83
92
  writeFileSync(CONFIG_PATH, JSON.stringify(obj, null, 2) + "\n");
84
93
  }
85
94
 
86
- function mergeDefaultConfigAndSeedInferenceProfiles(): void {
95
+ function mergeDefaultConfigAndSeedInferenceProfiles(db?: DrizzleDb): void {
87
96
  const defaultConfigMerge = mergeDefaultWorkspaceConfig();
88
97
  seedInferenceProfiles({
89
98
  preserveProfileNames: defaultConfigMerge.providedLlmProfileNames,
90
99
  preserveActiveProfile: defaultConfigMerge.providedLlmActiveProfile,
91
100
  isHatch: defaultConfigMerge.hadOverlay,
101
+ db,
92
102
  });
93
103
  }
94
104
 
105
+ function createProviderConnectionsDb(): DrizzleDb {
106
+ const sqlite = new Database(":memory:");
107
+ sqlite.exec("PRAGMA journal_mode=WAL");
108
+ const db = drizzle(sqlite, { schema });
109
+ migrateCreateProviderConnections(db);
110
+ migrateProviderConnectionStatusLabel(db);
111
+ migrateProviderConnectionBaseUrlAndModels(db);
112
+ return db;
113
+ }
114
+
95
115
  // ---------------------------------------------------------------------------
96
116
  // Tests: deepMergeOverwrite (unit) — JSON-null-as-deletion semantics
97
117
  //
@@ -523,7 +543,7 @@ describe("loadConfig startup behavior", () => {
523
543
  expect(raw.llm.profiles["custom-balanced"].provider_connection).toBe(
524
544
  "anthropic-personal",
525
545
  );
526
- // Managed profiles are also seeded for anthropic-managed.
546
+ // Managed balanced profile is seeded for anthropic-managed.
527
547
  expect(raw.llm.profiles.balanced.provider).toBe("anthropic");
528
548
  expect(raw.llm.profiles.balanced.provider_connection).toBe(
529
549
  "anthropic-managed",
@@ -589,11 +609,8 @@ describe("loadConfig startup behavior", () => {
589
609
  const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
590
610
  writeFileSync(
591
611
  overlayPath,
592
- JSON.stringify(
593
- { llm: { default: { provider: "openai" } } },
594
- null,
595
- 2,
596
- ) + "\n",
612
+ JSON.stringify({ llm: { default: { provider: "openai" } } }, null, 2) +
613
+ "\n",
597
614
  );
598
615
  process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
599
616
 
@@ -617,7 +634,7 @@ describe("loadConfig startup behavior", () => {
617
634
  "gpt-5.4-nano",
618
635
  );
619
636
 
620
- // Managed anthropic profiles are also seeded.
637
+ // Managed profiles are also seeded (balanced uses Anthropic).
621
638
  expect(raw.llm.profiles.balanced.provider).toBe("anthropic");
622
639
  expect(raw.llm.profiles.balanced.provider_connection).toBe(
623
640
  "anthropic-managed",
@@ -981,11 +998,8 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
981
998
  const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
982
999
  writeFileSync(
983
1000
  overlayPath,
984
- JSON.stringify(
985
- { llm: { default: { provider: "anthropic" } } },
986
- null,
987
- 2,
988
- ) + "\n",
1001
+ JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) +
1002
+ "\n",
989
1003
  );
990
1004
  process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
991
1005
 
@@ -997,6 +1011,92 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
997
1011
  expect(config.llm.profiles["cost-optimized"]?.status).toBe("disabled");
998
1012
  });
999
1013
 
1014
+ test("off-platform managed-inference hatch keeps selected managed connection active", () => {
1015
+ const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1016
+ writeFileSync(
1017
+ overlayPath,
1018
+ JSON.stringify(
1019
+ {
1020
+ llm: {
1021
+ default: { provider: "anthropic" },
1022
+ activeProfile: "balanced",
1023
+ },
1024
+ },
1025
+ null,
1026
+ 2,
1027
+ ) + "\n",
1028
+ );
1029
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
1030
+ const db = createProviderConnectionsDb();
1031
+
1032
+ mergeDefaultConfigAndSeedInferenceProfiles(db);
1033
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
1034
+
1035
+ expect(raw.llm.activeProfile).toBe("balanced");
1036
+ expect(raw.llm.profiles.balanced.provider_connection).toBe(
1037
+ "anthropic-managed",
1038
+ );
1039
+ expect("status" in raw.llm.profiles.balanced).toBe(false);
1040
+ expect(getConnection(db, "anthropic-managed")?.status).toBe("active");
1041
+ expect(getConnection(db, "openai-managed")?.status).toBe("disabled");
1042
+ expect(getConnection(db, "gemini-managed")?.status).toBe("disabled");
1043
+ });
1044
+
1045
+ test("off-platform managed-inference hatch respects explicit non-managed active connection", () => {
1046
+ const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1047
+ writeFileSync(
1048
+ overlayPath,
1049
+ JSON.stringify(
1050
+ {
1051
+ llm: {
1052
+ default: { provider: "anthropic" },
1053
+ profiles: {
1054
+ balanced: {
1055
+ source: "managed",
1056
+ provider: "anthropic",
1057
+ provider_connection: "anthropic-personal",
1058
+ model: "claude-sonnet-4-6",
1059
+ },
1060
+ },
1061
+ activeProfile: "balanced",
1062
+ },
1063
+ },
1064
+ null,
1065
+ 2,
1066
+ ) + "\n",
1067
+ );
1068
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
1069
+ const db = createProviderConnectionsDb();
1070
+
1071
+ mergeDefaultConfigAndSeedInferenceProfiles(db);
1072
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
1073
+
1074
+ expect(raw.llm.activeProfile).toBe("balanced");
1075
+ expect(raw.llm.profiles.balanced.provider_connection).toBe(
1076
+ "anthropic-personal",
1077
+ );
1078
+ expect(getConnection(db, "anthropic-managed")?.status).toBe("disabled");
1079
+ expect(getConnection(db, "openai-managed")?.status).toBe("disabled");
1080
+ expect(getConnection(db, "gemini-managed")?.status).toBe("disabled");
1081
+ });
1082
+
1083
+ test("off-platform BYOK hatch still disables managed connections", () => {
1084
+ const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1085
+ writeFileSync(
1086
+ overlayPath,
1087
+ JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) +
1088
+ "\n",
1089
+ );
1090
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
1091
+ const db = createProviderConnectionsDb();
1092
+
1093
+ mergeDefaultConfigAndSeedInferenceProfiles(db);
1094
+
1095
+ expect(getConnection(db, "anthropic-managed")?.status).toBe("disabled");
1096
+ expect(getConnection(db, "openai-managed")?.status).toBe("disabled");
1097
+ expect(getConnection(db, "gemini-managed")?.status).toBe("disabled");
1098
+ });
1099
+
1000
1100
  test("non-hatch off-platform boot does NOT auto-disable freshly-materialized managed profiles", () => {
1001
1101
  // Existing installs that upgrade to a version where the managed
1002
1102
  // profile didn't previously exist (e.g. a new template added later)
@@ -1029,11 +1129,8 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
1029
1129
  const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1030
1130
  writeFileSync(
1031
1131
  overlayPath,
1032
- JSON.stringify(
1033
- { llm: { default: { provider: "anthropic" } } },
1034
- null,
1035
- 2,
1036
- ) + "\n",
1132
+ JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) +
1133
+ "\n",
1037
1134
  );
1038
1135
  process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
1039
1136
 
@@ -219,36 +219,7 @@ describe("token estimator", () => {
219
219
  expect(largeFileTokens).toBe(smallFileTokens);
220
220
  });
221
221
 
222
- // Non-Anthropic providers use base64 payload size for image estimation
223
- test("scales image token estimate with base64 payload size (non-Anthropic)", () => {
224
- const smallImageTokens = estimateContentBlockTokens(
225
- {
226
- type: "image",
227
- source: {
228
- type: "base64",
229
- media_type: "image/png",
230
- data: "a".repeat(64),
231
- },
232
- },
233
- { providerName: "openai" },
234
- );
235
- const largeImageTokens = estimateContentBlockTokens(
236
- {
237
- type: "image",
238
- source: {
239
- type: "base64",
240
- media_type: "image/png",
241
- data: "a".repeat(60_000),
242
- },
243
- },
244
- { providerName: "openai" },
245
- );
246
-
247
- expect(largeImageTokens).toBeGreaterThan(smallImageTokens);
248
- expect(largeImageTokens - smallImageTokens).toBeGreaterThan(1000);
249
- });
250
-
251
- test("estimates Anthropic image tokens from dimensions, not base64 size", () => {
222
+ test("estimates image tokens from dimensions, not base64 size", () => {
252
223
  // Build a minimal valid PNG header encoding 1920x1080 dimensions.
253
224
  // PNG header: 8-byte signature + 4-byte IHDR length + 4-byte "IHDR" + 4-byte width + 4-byte height = 24 bytes minimum
254
225
  const pngHeader = Buffer.alloc(24);
@@ -278,55 +249,49 @@ describe("token estimator", () => {
278
249
  const fullPayload = Buffer.concat([pngHeader, padding]);
279
250
  const base64Data = fullPayload.toString("base64");
280
251
 
281
- const anthropicTokens = estimateContentBlockTokens(
282
- {
283
- type: "image",
284
- source: { type: "base64", media_type: "image/png", data: base64Data },
285
- },
286
- { providerName: "anthropic" },
287
- );
288
-
289
252
  // 1920x1080 scaled to fit 1568px bounding box: dimScale = 1568/1920 = 0.8167
290
253
  // scaledWidth = round(1920 * 0.8167) = 1568, scaledHeight = round(1080 * 0.8167) = 882
291
254
  // pixels = 1568 * 882 = 1,382,976 > 1,200,000 → mpScale = sqrt(1200000/1382976) = 0.9315
292
255
  // scaledWidth = round(1568 * 0.9315) = 1461, scaledHeight = round(882 * 0.9315) = 822
293
256
  // tokens = ceil(1461 * 822 / 750) = ceil(1601.26) = ~1,602
294
- // With IMAGE_BLOCK_OVERHEAD_TOKENS and media_type overhead, still well under 5000
295
- expect(anthropicTokens).toBeLessThan(5_000);
296
-
297
- // Verify it's NOT using base64 size (which would be ~50,000+ tokens)
298
- const nonAnthropicTokens = estimateContentBlockTokens(
299
- {
300
- type: "image",
301
- source: { type: "base64", media_type: "image/png", data: base64Data },
302
- },
303
- { providerName: "openai" },
304
- );
305
- expect(nonAnthropicTokens).toBeGreaterThan(50_000);
257
+ // With IMAGE_BLOCK_OVERHEAD_TOKENS and media_type overhead, still well under 5000.
258
+ // Same result for every provider — dimension-based estimate is universal.
259
+ for (const providerName of ["anthropic", "openai", "openrouter"]) {
260
+ const tokens = estimateContentBlockTokens(
261
+ {
262
+ type: "image",
263
+ source: { type: "base64", media_type: "image/png", data: base64Data },
264
+ },
265
+ { providerName },
266
+ );
267
+ expect(tokens).toBeLessThan(5_000);
268
+ }
306
269
  });
307
270
 
308
- test("falls back to max tokens when Anthropic image dimensions can't be parsed", () => {
271
+ test("falls back to max tokens when image dimensions can't be parsed", () => {
309
272
  // Corrupted base64 that won't parse as a valid image header
310
273
  const corruptedData = Buffer.from(
311
274
  "not-a-valid-image-header-at-all",
312
275
  ).toString("base64");
313
276
 
314
- const tokens = estimateContentBlockTokens(
315
- {
316
- type: "image",
317
- source: {
318
- type: "base64",
319
- media_type: "image/png",
320
- data: corruptedData,
277
+ for (const providerName of ["anthropic", "openai", "openrouter"]) {
278
+ const tokens = estimateContentBlockTokens(
279
+ {
280
+ type: "image",
281
+ source: {
282
+ type: "base64",
283
+ media_type: "image/png",
284
+ data: corruptedData,
285
+ },
321
286
  },
322
- },
323
- { providerName: "anthropic" },
324
- );
287
+ { providerName },
288
+ );
325
289
 
326
- // Should fall back to ANTHROPIC_IMAGE_MAX_TOKENS (1,600)
327
- // Total = 16 (block overhead) + ceil(9/4) (media_type) + 1600 = 1619
328
- expect(tokens).toBeGreaterThanOrEqual(1_600);
329
- expect(tokens).toBeLessThan(2_000);
290
+ // Falls back to the per-image cap (1,600 tokens). Total = 16 (block
291
+ // overhead) + ceil(9/4) (media_type) + 1600 = 1619.
292
+ expect(tokens).toBeGreaterThanOrEqual(1_600);
293
+ expect(tokens).toBeLessThan(2_000);
294
+ }
330
295
  });
331
296
 
332
297
  test("Anthropic image tokens are the same for same-dimension images regardless of payload size", () => {
@@ -386,6 +386,7 @@ mock.module("../daemon/history-repair.js", () => ({
386
386
 
387
387
  const recordUsageMock = mock(() => {});
388
388
  const recordRequestLogMock = mock(() => {});
389
+ const backfillMessageIdOnLogsMock = mock(() => {});
389
390
  mock.module("../daemon/conversation-usage.js", () => ({
390
391
  recordUsage: recordUsageMock,
391
392
  }));
@@ -482,7 +483,7 @@ mock.module("../memory/archive-store.js", () => ({
482
483
 
483
484
  mock.module("../memory/llm-request-log-store.js", () => ({
484
485
  recordRequestLog: recordRequestLogMock,
485
- backfillMessageIdOnLogs: () => {},
486
+ backfillMessageIdOnLogs: backfillMessageIdOnLogsMock,
486
487
  }));
487
488
 
488
489
  let mockHasProactiveArtifactCompleted = true;
@@ -658,6 +659,7 @@ beforeEach(() => {
658
659
  mockInjectionBlocks = {};
659
660
  recordUsageMock.mockClear();
660
661
  recordRequestLogMock.mockClear();
662
+ backfillMessageIdOnLogsMock.mockClear();
661
663
  syncMessageToDiskMock.mockClear();
662
664
  rebuildConversationDiskViewFromDbStateMock.mockClear();
663
665
  updateMessageMetadataMock.mockClear();
@@ -2855,6 +2857,60 @@ describe("session-agent-loop", () => {
2855
2857
  );
2856
2858
  expect(conversationErrors.length).toBeGreaterThanOrEqual(1);
2857
2859
  });
2860
+
2861
+ test("pipes synthetic assistant message id into provider-error log rows via backfill", async () => {
2862
+ // Codex P1 regression test: the provider-failure turn must not leave
2863
+ // its `llm_request_logs` row orphaned. Without the backfill call in
2864
+ // the synthetic-message branch, a later turn's `handleMessageComplete`
2865
+ // sweep would wrong-attach this row to the wrong assistant message.
2866
+ const events: ServerMessage[] = [];
2867
+
2868
+ const agentLoopRun: AgentLoopRun = async (messages, onEvent) => {
2869
+ // 1) handleProviderError -> writes an `llm_request_logs` row with
2870
+ // messageId=null (the orphan we are trying to link).
2871
+ onEvent({
2872
+ type: "provider_error",
2873
+ error: new Error("upstream 500"),
2874
+ rawRequest: { model: "gpt-4.1", messages: [] },
2875
+ actualProvider: "openai",
2876
+ });
2877
+ // 2) handleError -> sets `state.providerErrorUserMessage`, which
2878
+ // activates the synthetic-message branch below the loop.
2879
+ onEvent({
2880
+ type: "error",
2881
+ error: new Error("upstream 500"),
2882
+ });
2883
+ // Provider returned no assistant content — same messages back.
2884
+ return messages;
2885
+ };
2886
+
2887
+ const ctx = makeCtx({ agentLoopRun });
2888
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
2889
+
2890
+ // The orphan was written with messageId=undefined.
2891
+ expect(recordRequestLogMock).toHaveBeenCalledTimes(1);
2892
+ const recordCall = recordRequestLogMock.mock.calls[0] as unknown as [
2893
+ string,
2894
+ string,
2895
+ string,
2896
+ string | undefined,
2897
+ string | undefined,
2898
+ ];
2899
+ expect(recordCall[0]).toBe("test-conv");
2900
+ expect(recordCall[3]).toBeUndefined();
2901
+
2902
+ // The synthetic-message branch then piped the assigned message id
2903
+ // (from the mocked `addMessage` -> `{ id: "mock-msg-id" }`) into the
2904
+ // backfill primitive, scoped to this conversation.
2905
+ expect(backfillMessageIdOnLogsMock).toHaveBeenCalledTimes(1);
2906
+ const backfillCall =
2907
+ backfillMessageIdOnLogsMock.mock.calls[0] as unknown as [
2908
+ string,
2909
+ string,
2910
+ ];
2911
+ expect(backfillCall[0]).toBe("test-conv");
2912
+ expect(backfillCall[1]).toBe("mock-msg-id");
2913
+ });
2858
2914
  });
2859
2915
 
2860
2916
  describe("pkbSystemReminderBlock metadata persistence", () => {
@@ -23,7 +23,11 @@ function makeImageBlockWithSize(
23
23
  ): Extract<ContentBlock, { type: "image" }> {
24
24
  return {
25
25
  type: "image",
26
- source: { type: "base64", media_type: "image/png", data: "A".repeat(dataLength) },
26
+ source: {
27
+ type: "base64",
28
+ media_type: "image/png",
29
+ data: "A".repeat(dataLength),
30
+ },
27
31
  };
28
32
  }
29
33
 
@@ -103,16 +107,19 @@ describe("stripMediaPayloadsForRetry", () => {
103
107
  // ---------------------------------------------------------------------------
104
108
 
105
109
  test("budget-aware: keeps images that fit within token budget", () => {
106
- // Non-Anthropic estimation: estimateTextTokens(base64Data) + overhead (~19 tokens).
107
- // Data length 4000 1000 data tokens + 19 overhead 1019 tokens/image.
108
- // Budget of 3500 allows 3 images (3 * 1019 = 3057 <= 3500) but not 4.
109
- const images = Array.from({ length: 5 }, () => makeImageBlockWithSize(4000));
110
+ // Dimension-based estimation: when the base64 data has no parseable image
111
+ // header, fall back to IMAGE_MAX_TOKENS (1600) + overhead (~19 tokens)
112
+ // 1619 tokens/image. Budget of 5000 allows 3 images (3 * 1619 = 4857
113
+ // <= 5000) but not 4 (4 * 1619 = 6476 > 5000).
114
+ const images = Array.from({ length: 5 }, () =>
115
+ makeImageBlockWithSize(4000),
116
+ );
110
117
  const messages: Message[] = [
111
118
  makeUserMessage({ type: "text", text: "describe these" }, ...images),
112
119
  ];
113
120
 
114
121
  const result = stripMediaPayloadsForRetry(messages, {
115
- mediaTokenBudget: 3500,
122
+ mediaTokenBudget: 5000,
116
123
  providerName: "mock",
117
124
  });
118
125
  expect(result.modified).toBe(true);
@@ -120,7 +127,9 @@ describe("stripMediaPayloadsForRetry", () => {
120
127
  const content = result.messages[0].content;
121
128
  const keptImages = content.filter((b) => b.type === "image");
122
129
  const stubs = content.filter(
123
- (b) => b.type === "text" && (b as { text: string }).text.includes("Image omitted"),
130
+ (b) =>
131
+ b.type === "text" &&
132
+ (b as { text: string }).text.includes("Image omitted"),
124
133
  );
125
134
  expect(keptImages.length).toBe(3);
126
135
  expect(stubs.length).toBe(2);
@@ -174,7 +183,9 @@ describe("stripMediaPayloadsForRetry", () => {
174
183
  const content = result.messages[0].content;
175
184
  const keptImages = content.filter((b) => b.type === "image");
176
185
  const stubs = content.filter(
177
- (b) => b.type === "text" && (b as { text: string }).text.includes("Image omitted"),
186
+ (b) =>
187
+ b.type === "text" &&
188
+ (b as { text: string }).text.includes("Image omitted"),
178
189
  );
179
190
  expect(keptImages.length).toBe(3);
180
191
  expect(stubs.length).toBe(2);
@@ -930,6 +930,28 @@ describe("stripInjectionsForCompaction with NOW.md", () => {
930
930
  "Hello",
931
931
  );
932
932
  });
933
+
934
+ test("strips <background_turn> blocks", () => {
935
+ const messages: Message[] = [
936
+ {
937
+ role: "user",
938
+ content: [
939
+ {
940
+ type: "text",
941
+ text: "<background_turn>\nGuardian isn't watching — notify on anything noteworthy.\n</background_turn>",
942
+ },
943
+ { type: "text", text: "Hello" },
944
+ ],
945
+ },
946
+ ];
947
+
948
+ const result = stripInjectionsForCompaction(messages);
949
+ expect(result.length).toBe(1);
950
+ expect(result[0].content.length).toBe(1);
951
+ expect((result[0].content[0] as { type: "text"; text: string }).text).toBe(
952
+ "Hello",
953
+ );
954
+ });
933
955
  });
934
956
 
935
957
  // ---------------------------------------------------------------------------
@@ -1880,7 +1902,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
1880
1902
  },
1881
1903
  ];
1882
1904
 
1883
- const FLAT_REMINDER = buildPkbReminder([], false);
1905
+ const FLAT_REMINDER = buildPkbReminder([]);
1884
1906
 
1885
1907
  // Use a platform-agnostic absolute workspace root so the tests work on
1886
1908
  // macOS and Linux runners alike. `pkbRoot` sits under `pkbWorkingDir` to
@@ -2136,7 +2158,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2136
2158
  role: "user",
2137
2159
  content: [
2138
2160
  { type: "text", text: "hello" },
2139
- { type: "text", text: buildPkbReminder([], false) },
2161
+ { type: "text", text: buildPkbReminder([]) },
2140
2162
  ],
2141
2163
  };
2142
2164
  const hintedMessage: Message = {
@@ -2145,7 +2167,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2145
2167
  { type: "text", text: "hello" },
2146
2168
  {
2147
2169
  type: "text",
2148
- text: buildPkbReminder(["topics/alpha.md", "topics/beta.md"], false),
2170
+ text: buildPkbReminder(["topics/alpha.md", "topics/beta.md"]),
2149
2171
  },
2150
2172
  ],
2151
2173
  };
@@ -4827,7 +4849,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
4827
4849
  mode: "full",
4828
4850
  });
4829
4851
 
4830
- const expected = buildPkbReminder([], false);
4852
+ const expected = buildPkbReminder([]);
4831
4853
  expect(blocks.pkbSystemReminder).toBe(expected);
4832
4854
  });
4833
4855