@vellumai/assistant 0.11.2-dev.202608052129.f237b1e → 0.11.2-dev.202608052324.a11da34
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/openapi.yaml +13 -1
- package/package.json +1 -1
- package/src/__tests__/conversation-agent-loop.test.ts +87 -0
- package/src/__tests__/conversation-group-tools.test.ts +32 -2
- package/src/__tests__/conversation-placement-promotion.test.ts +198 -0
- package/src/__tests__/custom-group-listing-visibility.test.ts +1 -1
- package/src/__tests__/profile-availability-incomplete.test.ts +113 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +166 -0
- package/src/cli.ts +1 -1
- package/src/daemon/conversation-agent-loop.ts +53 -29
- package/src/daemon/handlers/conversation-history.ts +1 -1
- package/src/persistence/conversation-crud.ts +47 -11
- package/src/persistence/conversation-plugin-facade.ts +14 -3
- package/src/persistence/conversation-queries.ts +131 -31
- package/src/persistence/conversation-types.ts +10 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +64 -18
- package/src/providers/inference/connection-availability.ts +48 -11
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +254 -0
- package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +57 -6
- package/src/runtime/routes/conversation-cli-routes.ts +10 -12
- package/src/runtime/routes/conversation-list-routes.ts +27 -10
- package/src/runtime/routes/debug-routes.ts +4 -1
- package/src/runtime/routes/default-provider-routes.ts +7 -11
- package/src/runtime/routes/inference-profile-availability-guard.ts +6 -0
- package/src/runtime/routes/inference-profiles-routes.ts +2 -9
- package/src/tools/conversation-groups/move_to_group.ts +8 -2
- package/src/tools/skills/scaffold-managed.ts +82 -0
package/openapi.yaml
CHANGED
|
@@ -6585,6 +6585,16 @@ paths:
|
|
|
6585
6585
|
description:
|
|
6586
6586
|
Filter by origin channel. When provided, only conversations with this exact origin_channel value are
|
|
6587
6587
|
returned. Omit to include all channels.
|
|
6588
|
+
- name: groupId
|
|
6589
|
+
in: query
|
|
6590
|
+
required: false
|
|
6591
|
+
schema:
|
|
6592
|
+
type: string
|
|
6593
|
+
description:
|
|
6594
|
+
Filter to a single group, so each sidebar section can load independently of the paginated list. Pass
|
|
6595
|
+
"system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A
|
|
6596
|
+
group-scoped request is ordered by the user's arrangement (display order, then recency) and never has pinned
|
|
6597
|
+
rows appended to it. Omit to span every group.
|
|
6588
6598
|
responses:
|
|
6589
6599
|
"200":
|
|
6590
6600
|
description: Successful response
|
|
@@ -34586,13 +34596,14 @@ components:
|
|
|
34586
34596
|
type: string
|
|
34587
34597
|
enum:
|
|
34588
34598
|
- ok
|
|
34589
|
-
-
|
|
34599
|
+
- incomplete
|
|
34590
34600
|
- missing_connection
|
|
34591
34601
|
- missing_credential
|
|
34592
34602
|
- provider_mismatch
|
|
34593
34603
|
- unsupported_auth
|
|
34594
34604
|
- vellum_unauthenticated
|
|
34595
34605
|
- unknown
|
|
34606
|
+
- missing_default
|
|
34596
34607
|
message:
|
|
34597
34608
|
type: string
|
|
34598
34609
|
required:
|
|
@@ -34818,6 +34829,7 @@ components:
|
|
|
34818
34829
|
type: string
|
|
34819
34830
|
enum:
|
|
34820
34831
|
- ok
|
|
34832
|
+
- incomplete
|
|
34821
34833
|
- missing_connection
|
|
34822
34834
|
- missing_credential
|
|
34823
34835
|
- provider_mismatch
|
package/package.json
CHANGED
|
@@ -782,6 +782,7 @@ function makeCtx(
|
|
|
782
782
|
channelCapabilities: undefined,
|
|
783
783
|
commandIntent: undefined,
|
|
784
784
|
trustContext: undefined,
|
|
785
|
+
toolsDisabledDepth: 0,
|
|
785
786
|
|
|
786
787
|
allowedToolNames: undefined,
|
|
787
788
|
preactivatedSkillIds: undefined,
|
|
@@ -1187,6 +1188,92 @@ describe("session-agent-loop", () => {
|
|
|
1187
1188
|
runAgentLoopImpl(ctx, "hello", "msg-1", () => {}),
|
|
1188
1189
|
).rejects.toThrow("runAgentLoop called without prior persistUserMessage");
|
|
1189
1190
|
});
|
|
1191
|
+
|
|
1192
|
+
test("defers workspace Git work when tools are disabled", async () => {
|
|
1193
|
+
const ensureInitialized = mock(async () => {});
|
|
1194
|
+
const commitTurnChanges = mock(async () => {});
|
|
1195
|
+
const ctx = makeCtx({
|
|
1196
|
+
toolsDisabledDepth: 1,
|
|
1197
|
+
getWorkspaceGitService: () => ({ ensureInitialized }),
|
|
1198
|
+
commitTurnChanges,
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
|
|
1202
|
+
|
|
1203
|
+
expect(ensureInitialized).not.toHaveBeenCalled();
|
|
1204
|
+
expect(commitTurnChanges).not.toHaveBeenCalled();
|
|
1205
|
+
|
|
1206
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
1207
|
+
|
|
1208
|
+
expect(commitTurnChanges).toHaveBeenCalledTimes(1);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test("leaves a deferred commit to an immediate follow-up turn", async () => {
|
|
1212
|
+
const commitTurnChanges = mock(async () => {});
|
|
1213
|
+
const ctx = makeCtx({
|
|
1214
|
+
toolsDisabledDepth: 1,
|
|
1215
|
+
commitTurnChanges,
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
|
|
1219
|
+
ctx.setProcessing(true);
|
|
1220
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
1221
|
+
|
|
1222
|
+
expect(commitTurnChanges).not.toHaveBeenCalled();
|
|
1223
|
+
ctx.setProcessing(false);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test("initializes workspace Git before hooks when tools can run", async () => {
|
|
1227
|
+
let initialized = false;
|
|
1228
|
+
const ensureInitialized = mock(async () => {
|
|
1229
|
+
initialized = true;
|
|
1230
|
+
});
|
|
1231
|
+
const commitTurnChanges = mock(async () => {});
|
|
1232
|
+
const initializedDuringHook: boolean[] = [];
|
|
1233
|
+
registerPlugin({
|
|
1234
|
+
manifest: {
|
|
1235
|
+
name: "test-workspace-git-ready-before-hook",
|
|
1236
|
+
version: "1.0.0",
|
|
1237
|
+
},
|
|
1238
|
+
hooks: {
|
|
1239
|
+
"user-prompt-submit": async () => {
|
|
1240
|
+
initializedDuringHook.push(initialized);
|
|
1241
|
+
},
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
const ctx = makeCtx({
|
|
1245
|
+
getWorkspaceGitService: () => ({ ensureInitialized }),
|
|
1246
|
+
commitTurnChanges,
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
|
|
1250
|
+
|
|
1251
|
+
expect(ensureInitialized).toHaveBeenCalledTimes(1);
|
|
1252
|
+
expect(commitTurnChanges).toHaveBeenCalledTimes(1);
|
|
1253
|
+
expect(initializedDuringHook).toEqual([true]);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
test("continues a tool-capable turn when workspace Git initialization fails", async () => {
|
|
1257
|
+
const ensureInitialized = mock(async () => {
|
|
1258
|
+
throw new Error("simulated Git initialization failure");
|
|
1259
|
+
});
|
|
1260
|
+
const events: AssistantEvent[] = [];
|
|
1261
|
+
const ctx = makeCtx({
|
|
1262
|
+
getWorkspaceGitService: () => ({ ensureInitialized }),
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
await runAgentLoopImpl(ctx, "hello", "msg-1", (event) =>
|
|
1266
|
+
events.push(event),
|
|
1267
|
+
);
|
|
1268
|
+
|
|
1269
|
+
expect(ensureInitialized).toHaveBeenCalledTimes(1);
|
|
1270
|
+
expect(events.some((event) => event.type === "message_complete")).toBe(
|
|
1271
|
+
true,
|
|
1272
|
+
);
|
|
1273
|
+
expect(events.some((event) => event.type === "conversation_error")).toBe(
|
|
1274
|
+
false,
|
|
1275
|
+
);
|
|
1276
|
+
});
|
|
1190
1277
|
});
|
|
1191
1278
|
|
|
1192
1279
|
describe("disk pressure injection context", () => {
|
|
@@ -257,7 +257,7 @@ describe("conversation_move_to_group tool", () => {
|
|
|
257
257
|
expect(result.content).toContain("no conversation found");
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
-
test("
|
|
260
|
+
test("tells the model a custom group surfaces a hidden conversation", async () => {
|
|
261
261
|
createConversation({ id: "conv-bg", conversationType: "background" });
|
|
262
262
|
await executeConversationGroupCreate({ name: "Visible" }, ctx);
|
|
263
263
|
|
|
@@ -267,7 +267,37 @@ describe("conversation_move_to_group tool", () => {
|
|
|
267
267
|
);
|
|
268
268
|
|
|
269
269
|
expect(result.isError).toBe(false);
|
|
270
|
-
expect(result.content).toContain("
|
|
270
|
+
expect(result.content).toContain("surfaces it into the sidebar");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("does not claim a move to Recents surfaces a hidden conversation", async () => {
|
|
274
|
+
// Recents is a removal target, not a promotion: the write path leaves an
|
|
275
|
+
// unpromoted background row hidden there, so saying otherwise would report
|
|
276
|
+
// an outcome to the model that did not happen.
|
|
277
|
+
createConversation({ id: "conv-bg-all", conversationType: "background" });
|
|
278
|
+
|
|
279
|
+
const result = await executeConversationMoveToGroup(
|
|
280
|
+
{ group: "system:all", conversation_id: "conv-bg-all" },
|
|
281
|
+
ctx,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(result.isError).toBe(false);
|
|
285
|
+
expect(result.content).not.toContain("surfaces it into the sidebar");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("does not claim a move to Background surfaces a hidden conversation", async () => {
|
|
289
|
+
createConversation({
|
|
290
|
+
id: "conv-bg-demote",
|
|
291
|
+
conversationType: "background",
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const result = await executeConversationMoveToGroup(
|
|
295
|
+
{ group: "system:background", conversation_id: "conv-bg-demote" },
|
|
296
|
+
ctx,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
expect(result.isError).toBe(false);
|
|
300
|
+
expect(result.content).not.toContain("surfaces it into the sidebar");
|
|
271
301
|
});
|
|
272
302
|
|
|
273
303
|
test("rejects missing group", async () => {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filing a background or scheduled conversation into a section the user reads
|
|
3
|
+
* promotes it durably.
|
|
4
|
+
*
|
|
5
|
+
* Placement into Pinned or a custom group stamps `surfaced_at`, so the
|
|
6
|
+
* promotion outlives the placement that caused it: taking the conversation
|
|
7
|
+
* back out returns it to Recents rather than hiding it. Demotion is the
|
|
8
|
+
* mirror image, and filing into `system:background` / `system:scheduled`
|
|
9
|
+
* clears the stamp.
|
|
10
|
+
*
|
|
11
|
+
* The cases below pin both edges. Under-promoting hides a conversation the
|
|
12
|
+
* user deliberately filed somewhere; over-promoting drags hidden automation
|
|
13
|
+
* into the sidebar, so the "what promotion must not do" block is as
|
|
14
|
+
* load-bearing as the rest.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
batchSetDisplayOrders,
|
|
21
|
+
createConversation,
|
|
22
|
+
} from "../persistence/conversation-crud.js";
|
|
23
|
+
import { listConversations } from "../persistence/conversation-queries.js";
|
|
24
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
25
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
26
|
+
import { createGroup } from "../persistence/group-crud.js";
|
|
27
|
+
import { rawRun } from "../persistence/raw-query.js";
|
|
28
|
+
import { conversations } from "../persistence/schema/index.js";
|
|
29
|
+
|
|
30
|
+
await initializeDb();
|
|
31
|
+
|
|
32
|
+
function seedRoutedBackground(title: string, type: "background" | "scheduled") {
|
|
33
|
+
const conv = createConversation({ title, conversationType: type });
|
|
34
|
+
rawRun(
|
|
35
|
+
"test:routeToSystemBucket",
|
|
36
|
+
"UPDATE conversations SET group_id = ? WHERE id = ?",
|
|
37
|
+
type === "scheduled" ? "system:scheduled" : "system:background",
|
|
38
|
+
conv.id,
|
|
39
|
+
);
|
|
40
|
+
return conv;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Titles the sidebar's standard listing would render. */
|
|
44
|
+
function visibleTitles(): string[] {
|
|
45
|
+
return listConversations({ limit: 100 }).map((c) => c.title ?? "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function titlesInGroup(groupId: string): string[] {
|
|
49
|
+
return listConversations({ limit: 100, groupId }).map((c) => c.title ?? "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
getDb().delete(conversations).run();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("pinning a background conversation", () => {
|
|
57
|
+
test("shows it in Pinned", () => {
|
|
58
|
+
const conv = seedRoutedBackground("bg-job", "background");
|
|
59
|
+
expect(visibleTitles()).toEqual([]);
|
|
60
|
+
|
|
61
|
+
batchSetDisplayOrders([
|
|
62
|
+
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("unpinning lands it in Recents rather than hiding it again", () => {
|
|
69
|
+
const conv = seedRoutedBackground("bg-job", "background");
|
|
70
|
+
batchSetDisplayOrders([
|
|
71
|
+
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
batchSetDisplayOrders([
|
|
75
|
+
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("filing a background conversation into a custom group", () => {
|
|
83
|
+
test("shows it in that group", () => {
|
|
84
|
+
const group = createGroup("Car Chat");
|
|
85
|
+
const conv = seedRoutedBackground("bg-job", "background");
|
|
86
|
+
|
|
87
|
+
batchSetDisplayOrders([
|
|
88
|
+
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
expect(titlesInGroup(group.id)).toEqual(["bg-job"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("removing it from the group lands it in Recents rather than losing it", () => {
|
|
95
|
+
const group = createGroup("Car Chat");
|
|
96
|
+
const conv = seedRoutedBackground("bg-job", "background");
|
|
97
|
+
batchSetDisplayOrders([
|
|
98
|
+
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
batchSetDisplayOrders([
|
|
102
|
+
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
106
|
+
expect(visibleTitles()).toEqual(["bg-job"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("applies to scheduled conversations too", () => {
|
|
110
|
+
const group = createGroup("Morning Briefs");
|
|
111
|
+
const conv = seedRoutedBackground("sched-job", "scheduled");
|
|
112
|
+
|
|
113
|
+
batchSetDisplayOrders([
|
|
114
|
+
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
115
|
+
]);
|
|
116
|
+
batchSetDisplayOrders([
|
|
117
|
+
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
expect(titlesInGroup("system:all")).toEqual(["sched-job"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("what promotion must not do", () => {
|
|
125
|
+
test("filing into system:background still demotes", () => {
|
|
126
|
+
const conv = seedRoutedBackground("bg-job", "background");
|
|
127
|
+
// Promote first, so the demotion has something to undo.
|
|
128
|
+
batchSetDisplayOrders([
|
|
129
|
+
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
130
|
+
]);
|
|
131
|
+
expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
|
|
132
|
+
|
|
133
|
+
batchSetDisplayOrders([
|
|
134
|
+
{ id: conv.id, displayOrder: null, groupId: "system:background" },
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
expect(visibleTitles()).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a background conversation nobody placed anywhere stays hidden", () => {
|
|
141
|
+
seedRoutedBackground("untouched", "background");
|
|
142
|
+
|
|
143
|
+
expect(visibleTitles()).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("moving to system:all does not by itself promote", () => {
|
|
147
|
+
// Removal is not a promotion: a row that was never filed into a section
|
|
148
|
+
// the user reads must not become visible just by being moved out of its
|
|
149
|
+
// system bucket.
|
|
150
|
+
const conv = seedRoutedBackground("bg-job", "background");
|
|
151
|
+
|
|
152
|
+
batchSetDisplayOrders([
|
|
153
|
+
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
expect(visibleTitles()).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("unpinning through the legacy isPinned path still lands in Recents", () => {
|
|
160
|
+
// Old clients send `isPinned: false` with no `groupId`, and that branch
|
|
161
|
+
// restores the row's original system bucket to keep its provenance. It
|
|
162
|
+
// deliberately does not clear the promotion: unpinning is not a demotion,
|
|
163
|
+
// so the conversation belongs in Recents afterwards, the same as the
|
|
164
|
+
// modern path that sends `groupId: "system:all"`.
|
|
165
|
+
//
|
|
166
|
+
// The row therefore sits in `system:background` while remaining visible.
|
|
167
|
+
// That is the intended split: the bucket records where the conversation
|
|
168
|
+
// came from, `surfaced_at` records that the user promoted it, and only
|
|
169
|
+
// an explicit demotion clears the latter.
|
|
170
|
+
const conv = seedRoutedBackground("bg-job", "background");
|
|
171
|
+
batchSetDisplayOrders([
|
|
172
|
+
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
batchSetDisplayOrders([
|
|
176
|
+
{ id: conv.id, displayOrder: null, isPinned: false },
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
expect(visibleTitles()).toEqual(["bg-job"]);
|
|
180
|
+
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("standard conversations are not stamped as surfaced", () => {
|
|
184
|
+
// They are already visible, so stamping them would leave `surfaced_at`
|
|
185
|
+
// meaning nothing.
|
|
186
|
+
const group = createGroup("Car Chat");
|
|
187
|
+
const conv = createConversation("plain-chat");
|
|
188
|
+
|
|
189
|
+
batchSetDisplayOrders([
|
|
190
|
+
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
const row = getDb()
|
|
194
|
+
.all(`SELECT surfaced_at FROM conversations WHERE id = '${conv.id}'`)
|
|
195
|
+
.at(0) as { surfaced_at: number | null };
|
|
196
|
+
expect(row.surfaced_at).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -33,7 +33,7 @@ describe("custom-group standard-listing visibility", () => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
function listedIds(): string[] {
|
|
36
|
-
return listConversations(
|
|
36
|
+
return listConversations({ conversationType: "standard" }).map((c) => c.id);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
test("scheduled conversation in a custom group is listed", () => {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `computeProfileAvailability` on profiles that carry no dispatchable pair.
|
|
3
|
+
*
|
|
4
|
+
* A rung only wins if its profile carries both a provider and a model
|
|
5
|
+
* (`usableEntry` in config/llm-resolver.ts). A profile missing either is
|
|
6
|
+
* skipped and the call site resolves elsewhere, so the profiles read has to
|
|
7
|
+
* report that state rather than returning "nothing to judge", which clients
|
|
8
|
+
* render as healthy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
computeProfileAvailability,
|
|
15
|
+
isUnavailable,
|
|
16
|
+
} from "../providers/inference/connection-availability.js";
|
|
17
|
+
import { describeUnavailableProfile } from "../runtime/routes/inference-profile-availability-guard.js";
|
|
18
|
+
|
|
19
|
+
describe("incomplete profiles", () => {
|
|
20
|
+
test("no provider and no model reports both as missing", async () => {
|
|
21
|
+
const availability = await computeProfileAvailability({});
|
|
22
|
+
expect(availability?.status).toBe("incomplete");
|
|
23
|
+
expect(availability?.message).toContain("a provider and a model");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a model with no provider names the provider", async () => {
|
|
27
|
+
const availability = await computeProfileAvailability({
|
|
28
|
+
model: "claude-opus-5",
|
|
29
|
+
});
|
|
30
|
+
expect(availability?.status).toBe("incomplete");
|
|
31
|
+
expect(availability?.message).toContain("a provider");
|
|
32
|
+
expect(availability?.message).not.toContain("a provider and a model");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a provider with no model names the model", async () => {
|
|
36
|
+
const availability = await computeProfileAvailability({
|
|
37
|
+
provider: "anthropic",
|
|
38
|
+
});
|
|
39
|
+
expect(availability?.status).toBe("incomplete");
|
|
40
|
+
expect(availability?.message).toContain("a model");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// The destination is deliberately unnamed: which profile a call site falls
|
|
44
|
+
// back to depends on the call site, so the message states the effect only.
|
|
45
|
+
test("the message names the effect without naming a destination", async () => {
|
|
46
|
+
const availability = await computeProfileAvailability({});
|
|
47
|
+
expect(availability?.message).toContain("fall back to another profile");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("mix profiles", () => {
|
|
52
|
+
// A mix carries no provider or model of its own: the resolver expands it to
|
|
53
|
+
// a seeded arm and judges that arm. Reporting a mix as incomplete would put
|
|
54
|
+
// a warning on every working mix profile.
|
|
55
|
+
test("a mix still reports nothing to judge", async () => {
|
|
56
|
+
const availability = await computeProfileAvailability({
|
|
57
|
+
mix: [
|
|
58
|
+
{ profile: "balanced", weight: 1 },
|
|
59
|
+
{ profile: "quality", weight: 1 },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
expect(availability).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a mix that also names a provider and model is judged normally", async () => {
|
|
66
|
+
const availability = await computeProfileAvailability({
|
|
67
|
+
mix: [{ profile: "balanced", weight: 1 }],
|
|
68
|
+
provider: "anthropic",
|
|
69
|
+
model: "claude-opus-5",
|
|
70
|
+
});
|
|
71
|
+
expect(availability?.status).not.toBe("incomplete");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("selection guards", () => {
|
|
76
|
+
// The resolver skips an incomplete profile on every turn, so pinning one
|
|
77
|
+
// means the user's selection is not what runs. Before `incomplete` existed,
|
|
78
|
+
// a provider without a model reached the auto-resolve branch and could
|
|
79
|
+
// return `missing_connection`, which these guards already rejected; the new
|
|
80
|
+
// verdict has to keep that door shut rather than open it.
|
|
81
|
+
test("an incomplete profile counts as unavailable", async () => {
|
|
82
|
+
expect(isUnavailable(await computeProfileAvailability({}))).toBe(true);
|
|
83
|
+
expect(
|
|
84
|
+
isUnavailable(
|
|
85
|
+
await computeProfileAvailability({ provider: "anthropic" }),
|
|
86
|
+
),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a mix is still not judged, so it is not blocked", async () => {
|
|
91
|
+
expect(
|
|
92
|
+
isUnavailable(
|
|
93
|
+
await computeProfileAvailability({
|
|
94
|
+
mix: [{ profile: "balanced", weight: 1 }],
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// `describeUnavailableProfile` interpolates the provider, which an
|
|
101
|
+
// incomplete profile may not have. Its guidance must not leak "undefined".
|
|
102
|
+
test("the repair guidance names the fix without inventing a provider", async () => {
|
|
103
|
+
const availability = await computeProfileAvailability({});
|
|
104
|
+
const message = await describeUnavailableProfile({
|
|
105
|
+
availability: availability!,
|
|
106
|
+
provider: String(undefined),
|
|
107
|
+
repair: { kind: "repoint", profileName: "half-made" },
|
|
108
|
+
escapeHatch: false,
|
|
109
|
+
});
|
|
110
|
+
expect(message).not.toContain("undefined");
|
|
111
|
+
expect(message).toContain("provider and a model");
|
|
112
|
+
});
|
|
113
|
+
});
|