@vellumai/assistant 0.10.3-dev.202606290949.0cf8e08 → 0.10.3-dev.202606291342.c7a83ea

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 (52) hide show
  1. package/openapi.yaml +54 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-token-service.test.ts +0 -1
  4. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -0
  5. package/src/__tests__/btw-routes.test.ts +0 -1
  6. package/src/__tests__/config-watcher-skill-reseed.test.ts +37 -110
  7. package/src/__tests__/config-watcher.test.ts +53 -30
  8. package/src/__tests__/secret-routes-platform-proxy.test.ts +8 -6
  9. package/src/__tests__/tool-preview-lifecycle.test.ts +66 -0
  10. package/src/__tests__/tool-start-timestamp.test.ts +63 -0
  11. package/src/api/events/tool-use-preview-start.ts +11 -0
  12. package/src/api/events/tool-use-start.ts +14 -0
  13. package/src/api/responses/conversation-message.ts +10 -1
  14. package/src/config/feature-flag-registry.json +8 -0
  15. package/src/contacts/__tests__/guardian-contact-reader.test.ts +6 -10
  16. package/src/contacts/__tests__/guardian-delivery-reader.test.ts +3 -14
  17. package/src/contacts/contact-store.ts +6 -6
  18. package/src/contacts/guardian-contact-reader.ts +4 -6
  19. package/src/contacts/guardian-delivery-reader.ts +21 -23
  20. package/src/contacts/notify-contacts-changed.ts +17 -0
  21. package/src/daemon/__tests__/meet-host-supervisor.test.ts +0 -3
  22. package/src/daemon/app-source-watcher.ts +71 -10
  23. package/src/daemon/config-watcher.ts +69 -45
  24. package/src/daemon/conversation-agent-loop-handlers.ts +77 -0
  25. package/src/daemon/conversation-store.ts +56 -25
  26. package/src/daemon/handlers/__tests__/config-channels.test.ts +17 -11
  27. package/src/daemon/handlers/config-channels.ts +2 -2
  28. package/src/daemon/handlers/shared.ts +2 -0
  29. package/src/daemon/lifecycle.ts +28 -12
  30. package/src/daemon/meet-host-supervisor.ts +9 -36
  31. package/src/daemon/server.ts +1 -240
  32. package/src/daemon/shutdown-handlers.ts +13 -1
  33. package/src/ipc/assistant-server.ts +47 -0
  34. package/src/ipc/skill-server.ts +42 -6
  35. package/src/memory/__tests__/conversation-row-batch-delete.test.ts +199 -0
  36. package/src/memory/__tests__/memory-retrospective-job.test.ts +10 -0
  37. package/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +5 -0
  38. package/src/memory/memory-retrospective-job.ts +12 -7
  39. package/src/memory/memory-retrospective-state.ts +43 -29
  40. package/src/persistence/conversation-crud.ts +136 -74
  41. package/src/persistence/conversation-row-batch-delete.ts +205 -0
  42. package/src/persistence/db-connection.ts +5 -1
  43. package/src/runtime/__tests__/channel-verification-service.test.ts +9 -5
  44. package/src/runtime/__tests__/is-guardian-bound-for-channel.test.ts +3 -3
  45. package/src/runtime/routes/__tests__/channel-verification-revoke.test.ts +17 -23
  46. package/src/runtime/routes/events-routes.ts +2 -2
  47. package/src/runtime/routes/migration-routes.ts +135 -0
  48. package/src/runtime/routes/secret-routes.ts +7 -6
  49. package/src/schedule/schedule-store.ts +216 -123
  50. package/src/util/sqlite-retry.ts +123 -0
  51. package/src/contacts/contact-events.ts +0 -21
  52. package/src/runtime/routes/secrets-deps.ts +0 -21
package/openapi.yaml CHANGED
@@ -16667,6 +16667,8 @@ paths:
16667
16667
  type: string
16668
16668
  startedAt:
16669
16669
  type: number
16670
+ previewStartedAt:
16671
+ type: number
16670
16672
  completedAt:
