@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.
- package/docs/activation-funnel-telemetry.md +310 -0
- package/package.json +1 -1
- package/src/__tests__/activation-early-marking.test.ts +120 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
- package/src/__tests__/approval-cascade.test.ts +1 -1
- package/src/__tests__/compaction-direct.test.ts +32 -18
- package/src/__tests__/compaction-events.test.ts +2 -2
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/context-overflow-reducer.test.ts +5 -5
- package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-media-retry.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
- package/src/__tests__/conversation-process-callsite.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
- package/src/__tests__/conversation-speed-override.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
- package/src/__tests__/conversation-undo.test.ts +2 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +36 -0
- package/src/__tests__/history-repair-hook.test.ts +2 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
- package/src/__tests__/persist-unsendable-image.test.ts +97 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
- package/src/__tests__/title-generate-hook.test.ts +2 -0
- package/src/__tests__/web-fetch.test.ts +45 -0
- package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
- package/src/acp/resolve-agent.test.ts +0 -56
- package/src/acp/resolve-agent.ts +10 -38
- package/src/agent/loop.ts +13 -27
- package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
- package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
- package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
- package/src/cli/lib/install-from-github.ts +258 -41
- package/src/cli/lib/plugin-details.ts +20 -13
- package/src/cli/lib/plugin-marketplace.ts +23 -5
- package/src/cli/lib/search-plugins.ts +14 -8
- package/src/config/acp-defaults.ts +3 -3
- package/src/config/acp-schema.ts +1 -7
- package/src/config/bundled-skills/acp/SKILL.md +4 -17
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +3 -18
- package/src/context/post-turn-tool-result-truncation.ts +39 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
- package/src/daemon/conversation-agent-loop.ts +78 -78
- package/src/daemon/conversation-error.ts +31 -4
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-media-retry.ts +19 -6
- package/src/daemon/conversation-messaging.ts +17 -0
- package/src/daemon/conversation-process.ts +14 -5
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +37 -1
- package/src/daemon/conversation-surfaces.ts +141 -3
- package/src/daemon/conversation.ts +48 -13
- package/src/daemon/persist-unsendable-image.ts +62 -25
- package/src/daemon/process-message.ts +1 -1
- package/src/memory/__tests__/activation-session-store.test.ts +41 -0
- package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
- package/src/memory/activation-session-store.ts +43 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
- package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/onboarding-events-store.ts +66 -18
- package/src/memory/schema/infrastructure.ts +13 -0
- package/src/messaging/providers/telegram-bot/api.ts +14 -5
- package/src/notifications/adapters/telegram.ts +7 -1
- package/src/plugin-api/constants.ts +2 -2
- package/src/plugin-api/index.ts +2 -2
- package/src/plugin-api/types.ts +19 -5
- package/src/plugins/defaults/compaction/compact.ts +24 -15
- package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
- package/src/plugins/defaults/compaction/manager-store.ts +1 -1
- package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
- package/src/prompts/system-prompt.ts +61 -10
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
- package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
- package/src/providers/openai/chat-completions-provider.ts +25 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
- package/src/runtime/routes/acp-routes.test.ts +3 -20
- package/src/runtime/routes/conversation-routes.ts +2 -0
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
- package/src/runtime/routes/stt-routes.ts +45 -12
- package/src/runtime/routes/workspace-routes.ts +50 -15
- package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
- package/src/telemetry/activation-funnel.ts +167 -0
- package/src/telemetry/types.ts +13 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
- package/src/telemetry/usage-telemetry-reporter.ts +26 -1
- package/src/tools/acp/list-agents.test.ts +2 -18
- package/src/tools/acp/list-agents.ts +3 -15
- package/src/tools/acp/spawn.test.ts +0 -10
- package/src/tools/browser/browser-execution.ts +12 -2
- package/src/tools/network/web-fetch.ts +65 -24
- package/src/tools/ui-surface/definitions.ts +7 -0
- package/src/acp/feature-gate.test.ts +0 -48
- 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
|
|
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
|
|
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 (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
179
|
-
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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
|
-
*
|
|
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
|
|
70
|
-
*
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
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 &&
|
|
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
|
+
});
|