@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.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 (111) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/activation-early-marking.test.ts +120 -0
  4. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  5. package/src/__tests__/approval-cascade.test.ts +1 -1
  6. package/src/__tests__/compaction-direct.test.ts +32 -18
  7. package/src/__tests__/compaction-events.test.ts +2 -2
  8. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  9. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  10. package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
  11. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  12. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  13. package/src/__tests__/conversation-error.test.ts +15 -1
  14. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  15. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  16. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  17. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  18. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +1 -1
  20. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  21. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  23. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  24. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  25. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  26. package/src/__tests__/conversation-undo.test.ts +2 -2
  27. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  30. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  31. package/src/__tests__/history-repair-hook.test.ts +2 -0
  32. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  33. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  34. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  35. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  36. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  37. package/src/__tests__/title-generate-hook.test.ts +2 -0
  38. package/src/__tests__/web-fetch.test.ts +45 -0
  39. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  40. package/src/acp/resolve-agent.test.ts +0 -56
  41. package/src/acp/resolve-agent.ts +10 -38
  42. package/src/agent/loop.ts +13 -27
  43. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  44. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  45. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  47. package/src/cli/lib/install-from-github.ts +258 -41
  48. package/src/cli/lib/plugin-details.ts +20 -13
  49. package/src/cli/lib/plugin-marketplace.ts +23 -5
  50. package/src/cli/lib/search-plugins.ts +14 -8
  51. package/src/config/acp-defaults.ts +3 -3
  52. package/src/config/acp-schema.ts +1 -7
  53. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  54. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  55. package/src/config/feature-flag-registry.json +3 -18
  56. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  57. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  58. package/src/daemon/conversation-agent-loop.ts +78 -78
  59. package/src/daemon/conversation-error.ts +31 -4
  60. package/src/daemon/conversation-history.ts +1 -1
  61. package/src/daemon/conversation-media-retry.ts +19 -6
  62. package/src/daemon/conversation-messaging.ts +17 -0
  63. package/src/daemon/conversation-process.ts +14 -5
  64. package/src/daemon/conversation-queue-manager.ts +8 -0
  65. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  66. package/src/daemon/conversation-surfaces.ts +141 -3
  67. package/src/daemon/conversation.ts +48 -13
  68. package/src/daemon/persist-unsendable-image.ts +62 -25
  69. package/src/daemon/process-message.ts +1 -1
  70. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  71. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  72. package/src/memory/activation-session-store.ts +43 -0
  73. package/src/memory/db-init.ts +4 -0
  74. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  75. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  76. package/src/memory/migrations/index.ts +2 -0
  77. package/src/memory/onboarding-events-store.ts +66 -18
  78. package/src/memory/schema/infrastructure.ts +13 -0
  79. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  80. package/src/notifications/adapters/telegram.ts +7 -1
  81. package/src/plugin-api/constants.ts +2 -2
  82. package/src/plugin-api/index.ts +2 -2
  83. package/src/plugin-api/types.ts +19 -5
  84. package/src/plugins/defaults/compaction/compact.ts +24 -15
  85. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  86. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  87. package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
  88. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  89. package/src/prompts/system-prompt.ts +61 -10
  90. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  91. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  92. package/src/providers/openai/chat-completions-provider.ts +25 -0
  93. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  94. package/src/runtime/routes/acp-routes.test.ts +3 -20
  95. package/src/runtime/routes/conversation-routes.ts +2 -0
  96. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  97. package/src/runtime/routes/stt-routes.ts +45 -12
  98. package/src/runtime/routes/workspace-routes.ts +50 -15
  99. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  100. package/src/telemetry/activation-funnel.ts +167 -0
  101. package/src/telemetry/types.ts +13 -0
  102. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  103. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  104. package/src/tools/acp/list-agents.test.ts +2 -18
  105. package/src/tools/acp/list-agents.ts +3 -15
  106. package/src/tools/acp/spawn.test.ts +0 -10
  107. package/src/tools/browser/browser-execution.ts +12 -2
  108. package/src/tools/network/web-fetch.ts +65 -24
  109. package/src/tools/ui-surface/definitions.ts +7 -0
  110. package/src/acp/feature-gate.test.ts +0 -48
  111. package/src/acp/feature-gate.ts +0 -34
@@ -22,7 +22,7 @@ _If the user declines the port (`port_declined`)._ Don't re-pitch and don't ask
22
22
 
23
23
  On `port_declined`, render exactly ONE small structured intake surface before moving to Propose — a `ui_show` `choice` surface (`display: "inline"`, single-select) whose options are a short background list (Founder / Engineer / Creative / Operator / Investor / Student / Other), so the no-port branch still hands Propose a structured signal. (A `form` with one short field, or a `choice` phrased as a single top-of-mind question, is an acceptable substitute — pick one; the background `choice` is the default.) This is the deliberate structured-intake exception to the "don't enumerate options / the recommendation IS the click" guidance — scoped to the no-port branch only, where there's no paste to infer from, so a small menu is the cheapest way to get traction. The port (paste) branch is unchanged: it never renders this surface.
