@vellumai/assistant 0.7.3 → 0.8.0

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 (169) hide show
  1. package/ARCHITECTURE.md +29 -28
  2. package/Dockerfile +1 -0
  3. package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
  4. package/bun.lock +3 -0
  5. package/knip.json +1 -0
  6. package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
  7. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  8. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
  9. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
  11. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  12. package/openapi.yaml +22 -4
  13. package/package.json +3 -1
  14. package/src/__tests__/annotate-risk-options.test.ts +291 -0
  15. package/src/__tests__/approval-cascade.test.ts +8 -16
  16. package/src/__tests__/approval-routes-http.test.ts +6 -0
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
  18. package/src/__tests__/call-constants.test.ts +10 -1
  19. package/src/__tests__/call-controller.test.ts +127 -0
  20. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
  21. package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
  22. package/src/__tests__/context-search-memory-source.test.ts +3 -26
  23. package/src/__tests__/context-search-pkb-source.test.ts +12 -6
  24. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
  25. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  26. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  27. package/src/__tests__/conversation-agent-loop.test.ts +3 -3
  28. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
  29. package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -6
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
  32. package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
  33. package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
  34. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
  36. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
  37. package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
  38. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
  39. package/src/__tests__/filing-service.test.ts +2 -19
  40. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
  41. package/src/__tests__/injector-chain.test.ts +24 -16
  42. package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
  43. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
  44. package/src/__tests__/notification-decision-fallback.test.ts +91 -0
  45. package/src/__tests__/notification-decision-strategy.test.ts +22 -0
  46. package/src/__tests__/oauth-cli.test.ts +121 -0
  47. package/src/__tests__/relay-server.test.ts +46 -2
  48. package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
  49. package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
  50. package/src/__tests__/secret-response-routing.test.ts +7 -5
  51. package/src/__tests__/server-history-render.test.ts +82 -0
  52. package/src/__tests__/skill-include-graph.test.ts +31 -0
  53. package/src/__tests__/skill-load-tool.test.ts +44 -16
  54. package/src/__tests__/skills.test.ts +39 -0
  55. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
  56. package/src/__tests__/tool-executor.test.ts +155 -0
  57. package/src/__tests__/voice-session-bridge.test.ts +3 -0
  58. package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
  59. package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
  60. package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
  61. package/src/agent/loop.ts +11 -0
  62. package/src/approvals/guardian-decision-primitive.ts +0 -13
  63. package/src/approvals/guardian-request-resolvers.ts +4 -32
  64. package/src/calls/call-constants.ts +5 -8
  65. package/src/calls/call-controller.ts +130 -67
  66. package/src/calls/relay-server.ts +7 -1
  67. package/src/calls/voice-session-bridge.ts +1 -1
  68. package/src/cli/commands/memory-v2.ts +7 -7
  69. package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
  70. package/src/cli/commands/oauth/connect.ts +10 -52
  71. package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
  72. package/src/config/feature-flag-registry.json +1 -17
  73. package/src/config/loader.ts +72 -19
  74. package/src/config/schemas/memory-v2.ts +1 -1
  75. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
  76. package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
  77. package/src/daemon/conversation-agent-loop.ts +13 -10
  78. package/src/daemon/conversation-lifecycle.ts +22 -8
  79. package/src/daemon/conversation-surfaces.ts +16 -14
  80. package/src/daemon/conversation-tool-setup.ts +9 -5
  81. package/src/daemon/conversation.ts +1 -1
  82. package/src/daemon/handlers/shared.ts +26 -0
  83. package/src/daemon/host-bash-proxy.ts +1 -1
  84. package/src/daemon/host-browser-proxy.ts +1 -1
  85. package/src/daemon/host-cu-proxy.ts +1 -1
  86. package/src/daemon/host-file-proxy.ts +1 -1
  87. package/src/daemon/host-transfer-proxy.ts +2 -2
  88. package/src/daemon/lifecycle.ts +88 -73
  89. package/src/daemon/memory-v2-startup.ts +55 -14
  90. package/src/daemon/message-types/messages.ts +19 -1
  91. package/src/documents/document-store.ts +35 -1
  92. package/src/filing/filing-service.ts +2 -3
  93. package/src/heartbeat/heartbeat-service.ts +1 -1
  94. package/src/ipc/assistant-server.ts +93 -36
  95. package/src/ipc/skill-server.ts +99 -42
  96. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
  97. package/src/memory/context-search/sources/memory-v2.ts +1 -17
  98. package/src/memory/context-search/sources/memory.ts +2 -2
  99. package/src/memory/context-search/sources/pkb.ts +2 -3
  100. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
  101. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
  102. package/src/memory/graph/conversation-graph-memory.ts +32 -9
  103. package/src/memory/graph/graph-search.test.ts +6 -5
  104. package/src/memory/graph/graph-search.ts +3 -4
  105. package/src/memory/graph/retriever.test.ts +12 -7
  106. package/src/memory/graph/retriever.ts +4 -5
  107. package/src/memory/graph/tool-handlers.ts +3 -4
  108. package/src/memory/graph/tools.ts +4 -4
  109. package/src/memory/indexer.ts +1 -2
  110. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
  111. package/src/memory/jobs/embed-concept-page.ts +223 -87
  112. package/src/memory/jobs-worker.ts +8 -4
  113. package/src/memory/pkb/pkb-search.test.ts +6 -5
  114. package/src/memory/pkb/pkb-search.ts +4 -5
  115. package/src/memory/qdrant-client.ts +3 -0
  116. package/src/memory/search/semantic.ts +4 -5
  117. package/src/memory/v2/__tests__/activation.test.ts +35 -5
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
  119. package/src/memory/v2/__tests__/injection.test.ts +140 -23
  120. package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
  121. package/src/memory/v2/__tests__/sim.test.ts +118 -7
  122. package/src/memory/v2/__tests__/static-context.test.ts +1 -13
  123. package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
  124. package/src/memory/v2/consolidation-job.ts +7 -8
  125. package/src/memory/v2/injection.ts +32 -12
  126. package/src/memory/v2/page-store.ts +39 -0
  127. package/src/memory/v2/prompts/consolidation.ts +5 -0
  128. package/src/memory/v2/qdrant.ts +209 -48
  129. package/src/memory/v2/sim.ts +67 -26
  130. package/src/memory/v2/static-context.ts +4 -8
  131. package/src/memory/v2/sweep-job.ts +5 -6
  132. package/src/memory/v2/types.ts +7 -0
  133. package/src/notifications/copy-composer.ts +46 -12
  134. package/src/notifications/decision-engine.ts +46 -0
  135. package/src/permissions/gateway-threshold-reader.ts +116 -8
  136. package/src/permissions/prompter.ts +86 -96
  137. package/src/permissions/secret-prompter.ts +31 -31
  138. package/src/plugins/defaults/injectors.ts +1 -2
  139. package/src/proactive-artifact/job.test.ts +51 -4
  140. package/src/proactive-artifact/job.ts +16 -2
  141. package/src/proactive-artifact/message-copy.ts +18 -1
  142. package/src/prompts/templates/SOUL.md +13 -28
  143. package/src/runtime/auth/route-policy.ts +1 -0
  144. package/src/runtime/channel-approvals.ts +3 -2
  145. package/src/runtime/guardian-reply-router.ts +0 -10
  146. package/src/runtime/pending-interactions.ts +19 -15
  147. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
  148. package/src/runtime/routes/approval-routes.ts +7 -3
  149. package/src/runtime/routes/consolidation-routes.ts +8 -9
  150. package/src/runtime/routes/conversation-query-routes.ts +44 -1
  151. package/src/runtime/routes/debug-bash-routes.ts +2 -0
  152. package/src/runtime/routes/filing-routes.ts +2 -3
  153. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
  154. package/src/runtime/routes/memory-item-routes.test.ts +3 -9
  155. package/src/runtime/routes/memory-item-routes.ts +5 -6
  156. package/src/runtime/routes/memory-v2-routes.ts +103 -17
  157. package/src/skills/include-graph.ts +35 -13
  158. package/src/tools/document/document-tool.ts +20 -0
  159. package/src/tools/executor.ts +18 -2
  160. package/src/tools/memory/register.test.ts +7 -5
  161. package/src/tools/permission-checker.ts +15 -0
  162. package/src/tools/skills/load.ts +24 -20
  163. package/src/tools/tool-name-aliases.ts +19 -0
  164. package/src/tools/types.ts +19 -1
  165. package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
  166. package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
  167. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
  168. package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
  169. package/src/workspace/migrations/registry.ts +6 -0