16671
16673
  type: number
16672
16674
  confirmationDecision:
@@ -17097,6 +17099,8 @@ paths:
17097
17099
  type: string
17098
17100
  startedAt:
17099
17101
  type: number
17102
+ previewStartedAt:
17103
+ type: number
17100
17104
  completedAt:
17101
17105
  type: number
17102
17106
  confirmationDecision:
@@ -18665,6 +18669,56 @@ paths:
18665
18669
  type: object
18666
18670
  "404":
18667
18671
  description: No job matches the given id.
18672
+ /v1/migrations/preflight-from-gcs:
18673
+ post:
18674
+ operationId: migrations_preflightfromgcs_post
18675
+ summary: Dry-run import analysis from a signed GCS URL
18676
+ description:
18677
+ Fetch a .vbundle archive from a signed GCS download URL and return a preflight report — what would change
18678
+ if the bundle were imported — without writing anything to disk. Enables `vellum teleport --dry-run` against
18679
+ local and docker targets.
18680
+ tags:
18681
+ - migrations
18682
+ requestBody:
18683
+ required: true
18684
+ content:
18685
+ application/json:
18686
+ schema:
18687
+ type: object
18688
+ properties:
18689
+ bundle_url:
18690
+ type: string
18691
+ format: uri
18692
+ description: Signed GCS GET URL for the bundle.
18693
+ required:
18694
+ - bundle_url
18695
+ responses:
18696
+ "200":
18697
+ description: Successful response
18698
+ content:
18699
+ application/json:
18700
+ schema:
18701
+ type: object
18702
+ properties:
18703
+ can_import:
18704
+ type: boolean
18705
+ summary:
18706
+ type: object
18707
+ properties: {}
18708
+ additionalProperties: {}
18709
+ files:
18710
+ type: array
18711
+ items: {}
18712
+ conflicts:
18713
+ type: array
18714
+ items: {}
18715
+ validation:
18716
+ type: object
18717
+ properties: {}
18718
+ additionalProperties: {}
18719
+ required:
18720
+ - can_import
18721
+ additionalProperties: false
18668
18722
  /v1/migrations/validate:
18669
18723
  post:
18670
18724
  operationId: migrations_validate_post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606290949.0cf8e08",
3
+ "version": "0.10.3-dev.202606291342.c7a83ea",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -37,7 +37,6 @@ mock.module("../contacts/guardian-delivery-reader.js", () => ({
37
37
  fakeGuardianDelivery ? [fakeGuardianDelivery] : null,
38
38
  guardianForChannel: (list: { principalId?: string | null }[]) => list[0],
39
39
  invalidateGuardianDeliveryCache: () => {},
40
- onContactChange: () => {},
41
40
  }));
42
41
 
43
42
  import { getDb } from "../persistence/db-connection.js";