24
24
 
25
- **Propose.** Don't organize what they already told you — infer what they didn't. Name the unstated thing sitting in their context and say *why* you think it: point at the specific surface that made you say it. "You didn't say this, but —". Then recommend, and lean one way; the recommendation IS the click, not a neutral menu of equally-weighted options.
25
+ **Propose.** Don't organize what they already told you — infer what they didn't. Name the unstated thing sitting in their context and say _why_ you think it: point at the specific surface that made you say it. "You didn't say this, but —". Then recommend, and lean one way; the recommendation IS the click, not a neutral menu of equally-weighted options.
26
26
 
27
27
  "Unstated" is inference, not invention. Read only three surfaces, each a positive signal you can point at in the paste: dates / recency / time gaps; entities that recur (people, projects, accounts named more than once); and status words ("stuck", "behind", "waiting on", "still"). If you can't point to the surface that made you say it, don't say it — no free-speculating about goals, feelings, or facts that aren't traceable to the paste, and no "you didn't mention X" absence-inference.
28
28
 
@@ -71,7 +71,7 @@ These are enforcement rules, not advice.
71
71
 
72
72
  **Long turns show progress.** Any post-submit / post-skill-load turn must render a `task_progress` card within ~5s, or fall back to streaming text. Bind "long turn" → "task_progress emitted": a long-running turn that produces neither a progress card nor streaming text didn't satisfy this move.
73
73
 
74
- **Action Trust-Guarantee.** Sibling to the OAuth Trust-Guarantee. Before a bulk write / delete / destructive op, render a `ui_show` preview — a table surface showing total count, breakdown, sample rows, and the categories to confirm. The user commits or refines on that surface; only then do you execute. Single-item actions use the natural draft instead. Threshold for the preview gate: bulk *and* low recoverability. One of the two alone doesn't trip it.
74
+ **Action Trust-Guarantee.** Sibling to the OAuth Trust-Guarantee. Before a bulk write / delete / destructive op, render a `ui_show` preview — a table surface showing total count, breakdown, sample rows, and the categories to confirm. The user commits or refines on that surface; only then do you execute. Single-item actions use the natural draft instead. Threshold for the preview gate: bulk _and_ low recoverability. One of the two alone doesn't trip it.
75
75
 
76
76
  **Start Small.** On first execution of any skill, prefer the smallest meaningful result over the most complete result. Show, then offer to expand.
77
77
 
@@ -83,6 +83,41 @@ Identity writes (IDENTITY.md, SOUL.md), user-profile writes, journal entries: al
83
83
 
84
84
  The base BOOTSTRAP task_preferences fallback is not on this rail. Your opener is the Port pitch.
85
85
 
86
+ ## Telemetry: tag your `ui_show` surfaces
87
+
88
+ The activation funnel is measured passively — there is no separate tool to call.
89
+ When you render the `ui_show` surface for a rail move that IS a funnel moment,
90
+ add the optional `activation_moment` parameter to that same `ui_show` call. It's
91
+ a tag on a surface you're already showing, not extra work.
92
+
93
+ Most moments record when the user COMMITS the tagged surface (clicks an action /
94
+ submits / selects). The one exception is `first_wow_executed`, which records the
95
+ moment the surface RENDERS — because the wow has already happened by the time you
96
+ show its result, and a result card is often display-only with nothing to commit.
97
+
98
+ Which surface to tag with which moment:
99
+
100
+ - `moment_1` — the no-port intake `choice` surface, OR the Port-summary card
101
+ (background + top-of-mind captured, or the user explicitly skipped). Records on
102
+ commit (when the intake resolves).
103
+ - `moment_2` — the Propose offer surface (the `ui_show` offer card/choice where
104
+ the user picks an outcome). Records on commit.
105
+ - `moment_3` — the task-selection surface (the specific thing you're about to
106
+ run is chosen). Records on commit.
107
+ - `first_wow_executed` — the Run result surface (e.g. `work_result` / the result
108
+ the user sees after the wow ran against real data). Records on RENDER — you do
109
+ not need the user to click anything; just tag the result surface.
110
+ - `first_wow_interacted` — the user's first engagement AFTER the wow. Tag the
111
+ surface they act on (a result-card action button, or the follow-through
112
+ `choice` you render next). Records on commit. Don't put this on the same
113
+ surface as `first_wow_executed` — one tag per surface, and that surface already
114
+ records "executed" on render.
115
+
116
+ A surface carries at most one `activation_moment`. The milestone no-ops outside
117
+ an activation session, and a missing or mistimed tag is non-fatal — but accurate,
118
+ move-bound tags are what make the funnel meaningful. Omit `activation_moment` on
119
+ every non-funnel surface.
120
+
86
121
  ## Wrap