package/ARCHITECTURE.md CHANGED
@@ -1278,7 +1278,7 @@ graph TB
1278
1278
  - Managed-store writes are atomic (tmp file + rename) to prevent partial `SKILL.md` or `SKILLS.md` files.
1279
1279
  - After persist or delete, the file watcher triggers conversation eviction; the next turn runs in a fresh conversation. The model's system prompt instructs it to continue normally.
1280
1280
  - macOS UI shows Inspect and Delete controls for managed skills only (source = "managed").
1281
- - `skill_load` validates the recursive include graph (via `include-graph.ts`) before emitting output. Missing children and cycles produce `isError: true` with no `<loaded_skill>` marker. Valid includes produce an "Included Skills (immediate)" metadata section showing child ID, name, description, and path.
1281
+ - `skill_load` resolves the recursive include graph (via `include-graph.ts`) before emitting output. Missing children are listed as suggested skills without child `<loaded_skill>` markers; cycles still produce `isError: true` with no marker. Valid includes produce an "Included Skills (immediate)" metadata section showing child ID, name, description, and path.
1282
1282
 
1283
1283
  ### Skills Authoring via HTTP
1284
1284
 
@@ -1289,32 +1289,33 @@ The Skills page in the macOS client can author managed skills through the daemon
1289
1289
 
