@vellumai/assistant 0.4.18 → 0.4.20
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/runbook-trusted-contacts.md +5 -3
- package/package.json +1 -1
- package/src/__tests__/channel-approvals.test.ts +7 -1
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
- package/src/__tests__/daemon-server-session-init.test.ts +2 -0
- package/src/__tests__/gmail-integration.test.ts +13 -4
- package/src/__tests__/handle-user-message-secret-resume.test.ts +7 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -0
- package/src/__tests__/ingress-reconcile.test.ts +13 -5
- package/src/__tests__/mcp-cli.test.ts +1 -1
- package/src/__tests__/recording-intent-handler.test.ts +9 -1
- package/src/__tests__/send-endpoint-busy.test.ts +8 -2
- package/src/__tests__/sms-messaging-provider.test.ts +4 -0
- package/src/__tests__/system-prompt.test.ts +18 -2
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
- package/src/agent/loop.ts +324 -163
- package/src/cli/mcp.ts +81 -28
- package/src/config/bundled-skills/app-builder/SKILL.md +7 -5
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +6 -11
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -2
- package/src/config/bundled-skills/sms-setup/SKILL.md +8 -16
- package/src/config/bundled-skills/telegram-setup/SKILL.md +3 -3
- package/src/config/bundled-skills/trusted-contacts/SKILL.md +13 -25
- package/src/config/bundled-skills/twilio-setup/SKILL.md +13 -23
- package/src/config/system-prompt.ts +574 -518
- package/src/daemon/session-surfaces.ts +28 -0
- package/src/daemon/session.ts +255 -191
- package/src/daemon/tool-side-effects.ts +3 -13
- package/src/mcp/client.ts +2 -7
- package/src/security/secure-keys.ts +43 -3
- package/src/tools/apps/definitions.ts +5 -0
- package/src/tools/apps/executors.ts +18 -22
- package/src/tools/terminal/safe-env.ts +7 -0
- package/src/__tests__/response-tier.test.ts +0 -195
- package/src/daemon/response-tier.ts +0 -250
|
@@ -10,15 +10,11 @@
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
12
|
import { updatePublishedAppDeployment } from '../services/published-app-updater.js';
|
|
13
|
-
import { openAppViaSurface } from '../tools/apps/open-proxy.js';
|
|
14
13
|
import type { ToolExecutionResult } from '../tools/types.js';
|
|
15
14
|
import { getWorkspaceDir } from '../util/platform.js';
|
|
16
15
|
import { isDoordashCommand, updateDoordashProgress } from './doordash-steps.js';
|
|
17
16
|
import type { ServerMessage } from './ipc-protocol.js';
|
|
18
|
-
import {
|
|
19
|
-
refreshSurfacesForApp,
|
|
20
|
-
surfaceProxyResolver,
|
|
21
|
-
} from './session-surfaces.js';
|
|
17
|
+
import { refreshSurfacesForApp } from './session-surfaces.js';
|
|
22
18
|
import type { ToolSetupContext } from './session-tool-setup.js';
|
|
23
19
|
|
|
24
20
|
// ── Types ────────────────────────────────────────────────────────────
|
|
@@ -37,20 +33,16 @@ export type PostExecutionHook = (
|
|
|
37
33
|
|
|
38
34
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
39
35
|
|
|
40
|
-
/** Shared logic for refreshing app surfaces, broadcasting changes, and auto-
|
|
36
|
+
/** Shared logic for refreshing app surfaces, broadcasting changes, and triggering auto-deploy. */
|
|
41
37
|
function handleAppChange(
|
|
42
38
|
ctx: ToolSetupContext,
|
|
43
39
|
appId: string,
|
|
44
40
|
broadcastToAllClients: ((msg: ServerMessage) => void) | undefined,
|
|
45
41
|
opts?: { fileChange?: boolean; status?: string },
|
|
46
42
|
): void {
|
|
47
|
-
|
|
43
|
+
refreshSurfacesForApp(ctx, appId, opts);
|
|
48
44
|
broadcastToAllClients?.({ type: 'app_files_changed', appId });
|
|
49
45
|
void updatePublishedAppDeployment(appId);
|
|
50
|
-
if (!refreshed && !ctx.hasNoClient && !ctx.headlessLock) {
|
|
51
|
-
const resolver = (tn: string, pi: Record<string, unknown>) => surfaceProxyResolver(ctx, tn, pi);
|
|
52
|
-
void openAppViaSurface(appId, resolver);
|
|
53
|
-
}
|
|
54
46
|
}
|
|
55
47
|
|
|
56
48
|
// ── Registry ─────────────────────────────────────────────────────────
|
|
@@ -82,7 +74,6 @@ registerHook('app_create', (_name, _input, result, { ctx, broadcastToAllClients
|
|
|
82
74
|
});
|
|
83
75
|
|
|
84
76
|
// Auto-refresh workspace surfaces when a persisted app is updated.
|
|
85
|
-
// If no surface is currently showing the app, auto-open it.
|
|
86
77
|
registerHook('app_update', (_name, input, _result, { ctx, broadcastToAllClients }) => {
|
|
87
78
|
const appId = input.app_id as string | undefined;
|
|
88
79
|
if (appId) {
|
|
@@ -109,7 +100,6 @@ registerHook(
|
|
|
109
100
|
);
|
|
110
101
|
|
|
111
102
|
// Auto-refresh workspace surfaces when app files are edited.
|
|
112
|
-
// If no surface is currently showing the app, auto-open it.
|
|
113
103
|
registerHook(
|
|
114
104
|
['app_file_edit', 'app_file_write'],
|
|
115
105
|
(_name, input, _result, { ctx, broadcastToAllClients }) => {
|
package/src/mcp/client.ts
CHANGED
|
@@ -30,15 +30,13 @@ export class McpClient {
|
|
|
30
30
|
private transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null = null;
|
|
31
31
|
private connected = false;
|
|
32
32
|
private oauthProvider: McpOAuthProvider | null = null;
|
|
33
|
-
private quiet: boolean;
|
|
34
33
|
|
|
35
34
|
get isConnected(): boolean {
|
|
36
35
|
return this.connected;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
constructor(serverId: string
|
|
38
|
+
constructor(serverId: string) {
|
|
40
39
|
this.serverId = serverId;
|
|
41
|
-
this.quiet = opts?.quiet ?? false;
|
|
42
40
|
this.client = new Client({
|
|
43
41
|
name: 'vellum-assistant',
|
|
44
42
|
version: '1.0.0',
|
|
@@ -61,7 +59,7 @@ export class McpClient {
|
|
|
61
59
|
}
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
|
|
62
|
+
log.info({ serverId: this.serverId }, 'Connecting to MCP server');
|
|
65
63
|
this.transport = this.createTransport(transportConfig);
|
|
66
64
|
|
|
67
65
|
try {
|
|
@@ -82,13 +80,11 @@ export class McpClient {
|
|
|
82
80
|
if (isAuthError) {
|
|
83
81
|
// Auth-related — user can run `vellum mcp auth <name>` to authenticate.
|
|
84
82
|
log.info({ serverId: this.serverId, err }, 'MCP server requires authentication');
|
|
85
|
-
if (!this.quiet) console.log(`[MCP] Server "${this.serverId}" requires authentication. Run "vellum mcp auth ${this.serverId}" to authenticate.`);
|
|
86
83
|
return;
|
|
87
84
|
}
|
|
88
85
|
|
|
89
86
|
// Non-auth error (DNS, TLS, timeout, etc.) — log and re-throw
|
|
90
87
|
log.error({ serverId: this.serverId, err }, 'MCP server connection failed');
|
|
91
|
-
if (!this.quiet) console.error(`[MCP] Server "${this.serverId}" connection failed: ${err instanceof Error ? err.message : err}`);
|
|
92
88
|
throw err;
|
|
93
89
|
}
|
|
94
90
|
|
|
@@ -96,7 +92,6 @@ export class McpClient {
|
|
|
96
92
|
}
|
|
97
93
|
|
|
98
94
|
this.connected = true;
|
|
99
|
-
if (!this.quiet) console.log(`[MCP] Server "${this.serverId}" connected successfully`);
|
|
100
95
|
log.info({ serverId: this.serverId }, 'MCP client connected');
|
|
101
96
|
}
|
|
102
97
|
|
|
@@ -148,11 +148,25 @@ export function getSecureKey(account: string): string | undefined {
|
|
|
148
148
|
* Returns `true` on success, `false` on failure.
|
|
149
149
|
*/
|
|
150
150
|
export function setSecureKey(account: string, value: string): boolean {
|
|
151
|
-
|
|
151
|
+
const result = withKeychainFallback(
|
|
152
152
|
() => keychain.setKey(account, value),
|
|
153
153
|
() => encryptedStore.setKey(account, value),
|
|
154
154
|
false,
|
|
155
155
|
);
|
|
156
|
+
// When writing to the encrypted store after a keychain downgrade, clean up
|
|
157
|
+
// any stale keychain entry so the gateway's credential-reader (which tries
|
|
158
|
+
// keychain first) does not read an outdated value.
|
|
159
|
+
if (result && downgradedFromKeychain && getBackend() === "encrypted") {
|
|
160
|
+
keychainMissCache.delete(account);
|
|
161
|
+
try {
|
|
162
|
+
// Only attempt deletion if the key actually exists in keychain to
|
|
163
|
+
// avoid spawning a subprocess on every write.
|
|
164
|
+
if (keychain.getKey(account) !== undefined) {
|
|
165
|
+
keychain.deleteKey(account);
|
|
166
|
+
}
|
|
167
|
+
} catch { /* best-effort */ }
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
/**
|
|
@@ -278,7 +292,22 @@ export async function setSecureKeyAsync(
|
|
|
278
292
|
value: string,
|
|
279
293
|
): Promise<boolean> {
|
|
280
294
|
const backend = await getBackendAsync();
|
|
281
|
-
if (backend === "encrypted")
|
|
295
|
+
if (backend === "encrypted") {
|
|
296
|
+
const result = encryptedStore.setKey(account, value);
|
|
297
|
+
// Clean up stale keychain entry (mirrors setSecureKey logic).
|
|
298
|
+
if (result && downgradedFromKeychain) {
|
|
299
|
+
keychainMissCache.delete(account);
|
|
300
|
+
try {
|
|
301
|
+
// Only attempt deletion if the key actually exists in keychain to
|
|
302
|
+
// avoid spawning a subprocess on every write.
|
|
303
|
+
const exists = await keychain.getKeyAsync(account);
|
|
304
|
+
if (exists !== undefined) {
|
|
305
|
+
await keychain.deleteKeyAsync(account);
|
|
306
|
+
}
|
|
307
|
+
} catch { /* best-effort */ }
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
282
311
|
if (backend !== "keychain") return false;
|
|
283
312
|
|
|
284
313
|
const result = await keychain.setKeyAsync(account, value);
|
|
@@ -288,7 +317,18 @@ export async function setSecureKeyAsync(
|
|
|
288
317
|
);
|
|
289
318
|
resolvedBackend = "encrypted";
|
|
290
319
|
downgradedFromKeychain = true;
|
|
291
|
-
|
|
320
|
+
const fallbackResult = encryptedStore.setKey(account, value);
|
|
321
|
+
// Clean up stale keychain entry after runtime downgrade
|
|
322
|
+
if (fallbackResult) {
|
|
323
|
+
keychainMissCache.delete(account);
|
|
324
|
+
try {
|
|
325
|
+
const exists = await keychain.getKeyAsync(account);
|
|
326
|
+
if (exists !== undefined) {
|
|
327
|
+
await keychain.deleteKeyAsync(account);
|
|
328
|
+
}
|
|
329
|
+
} catch { /* best-effort */ }
|
|
330
|
+
}
|
|
331
|
+
return fallbackResult;
|
|
292
332
|
}
|
|
293
333
|
return result;
|
|
294
334
|
}
|
|
@@ -43,6 +43,11 @@ const appOpenTool: Tool = {
|
|
|
43
43
|
type: 'string',
|
|
44
44
|
description: 'The ID of the app to open',
|
|
45
45
|
},
|
|
46
|
+
open_mode: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
enum: ['preview', 'workspace'],
|
|
49
|
+
description: "Display mode. 'preview' shows an inline preview card in chat. 'workspace' opens the full app in a workspace panel. Defaults to 'workspace'.",
|
|
50
|
+
},
|
|
46
51
|
},
|
|
47
52
|
required: ['app_id'],
|
|
48
53
|
},
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import { setHomeBaseAppLink } from '../../home-base/app-link-store.js';
|
|
12
12
|
import type { AppDefinition } from '../../memory/app-store.js';
|
|
13
13
|
import type { EditEngineResult } from '../../memory/app-store.js';
|
|
14
|
-
import { openAppViaSurface } from './open-proxy.js';
|
|
15
14
|
|
|
16
15
|
// ---------------------------------------------------------------------------
|
|
17
16
|
// Shared result type
|
|
@@ -123,33 +122,30 @@ export async function executeAppCreate(
|
|
|
123
122
|
setHomeBaseAppLink(app.id, 'personalized');
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
//
|
|
125
|
+
// Emit the inline preview card via the proxy without opening a workspace panel.
|
|
126
|
+
// open_mode: "preview" signals to the client that this should be shown inline only.
|
|
127
127
|
if (autoOpen && proxyToolResolver) {
|
|
128
128
|
const createPreview = { ...(preview ?? {}), context: 'app_create' as const };
|
|
129
|
-
const extraInput = { preview: createPreview };
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
const extraInput = { preview: createPreview, open_mode: 'preview' };
|
|
130
|
+
try {
|
|
131
|
+
const openResult = await proxyToolResolver('app_open', { app_id: app.id, ...extraInput });
|
|
132
|
+
if (openResult.isError) {
|
|
133
|
+
return {
|
|
134
|
+
content: JSON.stringify({ ...app, auto_opened: false, auto_open_error: openResult.content }),
|
|
135
|
+
isError: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
content: JSON.stringify({ ...app, auto_opened: true, open_result: openResult.content }),
|
|
140
|
+
isError: false,
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
// Preview emission failure is non-fatal — the app was created successfully.
|
|
135
144
|
return {
|
|
136
|
-
content: JSON.stringify({
|
|
137
|
-
...app,
|
|
138
|
-
auto_opened: true,
|
|
139
|
-
open_result: openResultText,
|
|
140
|
-
}),
|
|
145
|
+
content: JSON.stringify({ ...app, auto_opened: false, auto_open_error: 'Failed to auto-open app. Use app_open to open it manually.' }),
|
|
141
146
|
isError: false,
|
|
142
147
|
};
|
|
143
148
|
}
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
content: JSON.stringify({
|
|
147
|
-
...app,
|
|
148
|
-
auto_opened: false,
|
|
149
|
-
auto_open_error: openResultText,
|
|
150
|
-
}),
|
|
151
|
-
isError: false,
|
|
152
|
-
};
|
|
153
149
|
}
|
|
154
150
|
|
|
155
151
|
return { content: JSON.stringify(app), isError: false };
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Shared by the sandbox bash tool and skill sandbox runner.
|
|
7
7
|
*/
|
|
8
8
|
import { getGatewayInternalBaseUrl, getIngressPublicBaseUrl } from '../../config/env.js';
|
|
9
|
+
import { isSigningKeyInitialized, mintEdgeRelayToken } from '../../runtime/auth/token-service.js';
|
|
9
10
|
|
|
10
11
|
const SAFE_ENV_VARS = [
|
|
11
12
|
'PATH',
|
|
@@ -42,5 +43,11 @@ export function buildSanitizedEnv(): Record<string, string> {
|
|
|
42
43
|
// back to the internal base so commands remain functional in local-only mode.
|
|
43
44
|
const publicGatewayBase = getIngressPublicBaseUrl()?.replace(/\/+$/, '');
|
|
44
45
|
env.GATEWAY_BASE_URL = publicGatewayBase || internalGatewayBase;
|
|
46
|
+
// Mint a short-lived JWT for gateway authentication when the signing key
|
|
47
|
+
// is available (daemon context). CLI-only contexts without daemon startup
|
|
48
|
+
// will not have the key initialized — gracefully skip.
|
|
49
|
+
if (isSigningKeyInitialized()) {
|
|
50
|
+
env.GATEWAY_AUTH_TOKEN = mintEdgeRelayToken();
|
|
51
|
+
}
|
|
45
52
|
return env;
|
|
46
53
|
}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
classifyResponseTierAsync,
|
|
5
|
-
classifyResponseTierDetailed,
|
|
6
|
-
resolveWithHint,
|
|
7
|
-
type SessionTierHint,
|
|
8
|
-
type TierClassification,
|
|
9
|
-
} from "../daemon/response-tier.js";
|
|
10
|
-
|
|
11
|
-
// ── classifyResponseTierDetailed ──────────────────────────────────────
|
|
12
|
-
|
|
13
|
-
describe("classifyResponseTierDetailed", () => {
|
|
14
|
-
describe("high confidence → high tier", () => {
|
|
15
|
-
test("long messages (>500 chars)", () => {
|
|
16
|
-
const result = classifyResponseTierDetailed("x".repeat(501), 0);
|
|
17
|
-
expect(result.tier).toBe("high");
|
|
18
|
-
expect(result.confidence).toBe("high");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("code fences", () => {
|
|
22
|
-
const result = classifyResponseTierDetailed(
|
|
23
|
-
"Here is some code:\n```\nconst x = 1;\n```",
|
|
24
|
-
0,
|
|
25
|
-
);
|
|
26
|
-
expect(result.tier).toBe("high");
|
|
27
|
-
expect(result.confidence).toBe("high");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("file paths", () => {
|
|
31
|
-
const result = classifyResponseTierDetailed("Look at ./src/index.ts", 0);
|
|
32
|
-
expect(result.tier).toBe("high");
|
|
33
|
-
expect(result.confidence).toBe("high");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("multi-paragraph", () => {
|
|
37
|
-
const result = classifyResponseTierDetailed(
|
|
38
|
-
"First paragraph.\n\nSecond paragraph.",
|
|
39
|
-
0,
|
|
40
|
-
);
|
|
41
|
-
expect(result.tier).toBe("high");
|
|
42
|
-
expect(result.confidence).toBe("high");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("build keyword imperatives", () => {
|
|
46
|
-
const result = classifyResponseTierDetailed(
|
|
47
|
-
"Build a REST API for user management",
|
|
48
|
-
0,
|
|
49
|
-
);
|
|
50
|
-
expect(result.tier).toBe("high");
|
|
51
|
-
expect(result.confidence).toBe("high");
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("high confidence → low tier", () => {
|
|
56
|
-
test("pure greetings under 40 chars", () => {
|
|
57
|
-
const result = classifyResponseTierDetailed("hey", 0);
|
|
58
|
-
expect(result.tier).toBe("low");
|
|
59
|
-
expect(result.confidence).toBe("high");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("short messages without build keywords", () => {
|
|
63
|
-
const result = classifyResponseTierDetailed("sounds good", 0);
|
|
64
|
-
expect(result.tier).toBe("low");
|
|
65
|
-
expect(result.confidence).toBe("high");
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("low confidence → medium tier", () => {
|
|
70
|
-
test("questions with build keywords fall to medium/low-confidence", () => {
|
|
71
|
-
const result = classifyResponseTierDetailed(
|
|
72
|
-
"how do I build authentication?",
|
|
73
|
-
0,
|
|
74
|
-
);
|
|
75
|
-
expect(result.tier).toBe("medium");
|
|
76
|
-
expect(result.confidence).toBe("low");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("ambiguous medium-length message", () => {
|
|
80
|
-
const result = classifyResponseTierDetailed(
|
|
81
|
-
"what do you think about the current approach to handling errors in the codebase?",
|
|
82
|
-
0,
|
|
83
|
-
);
|
|
84
|
-
expect(result.tier).toBe("medium");
|
|
85
|
-
expect(result.confidence).toBe("low");
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
// ── resolveWithHint ───────────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
describe("resolveWithHint", () => {
|
|
93
|
-
const lowConfMedium: TierClassification = {
|
|
94
|
-
tier: "medium",
|
|
95
|
-
reason: "default",
|
|
96
|
-
confidence: "low",
|
|
97
|
-
};
|
|
98
|
-
const highConfLow: TierClassification = {
|
|
99
|
-
tier: "low",
|
|
100
|
-
reason: "short_no_keywords",
|
|
101
|
-
confidence: "high",
|
|
102
|
-
};
|
|
103
|
-
const highConfHigh: TierClassification = {
|
|
104
|
-
tier: "high",
|
|
105
|
-
reason: "build_keyword",
|
|
106
|
-
confidence: "high",
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
test("high confidence: ignores hint that would downgrade", () => {
|
|
110
|
-
const hint: SessionTierHint = {
|
|
111
|
-
tier: "low",
|
|
112
|
-
turn: 5,
|
|
113
|
-
timestamp: Date.now(),
|
|
114
|
-
};
|
|
115
|
-
expect(resolveWithHint(highConfHigh, hint, 6)).toBe("high");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("high confidence: upgrades when hint is higher", () => {
|
|
119
|
-
const hint: SessionTierHint = {
|
|
120
|
-
tier: "medium",
|
|
121
|
-
turn: 5,
|
|
122
|
-
timestamp: Date.now(),
|
|
123
|
-
};
|
|
124
|
-
expect(resolveWithHint(highConfLow, hint, 6)).toBe("medium");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("high confidence: upgrades to high when hint is high", () => {
|
|
128
|
-
const hint: SessionTierHint = {
|
|
129
|
-
tier: "high",
|
|
130
|
-
turn: 5,
|
|
131
|
-
timestamp: Date.now(),
|
|
132
|
-
};
|
|
133
|
-
expect(resolveWithHint(highConfLow, hint, 6)).toBe("high");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("returns regex tier when no hint available", () => {
|
|
137
|
-
expect(resolveWithHint(lowConfMedium, null, 0)).toBe("medium");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("defers to hint when confidence is low and hint is fresh", () => {
|
|
141
|
-
const hint: SessionTierHint = {
|
|
142
|
-
tier: "high",
|
|
143
|
-
turn: 5,
|
|
144
|
-
timestamp: Date.now(),
|
|
145
|
-
};
|
|
146
|
-
expect(resolveWithHint(lowConfMedium, hint, 6)).toBe("high");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("ignores stale hint (too many turns old)", () => {
|
|
150
|
-
const hint: SessionTierHint = {
|
|
151
|
-
tier: "high",
|
|
152
|
-
turn: 0,
|
|
153
|
-
timestamp: Date.now(),
|
|
154
|
-
};
|
|
155
|
-
// 5 turns later exceeds HINT_MAX_TURN_AGE of 4
|
|
156
|
-
expect(resolveWithHint(lowConfMedium, hint, 5)).toBe("medium");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("ignores stale hint (too old by time)", () => {
|
|
160
|
-
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000 - 1;
|
|
161
|
-
const hint: SessionTierHint = {
|
|
162
|
-
tier: "high",
|
|
163
|
-
turn: 3,
|
|
164
|
-
timestamp: fiveMinutesAgo,
|
|
165
|
-
};
|
|
166
|
-
expect(resolveWithHint(lowConfMedium, hint, 4)).toBe("medium");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("uses hint at exact boundary (4 turns, within time)", () => {
|
|
170
|
-
const hint: SessionTierHint = {
|
|
171
|
-
tier: "high",
|
|
172
|
-
turn: 1,
|
|
173
|
-
timestamp: Date.now(),
|
|
174
|
-
};
|
|
175
|
-
// 5 - 1 = 4, which is not > 4, so hint is still valid
|
|
176
|
-
expect(resolveWithHint(lowConfMedium, hint, 5)).toBe("high");
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
// ── classifyResponseTierAsync ─────────────────────────────────────────
|
|
181
|
-
|
|
182
|
-
describe("classifyResponseTierAsync", () => {
|
|
183
|
-
test("returns null when no provider is available", async () => {
|
|
184
|
-
// getConfiguredProvider returns null when no API key is set
|
|
185
|
-
// We can't easily mock it here, but we can verify the function handles it
|
|
186
|
-
const result = await classifyResponseTierAsync(["hello"]);
|
|
187
|
-
// In test environment without a configured provider, should return null
|
|
188
|
-
expect(
|
|
189
|
-
result === undefined ||
|
|
190
|
-
result === "low" ||
|
|
191
|
-
result === "medium" ||
|
|
192
|
-
result === "high",
|
|
193
|
-
).toBe(true);
|
|
194
|
-
});
|
|
195
|
-
});
|