87
122
 
88
123
  <!-- Open question: the default wrap tone below is opinionated (brisk, dataset-first). Whether that's the right note to end the activation rail on is a conscious call we haven't made yet — flag, don't silently change. -->
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import OpenAI from "openai";
4
+
5
+ import { detectVisionNotSupported } from "../chat-completions-provider.js";
6
+
7
+ function buildApiError(
8
+ status: number,
9
+ body: unknown,
10
+ ): InstanceType<typeof OpenAI.APIError> {
11
+ return new OpenAI.APIError(
12
+ status,
13
+ body as Record<string, unknown>,
14
+ undefined,
15
+ new Headers(),
16
+ );
17
+ }
18
+
19
+ describe("detectVisionNotSupported", () => {
20
+ test("detects OpenRouter 'No endpoints found that support image input'", () => {
21
+ const err = buildApiError(404, {
22
+ error: {
23
+ message: "No endpoints found that support image input",
24
+ code: 404,
25
+ },
26
+ });
27
+ expect(detectVisionNotSupported(err)).toBe(true);
28
+ });
29
+
30
+ test("detects 'does not support image' phrasing", () => {
31
+ const err = buildApiError(400, {
32
+ error: {
33
+ message: "This model does not support image input",
34
+ },
35
+ });
36
+ expect(detectVisionNotSupported(err)).toBe(true);
37
+ });
38
+
39
+ test("detects 'image input is not supported'", () => {
40
+ const err = buildApiError(400, {
41
+ error: {
42
+ message: "image input is not supported for this model",
43
+ },
44
+ });
45
+ expect(detectVisionNotSupported(err)).toBe(true);
46
+ });
47
+
48
+ test("detects 'vision is not supported'", () => {
49
+ const err = buildApiError(400, {
50
+ error: {
51
+ message: "Vision is not supported by this model",
52
+ },
53
+ });
54
+ expect(detectVisionNotSupported(err)).toBe(true);
55
+ });
56
+
57
+ test("returns false for unrelated errors", () => {
58
+ const err = buildApiError(429, {
59
+ error: {
60
+ message: "Rate limit exceeded",
61
+ },
62
+ });
63
+ expect(detectVisionNotSupported(err)).toBe(false);
64
+ });
65
+
66
+ test("returns false for context overflow", () => {
67
+ const err = buildApiError(400, {
68
+ error: {
69
+ code: "context_length_exceeded",
70
+ message: "This model's maximum context length is 128000 tokens",
71
+ },
72
+ });
73
+ expect(detectVisionNotSupported(err)).toBe(false);
74
+ });
75
+ });
@@ -101,6 +101,22 @@ export function extractApiErrorDetail(
101
101
  * OpenRouter's `error.metadata.raw` strings, which are typically <1KB. */
102
102
  const MAX_API_ERROR_DETAIL_CHARS = 2000;
103
103
 
104
+ const VISION_NOT_SUPPORTED_PATTERNS = [
105
+ /no endpoints found that support image input/i,
106
+ /does not support image/i,
107
+ /image input is not supported/i,
108
+ /this model does not support vision/i,
109
+ /vision is not supported/i,
110
+ /multi-?modal.*not.*support/i,
111
+ ];
112
+
113
+ export function detectVisionNotSupported(
114
+ error: InstanceType<typeof OpenAI.APIError>,
115
+ ): boolean {
116
+ const haystack = `${error.message} ${JSON.stringify((error as { error?: unknown }).error ?? "")}`;
117
+ return VISION_NOT_SUPPORTED_PATTERNS.some((re) => re.test(haystack));
118
+ }
119
+
104
120
  /**
105
121
  * Fallback `content` for an assistant turn that has neither visible text nor
106
122
  * tool calls (e.g. a reasoning-only turn truncated at the output-token limit).
@@ -649,6 +665,15 @@ export class OpenAIChatCompletionsProvider implements Provider {
649
665
  cause: error,
650
666
  });
651
667
  }
668
+ if (detectVisionNotSupported(error)) {
669
+ const model = modelOverride ?? this.model;
670
+ throw new ProviderError(
671
+ `This model (${model}) doesn't support image input. Remove the image or switch to a vision-capable model.`,
672
+ this.name,
673
+ error.status,
674
+ abortReason ? { abortReason } : undefined,
675
+ );
676
+ }
652
677
  const retryAfterMs = extractRetryAfterMs(error.headers);
653
678
  const errorOptions: {
654
679
  retryAfterMs?: number;
@@ -1,3 +1,8 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFileSync } from "node:fs";
3
+ import { unlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
1
6
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
7
 
3
8
  // ---------------------------------------------------------------------------
@@ -26,6 +31,35 @@ mock.module("../../../providers/speech-to-text/resolve.js", () => ({
26
31
  },
27
32
  }));
28
33
 
34
+ // -- Spawn mock (ffmpeg/ffprobe) for the file-transcription path ------------
35
+
36
+ type SpawnResult = { exitCode: number; stdout: string; stderr: string };
37
+ let spawnOverride: ((args: string[]) => SpawnResult) | null = null;
38
+
39
+ /**
40
+ * Default spawn behavior: ffmpeg writes the requested output file so the
41
+ * downstream readFile succeeds, ffprobe reports a short duration.
42
+ */
43
+ function defaultSpawn(args: string[]): SpawnResult {
44
+ if (args[0] === "ffmpeg") {
45
+ const outputPath = args[args.length - 1];
46
+ writeFileSync(outputPath, Buffer.from("fake-wav-bytes"));
47
+ return { exitCode: 0, stdout: "", stderr: "" };
48
+ }
49
+ if (args[0] === "ffprobe") {
50
+ return { exitCode: 0, stdout: "1.0", stderr: "" };
51
+ }
52
+ return { exitCode: 0, stdout: "", stderr: "" };
53
+ }
54
+
55
+ mock.module("../../../util/spawn.js", () => ({
56
+ FFMPEG_TRANSCODE_TIMEOUT_MS: 60_000,
57
+ FFPROBE_TIMEOUT_MS: 10_000,
58
+ STT_REQUEST_TIMEOUT_MS: 300_000,
59
+ spawnWithTimeout: async (args: string[]) =>
60
+ (spawnOverride ?? defaultSpawn)(args),
61
+ }));
62
+
29
63
  // ---------------------------------------------------------------------------
30
64
  // Import under test — after mocks
31
65
  // ---------------------------------------------------------------------------
@@ -86,6 +120,7 @@ const fakeTranscriber: BatchTranscriber = {
86
120
  beforeEach(() => {
87
121
  mockTranscriber = fakeTranscriber;
88
122
  mockResolveError = null;
123
+ spawnOverride = null;
89
124
  });
90
125
 
91
126
  afterEach(() => {
@@ -366,4 +401,81 @@ describe("stt-routes", () => {
366
401
  "BAD_GATEWAY",
367
402
  );
368
403
  });
404
+
405
+ // -- File transcription: provider error categorization --------------------
406
+
407
+ async function withTempAudioFile(
408
+ run: (filePath: string) => Promise<void>,
409
+ ): Promise<void> {
410
+ const filePath = join(tmpdir(), `vellum-stt-test-${randomUUID()}.wav`);
411
+ await writeFile(filePath, Buffer.from("fake-audio"));
412
+ try {
413
+ await run(filePath);
414
+ } finally {
415
+ await unlink(filePath).catch(() => {});
416
+ }
417
+ }
418
+
419
+ test("transcribe-file maps provider auth failures (403) to 401", async () => {
420
+ // Reproduces JARVIS-994: a raw Gemini 403 denial must surface as an
421
+ // actionable 401, not a generic 502, and must not be treated as a
422
+ // daemon-level error.
423
+ mockTranscriber = {
424
+ providerId: "google-gemini",
425
+ boundaryId: "daemon-batch",
426
+ transcribe: async () => {
427
+ throw new Error(
428
+ 'Google Gemini API error (403): {"error":{"code":403,"message":"Your project has been denied access. Please contact support.","status":"PERMISSION_DENIED"}}',
429
+ );
430
+ },
431
+ };
432
+
433
+ await withTempAudioFile(async (filePath) => {
434
+ const { handler } = getRoute("stt/transcribe-file");
435
+ const err = await expectRouteError(
436
+ () => handler(makeArgs({ filePath })),
437
+ 401,
438
+ "UNAUTHORIZED",
439
+ );
440
+ expect(err.message).toContain("credentials");
441
+ });
442
+ });
443
+
444
+ test("transcribe-file maps generic provider failures to 502", async () => {
445
+ mockTranscriber = {
446
+ providerId: "openai-whisper",
447
+ boundaryId: "daemon-batch",
448
+ transcribe: async () => {
449
+ throw new Error("upstream kaboom");
450
+ },
451
+ };
452
+
453
+ await withTempAudioFile(async (filePath) => {
454
+ const { handler } = getRoute("stt/transcribe-file");
455
+ await expectRouteError(
456
+ () => handler(makeArgs({ filePath })),
457
+ 502,
458
+ "BAD_GATEWAY",
459
+ );
460
+ });
461
+ });
462
+
463
+ test("transcribe-file maps ffmpeg failures to 502 even when stderr contains a status-like number", async () => {
464
+ // Guards against ffmpeg stderr (or a user-supplied path) deciding the HTTP
465
+ // category: a conversion failure whose message contains "403" must stay a
466
+ // 502 conversion error, not become a misleading 401 auth error.
467
+ spawnOverride = (args) =>
468
+ args[0] === "ffmpeg"
469
+ ? { exitCode: 1, stdout: "", stderr: "Error opening /tmp/403/clip.wav" }
470
+ : { exitCode: 0, stdout: "1.0", stderr: "" };
471
+
472
+ await withTempAudioFile(async (filePath) => {
473
+ const { handler } = getRoute("stt/transcribe-file");
474
+ await expectRouteError(
475
+ () => handler(makeArgs({ filePath })),
476
+ 502,
477
+ "BAD_GATEWAY",
478
+ );
479
+ });
480
+ });
369
481
  });
@@ -2,9 +2,9 @@
2
2
  * Tests for the ACP route handlers.
3
3
  *
4
4
  * Suites:
5
- * - POST /v1/acp/spawn: resolver failure paths (acp_disabled,
6
- * unknown_agent) and the body-shape guard. The binary_not_found /
7
- * auto-install surface is covered in `__tests__/acp-routes.test.ts`.
5
+ * - POST /v1/acp/spawn: resolver failure paths (unknown_agent) and the
6
+ * body-shape guard. The binary_not_found / auto-install surface is
7
+ * covered in `__tests__/acp-routes.test.ts`.
8
8
  * - POST /v1/acp/spawn (env injection) — CLAUDE_CODE_OAUTH_TOKEN is read
9
9
  * from the credential broker (policy-gated + audited) and merged into
10
10
  * `agentConfig.env` ONLY for the `claude` agent.
@@ -200,21 +200,6 @@ beforeEach(() => {
200
200
  // ---------------------------------------------------------------------------
201
201
 
202
202
  describe("POST /v1/acp/spawn", () => {
203
- test("throws BadRequestError when ACP is disabled", async () => {
204
- config.setConfig({ enabled: false });
205
-
206
- const handler = getSpawnHandler();
207
- await expect(
208
- handler({
209
- body: {
210
- agent: "claude",
211
- task: "do a thing",
212
- conversationId: "conv-1",
213
- },
214
- }),
215
- ).rejects.toThrow("acp.enabled");
216
- });
217
-
218
203
  test("throws BadRequestError with merged available list when agent id is unknown", async () => {
219
204
  config.setConfig({
220
205
  agents: {
@@ -235,8 +220,6 @@ describe("POST /v1/acp/spawn", () => {
235
220
  });
236
221
 
237
222
  test("body-shape guard short-circuits before the resolver runs", async () => {
238
- config.setConfig({ enabled: false });
239
-
240
223
  const handler = getSpawnHandler();
241
224
  await expect(handler({ body: { agent: "claude" } })).rejects.toThrow(
242
225
  "agent, task, and conversationId are required",
@@ -1741,6 +1741,7 @@ export async function handleSendMessage(
1741
1741
  ...(body.automated === true ? { automated: true } : {}),
1742
1742
  },
1743
1743
  isInteractive,
1744
+ sourceActorPrincipalId,
1744
1745
  transport,
1745
1746
  clientMessageId,
1746
1747
  });
@@ -1842,6 +1843,7 @@ export async function handleSendMessage(
1842
1843
  userMessageInterface: sourceInterface,
1843
1844
  assistantMessageInterface: sourceInterface,
1844
1845
  });
1846
+ conversation.currentTurnSourceActorPrincipalId = sourceActorPrincipalId;
1845
1847
 
1846
1848
  await conversation.ensureActorScopedHistory();
1847
1849
 
@@ -18,8 +18,8 @@ mock.module("../helpers.js", () => ({
18
18
  addPlaygroundMessage: async () => ({ id: "msg-test" }),
19
19
  }));
20
20
 
21
- import type { ContextWindowResult } from "../../../../context/window-manager.js";
22
21
  import type { Conversation } from "../../../../daemon/conversation.js";
22
+ import type { ContextWindowResult } from "../../../../plugins/defaults/compaction/window-manager.js";
23
23
  import type { Message } from "../../../../providers/types.js";
24
24
  import { RouteError } from "../../errors.js";
25
25
  import { ROUTES } from "../index.js";
@@ -17,6 +17,7 @@ import { listProviderEntries } from "../../providers/speech-to-text/provider-cat
17
17
  import { resolveBatchTranscriber } from "../../providers/speech-to-text/resolve.js";
18
18
  import { normalizeSttError } from "../../stt/daemon-batch-transcriber.js";
19
19
  import type { BatchTranscriber, SttErrorCategory } from "../../stt/types.js";
20
+ import { SttError } from "../../stt/types.js";
20
21
  import { getLogger } from "../../util/logger.js";
21
22
  import {
22
23
  FFMPEG_TRANSCODE_TIMEOUT_MS,
@@ -141,6 +142,27 @@ async function toWav(inputPath: string, isVideo: boolean): Promise<string> {
141
142
  return wavPath;
142
143
  }
143
144
 
145
+ /**
146
+ * Call the provider and tag any failure as an {@link SttError} so callers can
147
+ * distinguish provider failures (categorized HTTP statuses) from local
148
+ * preprocessing failures (ffmpeg/ffprobe), which stay on the bad-gateway path.
149
+ */
150
+ async function transcribeChunk(
151
+ transcriber: BatchTranscriber,
152
+ audioBuffer: Buffer,
153
+ ): Promise<string> {
154
+ try {
155
+ const result = await transcriber.transcribe({
156
+ audio: audioBuffer,
157
+ mimeType: "audio/wav",
158
+ signal: AbortSignal.timeout(STT_REQUEST_TIMEOUT_MS),
159
+ });
160
+ return result.text;
161
+ } catch (err) {
162
+ throw normalizeSttError(err);
163
+ }
164
+ }
165
+
144
166
  async function transcribeWithProvider(
145
167
  audioPath: string,
146
168
  transcriber: BatchTranscriber,
@@ -151,12 +173,7 @@ async function transcribeWithProvider(
151
173
  // If small enough, send directly
152
174
  if (fileSize <= STT_CHUNK_MAX_BYTES) {
153
175
  const audioBuffer = await readFile(audioPath);
154
- const result = await transcriber.transcribe({
155
- audio: audioBuffer,
156
- mimeType: "audio/wav",
157
- signal: AbortSignal.timeout(STT_REQUEST_TIMEOUT_MS),
158
- });
159
- return result.text;
176
+ return transcribeChunk(transcriber, audioBuffer);
160
177
  }
161
178
 
162
179
  // Split into chunks for large files
@@ -175,12 +192,8 @@ async function transcribeWithProvider(
175
192
  ` Transcribing chunk ${i + 1}/${chunks.length}...\n`,
176
193
  );
177
194
  const audioBuffer = await readFile(chunks[i]);
178
- const result = await transcriber.transcribe({
179
- audio: audioBuffer,
180
- mimeType: "audio/wav",
181
- signal: AbortSignal.timeout(STT_REQUEST_TIMEOUT_MS),
182
- });
183
- if (result.text) parts.push(result.text);
195
+ const text = await transcribeChunk(transcriber, audioBuffer);
196
+ if (text) parts.push(text);
184
197
  }
185
198
 
186
199
  return parts.join(" ");
@@ -363,6 +376,26 @@ async function handleTranscribeFile({ body }: RouteHandlerArgs) {
363
376
  durationSeconds,
364
377
  };
365
378
  } catch (err) {
379
+ // Only provider failures (tagged as SttError by transcribeChunk) are
380
+ // categorized into HTTP statuses. Local preprocessing failures (ffmpeg/
381
+ // ffprobe) stay on the bad-gateway path so their stderr never decides the
382
+ // category (e.g. a path under a dir named "403" must not become a 401).
383
+ if (err instanceof SttError) {
384
+ // Auth/rate-limit/timeout/invalid-audio are user-config or transient
385
+ // conditions, not daemon defects, so log them at warn to keep them out
386
+ // of error telemetry. Reserve error level (with the raw err for a Sentry
387
+ // stack) for genuine provider/unexpected failures.
388
+ if (err.category === "provider-error") {
389
+ log.error({ err, filePath }, "File transcription failed");
390
+ } else {
391
+ log.warn(
392
+ { category: err.category, message: err.message, filePath },
393
+ "File transcription failed",
394
+ );
395
+ }
396
+ throw STT_ERROR_MAP[err.category]();
397
+ }
398
+
366
399
  log.error({ err, filePath }, "File transcription failed");
367
400
  throw new BadGatewayError(
368
401
  err instanceof Error ? err.message : "Transcription failed",
@@ -46,14 +46,24 @@ const workspaceTreeEntrySchema = z.object({
46
46
 
47
47
  type TreeEntry = z.infer<typeof workspaceTreeEntrySchema>;
48
48
 
49
- // Total number of filesystem entries we're willing to traverse when computing
50
- // recursive directory sizes for a single tree listing. The budget is shared
51
- // across every directory in the response so a giant `repos/` checkout cannot
52
- // starve the request — once exhausted, remaining directories report `size:
53
- // null` (caller renders an unknown-size placeholder). 50k is generous for
54
- // typical workspaces (sounds, db, logs) while bailing fast on multi-repo
55
- // trees.
56
- const DIR_SIZE_TOTAL_ENTRY_BUDGET = 50_000;
49
+ // Recursive directory sizing walks the filesystem synchronously, so it is
50
+ // bounded by two caps that work together:
51
+ //
52
+ // - `DIR_SIZE_PER_DIR_ENTRY_BUDGET` caps a single directory. No one giant
53
+ // subtree (e.g. a `node_modules/` or `repos/` checkout) can consume the whole
54
+ // listing's budget and starve its siblings; only the oversized directory
55
+ // itself reports `size: null` (the caller renders an unknown-size
56
+ // placeholder) while every other folder still shows an accurate size.
57
+ // - `DIR_SIZE_TOTAL_ENTRY_BUDGET` caps the listing as a whole, so a workspace
58
+ // with many large sibling directories still can't block the daemon event loop
59
+ // for an unbounded amount of time. Once it is exhausted, the remaining
60
+ // directories report `size: null`.
61
+ //
62
+ // Each directory is allotted `min(per-dir cap, remaining listing budget)`
63
+ // entries, and whatever it actually consumes is deducted from the listing
64
+ // budget.
65
+ const DIR_SIZE_PER_DIR_ENTRY_BUDGET = 250_000;
66
+ const DIR_SIZE_TOTAL_ENTRY_BUDGET = 1_000_000;
57
67
 
58
68
  interface DirSizeBudget {
59
69
  remaining: number;
@@ -61,13 +71,13 @@ interface DirSizeBudget {
61
71
 
62
72
  /**
63
73
  * Recursively sum the byte size of every regular file under `absPath`,
64
- * sharing a single entry budget across all directories in the same listing.
74
+ * traversing up to `budget.remaining` filesystem entries.
65
75
  *
66
76
  * Returns:
67
77
  * - the total size in bytes when the entire subtree was traversed within
68
78
  * budget
69
- * - `null` if the shared budget was exhausted before completion (the caller
70
- * surfaces that as an unknown size in the UI)
79
+ * - `null` if the budget was exhausted before completion (the caller surfaces
80
+ * that as an unknown size in the UI)
71
81
  *
72
82
  * Symlinks are not followed. We rely on `withFileTypes` so we never `stat`
73
83
  * directories purely to discover their type.
@@ -108,6 +118,27 @@ function computeDirSize(absPath: string, budget: DirSizeBudget): number | null {
108
118
  return total;
109
119
  }
110
120
 
121
+ /**
122
+ * Compute a directory's recursive size while drawing from a shared listing
123
+ * budget. The directory is allotted at most `DIR_SIZE_PER_DIR_ENTRY_BUDGET`
124
+ * entries — and never more than the listing has left — and whatever it consumes
125
+ * is deducted from `listingBudget` so the next sibling sees the reduced
126
+ * remainder. Returns `null` (unknown size) when the allotment is exhausted.
127
+ */
128
+ function computeDirSizeWithinListing(
129
+ absPath: string,
130
+ listingBudget: DirSizeBudget,
131
+ ): number | null {
132
+ const allotment = Math.min(
133
+ DIR_SIZE_PER_DIR_ENTRY_BUDGET,
134
+ listingBudget.remaining,
135
+ );
136
+ const dirBudget: DirSizeBudget = { remaining: allotment };
137
+ const size = computeDirSize(absPath, dirBudget);
138
+ listingBudget.remaining -= allotment - dirBudget.remaining;
139
+ return size;
140
+ }
141
+
111
142
  const SOUNDS_WORKSPACE_PATH = "data/sounds";
112
143
 
113
144
  function normaliseWorkspacePathForSync(path: string): string {
@@ -153,9 +184,11 @@ function handleWorkspaceTree({ queryParams }: RouteHandlerArgs) {
153
184
  const dirents = readdirSync(resolved, { withFileTypes: true });
154
185
  const workspaceDir = getWorkspaceDir();
155
186
 
156
- // Shared budget across every directory in this listing. When opted out,
157
- // we never construct one, so directories report `size: null` as before.
158
- const dirSizeBudget: DirSizeBudget | undefined = includeDirSizes
187
+ // Listing-wide budget, only constructed when sizes are requested. Each
188
+ // directory draws a per-directory allotment from it so one oversized
189
+ // subtree can't starve its siblings, while the shared remainder still caps
190
+ // total synchronous traversal for the whole listing.
191
+ const listingBudget: DirSizeBudget | undefined = includeDirSizes
159
192
  ? { remaining: DIR_SIZE_TOTAL_ENTRY_BUDGET }
160
193
  : undefined;
161
194
 
@@ -176,7 +209,9 @@ function handleWorkspaceTree({ queryParams }: RouteHandlerArgs) {
176
209
  const relativePath = fullPath.slice(workspaceDir.length + 1);
177
210
 
178
211
  const dirSize =
179
- isDir && dirSizeBudget ? computeDirSize(fullPath, dirSizeBudget) : null;
212
+ isDir && listingBudget
213
+ ? computeDirSizeWithinListing(fullPath, listingBudget)
214
+ : null;
180
215
 
181
216
  entries.push({
182
217
  name: entry.name,
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ ACTIVATION_MOMENT_PARAMS,
5
+ ACTIVATION_STEPS,
6
+ activationStepIndex,
7
+ activationStepNameForMomentParam,
8
+ buildActivationDaemonEventId,
9
+ isActivationMomentParam,
10
+ isActivationStepName,
11
+ } from "../activation-funnel.js";
12
+
13
+ const ALL_STEP_NAMES = Object.values(ACTIVATION_STEPS).map((s) => s.stepName);
14
+
15
+ describe("activation funnel vocabulary", () => {
16
+ test("step indices are 1-5 and unique", () => {
17
+ const indices = Object.values(ACTIVATION_STEPS).map((s) => s.stepIndex);
18
+ expect([...indices].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
19
+ expect(new Set(indices).size).toBe(indices.length);
20
+ });
21
+
22
+ test("activationStepIndex resolves index from name", () => {
23
+ for (const { stepName, stepIndex } of Object.values(ACTIVATION_STEPS)) {
24
+ expect(activationStepIndex(stepName)).toBe(stepIndex);
25
+ }
26
+ });
27
+
28
+ test("isActivationStepName accepts all five step names", () => {
29
+ expect(ALL_STEP_NAMES).toHaveLength(5);
30
+ for (const stepName of ALL_STEP_NAMES) {
31
+ expect(isActivationStepName(stepName)).toBe(true);
32
+ }
33
+ });
34
+
35
+ test("isActivationStepName rejects unknown names", () => {
36
+ expect(isActivationStepName("bogus")).toBe(false);
37
+ });
38
+
39
+ test("buildActivationDaemonEventId is deterministic", () => {
40
+ expect(
41
+ buildActivationDaemonEventId("conv-abc", "activation_moment_1_complete"),
42
+ ).toBe("activation_v1_2026_06:conv-abc:activation_moment_1_complete");
43
+ });
44
+ });
45
+
46
+ describe("activation moment param (model-facing tag)", () => {
47
+ test("ACTIVATION_MOMENT_PARAMS lists the five tokens in step order", () => {
48
+ expect(ACTIVATION_MOMENT_PARAMS).toEqual([
49
+ "moment_1",
50
+ "moment_2",
51
+ "moment_3",
52
+ "first_wow_executed",
53
+ "first_wow_interacted",
54
+ ]);
55
+ });
56
+
57
+ test("isActivationMomentParam accepts all five tokens", () => {
58
+ for (const param of ACTIVATION_MOMENT_PARAMS) {
59
+ expect(isActivationMomentParam(param)).toBe(true);
60
+ }
61
+ });
62
+
63
+ test("isActivationMomentParam rejects unknown tokens (incl. wire step names)", () => {
64
+ expect(isActivationMomentParam("bogus")).toBe(false);
65
+ // The wire step name is NOT a valid model-facing token.
66
+ expect(isActivationMomentParam("activation_moment_1_complete")).toBe(false);
67
+ expect(isActivationMomentParam("moment_4")).toBe(false);
68
+ });
69
+
70
+ test("activationStepNameForMomentParam maps each token to its step name", () => {
71
+ expect(activationStepNameForMomentParam("moment_1")).toBe(
72
+ ACTIVATION_STEPS.moment1.stepName,
73
+ );
74
+ expect(activationStepNameForMomentParam("moment_2")).toBe(
75
+ ACTIVATION_STEPS.moment2.stepName,
76
+ );
77
+ expect(activationStepNameForMomentParam("moment_3")).toBe(
78
+ ACTIVATION_STEPS.moment3.stepName,
79
+ );
80
+ expect(activationStepNameForMomentParam("first_wow_executed")).toBe(
81
+ ACTIVATION_STEPS.firstWowExecuted.stepName,
82
+ );
83
+ expect(activationStepNameForMomentParam("first_wow_interacted")).toBe(
84
+ ACTIVATION_STEPS.firstWowInteracted.stepName,
85
+ );
86
+ });
87
+
88
+ test("every moment param maps to a valid wire step name", () => {
89
+ for (const param of ACTIVATION_MOMENT_PARAMS) {
90
+ expect(
91
+ isActivationStepName(activationStepNameForMomentParam(param)),
92
+ ).toBe(true);
93
+ }
94
+ });
95
+ });