1290
1290
  ### Include Graph Validation
1291
1291
 
1292
- Skills can declare child relationships via the `includes` frontmatter field (a JSON array of skill IDs). When `skill_load` loads a parent skill, it validates the full recursive include graph before emitting output.
1292
+ Skills can declare child relationships via the `includes` frontmatter field (a JSON array of skill IDs). When `skill_load` loads a parent skill, it attempts to resolve and auto-install missing includes before emitting output. Available includes are appended to the loaded skill output; unavailable includes are surfaced as suggestions instead of blocking the parent skill.
1293
1293
 
1294
1294
  ```mermaid
1295
1295
  graph LR
1296
1296
  LOAD["skill_load(parent)"] --> CATALOG["loadSkillCatalog()"]
1297
1297
  CATALOG --> INDEX["indexCatalogById()"]
1298
- INDEX --> VALIDATE["validateIncludes(rootId, index)"]
1299
- VALIDATE -->|"ok"| OUTPUT["Emit output +<br/>Included Skills (immediate)<br/>+ loaded_skill marker"]
1300
- VALIDATE -->|"missing child"| ERR_MISSING["isError: true<br/>no loaded_skill marker"]
1301
- VALIDATE -->|"cycle detected"| ERR_CYCLE["isError: true<br/>no loaded_skill marker"]
1298
+ INDEX --> AUTOINSTALL["Attempt catalog auto-install<br/>for missing includes"]
1299
+ AUTOINSTALL --> RESOLVE["collectAllMissing(rootId, index)<br/>+ validateIncludeCycles(rootId, index)"]
1300
+ RESOLVE -->|"ok + no missing child"| OUTPUT["Emit output +<br/>Included Skills (immediate)<br/>+ loaded_skill markers"]
1301
+ RESOLVE -->|"ok + missing child"| OUTPUT_MISSING["Emit parent output +<br/>Suggested Included Skills<br/>without child markers"]
1302
+ RESOLVE -->|"cycle detected"| ERR_CYCLE["isError: true<br/>no loaded_skill marker"]
1302
1303
  ```
1303
1304
 
1304
1305
  **Validation rules:**
1305
1306
 
