@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.
Files changed (27) hide show
  1. package/openapi.yaml +13 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/conversation-agent-loop.test.ts +87 -0
  4. package/src/__tests__/conversation-group-tools.test.ts +32 -2
  5. package/src/__tests__/conversation-placement-promotion.test.ts +198 -0
  6. package/src/__tests__/custom-group-listing-visibility.test.ts +1 -1
  7. package/src/__tests__/profile-availability-incomplete.test.ts +113 -0
  8. package/src/__tests__/scaffold-managed-skill-tool.test.ts +166 -0
  9. package/src/cli.ts +1 -1
  10. package/src/daemon/conversation-agent-loop.ts +53 -29
  11. package/src/daemon/handlers/conversation-history.ts +1 -1
  12. package/src/persistence/conversation-crud.ts +47 -11
  13. package/src/persistence/conversation-plugin-facade.ts +14 -3
  14. package/src/persistence/conversation-queries.ts +131 -31
  15. package/src/persistence/conversation-types.ts +10 -0
  16. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +64 -18
  17. package/src/providers/inference/connection-availability.ts +48 -11
  18. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +254 -0
  19. package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +57 -6
  20. package/src/runtime/routes/conversation-cli-routes.ts +10 -12
  21. package/src/runtime/routes/conversation-list-routes.ts +27 -10
  22. package/src/runtime/routes/debug-routes.ts +4 -1
  23. package/src/runtime/routes/default-provider-routes.ts +7 -11
  24. package/src/runtime/routes/inference-profile-availability-guard.ts +6 -0
  25. package/src/runtime/routes/inference-profiles-routes.ts +2 -9
  26. package/src/tools/conversation-groups/move_to_group.ts +8 -2
  27. 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
- - missing_default
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608052129.f237b1e",
3
+ "version": "0.11.2-dev.202608052324.a11da34",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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("notes hidden conversation types", async () => {
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("hidden from the sidebar");
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(undefined, "standard").map((c) => c.id);
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
+ });