@@ -116,6 +116,10 @@ mock.module("../persistence/conversation-crud.js", () => ({
116
116
  deleteMessageById: mock(() => {}),
117
117
  clearAll: mock(async () => ({ conversations: 0, messages: 0 })),
118
118
  deleteConversation: mock(() => ({ memoryIds: [] })),
119
+ deleteConversationGently: mock(async () => ({
120
+ segmentIds: [],
121
+ deletedSummaryIds: [],
122
+ })),
119
123
  deleteLastExchange: mock(() => 0),
120
124
  findAnalysisConversationFor: mock(() => null),
121
125
  findMostRecentRetrospectiveFor: mock(() => null),
@@ -103,7 +103,6 @@ mock.module("../daemon/conversation-store.js", () => ({
103
103
  conversationEntries: () => [][Symbol.iterator](),
104
104
  conversationIds: () => [][Symbol.iterator](),
105
105
  getConversationMap: () => new Map(),
106
- initConversationLifecycle: () => {},
107
106
  registerConversationFactory: () => {},
108
107
  getOrCreateActiveConversation: mockGetOrCreateConversation,
109
108
  getConversationOptions: () => undefined,
@@ -34,6 +34,11 @@ interface CapturedWatcher {
34
34
  const capturedWatchers: CapturedWatcher[] = [];
35
35
  let recursiveWatchAvailable = false;
36
36
 
37
+ // The skills watcher reacts by calling these directly; count the calls so tests
38
+ // can assert dispatch without driving real eviction or skill-memory reseeding.
39
+ let evictCalls = 0;
40
+ let skillsChangedCalls = 0;
41
+
37
42
  mock.module("node:fs", () => {
38
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
44
  const actual = require("node:fs");
@@ -114,6 +119,29 @@ mock.module("../signals/user-message.js", () => ({
114
119
  handleUserMessageSignal: () => {},
115
120
  }));
116
121
 
122
+ mock.module("../daemon/conversation-store.js", () => ({
123
+ evictConversationsForReload: () => {
124
+ evictCalls++;
125
+ },
126
+ }));
127
+
128
+ mock.module("../daemon/skill-memory-refresh.js", () => ({
129
+ refreshSkillCapabilityMemories: () => {
130
+ skillsChangedCalls++;
131
+ },
132
+ }));
133
+
134
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
135
+ publishIdentityChanged: () => {},
136
+ publishConfigChanged: () => {},
137
+ publishSoundsConfigUpdated: () => {},
138
+ publishAvatarChanged: () => {},
139
+ }));
140
+
141
+ mock.module("../platform/sync-identity.js", () => ({
142
+ syncIdentityNameToPlatform: () => {},
143
+ }));
144
+
117
145
  const { ConfigWatcher } = await import("../daemon/config-watcher.js");
118
146
 
119
147
  function findWatcher(dir: string): CapturedWatcher | undefined {
@@ -130,8 +158,6 @@ function capturedWatcherCount(dir: string): number {
130
158
 
131
159
  describe("ConfigWatcher skills watcher reseeding", () => {
132
160
  let watcher: InstanceType<typeof ConfigWatcher>;
133
- let evictCalls: number;
134
- let skillsChangedCalls: number;
135
161
 
136
162
  beforeEach(() => {
137
163
  capturedWatchers.length = 0;
@@ -152,18 +178,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
152
178
  });
153
179
 
154
180
  test("watched skill file changes evict conversations and refresh skill memories", async () => {
155
- watcher.start(
156
- () => {
157
- evictCalls++;
158
- },
159
- undefined,
160
- undefined,
161
- undefined,
162
- undefined,
163
- () => {
164
- skillsChangedCalls++;
165
- },
166
- );
181
+ watcher.start();
167
182
 
168
183
  const skillsWatcher = findWatcher(SKILLS_DIR);
169
184
  expect(skillsWatcher).toBeDefined();
@@ -178,18 +193,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
178
193
  test("recursive watcher ignores skipped dependency and staging paths", async () => {
179
194
  recursiveWatchAvailable = true;
180
195
 
181
- watcher.start(
182
- () => {
183
- evictCalls++;
184
- },
185
- undefined,
186
- undefined,
187
- undefined,
188
- undefined,
189
- () => {
190
- skillsChangedCalls++;
191
- },
192
- );
196
+ watcher.start();
193
197
 
194
198
  const skillsWatcher = findWatcher(SKILLS_DIR);
195
199
  expect(skillsWatcher).toBeDefined();
@@ -218,18 +222,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
218
222
  test("recursive watcher reloads skill memories for build output changes", async () => {
219
223
  recursiveWatchAvailable = true;
220
224
 
221
- watcher.start(
222
- () => {
223
- evictCalls++;
224
- },
225
- undefined,
226
- undefined,
227
- undefined,
228
- undefined,
229
- () => {
230
- skillsChangedCalls++;
231
- },
232
- );
225
+ watcher.start();
233
226
 
234
227
  const skillsWatcher = findWatcher(SKILLS_DIR);
235
228
  expect(skillsWatcher).toBeDefined();
@@ -242,18 +235,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
242
235
  });
243
236
 
244
237
  test("coalesces multiple skill file changes into one catalog refresh", async () => {
245
- watcher.start(
246
- () => {
247
- evictCalls++;
248
- },
249
- undefined,
250
- undefined,
251
- undefined,
252
- undefined,
253
- () => {
254
- skillsChangedCalls++;
255
- },
256
- );
238
+ watcher.start();
257
239
 
258
240
  const skillsWatcher = findWatcher(SKILLS_DIR);
259
241
  expect(skillsWatcher).toBeDefined();
@@ -271,18 +253,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
271
253
  const referencesDir = join(SKILLS_DIR, "example-skill", "references");
272
254
  mkdirSync(referencesDir, { recursive: true });
273
255
 
274
- watcher.start(
275
- () => {
276
- evictCalls++;
277
- },
278
- undefined,
279
- undefined,
280
- undefined,
281
- undefined,
282
- () => {
283
- skillsChangedCalls++;
284
- },
285
- );
256
+ watcher.start();
286
257
 
287
258
  const referencesWatcher = findWatcher(referencesDir);
288
259
  expect(referencesWatcher).toBeDefined();
@@ -305,18 +276,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
305
276
  mkdirSync(stagedSkillDir, { recursive: true });
306
277
  mkdirSync(referencesDir, { recursive: true });
307
278
 
308
- watcher.start(
309
- () => {
310
- evictCalls++;
311
- },
312
- undefined,
313
- undefined,
314
- undefined,
315
- undefined,
316
- () => {
317
- skillsChangedCalls++;
318
- },
319
- );
279
+ watcher.start();
320
280
 
321
281
  expect(findWatcher(nodeModulesDir)).toBeUndefined();
322
282
  expect(findWatcher(dependencyDir)).toBeUndefined();
@@ -353,18 +313,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
353
313
  mkdirSync(distDir, { recursive: true });
354
314
  mkdirSync(buildDir, { recursive: true });
355
315
 
356
- watcher.start(
357
- () => {
358
- evictCalls++;
359
- },
360
- undefined,
361
- undefined,
362
- undefined,
363
- undefined,
364
- () => {
365
- skillsChangedCalls++;
366
- },
367
- );
316
+ watcher.start();
368
317
 
369
318
  const distWatcher = findWatcher(distDir);
370
319
  const buildWatcher = findWatcher(buildDir);
@@ -392,18 +341,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
392
341
  const generatedDir = join(toolsDir, "generated");
393
342
  mkdirSync(skillDir, { recursive: true });
394
343
 
395
- watcher.start(
396
- () => {
397
- evictCalls++;
398
- },
399
- undefined,
400
- undefined,
401
- undefined,
402
- undefined,
403
- () => {
404
- skillsChangedCalls++;
405
- },
406
- );
344
+ watcher.start();
407
345
 
408
346
  const skillWatcher = findWatcher(skillDir);
409
347
  expect(skillWatcher).toBeDefined();
@@ -424,18 +362,7 @@ describe("ConfigWatcher skills watcher reseeding", () => {
424
362
  const deepDir = join(referencesDir, "deep");
425
363
  mkdirSync(deepDir, { recursive: true });
426
364
 
427
- watcher.start(
428
- () => {
429
- evictCalls++;
430
- },
431
- undefined,
432
- undefined,
433
- undefined,
434
- undefined,
435
- () => {
436
- skillsChangedCalls++;
437
- },
438
- );
365
+ watcher.start();
439
366
 
440
367
  expect(findWatcher(referencesDir)).toBeDefined();
441
368
  expect(findWatcher(deepDir)).toBeDefined();
@@ -142,6 +142,35 @@ mock.module("../signals/cancel.js", () => ({
142
142
  handleCancelSignal: () => {},
143
143
  }));
144
144
 
145
+ // The watcher reacts to file changes by calling these directly; capture the
146
+ // reaction counts so tests can assert dispatch without driving real eviction
147
+ // or client broadcasts.
148
+ let evictCallCount = 0;
149
+ let identityCallCount = 0;
150
+
151
+ mock.module("../daemon/conversation-store.js", () => ({
152
+ evictConversationsForReload: () => {
153
+ evictCallCount++;
154
+ },
155
+ }));
156
+
157
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
158
+ publishIdentityChanged: () => {
159
+ identityCallCount++;
160
+ },
161
+ publishConfigChanged: () => {},
162
+ publishSoundsConfigUpdated: () => {},
163
+ publishAvatarChanged: () => {},
164
+ }));
165
+
166
+ mock.module("../daemon/skill-memory-refresh.js", () => ({
167
+ refreshSkillCapabilityMemories: () => {},
168
+ }));
169
+
170
+ mock.module("../platform/sync-identity.js", () => ({
171
+ syncIdentityNameToPlatform: () => {},
172
+ }));
173
+
145
174
  // Import after mocks are set up
146
175
  const { ConfigWatcher } = await import("../daemon/config-watcher.js");
147
176
 
@@ -206,16 +235,13 @@ beforeAll(() => {
206
235
  });
207
236
 
208
237
  let watcher: InstanceType<typeof ConfigWatcher>;
209
- let evictCallCount: number;
210
- const onConversationEvict = () => {
211
- evictCallCount++;
212
- };
213
238
 
214
239
  beforeEach(() => {
215
240
  capturedWatchers.length = 0;
216
241
  capturedFileWatches.length = 0;
217
242
  inoMap.clear();
218
243
  evictCallCount = 0;
244
+ identityCallCount = 0;
219
245
  watcher = new ConfigWatcher(undefined, TEST_DEBOUNCE_MS);
220
246
  });
221
247
 
@@ -224,32 +250,29 @@ afterEach(() => {
224
250
  });
225
251
 
226
252
  describe("ConfigWatcher workspace file handlers", () => {
227
- test("SOUL.md change triggers onConversationEvict", async () => {
228
- watcher.start(onConversationEvict);
253
+ test("SOUL.md change evicts conversations", async () => {
254
+ watcher.start();
229
255
  simulateFileChange(WORKSPACE_DIR, "SOUL.md");
230
256
  await new Promise((r) => setTimeout(r, WAIT_MS));
231
257
  expect(evictCallCount).toBe(1);
232
258
  });
233
259
 
234
- test("IDENTITY.md change triggers onConversationEvict", async () => {
235
- watcher.start(onConversationEvict);
260
+ test("IDENTITY.md change evicts conversations", async () => {
261
+ watcher.start();
236
262
  simulateFileChange(WORKSPACE_DIR, "IDENTITY.md");
237
263
  await new Promise((r) => setTimeout(r, WAIT_MS));
238
264
  expect(evictCallCount).toBe(1);
239
265
  });
240
266
 
241
- test("IDENTITY.md change triggers onIdentityChanged", async () => {
242
- let identityCallCount = 0;
243
- watcher.start(onConversationEvict, () => {
244
- identityCallCount += 1;
245
- });
267
+ test("IDENTITY.md change broadcasts the identity update", async () => {
268
+ watcher.start();
246
269
  simulateFileChange(WORKSPACE_DIR, "IDENTITY.md");
247
270
  await new Promise((r) => setTimeout(r, WAIT_MS));
248
271
  expect(identityCallCount).toBe(1);
249
272
  });
250
273
 
251
274
  test("unregistered workspace files are not subscribed (only the registered handler set is)", () => {
252
- watcher.start(onConversationEvict);
275
+ watcher.start();
253
276
  // Per-file watching only registers config.json, SOUL.md, IDENTITY.md.
254
277
  // The whole workspace dir must not be watched either — that was the
255
278
  // ENXIO-on-Unix-sockets bug.
@@ -263,16 +286,16 @@ describe("ConfigWatcher workspace file handlers", () => {
263
286
  refreshCalled = true;
264
287
  return false;
265
288
  };
266
- watcher.start(onConversationEvict);
289
+ watcher.start();
267
290
  simulateFileChange(WORKSPACE_DIR, "config.json");
268
291
  await new Promise((r) => setTimeout(r, WAIT_MS));
269
292
  expect(refreshCalled).toBe(true);
270
293
  expect(evictCallCount).toBe(0);
271
294
  });
272
295
 
273
- test("config.json change triggers onConversationEvict when config actually changed", async () => {
296
+ test("config.json change evicts conversations when config actually changed", async () => {
274
297
  watcher.refreshConfigFromSources = async () => true;
275
- watcher.start(onConversationEvict);
298
+ watcher.start();
276
299
  simulateFileChange(WORKSPACE_DIR, "config.json");
277
300
  await new Promise((r) => setTimeout(r, WAIT_MS));
278
301
  expect(evictCallCount).toBe(1);
@@ -285,7 +308,7 @@ describe("ConfigWatcher workspace file handlers", () => {
285
308
  return true;
286
309
  };
287
310
  watcher.suppressConfigReload = true;
288
- watcher.start(onConversationEvict);
311
+ watcher.start();
289
312
  simulateFileChange(WORKSPACE_DIR, "config.json");
290
313
  await new Promise((r) => setTimeout(r, WAIT_MS));
291
314
  expect(refreshCalled).toBe(false);
@@ -295,7 +318,7 @@ describe("ConfigWatcher workspace file handlers", () => {
295
318
 
296
319
  describe("ConfigWatcher watcher lifecycle", () => {
297
320
  test("start does NOT subscribe to /workspace as a directory (regression: ENXIO on Unix sockets)", () => {
298
- watcher.start(onConversationEvict);
321
+ watcher.start();
299
322
  expect(findWatcher(WORKSPACE_DIR)).toBeUndefined();
300
323
  // The per-file watchFile subscriptions are tracked separately from
301
324
  // capturedWatchers; assert the expected ones are present.
@@ -305,7 +328,7 @@ describe("ConfigWatcher watcher lifecycle", () => {
305
328
  });
306
329
 
307
330
  test("stop cancels pending debounce work, no eviction fires after", async () => {
308
- watcher.start(onConversationEvict);
331
+ watcher.start();
309
332
  simulateFileChange(WORKSPACE_DIR, "SOUL.md");
310
333
  watcher.stop();
311
334
  await new Promise((r) => setTimeout(r, WAIT_MS));
@@ -313,7 +336,7 @@ describe("ConfigWatcher watcher lifecycle", () => {
313
336
  });
314
337
 
315
338
  test("multiple rapid changes to the same workspace file are coalesced to one eviction", async () => {
316
- watcher.start(onConversationEvict);
339
+ watcher.start();
317
340
  simulateFileChange(WORKSPACE_DIR, "SOUL.md");
318
341
  simulateFileChange(WORKSPACE_DIR, "SOUL.md");
319
342
  simulateFileChange(WORKSPACE_DIR, "SOUL.md");
@@ -322,7 +345,7 @@ describe("ConfigWatcher watcher lifecycle", () => {
322
345
  });
323
346
 
324
347
  test("changes to different files each trigger their own handler", async () => {
325
- watcher.start(onConversationEvict);
348
+ watcher.start();
326
349
  simulateFileChange(WORKSPACE_DIR, "SOUL.md");
327
350
  simulateFileChange(WORKSPACE_DIR, "IDENTITY.md");
328
351
  await new Promise((r) => setTimeout(r, WAIT_MS));
@@ -335,7 +358,7 @@ describe("ConfigWatcher per-file polling listener", () => {
335
358
  // Simulates `writeFile(tmp) + rename(tmp, target)`: the inode at the
336
359
  // path is replaced. The listener should fire once per debounced window.
337
360
  watcher.refreshConfigFromSources = async () => true;
338
- watcher.start(onConversationEvict);
361
+ watcher.start();
339
362
  const fw = findFileWatch(join(WORKSPACE_DIR, "config.json"));
340
363
  expect(fw).toBeDefined();
341
364
  fw!.listener({ ino: 2, mtimeMs: 1_001 }, { ino: 1, mtimeMs: 1_000 });
@@ -345,7 +368,7 @@ describe("ConfigWatcher per-file polling listener", () => {
345
368
 
346
369
  test("mtime change is treated as a file change (in-place edit)", async () => {
347
370
  watcher.refreshConfigFromSources = async () => true;
348
- watcher.start(onConversationEvict);
371
+ watcher.start();
349
372
  const fw = findFileWatch(join(WORKSPACE_DIR, "config.json"));
350
373
  expect(fw).toBeDefined();
351
374
  // Same inode, different mtime — what an `echo >> file` produces.
@@ -358,7 +381,7 @@ describe("ConfigWatcher per-file polling listener", () => {
358
381
  // fs.watchFile sometimes invokes the listener with curr === prev
359
382
  // (e.g. on initial subscription); the watcher must not re-fire in that case.
360
383
  watcher.refreshConfigFromSources = async () => true;
361
- watcher.start(onConversationEvict);
384
+ watcher.start();
362
385
  const fw = findFileWatch(join(WORKSPACE_DIR, "config.json"));
363
386
  expect(fw).toBeDefined();
364
387
  fw!.listener({ ino: 1, mtimeMs: 1_000 }, { ino: 1, mtimeMs: 1_000 });
@@ -370,8 +393,8 @@ describe("ConfigWatcher per-file polling listener", () => {
370
393
  describe("ConfigWatcher users directory watcher", () => {
371
394
  const USERS_DIR = join(WORKSPACE_DIR, "users");
372
395
 
373
- test("editing users/<slug>.md triggers onConversationEvict", async () => {
374
- watcher.start(onConversationEvict);
396
+ test("editing users/<slug>.md evicts conversations", async () => {
397
+ watcher.start();
375
398
  simulateFileChange(USERS_DIR, "alice.md");
376
399
 
377
400
  await new Promise((r) => setTimeout(r, WAIT_MS));
@@ -379,7 +402,7 @@ describe("ConfigWatcher users directory watcher", () => {
379
402
  });
380
403
 
381
404
  test("non-.md files in users/ do NOT trigger eviction", async () => {
382
- watcher.start(onConversationEvict);
405
+ watcher.start();
383
406
  simulateFileChange(USERS_DIR, "alice.json");
384
407
  simulateFileChange(USERS_DIR, "notes.txt");
385
408
  simulateFileChange(USERS_DIR, "README");
@@ -389,7 +412,7 @@ describe("ConfigWatcher users directory watcher", () => {
389
412
  });
390
413
 
391
414
  test("null filename in users/ does not trigger eviction", async () => {
392
- watcher.start(onConversationEvict);
415
+ watcher.start();
393
416
  const usersWatcher = findWatcher(USERS_DIR);
394
417
  expect(usersWatcher).toBeDefined();
395
418
  usersWatcher!.callback("change", null);
@@ -399,7 +422,7 @@ describe("ConfigWatcher users directory watcher", () => {
399
422
  });
400
423
 
401
424
  test("multiple rapid changes to the same persona file are debounced", async () => {
402
- watcher.start(onConversationEvict);
425
+ watcher.start();
403
426
  simulateFileChange(USERS_DIR, "bob.md");
404
427
  simulateFileChange(USERS_DIR, "bob.md");
405
428
  simulateFileChange(USERS_DIR, "bob.md");
@@ -129,13 +129,20 @@ mock.module("../daemon/memory-v2-startup.js", () => ({
129
129
  maybeReseedCapabilitiesAfterManagedCredential: async () => {},
130
130
  }));
131
131
 
132
+ // secret-routes evicts conversations after a credential change so the next turn
133
+ // rebuilds against the new providers; count the calls to assert that happens.
134
+ mock.module("../daemon/conversation-store.js", () => ({
135
+ evictConversationsForReload: () => {
136
+ providerRefreshCalls++;
137
+ },
138
+ }));
139
+
132
140
  import {
133
141
  getProviderRoutingSource,
134
142
  initializeProviders,
135
143
  listProviders,
136
144
  } from "../providers/registry.js";
137
145
  import { ROUTES } from "../runtime/routes/secret-routes.js";
138
- import { registerSecretsDeps } from "../runtime/routes/secrets-deps.js";
139
146
 
140
147
  const addRoute = ROUTES.find(
141
148
  (r) => r.method === "POST" && r.endpoint === "secrets",
@@ -177,11 +184,6 @@ describe("secret routes managed proxy registry sync", () => {
177
184
  lastGeminiConstructorOpts = null;
178
185
  platformBaseUrlOverride = undefined;
179
186
  providerRefreshCalls = 0;
180
- registerSecretsDeps({
181
- onProviderCredentialsChanged: () => {
182
- providerRefreshCalls++;
183
- },
184
- });
185
187
  await initializeProviders(mockConfig);
186
188
  });
187
189
 
@@ -256,6 +256,72 @@ describe("tool preview lifecycle", () => {
256
256
  expect((emitted as any).conversationId).toBe("test-session-id");
257
257
  });
258
258
 
259
+ test("stamps previewStartedAt on the event and stores it in state", () => {
260
+ const collector = createEventCollector();
261
+ const deps = createMockDeps({
262
+ onEvent: collector.onEvent,
263
+ ctx: {
264
+ ...createMockDeps().ctx,
265
+ emitActivityState: collector.emitActivityState,
266
+ } as unknown as EventHandlerDeps["ctx"],
267
+ });
268
+
269
+ const before = Date.now();
270
+ handleToolUsePreviewStart(state, deps, {
271
+ type: "tool_use_preview_start",
272
+ toolUseId: "toolu_preview_ts",
273
+ toolName: "bash",
274
+ });
275
+ const after = Date.now();
276
+
277
+ const emitted = collector.events[0] as { previewStartedAt?: number };
278
+ expect(typeof emitted.previewStartedAt).toBe("number");
279
+ expect(emitted.previewStartedAt!).toBeGreaterThanOrEqual(before);
280
+ expect(emitted.previewStartedAt!).toBeLessThanOrEqual(after);
281
+ // The same first-byte timestamp is retained in state so tool_use_start
282
+ // can carry it through.
283
+ expect(state.toolPreviewStartedAt.get("toolu_preview_ts")).toBe(
284
+ emitted.previewStartedAt,
285
+ );
286
+ });
287
+
288
+ test("handleToolUse carries the stored previewStartedAt onto tool_use_start", () => {
289
+ const collector = createEventCollector();
290
+ const deps = createMockDeps({
291
+ onEvent: collector.onEvent,
292
+ ctx: {
293
+ ...createMockDeps().ctx,
294
+ emitActivityState: collector.emitActivityState,
295
+ } as unknown as EventHandlerDeps["ctx"],
296
+ });
297
+
298
+ // GIVEN a preview was recognized first
299
+ handleToolUsePreviewStart(state, deps, {
300
+ type: "tool_use_preview_start",
301
+ toolUseId: "toolu_carry",
302
+ toolName: "bash",
303
+ });
304
+ const previewStartedAt = state.toolPreviewStartedAt.get("toolu_carry");
305
+
306
+ // WHEN the tool actually begins executing
307
+ handleToolUse(state, deps, {
308
+ type: "tool_use",
309
+ id: "toolu_carry",
310
+ name: "bash",
311
+ input: { command: "ls" },
312
+ });
313
+
314
+ // THEN the tool_use_start event carries the first-byte anchor alongside
315
+ // its own (later) execution startedAt
316
+ const toolUseStart = collector.events.find(
317
+ (e) => e.type === "tool_use_start",
318
+ ) as { previewStartedAt?: number; startedAt?: number };
319
+ expect(toolUseStart).toBeDefined();
320
+ expect(toolUseStart.previewStartedAt).toBe(previewStartedAt);
321
+ expect(typeof toolUseStart.startedAt).toBe("number");
322
+ expect(toolUseStart.startedAt!).toBeGreaterThanOrEqual(previewStartedAt!);
323
+ });
324
+
259
325
  test("emits activity state with tool_running phase and preview_start reason", () => {
260
326
  const collector = createEventCollector();
261
327
  const deps = createMockDeps({