1306
- - **Missing children**: If any skill in the recursive graph references an `includes` ID not found in the catalog, validation fails with the full path from root to the missing reference.
1307
+ - **Missing children**: Missing includes trigger catalog auto-install attempts. Any include still unavailable is listed under "Suggested Included Skills (not loaded)" and does not receive a `<loaded_skill>` marker.
1307
1308
  - **Cycles**: Three-state DFS (unseen → visiting → done) detects direct and indirect cycles. The error includes the cycle path.
1308
- - **Fail-closed**: On any validation error, `skill_load` returns `isError: true` with no `<loaded_skill>` marker, preventing the agent from using a skill with broken dependencies.
1309
+ - **Fail-closed cycles**: Circular include chains still return `isError: true` with no `<loaded_skill>` marker.
1309
1310
 
1310
- **Key constraint**: Include metadata is metadata-only. Child skills are **not** auto-activated the agent must explicitly call `skill_load` for each child. The `projectSkillTools()` function only projects tools for skills with explicit `<loaded_skill>` markers in conversation history.
1311
+ **Key constraint**: Include metadata is advisory. Available included skills are appended to the parent output and receive explicit `<loaded_skill>` markers; unavailable included skills remain suggestions so the agent can search for and install them if the task needs their guidance or tools.
1311
1312
 
1312
- | Source File | Purpose |
1313
- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
1314
- | `assistant/src/skills/include-graph.ts` | `indexCatalogById()`, `getImmediateChildren()`, `validateIncludes()`, `traverseIncludes()` |
1315
- | `assistant/src/tools/skills/load.ts` | Include validation integration in `skill_load` execute path |
1316
- | `assistant/src/config/skills.ts` | `includes` field parsing from SKILL.md frontmatter |
1317
- | `assistant/src/skills/managed-store.ts` | `includes` emission in `buildSkillMarkdown()` |
1313
+ | Source File | Purpose |
1314
+ | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
1315
+ | `assistant/src/skills/include-graph.ts` | `indexCatalogById()`, `getImmediateChildren()`, `validateIncludes()`, `validateIncludeCycles()`, `traverseIncludes()` |
1316
+ | `assistant/src/tools/skills/load.ts` | Include resolution integration in `skill_load` execute path |
1317
+ | `assistant/src/config/skills.ts` | `includes` field parsing from SKILL.md frontmatter |
1318
+ | `assistant/src/skills/managed-store.ts` | `includes` emission in `buildSkillMarkdown()` |
1318
1319
 
1319
1320
  ---
1320
1321
 
@@ -1553,19 +1554,19 @@ Every layer in the pipeline defaults to rejection rather than silent degradation
1553
1554
 
1554
1555
  ### Key Source Files
1555
1556
 
