@vellumai/assistant 0.10.9-staging.1 → 0.10.9
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/AGENTS.md +4 -0
- package/knip.json +1 -4
- package/openapi.yaml +27 -17
- package/package.json +1 -1
- package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
- package/src/__tests__/app-routes-csp.test.ts +13 -4
- package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
- package/src/__tests__/disk-pressure-tools.test.ts +4 -4
- package/src/__tests__/file-list-tool.test.ts +22 -12
- package/src/__tests__/list-all-apps.test.ts +204 -0
- package/src/__tests__/messaging-send-tool.test.ts +67 -0
- package/src/__tests__/mtime-cache.test.ts +42 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
- package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
- package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
- package/src/__tests__/subagent-notify-parent.test.ts +4 -3
- package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
- package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
- package/src/apps/app-store.ts +302 -2
- package/src/cli/AGENTS.md +17 -0
- package/src/cli/commands/__tests__/gateway.test.ts +55 -0
- package/src/cli/commands/apps.help.ts +17 -11
- package/src/cli/commands/apps.ts +5 -15
- package/src/cli/commands/gateway.help.ts +30 -0
- package/src/cli/commands/gateway.ts +40 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
- package/src/cli/commands/platform/index.help.ts +3 -2
- package/src/cli/commands/platform/index.ts +0 -9
- package/src/cli/commands/routes.help.ts +13 -7
- package/src/cli/commands/routes.ts +5 -3
- package/src/cli/commands/status.help.ts +24 -0
- package/src/cli/commands/status.ts +29 -0
- package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
- package/src/cli/lib/bundled-marketplace.json +187 -173
- package/src/cli/lib/global-json-option.ts +41 -0
- package/src/cli/lib/plugin-fingerprint.ts +18 -7
- package/src/cli/program.ts +5 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/feature-flag-registry.json +1 -9
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
- package/src/daemon/conversation-tool-setup.ts +5 -90
- package/src/daemon/conversation.ts +2 -2
- package/src/messaging/provider-types.ts +9 -0
- package/src/messaging/providers/outlook/adapter.ts +17 -2
- package/src/messaging/providers/outlook/client.ts +11 -2
- package/src/messaging/providers/outlook/types.ts +9 -0
- package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
- package/src/monitoring/plugin-source-watch.ts +53 -0
- package/src/oauth/seed-providers.ts +6 -0
- package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
- package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
- package/src/plugin-api/conversation-turn.ts +234 -0
- package/src/plugin-api/index.ts +12 -0
- package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
- package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
- package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
- package/src/plugins/mtime-cache.ts +26 -13
- package/src/plugins/plugin-tree-walk.ts +30 -0
- package/src/plugins/source-fingerprint.ts +9 -2
- package/src/plugins/surface-import.ts +28 -0
- package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
- package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
- package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
- package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
- package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
- package/src/runtime/routes/app-management-routes.ts +96 -32
- package/src/runtime/routes/app-routes.ts +33 -17
- package/src/runtime/routes/gateway-status-routes.ts +69 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +4 -14
- package/src/runtime/routes/plugins-routes.ts +58 -1
- package/src/runtime/routes/settings-routes.ts +8 -6
- package/src/runtime/routes/user-route-dispatcher.ts +6 -86
- package/src/runtime/routes/user-route-resolution.ts +141 -0
- package/src/runtime/routes/user-routes-cli.ts +56 -35
- package/src/telemetry/AGENTS.md +15 -0
- package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
- package/src/telemetry/telemetry-wire-validation.ts +125 -0
- package/src/telemetry/telemetry-wire.generated.ts +60 -9
- package/src/telemetry/types.test.ts +38 -0
- package/src/telemetry/types.ts +183 -91
- package/src/telemetry/usage-telemetry-reporter.ts +5 -0
- package/src/telemetry/watchdog-direct-emit.ts +6 -0
- package/src/tools/skills/scaffold-managed.ts +43 -11
|
@@ -13,10 +13,17 @@ import { z } from "zod";
|
|
|
13
13
|
|
|
14
14
|
import { getConfig } from "../../config/loader.js";
|
|
15
15
|
import { getPublicBaseUrl } from "../../inbound/public-ingress-urls.js";
|
|
16
|
-
import { getWorkspaceRoutesDir } from "../../util/platform.js";
|
|
16
|
+
import { getWorkspaceDir, getWorkspaceRoutesDir } from "../../util/platform.js";
|
|
17
17
|
import { LOCAL_PRINCIPALS } from "../auth/route-policy.js";
|
|
18
18
|
import { NotFoundError } from "./errors.js";
|
|
19
19
|
import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
|
|
20
|
+
import {
|
|
21
|
+
HANDLER_EXTENSIONS,
|
|
22
|
+
isReservedWorkspaceRoutePath,
|
|
23
|
+
listPluginRouteRoots,
|
|
24
|
+
resolveHandlerFile,
|
|
25
|
+
resolveRouteLocation,
|
|
26
|
+
} from "./user-route-resolution.js";
|
|
20
27
|
|
|
21
28
|
// ── Constants ───────────────────────────────────────────────────────
|
|
22
29
|
|
|
@@ -32,8 +39,6 @@ const HTTP_METHODS = [
|
|
|
32
39
|
|
|
33
40
|
type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
34
41
|
|
|
35
|
-
const HANDLER_EXTENSIONS = [".ts", ".js"] as const;
|
|
36
|
-
|
|
37
42
|
type HandlerExtension = (typeof HANDLER_EXTENSIONS)[number];
|
|
38
43
|
|
|
39
44
|
// ── Schemas ─────────────────────────────────────────────────────────
|
|
@@ -146,51 +151,67 @@ function tryGetPublicBaseUrl(): string | null {
|
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (existsSync(candidate)) {
|
|
158
|
-
return candidate;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
for (const ext of HANDLER_EXTENSIONS) {
|
|
163
|
-
const candidate = join(basePath, `index${ext}`);
|
|
164
|
-
if (existsSync(candidate)) {
|
|
165
|
-
return candidate;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return null;
|
|
154
|
+
/**
|
|
155
|
+
* Strip a leading `/x/` (or `x/`) and surrounding slashes from a route path so
|
|
156
|
+
* `routes inspect` accepts both the bare sub-path (`ping`) and the `/x/`-prefixed
|
|
157
|
+
* form that `routes list` prints (`/x/plugins/demo/status`).
|
|
158
|
+
*/
|
|
159
|
+
function normalizeInspectPath(input: string): string {
|
|
160
|
+
const trimmed = input.replace(/^\/+/, "");
|
|
161
|
+
return trimmed.startsWith("x/") ? trimmed.slice(2) : trimmed;
|
|
170
162
|
}
|
|
171
163
|
|
|
172
164
|
// ── Handlers ────────────────────────────────────────────────────────
|
|
173
165
|
|
|
174
166
|
async function handleUserRoutesList() {
|
|
175
|
-
const routesDir = getWorkspaceRoutesDir();
|
|
176
|
-
const discovered = await discoverRoutes(routesDir);
|
|
177
167
|
const publicBase = tryGetPublicBaseUrl();
|
|
168
|
+
const workspaceDir = getWorkspaceDir();
|
|
178
169
|
|
|
179
|
-
const
|
|
180
|
-
routePath: `/x/${
|
|
170
|
+
const toEntry = (xPath: string, r: DiscoveredRoute) => ({
|
|
171
|
+
routePath: `/x/${xPath}`,
|
|
181
172
|
methods: r.methods,
|
|
182
173
|
description: r.description ?? null,
|
|
183
|
-
filePath: relative(
|
|
184
|
-
publicUrl: publicBase ? `${publicBase}/x/${
|
|
185
|
-
})
|
|
174
|
+
filePath: relative(workspaceDir, r.filePath),
|
|
175
|
+
publicUrl: publicBase ? `${publicBase}/x/${xPath}` : null,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const routes: ReturnType<typeof toEntry>[] = [];
|
|
179
|
+
|
|
180
|
+
// Workspace routes at `/x/<path>`. Paths shadowed by the reserved plugin
|
|
181
|
+
// namespace are skipped: `resolveRouteLocation` routes those to a plugin
|
|
182
|
+
// directory, so a `<workspace>/routes/plugins/…` file is never served and
|
|
183
|
+
// must not be advertised here.
|
|
184
|
+
for (const r of await discoverRoutes(getWorkspaceRoutesDir())) {
|
|
185
|
+
if (isReservedWorkspaceRoutePath(r.routePath)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
routes.push(toEntry(r.routePath, r));
|
|
189
|
+
}
|
|
186
190
|
|
|
191
|
+
// Plugin routes at `/x/plugins/<name>/<sub>`, from each enabled plugin's
|
|
192
|
+
// `routes/` directory (same enumeration the dispatcher resolves against).
|
|
193
|
+
for (const { pluginName, routesDir } of listPluginRouteRoots()) {
|
|
194
|
+
for (const r of await discoverRoutes(routesDir)) {
|
|
195
|
+
const xPath = r.routePath
|
|
196
|
+
? `plugins/${pluginName}/${r.routePath}`
|
|
197
|
+
: `plugins/${pluginName}`;
|
|
198
|
+
routes.push(toEntry(xPath, r));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
routes.sort((a, b) => a.routePath.localeCompare(b.routePath));
|
|
187
203
|
return { ok: true, routes };
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
async function handleUserRoutesInspect({ body = {} }: RouteHandlerArgs) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
const
|
|
207
|
+
const routePath = normalizeInspectPath(InspectParams.parse(body).path);
|
|
208
|
+
|
|
209
|
+
const location = routePath.includes("..")
|
|
210
|
+
? null
|
|
211
|
+
: resolveRouteLocation(routePath);
|
|
212
|
+
const filePath = location
|
|
213
|
+
? resolveHandlerFile(location.routesDir, location.subPath)
|
|
214
|
+
: null;
|
|
194
215
|
|
|
195
216
|
if (!filePath) {
|
|
196
217
|
throw new NotFoundError(
|
|
@@ -209,7 +230,7 @@ async function handleUserRoutesInspect({ body = {} }: RouteHandlerArgs) {
|
|
|
209
230
|
routePath: `/x/${routePath}`,
|
|
210
231
|
methods,
|
|
211
232
|
description: description ?? null,
|
|
212
|
-
filePath,
|
|
233
|
+
filePath: relative(getWorkspaceDir(), filePath),
|
|
213
234
|
publicUrl,
|
|
214
235
|
fileSize: stat.size,
|
|
215
236
|
modifiedAt: stat.mtime.toISOString(),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Telemetry — Agent Instructions
|
|
2
|
+
|
|
3
|
+
## Wire contract
|
|
4
|
+
|
|
5
|
+
`telemetry-wire.generated.ts` (in this directory) is generated from the platform's telemetry ingest serializers and auto-synced here on platform merges (the platform's `sync-telemetry-wire.yaml` workflow). **Never edit it by hand** — contract changes belong in `vellum-assistant-platform` at `django/app/assistant/self_hosted_local/serializers.py`.
|
|
6
|
+
|
|
7
|
+
`types.ts` is the override layer on top of it: simple events flow through `WireEventMap` without restating fields — they use the generated types directly, so their construction sites get excess-property/missing-field errors when the contract moves. Events where the daemon's type is intentionally richer live in `Overrides`, each pinned to the wire type by compile-time guards covering both drift directions: `_*Narrows` (daemon values stay wire-assignable — catches wire-side tightening) and `_*KeysExist` (the daemon emits no field the wire no longer declares — catches platform-side field removals/renames, which structural subtyping would otherwise let through silently). Daemon-only events live in `Extensions`. A red guard or a failing `types.test.ts` on a sync PR means the platform contract moved — reconcile the override to the new wire shape, don't loosen the guard.
|
|
8
|
+
|
|
9
|
+
Pre-flush validation (`telemetry-wire-validation.ts`) checks outgoing events against the wire schemas and logs any the server would silently drop; it is observability only and never blocks or mutates the batch.
|
|
10
|
+
|
|
11
|
+
## Adding a new event type starts platform-side, not here
|
|
12
|
+
|
|
13
|
+
The ingest endpoint silently skips events whose type has no registered serializer (the batch still 2xxes and the daemon acks away its outbox rows), so an emitter shipped before its platform serializer loses every event it records — pre-flush validation logs the drop but does not prevent it.
|
|
14
|
+
|
|
15
|
+
The full cross-repo checklist lives with the serializer registry it governs, in `vellum-assistant-platform` at `django/app/assistant/self_hosted_local/AGENTS.md` ("Adding a new telemetry ingest event type"). The daemon emitter is the **last** step — added only after the platform serializer has merged and the wire sync PR has landed here. A new emitter is an outbox store plus registration in `telemetry-event-sources.ts` (or a watermark source for high-volume tables); if the type previously existed daemon-only, also move it out of `Extensions` in `types.ts`.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared sample telemetry events for the wire-contract test suites
|
|
3
|
+
* (`types.test.ts` and `telemetry-wire-validation.test.ts`).
|
|
4
|
+
*
|
|
5
|
+
* `wireEventSamples` holds exactly one daemon-typed, wire-valid sample per
|
|
6
|
+
* generated wire event type — when the wire contract gains an event type,
|
|
7
|
+
* add its sample here and both suites pick it up. The daemon-type
|
|
8
|
+
* annotations are themselves part of the contract test: they compile-check
|
|
9
|
+
* that realistic daemon values inhabit the hand-written override types.
|
|
10
|
+
*
|
|
11
|
+
* Imported by `*.test.ts` files only — never by the test preload — so
|
|
12
|
+
* importing daemon types from `src/` is safe (AGENTS.md "Test machinery
|
|
13
|
+
* isolation" scopes its no-`src/` rule to preload-time machinery). The
|
|
14
|
+
* imports are type-only; this module has no runtime side effects.
|
|
15
|
+
*/
|
|
16
|
+
import type {
|
|
17
|
+
AuthFallbackTelemetryEvent,
|
|
18
|
+
ConfigSettingTelemetryEvent,
|
|
19
|
+
LifecycleTelemetryEvent,
|
|
20
|
+
LlmUsageTelemetryEvent,
|
|
21
|
+
OnboardingResearchTelemetryEvent,
|
|
22
|
+
OnboardingTelemetryEvent,
|
|
23
|
+
SkillLoadedTelemetryEvent,
|
|
24
|
+
ToolExecutedTelemetryEvent,
|
|
25
|
+
TurnTelemetryEvent,
|
|
26
|
+
TurnTrace,
|
|
27
|
+
WatchdogTelemetryEvent,
|
|
28
|
+
} from "../types.js";
|
|
29
|
+
|
|
30
|
+
const RECORDED_AT = 1_750_000_000_000;
|
|
31
|
+
|
|
32
|
+
const llmUsage: LlmUsageTelemetryEvent = {
|
|
33
|
+
type: "llm_usage",
|
|
34
|
+
daemon_event_id: "evt-llm-usage-001",
|
|
35
|
+
recorded_at: RECORDED_AT,
|
|
36
|
+
assistant_version: "1.2.3",
|
|
37
|
+
conversation_id: "conv-xyz",
|
|
38
|
+
conversation_type: "standard",
|
|
39
|
+
// Zero is legitimate here (wire bound is min(0)) — the wire schema must
|
|
40
|
+
// not reject it.
|
|
41
|
+
turn_index: 0,
|
|
42
|
+
provider: "anthropic",
|
|
43
|
+
model: "claude-fable-5",
|
|
44
|
+
input_tokens: 1200,
|
|
45
|
+
output_tokens: 340,
|
|
46
|
+
cache_creation_input_tokens: 256,
|
|
47
|
+
cache_read_input_tokens: 1024,
|
|
48
|
+
llm_call_count: 3,
|
|
49
|
+
raw_usage: {
|
|
50
|
+
input_tokens: 1200,
|
|
51
|
+
cache_creation: { ephemeral_5m_input_tokens: 256 },
|
|
52
|
+
},
|
|
53
|
+
actor: "user",
|
|
54
|
+
llm_call_site: "mainAgent",
|
|
55
|
+
inference_profile: "balanced",
|
|
56
|
+
inference_profile_source: "active",
|
|
57
|
+
cost: 0.0123,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const trace: TurnTrace = {
|
|
61
|
+
schema_version: 3,
|
|
62
|
+
messages: [
|
|
63
|
+
{
|
|
64
|
+
id: "msg-1",
|
|
65
|
+
role: "user",
|
|
66
|
+
created_at: RECORDED_AT - 5_000,
|
|
67
|
+
content: [{ type: "text", text: "What's on my calendar today?" }],
|
|
68
|
+
model: null,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "msg-2",
|
|
72
|
+
role: "assistant",
|
|
73
|
+
created_at: RECORDED_AT - 1_000,
|
|
74
|
+
content: [{ type: "text", text: "You have two meetings." }],
|
|
75
|
+
model: "claude-fable-5",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
tool_calls: [
|
|
79
|
+
{
|
|
80
|
+
id: "inv-1",
|
|
81
|
+
tool_name: "bash",
|
|
82
|
+
input: { command: "ls" },
|
|
83
|
+
result: "ok",
|
|
84
|
+
decision: "allow",
|
|
85
|
+
duration_ms: 42,
|
|
86
|
+
created_at: RECORDED_AT - 3_000,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
system_prompt: "You are a helpful assistant.",
|
|
90
|
+
tool_definitions: [
|
|
91
|
+
{
|
|
92
|
+
name: "bash",
|
|
93
|
+
description: "Run a shell command",
|
|
94
|
+
input_schema: { type: "object", properties: {} },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Exported separately: both suites build negative `client`-bag cases on it. */
|
|
100
|
+
export const turnEventSample: TurnTelemetryEvent = {
|
|
101
|
+
type: "turn",
|
|
102
|
+
daemon_event_id: "evt-turn-001",
|
|
103
|
+
recorded_at: RECORDED_AT,
|
|
104
|
+
assistant_version: "1.2.3",
|
|
105
|
+
conversation_id: "conv-xyz",
|
|
106
|
+
conversation_type: "standard",
|
|
107
|
+
turn_index: 1,
|
|
108
|
+
interface_id: "web",
|
|
109
|
+
channel_id: "vellum",
|
|
110
|
+
client: {
|
|
111
|
+
browser_family: "chrome",
|
|
112
|
+
browser_version: "124",
|
|
113
|
+
os: "macos",
|
|
114
|
+
interface_version: "0.8.2",
|
|
115
|
+
},
|
|
116
|
+
outcome: "failed",
|
|
117
|
+
failure_code: "PROVIDER_RATE_LIMIT",
|
|
118
|
+
trace,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const lifecycle: LifecycleTelemetryEvent = {
|
|
122
|
+
type: "lifecycle",
|
|
123
|
+
daemon_event_id: "evt-lifecycle-001",
|
|
124
|
+
recorded_at: RECORDED_AT,
|
|
125
|
+
assistant_version: "1.2.3",
|
|
126
|
+
event_name: "app_open",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onboarding: OnboardingTelemetryEvent = {
|
|
130
|
+
type: "onboarding",
|
|
131
|
+
daemon_event_id: "evt-onboarding-001",
|
|
132
|
+
recorded_at: RECORDED_AT,
|
|
133
|
+
assistant_version: "1.2.3",
|
|
134
|
+
screen: "tools",
|
|
135
|
+
tools: ["gmail", "calendar"],
|
|
136
|
+
tasks: ["email-triage"],
|
|
137
|
+
tone: "friendly",
|
|
138
|
+
google_connected: true,
|
|
139
|
+
google_scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
|
|
140
|
+
ab_variant: "control",
|
|
141
|
+
session_id: "sess-123",
|
|
142
|
+
step_name: "select-tools",
|
|
143
|
+
step_index: 2,
|
|
144
|
+
completed_at: "2026-07-13T00:00:00Z",
|
|
145
|
+
funnel_version: "v2",
|
|
146
|
+
user_id: "user-123",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const authFallback: AuthFallbackTelemetryEvent = {
|
|
150
|
+
type: "auth_fallback",
|
|
151
|
+
daemon_event_id: "evt-auth-fallback-001",
|
|
152
|
+
recorded_at: RECORDED_AT,
|
|
153
|
+
assistant_version: "1.2.3",
|
|
154
|
+
guard: "edge",
|
|
155
|
+
path: "/v1/conversations",
|
|
156
|
+
failure_kind: "missing_authorization",
|
|
157
|
+
count: 7,
|
|
158
|
+
window_start: RECORDED_AT - 300_000,
|
|
159
|
+
window_end: RECORDED_AT,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const toolExecuted: ToolExecutedTelemetryEvent = {
|
|
163
|
+
type: "tool_executed",
|
|
164
|
+
daemon_event_id: "evt-tool-executed-001",
|
|
165
|
+
recorded_at: RECORDED_AT,
|
|
166
|
+
assistant_version: "1.2.3",
|
|
167
|
+
provider: "anthropic",
|
|
168
|
+
model: "claude-fable-5",
|
|
169
|
+
inference_profile: "balanced",
|
|
170
|
+
inference_profile_source: "call_site",
|
|
171
|
+
tool_name: "bash",
|
|
172
|
+
status: "fulfilled",
|
|
173
|
+
duration_ms: 42,
|
|
174
|
+
arg_bytes: 128,
|
|
175
|
+
result_bytes: 2048,
|
|
176
|
+
conversation_id: "conv-xyz",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const skillLoaded: SkillLoadedTelemetryEvent = {
|
|
180
|
+
type: "skill_loaded",
|
|
181
|
+
daemon_event_id: "evt-skill-loaded-001",
|
|
182
|
+
recorded_at: RECORDED_AT,
|
|
183
|
+
assistant_version: "1.2.3",
|
|
184
|
+
provider: "anthropic",
|
|
185
|
+
model: "claude-fable-5",
|
|
186
|
+
inference_profile: "balanced",
|
|
187
|
+
inference_profile_source: "conversation",
|
|
188
|
+
skill_name: "plugin-builder",
|
|
189
|
+
skill_updated_at: "2026-06-01T00:00:00Z",
|
|
190
|
+
conversation_id: "conv-xyz",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const watchdog: WatchdogTelemetryEvent = {
|
|
194
|
+
type: "watchdog",
|
|
195
|
+
daemon_event_id: "evt-watchdog-001",
|
|
196
|
+
recorded_at: RECORDED_AT,
|
|
197
|
+
assistant_version: "1.2.3",
|
|
198
|
+
check_name: "event_loop_blocked",
|
|
199
|
+
value: 1200,
|
|
200
|
+
detail: { reason: "gc", blocked_ms: 1200 },
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const configSetting: ConfigSettingTelemetryEvent = {
|
|
204
|
+
type: "config_setting",
|
|
205
|
+
daemon_event_id: "evt-config-setting-001",
|
|
206
|
+
recorded_at: RECORDED_AT,
|
|
207
|
+
assistant_version: "1.2.3",
|
|
208
|
+
config_key: "memory.enabled",
|
|
209
|
+
config_value: "true",
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `onboarding_research` sample. The daemon's type is richer than the wire
|
|
214
|
+
* (structured `claims`/`suggestions`, closed `status`), so it is an `Overrides`
|
|
215
|
+
* entry; the wire schema (opaque JSON arrays, size-bounded) still accepts it.
|
|
216
|
+
*/
|
|
217
|
+
export const onboardingResearch: OnboardingResearchTelemetryEvent = {
|
|
218
|
+
type: "onboarding_research",
|
|
219
|
+
daemon_event_id: "evt-onboarding-research-1",
|
|
220
|
+
recorded_at: RECORDED_AT,
|
|
221
|
+
assistant_version: "1.2.3",
|
|
222
|
+
conversation_id: "conv-xyz",
|
|
223
|
+
status: "done",
|
|
224
|
+
claims: [
|
|
225
|
+
{
|
|
226
|
+
claim: "Works on developer tooling",
|
|
227
|
+
confidence: "confident",
|
|
228
|
+
sources: ["https://example.com/profile"],
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
claim_count: 1,
|
|
232
|
+
claims_confident: 1,
|
|
233
|
+
claims_maybe: 0,
|
|
234
|
+
claims_guessing: 0,
|
|
235
|
+
suggestions: [
|
|
236
|
+
{
|
|
237
|
+
suggestion: "Set up email triage",
|
|
238
|
+
prompt: "Help me triage my inbox",
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
suggestion_count: 1,
|
|
242
|
+
plugins: ["gmail"],
|
|
243
|
+
installed_plugins: ["gmail", "calendar"],
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** One daemon-typed, wire-valid sample per generated wire event type. */
|
|
247
|
+
export const wireEventSamples = [
|
|
248
|
+
llmUsage,
|
|
249
|
+
turnEventSample,
|
|
250
|
+
lifecycle,
|
|
251
|
+
onboarding,
|
|
252
|
+
authFallback,
|
|
253
|
+
toolExecuted,
|
|
254
|
+
skillLoaded,
|
|
255
|
+
watchdog,
|
|
256
|
+
configSetting,
|
|
257
|
+
onboardingResearch,
|
|
258
|
+
] as const;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for pre-flush telemetry wire validation.
|
|
3
|
+
*
|
|
4
|
+
* Validation is observability only — it must count and warn, never mutate,
|
|
5
|
+
* filter, or block — and its warn payloads must carry the event type and
|
|
6
|
+
* issue `{ path, code }` shapes only, never field values. `daemon_event_id`
|
|
7
|
+
* is a field value too: activation-funnel ids embed the onboarding session
|
|
8
|
+
* id (traces/claims can hold PII). Issue paths are sanitized as well: dynamic
|
|
9
|
+
* record keys (e.g. `client` bag keys) are redacted to `*` before logging.
|
|
10
|
+
*/
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import type { Logger } from "pino";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
turnEventSample,
|
|
17
|
+
wireEventSamples,
|
|
18
|
+
} from "./__tests__/telemetry-event-fixtures.js";
|
|
19
|
+
import { telemetryEventSchema } from "./telemetry-wire.generated.js";
|
|
20
|
+
import {
|
|
21
|
+
resetUnknownTypeWarningsForTests,
|
|
22
|
+
validateWireEvents,
|
|
23
|
+
} from "./telemetry-wire-validation.js";
|
|
24
|
+
|
|
25
|
+
let warnCalls: unknown[][] = [];
|
|
26
|
+
const stubLog = {
|
|
27
|
+
warn: (...args: unknown[]) => {
|
|
28
|
+
warnCalls.push(args);
|
|
29
|
+
},
|
|
30
|
+
} as unknown as Logger;
|
|
31
|
+
|
|
32
|
+
describe("validateWireEvents", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
warnCalls = [];
|
|
35
|
+
resetUnknownTypeWarningsForTests();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a valid event of each wire type passes with no warnings", () => {
|
|
39
|
+
// Fixture coverage: one sample per generated union discriminator (with a
|
|
40
|
+
// floor of the 9 shipped types), so a new wire type without a fixture —
|
|
41
|
+
// or a shrunken union — turns this red.
|
|
42
|
+
const discriminators = telemetryEventSchema.options.map(
|
|
43
|
+
(option) => option.shape.type.value,
|
|
44
|
+
);
|
|
45
|
+
expect(discriminators.length).toBeGreaterThanOrEqual(9);
|
|
46
|
+
expect(new Set(wireEventSamples.map((event) => event.type))).toEqual(
|
|
47
|
+
new Set(discriminators),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const result = validateWireEvents(wireEventSamples, stubLog);
|
|
51
|
+
expect(result).toEqual({
|
|
52
|
+
checked: wireEventSamples.length,
|
|
53
|
+
invalid: 0,
|
|
54
|
+
unknownTypes: [],
|
|
55
|
+
});
|
|
56
|
+
expect(warnCalls).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("invalid events are counted and warned with {path, code} issues and no field values", () => {
|
|
60
|
+
const sentinel = "SENTINEL_PII_VALUE_do_not_log";
|
|
61
|
+
const invalidTurn = {
|
|
62
|
+
...turnEventSample,
|
|
63
|
+
// Nested object values violate the wire contract's client bounds; the
|
|
64
|
+
// sentinel must never appear in any warn payload.
|
|
65
|
+
client: { nested: { secret: sentinel } },
|
|
66
|
+
};
|
|
67
|
+
const invalidLifecycle = {
|
|
68
|
+
type: "lifecycle",
|
|
69
|
+
// An id embedding the onboarding session id and padded past the wire
|
|
70
|
+
// schema's 128-char daemon_event_id bound — the id value itself must
|
|
71
|
+
// never be logged, only its (redacted) issue path.
|
|
72
|
+
daemon_event_id: `activation_v1_2026_06:${sentinel}:${"x".repeat(100)}`,
|
|
73
|
+
recorded_at: 1_700_000_000_011,
|
|
74
|
+
assistant_version: sentinel,
|
|
75
|
+
// event_name is missing.
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = validateWireEvents([invalidTurn, invalidLifecycle], stubLog);
|
|
79
|
+
expect(result).toEqual({ checked: 2, invalid: 2, unknownTypes: [] });
|
|
80
|
+
expect(warnCalls).toHaveLength(2);
|
|
81
|
+
|
|
82
|
+
for (const call of warnCalls) {
|
|
83
|
+
const bag = call[0] as {
|
|
84
|
+
eventType: string;
|
|
85
|
+
issues: Array<Record<string, unknown>>;
|
|
86
|
+
};
|
|
87
|
+
// The bag carries the event type and issues only — no daemon_event_id
|
|
88
|
+
// (it is a payload value; activation ids embed the session id).
|
|
89
|
+
expect(Object.keys(bag).sort()).toEqual(["eventType", "issues"]);
|
|
90
|
+
expect(bag.issues.length).toBeGreaterThan(0);
|
|
91
|
+
for (const issue of bag.issues) {
|
|
92
|
+
// Issues carry path + code only — never messages or field values.
|
|
93
|
+
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
|
|
94
|
+
expect(issue.path).toBeString();
|
|
95
|
+
expect(issue.code).toBeString();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
expect(
|
|
99
|
+
(warnCalls[0][0] as { issues: Array<{ path: string }> }).issues[0].path,
|
|
100
|
+
).toStartWith("client");
|
|
101
|
+
const lifecyclePaths = (
|
|
102
|
+
warnCalls[1][0] as { issues: Array<{ path: string }> }
|
|
103
|
+
).issues.map((issue) => issue.path);
|
|
104
|
+
expect(lifecyclePaths).toContain("daemon_event_id");
|
|
105
|
+
expect(lifecyclePaths).toContain("event_name");
|
|
106
|
+
|
|
107
|
+
// The planted sentinel (a field value) must be absent from all log args.
|
|
108
|
+
expect(JSON.stringify(warnCalls)).not.toInclude(sentinel);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("dynamic record keys in issue paths are redacted to *", () => {
|
|
112
|
+
const sentinel = "sentinel-user@example.com";
|
|
113
|
+
const invalidTurn = {
|
|
114
|
+
...turnEventSample,
|
|
115
|
+
// The client bag's keys come from an uncontrolled JSON metadata column;
|
|
116
|
+
// the superRefine emits issues at ['client', <key>], so a key carrying
|
|
117
|
+
// user text must be redacted from the logged path. The nested object
|
|
118
|
+
// value makes the bag invalid.
|
|
119
|
+
client: { [sentinel]: { nested: true } },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const result = validateWireEvents([invalidTurn], stubLog);
|
|
123
|
+
expect(result).toEqual({ checked: 1, invalid: 1, unknownTypes: [] });
|
|
124
|
+
expect(warnCalls).toHaveLength(1);
|
|
125
|
+
|
|
126
|
+
const bag = warnCalls[0][0] as { issues: Array<{ path: string }> };
|
|
127
|
+
// The dynamic key component is replaced with a literal `*`; the
|
|
128
|
+
// schema-defined depth-0 component (`client`) is kept.
|
|
129
|
+
expect(bag.issues.map((issue) => issue.path)).toContain("client.*");
|
|
130
|
+
expect(JSON.stringify(warnCalls)).not.toInclude(sentinel);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("unknown event types are reported and warned once per process per type", () => {
|
|
134
|
+
// A type the platform ingest endpoint has no serializer for: the server
|
|
135
|
+
// silently skips it. Uses a synthetic never-shipped discriminant so the
|
|
136
|
+
// test stays valid as real event types graduate into the wire contract.
|
|
137
|
+
const unknownEvent = {
|
|
138
|
+
type: "speculative_future_event",
|
|
139
|
+
daemon_event_id: "evt-unknown-1",
|
|
140
|
+
recorded_at: 1_700_000_000_020,
|
|
141
|
+
assistant_version: "1.2.3",
|
|
142
|
+
};
|
|
143
|
+
const first = validateWireEvents([unknownEvent], stubLog);
|
|
144
|
+
const second = validateWireEvents([unknownEvent], stubLog);
|
|
145
|
+
|
|
146
|
+
expect(first).toEqual({
|
|
147
|
+
checked: 0,
|
|
148
|
+
invalid: 0,
|
|
149
|
+
unknownTypes: ["speculative_future_event"],
|
|
150
|
+
});
|
|
151
|
+
expect(second.unknownTypes).toEqual(["speculative_future_event"]);
|
|
152
|
+
|
|
153
|
+
// Rate-limited: the warn fires exactly once across both calls.
|
|
154
|
+
expect(warnCalls).toHaveLength(1);
|
|
155
|
+
expect(warnCalls[0][0]).toEqual({ eventType: "speculative_future_event" });
|
|
156
|
+
});
|
|
157
|
+
});
|