1556
- | File | Role |
1557
- | --------------------------------------------------- | ------------------------------------------------------------------------------------------ |
1558
- | `assistant/src/config/skills.ts` | Skill catalog loading: bundled, managed, workspace, extra directories |
1559
- | `assistant/src/config/bundled-skills/` | Bundled skill directories (browser, gmail, computer-use, weather, etc.) |
1560
- | `assistant/src/skills/tool-manifest.ts` | `TOOLS.json` parser and validator |
1561
- | `assistant/src/skills/active-skill-tools.ts` | `deriveActiveSkills()` — scans history for `<loaded_skill>` markers |
1562
- | `assistant/src/skills/include-graph.ts` | Include graph builder: `indexCatalogById()`, `validateIncludes()`, cycle/missing detection |
1563
- | `assistant/src/daemon/conversation-skill-tools.ts` | `projectSkillTools()` — per-turn projection, register/unregister lifecycle |
1564
- | `assistant/src/tools/skills/skill-tool-factory.ts` | `createSkillToolsFromManifest()` — manifest entries to Tool objects |
1565
- | `assistant/src/tools/skills/skill-script-runner.ts` | Host runner: dynamic import + `run()` call |
1566
- | `assistant/src/tools/skills/sandbox-runner.ts` | Sandbox runner: isolated subprocess execution |
1567
- | `assistant/src/tools/registry.ts` | `registerSkillTools()` / `unregisterSkillTools()` — global tool registry |
1568
- | `assistant/src/permissions/checker.ts` | Skill-origin default-ask permission policy |
1557
+ | File | Role |
1558
+ | --------------------------------------------------- | -------------------------------------------------------------------------------------------- |
1559
+ | `assistant/src/config/skills.ts` | Skill catalog loading: bundled, managed, workspace, extra directories |
1560
+ | `assistant/src/config/bundled-skills/` | Bundled skill directories (browser, gmail, computer-use, weather, etc.) |
1561
+ | `assistant/src/skills/tool-manifest.ts` | `TOOLS.json` parser and validator |
1562
+ | `assistant/src/skills/active-skill-tools.ts` | `deriveActiveSkills()` — scans history for `<loaded_skill>` markers |
1563
+ | `assistant/src/skills/include-graph.ts` | Include graph builder: `indexCatalogById()`, `validateIncludes()`, `validateIncludeCycles()` |
1564
+ | `assistant/src/daemon/conversation-skill-tools.ts` | `projectSkillTools()` — per-turn projection, register/unregister lifecycle |
1565
+ | `assistant/src/tools/skills/skill-tool-factory.ts` | `createSkillToolsFromManifest()` — manifest entries to Tool objects |
1566
+ | `assistant/src/tools/skills/skill-script-runner.ts` | Host runner: dynamic import + `run()` call |
1567
+ | `assistant/src/tools/skills/sandbox-runner.ts` | Sandbox runner: isolated subprocess execution |
1568
+ | `assistant/src/tools/registry.ts` | `registerSkillTools()` / `unregisterSkillTools()` — global tool registry |
1569
+ | `assistant/src/permissions/checker.ts` | Skill-origin default-ask permission policy |
1569
1570
 
1570
1571
  ---
1571
1572
 
package/Dockerfile CHANGED
@@ -22,6 +22,7 @@ COPY packages/service-contracts ./packages/service-contracts
22
22
  COPY packages/credential-storage ./packages/credential-storage
23
23
  COPY packages/egress-proxy ./packages/egress-proxy
24
24
  COPY packages/gateway-client ./packages/gateway-client
25
+ COPY packages/ipc-server-utils ./packages/ipc-server-utils
25
26
  COPY packages/skill-host-contracts ./packages/skill-host-contracts
26
27
  COPY packages/slack-text ./packages/slack-text
27
28
  COPY packages/twilio-client ./packages/twilio-client
@@ -31,18 +31,70 @@ mock.module("../../src/ipc/gateway-client.js", () => ({
31
31
  },
32
32
  }));
33
33
 
34
- // Suppress logger output in tests
35
- mock.module("../../src/util/logger.js", () => ({
36
- getLogger: () => ({
37
- warn: () => {},
38
- info: () => {},
39
- error: () => {},
40
- debug: () => {},
41
- }),
42
- }));
34
+ // Capture logger output so coalescing tests can assert on it. Existing
35
+ // tests don't read the array, so capturing is invisible to them.
36
+ //
37
+ // Bun's `mock.module("../../src/util/logger.js", ...)` does not intercept
38
+ // transitive imports (see comment in stt-hints.test.ts and avatar-e2e.test.ts).
39
+ // Mocking `pino` at the package level works because getLogger uses pino
40
+ // child loggers under the hood — intercepting pino captures everything.
41
+ interface LogCall {
42
+ level: "warn" | "info" | "error" | "debug";
43
+ fields: Record<string, unknown>;
44
+ message: string;
45
+ }
46
+ const logCalls: LogCall[] = [];
47
+
48
+ function makeLogFn(level: LogCall["level"]) {
49
+ return (
50
+ fieldsOrMsg: Record<string, unknown> | string,
51
+ maybeMsg?: string,
52
+ ) => {
53
+ if (typeof fieldsOrMsg === "string") {
54
+ logCalls.push({ level, fields: {}, message: fieldsOrMsg });
55
+ } else {
56
+ logCalls.push({
57
+ level,
58
+ fields: fieldsOrMsg,
59
+ message: maybeMsg ?? "",
60
+ });
61
+ }
62
+ };
63
+ }
64
+
65
+ const mockChildLogger = {
66
+ debug: () => {},
67
+ info: makeLogFn("info"),
68
+ warn: makeLogFn("warn"),
69
+ error: makeLogFn("error"),
70
+ fatal: () => {},
71
+ trace: () => {},
72
+ silent: () => {},
73
+ // pino loggers are themselves callable as a no-op shorthand; child() returns
74
+ // another logger.
75
+ child(): typeof mockChildLogger {
76
+ return mockChildLogger;
77
+ },
78
+ bindings: () => ({}),
79
+ level: "info",
80
+ };
81
+
82
+ const mockPinoLogger = Object.assign(() => mockChildLogger, {
83
+ destination: () => ({}),
84
+ multistream: () => ({}),
85
+ stdTimeFunctions: { isoTime: () => "" },
86
+ stdSerializers: {},
87
+ symbols: {},
88
+ });
89
+
90
+ mock.module("pino", () => ({ default: mockPinoLogger }));
91
+ mock.module("pino-pretty", () => ({ default: () => ({}) }));
43
92
 
44
93
  import {
45
94
  _clearGlobalCacheForTesting,
95
+ _getFailureStateForTesting,
96
+ _resetFailureCoalesceForTesting,
97
+ _setFailureWarnIntervalForTesting,
46
98
  getAutoApproveThreshold,
47
99
  } from "../../src/permissions/gateway-threshold-reader.js";
48
100
 
@@ -51,7 +103,9 @@ import {
51
103
  function resetMocks(): void {
52
104
  ipcCallLog.length = 0;
53
105
  ipcHandler = () => undefined;
106
+ logCalls.length = 0;
54
107
  _clearGlobalCacheForTesting();
108
+ _resetFailureCoalesceForTesting();
55
109
  }
56
110
 
57
111
  afterEach(resetMocks);
@@ -221,3 +275,176 @@ describe("getAutoApproveThreshold", () => {
221
275
  expect(ipcCallLog).toEqual(["/v1/permissions/thresholds"]);
222
276
  });
223
277
  });
278
+
279
+ // ── Failure coalescing ───────────────────────────────────────────────────────
280
+
281
+ describe("failure-coalescing log behavior", () => {
282
+ test("first failure WARNs immediately and starts a streak", async () => {
283
+ ipcHandler = () => {
284
+ throw new Error("Connection refused");
285
+ };
286
+
287
+ expect(await getAutoApproveThreshold(undefined, "background")).toBe("none");
288
+
289
+ const warns = logCalls.filter((c) => c.level === "warn");
290
+ expect(warns.length).toBe(1);
291
+ expect(warns[0]?.fields).toMatchObject({
292
+ op: "global_thresholds",
293
+ consecutiveFailures: 1,
294
+ event: "ipc_threshold_failure",
295
+ });
296
+
297
+ const state = _getFailureStateForTesting("global_thresholds");
298
+ expect(state).toBeDefined();
299
+ expect(state?.consecutiveFailures).toBe(1);
300
+ });
301
+
302
+ test("subsequent failures within the WARN window do not log but still increment state", async () => {
303
+ // 1-hour window so the test never accidentally crosses it.
304
+ _setFailureWarnIntervalForTesting(60 * 60 * 1000);
305
+ ipcHandler = () => {
306
+ throw new Error("ENOENT");
307
+ };
308
+
309
+ for (let i = 0; i < 100; i++) {
310
+ _clearGlobalCacheForTesting(); // force re-fetch each call
311
+ await getAutoApproveThreshold(undefined, "background");
312
+ }
313
+
314
+ const warns = logCalls.filter((c) => c.level === "warn");
315
+ // At most one WARN — the very first call. All 99 follow-ups suppressed.
316
+ expect(warns.length).toBe(1);
317
+
318
+ const state = _getFailureStateForTesting("global_thresholds");
319
+ expect(state?.consecutiveFailures).toBe(100);
320
+ });
321
+
322
+ test("a fresh WARN fires once the cadence window elapses", async () => {
323
+ // 5ms window so the test runs fast.
324
+ _setFailureWarnIntervalForTesting(5);
325
+ ipcHandler = () => {
326
+ throw new Error("ENOENT");
327
+ };
328
+
329
+ await getAutoApproveThreshold(undefined, "background");
330
+ expect(logCalls.filter((c) => c.level === "warn").length).toBe(1);
331
+
332
+ // Wait past the window then fail again.
333
+ await new Promise((r) => setTimeout(r, 20));
334
+ _clearGlobalCacheForTesting();
335
+ await getAutoApproveThreshold(undefined, "background");
336
+
337
+ const warns = logCalls.filter((c) => c.level === "warn");
338
+ expect(warns.length).toBe(2);
339
+ // Second WARN includes the streak metadata so dashboards can see how
340
+ // many failures were swallowed in between.
341
+ expect(warns[1]?.fields).toMatchObject({
342
+ op: "global_thresholds",
343
+ consecutiveFailures: 2,
344
+ event: "ipc_threshold_failure",
345
+ });
346
+ expect(warns[1]?.fields.streakDurationMs).toBeDefined();
347
+ });
348
+
349
+ test("recovery emits an INFO with the swallowed-failure count and clears state", async () => {
350
+ _setFailureWarnIntervalForTesting(60 * 60 * 1000);
351
+ let working = false;
352
+ ipcHandler = (method) => {
353
+ if (working && method === "get_global_thresholds") {
354
+ return { interactive: "medium", autonomous: "low" };
355
+ }
356
+ throw new Error("ENOENT");
357
+ };
358
+
359
+ // Three failures, then it recovers.
360
+ for (let i = 0; i < 3; i++) {
361
+ _clearGlobalCacheForTesting();
362
+ await getAutoApproveThreshold(undefined, "background");
363
+ }
364
+ expect(_getFailureStateForTesting("global_thresholds")?.consecutiveFailures).toBe(
365
+ 3,
366
+ );
367
+
368
+ working = true;
369
+ _clearGlobalCacheForTesting();
370
+ expect(await getAutoApproveThreshold(undefined, "background")).toBe("low");
371
+
372
+ const infos = logCalls.filter((c) => c.level === "info");
373
+ expect(infos.length).toBe(1);
374
+ expect(infos[0]?.fields).toMatchObject({
375
+ op: "global_thresholds",
376
+ swallowedFailures: 3,
377
+ event: "ipc_threshold_recovered",
378
+ });
379
+ expect(infos[0]?.fields.streakDurationMs).toBeDefined();
380
+
381
+ expect(_getFailureStateForTesting("global_thresholds")).toBeUndefined();
382
+ });
383
+
384
+ test("conversation and global ops have independent failure streaks", async () => {
385
+ _setFailureWarnIntervalForTesting(60 * 60 * 1000);
386
+ // conversation IPC fails (transport — returns undefined), global IPC works.
387
+ ipcHandler = (method) => {
388
+ if (method === "get_conversation_threshold") return undefined;
389
+ if (method === "get_global_thresholds") {
390
+ return { interactive: "medium", autonomous: "low" };
391
+ }
392
+ return undefined;
393
+ };
394
+
395
+ // First call: conversation transport fails, global succeeds.
396
+ expect(await getAutoApproveThreshold("conv-1", "conversation")).toBe(
397
+ "medium",
398
+ );
399
+
400
+ expect(
401
+ _getFailureStateForTesting("conversation_threshold")?.consecutiveFailures,
402
+ ).toBe(1);
403
+ expect(_getFailureStateForTesting("global_thresholds")).toBeUndefined();
404
+
405
+ const warns = logCalls.filter((c) => c.level === "warn");
406
+ expect(warns.length).toBe(1);
407
+ expect(warns[0]?.fields.op).toBe("conversation_threshold");
408
+ });
409
+
410
+ test("a successful conversation override clears the conversation streak even when the gateway returns null (no override)", async () => {
411
+ _setFailureWarnIntervalForTesting(60 * 60 * 1000);
412
+
413
+ // First two calls: conversation IPC returns undefined (transport failure).
414
+ let working = false;
415
+ ipcHandler = (method) => {
416
+ if (method === "get_conversation_threshold") {
417
+ return working ? null : undefined;
418
+ }
419
+ if (method === "get_global_thresholds") {
420
+ return { interactive: "low", autonomous: "none" };
421
+ }
422
+ return undefined;
423
+ };
424
+
425
+ // Force two transport failures.
426
+ await getAutoApproveThreshold("conv-2", "conversation");
427
+ await new Promise((r) => setTimeout(r, 6)); // bypass the 5s convo cache
428
+ _clearGlobalCacheForTesting();
429
+ // Convo cache is keyed on conversationId — change the id to bypass.
430
+ await getAutoApproveThreshold("conv-3", "conversation");
431
+ expect(
432
+ _getFailureStateForTesting("conversation_threshold")?.consecutiveFailures,
433
+ ).toBe(2);
434
+
435
+ // Now the IPC starts working — even a null "no override" response is a
436
+ // successful round-trip and must clear the streak.
437
+ working = true;
438
+ _clearGlobalCacheForTesting();
439
+ await getAutoApproveThreshold("conv-4", "conversation");
440
+
441
+ const infos = logCalls.filter((c) => c.level === "info");
442
+ expect(infos.length).toBe(1);
443
+ expect(infos[0]?.fields).toMatchObject({
444
+ op: "conversation_threshold",
445
+ swallowedFailures: 2,
446
+ event: "ipc_threshold_recovered",
447
+ });
448
+ expect(_getFailureStateForTesting("conversation_threshold")).toBeUndefined();
449
+ });
450
+ });
package/bun.lock CHANGED
@@ -18,6 +18,7 @@
18
18
  "@vellumai/credential-storage": "file:../packages/credential-storage",
19
19
  "@vellumai/egress-proxy": "file:../packages/egress-proxy",
20
20
  "@vellumai/gateway-client": "file:../packages/gateway-client",
21
+ "@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
21
22
  "@vellumai/service-contracts": "file:../packages/service-contracts",
22
23
  "@vellumai/skill-host-contracts": "file:../packages/skill-host-contracts",
23
24
  "@vellumai/slack-text": "file:../packages/slack-text",
@@ -421,6 +422,8 @@
421
422
 
422
423
  "@vellumai/gateway-client": ["@vellumai/gateway-client@file:../packages/gateway-client", { "dependencies": { "@vellumai/service-contracts": "file:../service-contracts" }, "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
423
424
 
425
+ "@vellumai/ipc-server-utils": ["@vellumai/ipc-server-utils@file:../packages/ipc-server-utils", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
426
+
424
427
  "@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", { "dependencies": { "zod": "4.3.6" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
425
428
 
426
429
  "@vellumai/skill-host-contracts": ["@vellumai/skill-host-contracts@file:../packages/skill-host-contracts", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
package/knip.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "@vellumai/credential-storage",
12
12
  "@vellumai/egress-proxy",
13
13
  "@vellumai/gateway-client",
14
+ "@vellumai/ipc-server-utils",
14
15
  "@vellumai/service-contracts",
15
16
  "@vellumai/slack-text",
16
17
  "@vellumai/twilio-client",
@@ -0,0 +1,24 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "@vellumai/ipc-server-utils",
7
+ "devDependencies": {
8
+ "@types/bun": "1.3.10",
9
+ "typescript": "5.9.3",
10
+ },
11
+ },
12
+ },
13
+ "packages": {
14
+ "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
15
+
16
+ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
17
+
18
+ "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
19
+
20
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
21
+
22
+ "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
23
+ }
24
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@vellumai/ipc-server-utils",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "bunx tsc --noEmit",
12
+ "test": "bun test src/"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.10",
16
+ "typescript": "5.9.3"
17
+ }
18
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ SocketWatchdog,
3
+ ensureSocketDir,
4
+ type SocketWatchdogOptions,
5
+ type SocketWatchdogLogger,
6
+ } from "./socket-watchdog.js";