@vellumai/assistant 0.10.0-dev.202606200318.c052d10 → 0.10.0-dev.202606201453.1417592

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/agent-loop-callsite-precedence.test.ts +1 -40
  3. package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
  4. package/src/__tests__/app-source-watcher.test.ts +30 -10
  5. package/src/__tests__/config-schema.test.ts +34 -0
  6. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
  7. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
  8. package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
  9. package/src/__tests__/conversation-agent-loop.test.ts +3 -0
  10. package/src/__tests__/conversation-process-callsite.test.ts +0 -14
  11. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
  12. package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
  13. package/src/__tests__/heartbeat-service.test.ts +6 -0
  14. package/src/__tests__/list-messages-attachments.test.ts +41 -0
  15. package/src/__tests__/plugin-source-watcher.test.ts +33 -1
  16. package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
  17. package/src/acp/__tests__/session-manager.test.ts +72 -1
  18. package/src/acp/index.ts +10 -0
  19. package/src/acp/session-manager.ts +35 -0
  20. package/src/agent/loop.ts +28 -22
  21. package/src/config/schemas/memory-lifecycle.ts +5 -3
  22. package/src/config/schemas/timeouts.ts +24 -0
  23. package/src/daemon/app-source-watcher.ts +31 -18
  24. package/src/daemon/conversation-agent-loop.ts +8 -5
  25. package/src/daemon/conversation.ts +30 -41
  26. package/src/daemon/handlers/conversations.ts +7 -0
  27. package/src/daemon/plugin-source-watcher.ts +5 -0
  28. package/src/daemon/workspace-tools-watcher.ts +4 -0
  29. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  30. package/src/heartbeat/heartbeat-service.ts +3 -4
  31. package/src/memory/__tests__/db-maintenance.test.ts +27 -35
  32. package/src/memory/conversation-crud.ts +9 -3
  33. package/src/memory/db-init.ts +33 -5
  34. package/src/memory/db-maintenance.ts +43 -38
  35. package/src/memory/job-handlers/cleanup.ts +6 -0
  36. package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
  37. package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
  38. package/src/memory/migrations/index.ts +1 -0
  39. package/src/plugin-api/index.ts +7 -0
  40. package/src/plugin-api/vision-support.ts +75 -0
  41. package/src/prompts/system-prompt.ts +1 -1
  42. package/src/runtime/__tests__/agent-wake.test.ts +6 -4
  43. package/src/runtime/agent-wake.ts +15 -7
  44. package/src/runtime/routes/conversation-routes.ts +24 -3
  45. package/src/runtime/routes/migration-routes.ts +35 -39
  46. package/src/schedule/scheduler.ts +5 -9
  47. package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
  48. package/src/tools/ask-question/ask-question-tool.ts +14 -73
  49. package/src/util/fs-watcher-error.ts +36 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.0-dev.202606200318.c052d10",
3
+ "version": "0.10.0-dev.202606201453.1417592",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,8 +9,7 @@
9
9
  * for these knobs is silently ignored.
10
10
  *
11
11
  * Precedence (highest wins):
12
- * 1. Per-turn explicit (from `resolveSystemPrompt`'s
13
- * `resolved.maxTokens` / `resolved.model`)
12
+ * 1. Per-run explicit (from `run()`'s `model` param)
14
13
  * 2. Call-site resolved values (from `resolveCallSiteConfig` via the
15
14
  * normalizer)
16
15
  * 3. Conversation defaults (`this.config.*`, from `llm.default`)
@@ -33,7 +32,6 @@ mock.module("../config/loader.js", () => ({
33
32
  getConfig: () => ({ llm: mockLlmConfig }),
34
33
  }));
35
34
 
36
- import type { ResolvedSystemPrompt } from "../agent/loop.js";
37
35
  import { AgentLoop } from "../agent/loop.js";
38
36
  import { LLMSchema } from "../config/schemas/llm.js";
39
37
  import { RetryProvider } from "../providers/retry.js";
@@ -298,41 +296,4 @@ describe("AgentLoop — call-site precedence", () => {
298
296
  // No callSite → loop sets the wire-format thinking directly.
299
297
  expect(config.thinking).toEqual({ type: "adaptive" });
300
298
  });
301
-
302
- test("per-turn resolveSystemPrompt.maxTokens wins over both call-site and default", async () => {
303
- setLlmConfig({
304
- default: {
305
- provider: "anthropic",
306
- model: "claude-default",
307
- maxTokens: 64000,
308
- },
309
- callSites: { mainAgent: { maxTokens: 4096 } },
310
- });
311
-
312
- const { provider, lastConfig } = makePipeline("anthropic");
313
- const resolveSystemPrompt = (): ResolvedSystemPrompt => ({
314
- systemPrompt: "per-turn system",
315
- maxTokens: 8192,
316
- });
317
-
318
- const loop = new AgentLoop({
319
- provider: provider,
320
- systemPrompt: "system",
321
- conversationId: "test-conversation",
322
- config: { maxTokens: 64000 },
323
- resolveSystemPrompt: resolveSystemPrompt,
324
- });
325
-
326
- await loop.run({
327
- requestId: "test-request",
328
- messages: [userMessage],
329
- onEvent: () => {},
330
- trust: { sourceChannel: "vellum", trustClass: "unknown" },
331
- callSite: "mainAgent",
332
- });
333
-
334
- // Per-turn explicit value beats both the call-site (4096) and the
335
- // default (64000).
336
- expect(lastConfig()!.max_tokens).toBe(8192);
337
- });
338
299
  });
@@ -103,6 +103,8 @@ function makeTarget(): {
103
103
  drainQueue: async () => {},
104
104
  // Pre-run auto-compaction gate — no-op for these tests.
105
105
  maybeCompact: async () => null,
106
+ buildCurrentSystemPrompt: () => "mock-system-prompt",
107
+ modelOverride: undefined,
106
108
  };
107
109
  return { target: target as unknown as Conversation, runArgs };
108
110
  }
@@ -3,14 +3,7 @@
3
3
  * file changes and triggers debounced recompile + surface refresh.
4
4
  */
5
5
 
6
- import {
7
- afterEach,
8
- beforeEach,
9
- describe,
10
- expect,
11
- mock,
12
- test,
13
- } from "bun:test";
6
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
14
7
 
15
8
  // ---------------------------------------------------------------------------
16
9
  // Mocks — must be set up before importing the module under test
@@ -19,8 +12,16 @@ import {
19
12
  const TEST_APPS_DIR = "/tmp/test-apps";
20
13
  const testDirNameMap = new Map<string, string>([["my-app", "app-id-1"]]);
21
14
 
22
- let capturedWatchCallback: ((eventType: string, filename: string | null) => void) | null = null;
23
- const mockWatcher = { close: mock(() => {}) };
15
+ let capturedWatchCallback:
16
+ | ((eventType: string, filename: string | null) => void)
17
+ | null = null;
18
+ let capturedErrorHandler: ((err: unknown) => void) | null = null;
19
+ const mockWatcher = {
20
+ close: mock(() => {}),
21
+ on: mock((event: string, handler: (err: unknown) => void) => {
22
+ if (event === "error") capturedErrorHandler = handler;
23
+ }),
24
+ };
24
25
  const mockExistsSync = mock((p: string): boolean => p === TEST_APPS_DIR);
25
26
  const mockWatch = mock(
26
27
  (
@@ -68,7 +69,9 @@ describe("AppSourceWatcher", () => {
68
69
  watcher = new AppSourceWatcher();
69
70
  onChangeSpy = mock(() => {});
70
71
  capturedWatchCallback = null;
72
+ capturedErrorHandler = null;
71
73
  mockWatcher.close.mockClear();
74
+ mockWatcher.on.mockClear();
72
75
  // Reset existsSync to default behavior for each test
73
76
  mockExistsSync.mockImplementation((p: string) => p === TEST_APPS_DIR);
74
77
  });
@@ -182,4 +185,21 @@ describe("AppSourceWatcher", () => {
182
185
  watcher.ensureStarted();
183
186
  expect(mockWatch.mock.calls.length).toBe(callCountAfterStart); // no extra watch call
184
187
  });
188
+
189
+ /**
190
+ * REGRESSION: a recursive watch over a large app tree (node_modules) can
191
+ * exhaust the inotify watch limit and emit ENOSPC asynchronously as an
192
+ * 'error' event. Without an 'error' listener that rethrows as an
193
+ * uncaughtException and crashes the daemon. The watcher must swallow it.
194
+ */
195
+ test("attaches an 'error' handler that does not rethrow on async ENOSPC", () => {
196
+ watcher.start(onChangeSpy);
197
+
198
+ expect(capturedErrorHandler).not.toBeNull();
199
+ const enospc = Object.assign(
200
+ new Error("ENOSPC: no space left on device, watch"),
201
+ { code: "ENOSPC", errno: -28, syscall: "watch" },
202
+ );
203
+ expect(() => capturedErrorHandler!(enospc)).not.toThrow();
204
+ });
185
205
  });
@@ -122,6 +122,8 @@ describe("AssistantConfigSchema", () => {
122
122
  permissionTimeoutSec: 300,
123
123
  toolExecutionTimeoutSec: 120,
124
124
  providerStreamTimeoutSec: 1800,
125
+ backgroundTurnTimeoutSec: 1800,
126
+ scheduleTurnTimeoutSec: 1800,
125
127
  });
126
128
  expect(result.rateLimit).toEqual({
127
129
  maxRequestsPerMinute: 0,
@@ -666,6 +668,38 @@ describe("AssistantConfigSchema", () => {
666
668
  expect(result.timeouts.permissionTimeoutSec).toBe(300);
667
669
  });
668
670
 
671
+ test("background/schedule turn timeouts default to 1800s when unset", () => {
672
+ const result = AssistantConfigSchema.parse({
673
+ timeouts: { shellDefaultTimeoutSec: 30 },
674
+ });
675
+ expect(result.timeouts.backgroundTurnTimeoutSec).toBe(1800);
676
+ expect(result.timeouts.scheduleTurnTimeoutSec).toBe(1800);
677
+ });
678
+
679
+ test("custom background/schedule turn timeouts flow through to resolved config", () => {
680
+ const result = AssistantConfigSchema.parse({
681
+ timeouts: {
682
+ backgroundTurnTimeoutSec: 3600,
683
+ scheduleTurnTimeoutSec: 10800,
684
+ },
685
+ });
686
+ expect(result.timeouts.backgroundTurnTimeoutSec).toBe(3600);
687
+ expect(result.timeouts.scheduleTurnTimeoutSec).toBe(10800);
688
+ });
689
+
690
+ test("rejects non-integer and out-of-range turn timeouts", () => {
691
+ expect(
692
+ AssistantConfigSchema.safeParse({
693
+ timeouts: { backgroundTurnTimeoutSec: 12.5 },
694
+ }).success,
695
+ ).toBe(false);
696
+ expect(
697
+ AssistantConfigSchema.safeParse({
698
+ timeouts: { scheduleTurnTimeoutSec: 2147484 },
699
+ }).success,
700
+ ).toBe(false);
701
+ });
702
+
669
703
  test("accepts zero for non-negative fields", () => {
670
704
  const result = AssistantConfigSchema.parse({
671
705
  rateLimit: { maxRequestsPerMinute: 0 },
@@ -178,6 +178,9 @@ function makeCtx(overrides: Partial<Context> = {}): Conversation {
178
178
  drainQueue: async () => {},
179
179
  getTurnInterfaceContext: () => null,
180
180
  getTurnChannelContext: () => null,
181
+
182
+ buildCurrentSystemPrompt: () => "system prompt",
183
+ modelOverride: undefined,
181
184
  graphMemory: {} as Context["graphMemory"],
182
185
  ...overrides,
183
186
  } as unknown as Conversation;
@@ -507,6 +507,9 @@ function makeCtx(
507
507
  assistantMessageChannel: "vellum" as const,
508
508
  }),
509
509
 
510
+ buildCurrentSystemPrompt: () => "system prompt",
511
+ modelOverride: undefined,
512
+
510
513
  graphMemory: {
511
514
  onCompacted: async () => {},
512
515
  prepareMemory: async () => ({
@@ -651,6 +651,9 @@ function makeCtx(
651
651
  assistantMessageChannel: "vellum" as const,
652
652
  }),
653
653
 
654
+ buildCurrentSystemPrompt: () => "system prompt",
655
+ modelOverride: undefined,
656
+
654
657
  graphMemory: {
655
658
  onCompacted: async () => {},
656
659
  prepareMemory: async () => ({
@@ -759,6 +759,9 @@ function makeCtx(
759
759
  assistantMessageChannel: "vellum" as const,
760
760
  }),
761
761
 
762
+ buildCurrentSystemPrompt: () => "system prompt",
763
+ modelOverride: undefined,
764
+
762
765
  graphMemory: {
763
766
  onCompacted: async () => {},
764
767
  prepareMemory: async () => ({
@@ -20,15 +20,11 @@ import type { Message, ProviderResponse } from "../providers/types.js";
20
20
  const captured: {
21
21
  callSite?: string;
22
22
  constructorMaxTokens?: unknown;
23
- resolvedMaxTokens?: unknown;
24
- resolvedHasMaxTokens?: boolean;
25
23
  } = {};
26
24
 
27
25
  function clearCaptured(): void {
28
26
  captured.callSite = undefined;
29
27
  captured.constructorMaxTokens = undefined;
30
- captured.resolvedMaxTokens = undefined;
31
- captured.resolvedHasMaxTokens = undefined;
32
28
  }
33
29
 
34
30
  mock.module("../util/logger.js", () => ({
@@ -207,14 +203,8 @@ mock.module("../agent/loop.js", () => ({
207
203
  provider?: unknown;
208
204
  systemPrompt?: string;
209
205
  config?: Record<string, unknown>;
210
- resolveSystemPrompt?: (history: Message[]) => Record<string, unknown>;
211
206
  }) {
212
207
  captured.constructorMaxTokens = options?.config?.maxTokens;
213
- const resolved = options?.resolveSystemPrompt?.([]);
214
- captured.resolvedMaxTokens = resolved?.maxTokens;
215
- captured.resolvedHasMaxTokens =
216
- resolved !== undefined &&
217
- Object.prototype.hasOwnProperty.call(resolved, "maxTokens");
218
208
  }
219
209
  getToolTokenBudget() {
220
210
  return 0;
@@ -381,8 +371,6 @@ describe("processMessage callSite threading", () => {
381
371
  await getOrCreateConversation("conv-store-default");
382
372
 
383
373
  expect(captured.constructorMaxTokens).toBeUndefined();
384
- expect(captured.resolvedMaxTokens).toBeUndefined();
385
- expect(captured.resolvedHasMaxTokens).toBe(false);
386
374
  });
387
375
 
388
376
  test("preserves explicit maxResponseTokens at conversation creation", async () => {
@@ -403,8 +391,6 @@ describe("processMessage callSite threading", () => {
403
391
  });
404
392
 
405
393
  expect(captured.constructorMaxTokens).toBe(1234);
406
- expect(captured.resolvedMaxTokens).toBe(1234);
407
- expect(captured.resolvedHasMaxTokens).toBe(true);
408
394
  });
409
395
 
410
396
  test("applies clientTimezone in the create and reuse transport metadata path", async () => {
@@ -25,6 +25,7 @@ import { getSqliteFrom } from "../memory/db-connection.js";
25
25
  import { initializeDb } from "../memory/db-init.js";
26
26
  import { migrateLlmRequestLogProvider } from "../memory/migrations/184-llm-request-log-provider.js";
27
27
  import * as schema from "../memory/schema.js";
28
+ import { getLogsDbPath } from "../util/logs-db-path.js";
28
29
  import { getDbPath } from "../util/platform.js";
29
30
  import { resetDbForTesting } from "./db-test-helpers.js";
30
31
 
@@ -62,11 +63,13 @@ describe("llm_request_logs provider migration", () => {
62
63
  process.env.BUN_TEST = "0";
63
64
  resetDbForTesting();
64
65
  removeTestDbFiles(getDbPath());
66
+ removeTestDbFiles(getLogsDbPath());
65
67
  });
66
68
 
67
69
  afterEach(() => {
68
70
  resetDbForTesting();
69
71
  removeTestDbFiles(getDbPath());
72
+ removeTestDbFiles(getLogsDbPath());
70
73
  });
71
74
 
72
75
  afterAll(() => {
@@ -77,12 +80,14 @@ describe("llm_request_logs provider migration", () => {
77
80
  }
78
81
  resetDbForTesting();
79
82
  removeTestDbFiles(getDbPath());
83
+ removeTestDbFiles(getLogsDbPath());
80
84
  });
81
85
 
82
86
  test("fresh DB initialization includes llm_request_logs.provider", () => {
83
87
  initializeDb();
84
88
 
85
- const raw = new Database(getDbPath());
89
+ // llm_request_logs now lives in the attached logs database.
90
+ const raw = new Database(getLogsDbPath());
86
91
  const columns = getColumnInfo(raw);
87
92
 
88
93
  expect(columns.some((column) => column.name === "provider")).toBe(true);
@@ -26,6 +26,9 @@ mock.module("../config/loader.js", () => ({
26
26
  activeHoursStart: undefined,
27
27
  activeHoursEnd: undefined,
28
28
  },
29
+ timeouts: {
30
+ backgroundTurnTimeoutSec: 1800,
31
+ },
29
32
  }),
30
33
  loadConfig: () => ({}),
31
34
  loadRawConfig: () => ({}),
@@ -47,6 +47,9 @@ let mockConfig = {
47
47
  activeHoursEnd: undefined as number | undefined,
48
48
  disposition: "Default disposition text mentioning notifications skill.",
49
49
  },
50
+ timeouts: {
51
+ backgroundTurnTimeoutSec: 1800,
52
+ },
50
53
  };
51
54
 
52
55
  mock.module("../config/loader.js", () => ({
@@ -399,6 +402,9 @@ describe("HeartbeatService", () => {
399
402
  activeHoursEnd: undefined,
400
403
  disposition: "Default disposition text mentioning notifications skill.",
401
404
  },
405
+ timeouts: {
406
+ backgroundTurnTimeoutSec: 1800,
407
+ },
402
408
  };
403
409
  });
404
410
 
@@ -164,6 +164,47 @@ describe("handleListMessages attachments", () => {
164
164
  expect(imgAtt!.data).toBe(IMAGE_BASE64);
165
165
  expect(docAtt!.data).toBeUndefined();
166
166
  });
167
+
168
+ test("attachment-only assistant message synthesizes contentBlocks", async () => {
169
+ // When the assistant's entire response was a <vellum-attachment/> tag,
170
+ // parseDirectives strips it → cleanText is empty → renderHistoryContent
171
+ // drops the empty text block → contentBlocks is []. The serializer must
172
+ // synthesize attachment blocks from msgAttachments so the client has a
173
+ // block to anchor the attachment chip.
174
+ const conv = createConversation();
175
+ // Persist the post-strip content: an empty text block (what
176
+ // cleanAssistantContent leaves after stripping the directive tag).
177
+ const msg = await addMessage(
178
+ conv.id,
179
+ "assistant",
180
+ JSON.stringify([{ type: "text", text: "" }]),
181
+ );
182
+ const stored = uploadAttachment("output.png", "image/png", IMAGE_BASE64);
183
+ linkAttachmentToMessage(msg.id, stored.id, 0);
184
+
185
+ const response = handleListMessages(createTestArgs(conv.id));
186
+ const body = response as {
187
+ messages: {
188
+ attachments?: AttachmentPayload[];
189
+ contentBlocks?: Array<{
190
+ type: string;
191
+ attachment?: { id: string; filename: string };
192
+ }>;
193
+ }[];
194
+ };
195
+
196
+ expect(body.messages).toHaveLength(1);
197
+ // Attachments are always on the wire
198
+ expect(body.messages[0].attachments).toBeDefined();
199
+ expect(body.messages[0].attachments).toHaveLength(1);
200
+ // contentBlocks must be synthesized — not omitted
201
+ expect(body.messages[0].contentBlocks).toBeDefined();
202
+ expect(body.messages[0].contentBlocks).toHaveLength(1);
203
+ expect(body.messages[0].contentBlocks![0].type).toBe("attachment");
204
+ expect(body.messages[0].contentBlocks![0].attachment!.filename).toBe(
205
+ "output.png",
206
+ );
207
+ });
167
208
  });
168
209
 
169
210
  describe("handleListMessages no_response filtering", () => {
@@ -19,7 +19,13 @@ let capturedWatchCallback:
19
19
  | ((eventType: string, filename: string | null) => void)
20
20
  | null = null;
21
21
  let mockWatchShouldThrow = false;
22
- const mockWatcher = { close: mock(() => {}) };
22
+ let capturedErrorHandler: ((err: unknown) => void) | null = null;
23
+ const mockWatcher = {
24
+ close: mock(() => {}),
25
+ on: mock((event: string, handler: (err: unknown) => void) => {
26
+ if (event === "error") capturedErrorHandler = handler;
27
+ }),
28
+ };
23
29
 
24
30
  const mockWatch = mock(
25
31
  (
@@ -90,8 +96,10 @@ describe("PluginSourceWatcher", () => {
90
96
  beforeEach(() => {
91
97
  PluginSourceWatcher.resetForTests();
92
98
  capturedWatchCallback = null;
99
+ capturedErrorHandler = null;
93
100
  mockWatchShouldThrow = false;
94
101
  mockWatcher.close.mockClear();
102
+ mockWatcher.on.mockClear();
95
103
  mockWatch.mockClear();
96
104
  mockRereadirSync.mockClear();
97
105
  mockGetRegisteredPlugin.mockClear();
@@ -240,6 +248,30 @@ describe("PluginSourceWatcher", () => {
240
248
  expect(capturedWatchCallback).toBe(firstCallback);
241
249
  });
242
250
 
251
+ /**
252
+ * REGRESSION: a recursive watch over a large plugin tree (node_modules)
253
+ * can exhaust the inotify watch limit and emit ENOSPC asynchronously as an
254
+ * 'error' event. An FSWatcher with no 'error' listener rethrows, which
255
+ * becomes an uncaughtException and crashes the daemon (CrashLoopBackOff).
256
+ * The watcher must register an 'error' handler that swallows it.
257
+ */
258
+ test("attaches an 'error' handler that does not rethrow on async ENOSPC", () => {
259
+ const watcher = PluginSourceWatcher.getInstance();
260
+ watcher.start();
261
+
262
+ expect(capturedErrorHandler).not.toBeNull();
263
+ const enospc = Object.assign(
264
+ new Error("ENOSPC: no space left on device, watch"),
265
+ {
266
+ code: "ENOSPC",
267
+ errno: -28,
268
+ syscall: "watch",
269
+ },
270
+ );
271
+ // Must not throw — the daemon stays up, the watcher just stops delivering.
272
+ expect(() => capturedErrorHandler!(enospc)).not.toThrow();
273
+ });
274
+
243
275
  test("singleton instance is shared across calls", () => {
244
276
  const watcher1 = PluginSourceWatcher.getInstance();
245
277
  const watcher2 = PluginSourceWatcher.getInstance();
@@ -96,9 +96,13 @@ function insertRequestLog(args: {
96
96
  createdAt: number;
97
97
  responsePayload: string;
98
98
  }): void {
99
+ // Migration 140 runs before the table is relocated to the logs database (297
100
+ // runs last), so it reads llm_request_logs from `main`. Seed there to mirror
101
+ // that ordering — the unqualified reads in the migration resolve to `main`
102
+ // when a same-named table is present there.
99
103
  rawRun(
100
104
  /*sql*/ `
101
- INSERT INTO llm_request_logs (
105
+ INSERT INTO main.llm_request_logs (
102
106
  id,
103
107
  conversation_id,
104
108
  request_payload,
@@ -144,7 +148,18 @@ function foreignResponsePayload(): string {
144
148
 
145
149
  describe("migrateBackfillUsageCacheAccounting", () => {
146
150
  beforeEach(() => {
147
- getSqlite().run(`DELETE FROM llm_request_logs`);
151
+ // Recreate the pre-relocation `main.llm_request_logs` that migration 140
152
+ // reads (the live DB keeps the table in the attached logs database).
153
+ getSqlite().exec(`
154
+ CREATE TABLE IF NOT EXISTS main.llm_request_logs (
155
+ id TEXT PRIMARY KEY,
156
+ conversation_id TEXT NOT NULL,
157
+ request_payload TEXT NOT NULL,
158
+ response_payload TEXT NOT NULL,
159
+ created_at INTEGER NOT NULL
160
+ )
161
+ `);
162
+ getSqlite().run(`DELETE FROM main.llm_request_logs`);
148
163
  getSqlite().run(`DELETE FROM llm_usage_events`);
149
164
  rawRun(`DELETE FROM memory_checkpoints WHERE key = ?`, CHECKPOINT_KEY);
150
165
  mockPricingOverrides = [];
@@ -8,6 +8,10 @@ import { describe, expect, mock, test } from "bun:test";
8
8
 
9
9
  import type { AcpSessionState } from "../types.js";
10
10
 
11
+ // Records every `cancel(protocolSessionId)` the manager dispatches to a fake
12
+ // process, so tests can assert which sessions were cancelled.
13
+ const cancelCalls: string[] = [];
14
+
11
15
  // Stub the agent-process module so spawn() does not actually launch a child
12
16
  // process. Each fake instance records the cwd it was spawned in and resolves
13
17
  // every protocol method synchronously. The mock is process-global (Bun's
@@ -30,7 +34,9 @@ mock.module("../agent-process.js", () => ({
30
34
  // the duration of the test so cleanup logic doesn't tear it down.
31
35
  return new Promise(() => {});
32
36
  }
33
- async cancel(): Promise<void> {}
37
+ async cancel(sessionId: string): Promise<void> {
38
+ cancelCalls.push(sessionId);
39
+ }
34
40
  kill(): void {}
35
41
  },
36
42
  }));
@@ -81,3 +87,68 @@ describe("AcpSessionManager — parentConversationId population", () => {
81
87
  expect(parents).toEqual(["conv-parent-1", "conv-parent-2"]);
82
88
  });
83
89
  });
90
+
91
+ describe("AcpSessionManager — cancelForParent", () => {
92
+ const noopSend = () => {};
93
+
94
+ test("cancels only the sessions spawned by the given parent", async () => {
95
+ cancelCalls.length = 0;
96
+ const manager = new AcpSessionManager(5);
97
+
98
+ const a1 = await manager.spawn(
99
+ "agent-a1",
100
+ { command: "echo", args: ["hi"] },
101
+ "task",
102
+ "/tmp",
103
+ "parent-A",
104
+ noopSend,
105
+ );
106
+ const a2 = await manager.spawn(
107
+ "agent-a2",
108
+ { command: "echo", args: ["hi"] },
109
+ "task",
110
+ "/tmp",
111
+ "parent-A",
112
+ noopSend,
113
+ );
114
+ const b1 = await manager.spawn(
115
+ "agent-b1",
116
+ { command: "echo", args: ["hi"] },
117
+ "task",
118
+ "/tmp",
119
+ "parent-B",
120
+ noopSend,
121
+ );
122
+
123
+ // WHEN parent-A is cancelled
124
+ const count = manager.cancelForParent("parent-A");
125
+
126
+ // THEN it reports the two parent-A sessions and leaves parent-B alone
127
+ expect(count).toBe(2);
128
+
129
+ // Let the detached per-session cancels settle (each awaits a protocol
130
+ // notification before flipping status).
131
+ await new Promise((resolve) => setTimeout(resolve, 0));
132
+
133
+ expect((manager.getStatus(a1.acpSessionId) as AcpSessionState).status).toBe(
134
+ "cancelled",
135
+ );
136
+ expect((manager.getStatus(a2.acpSessionId) as AcpSessionState).status).toBe(
137
+ "cancelled",
138
+ );
139
+ expect((manager.getStatus(b1.acpSessionId) as AcpSessionState).status).toBe(
140
+ "running",
141
+ );
142
+
143
+ // AND the cancel reached each parent-A agent process exactly once.
144
+ expect(cancelCalls.sort()).toEqual(["proto-agent-a1", "proto-agent-a2"]);
145
+ });
146
+
147
+ test("returns 0 and dispatches nothing when the parent has no sessions", () => {
148
+ cancelCalls.length = 0;
149
+ const manager = new AcpSessionManager(5);
150
+
151
+ expect(manager.cancelForParent("parent-with-nothing")).toBe(0);
152
+ expect(cancelCalls).toEqual([]);
153
+ });
154
+ });
package/src/acp/index.ts CHANGED
@@ -18,6 +18,16 @@ export function getAcpSessionManager(): AcpSessionManager {
18
18
  return manager;
19
19
  }
20
20
 
21
+ /**
22
+ * Returns the existing AcpSessionManager singleton, or null when none has been
23
+ * created yet. Use this on cleanup hot paths (e.g. cancelling a conversation)
24
+ * that must not spin up a manager just to discover there are no sessions to
25
+ * act on.
26
+ */
27
+ export function peekAcpSessionManager(): AcpSessionManager | null {
28
+ return manager;
29
+ }
30
+
21
31
  /**
22
32
  * Disposes the singleton AcpSessionManager and nulls the reference.
23
33
  */
@@ -691,6 +691,41 @@ export class AcpSessionManager {
691
691
  }
692
692
  }
693
693
 
694
+ /**
695
+ * Cancels every in-flight session spawned by `parentConversationId`.
696
+ *
697
+ * Mirrors the subagent manager's `abortAllForParent`: when the user cancels
698
+ * a turn, the ACP agents it launched should stop rather than keep running in
699
+ * the background — holding a child process — and then, on completion, enqueue
700
+ * a follow-up message into the conversation the user just stopped. Cancelling
701
+ * settles each in-flight prompt down its `"cancelled"` path, which sends a
702
+ * client event but does NOT notify the parent, so no model activity follows
703
+ * the stop.
704
+ *
705
+ * Each session's `cancel()` runs detached (it awaits a protocol notification
706
+ * to the child) so callers on the cancel hot path never block on an
707
+ * unresponsive agent; failures are logged. Session ids are snapshotted before
708
+ * dispatch so concurrent teardown can't disturb the iteration. Returns the
709
+ * number of sessions a cancel was kicked off for.
710
+ */
711
+ cancelForParent(parentConversationId: string): number {
712
+ const ids: string[] = [];
713
+ for (const [acpSessionId, entry] of this.sessions) {
714
+ if (entry.parentConversationId === parentConversationId) {
715
+ ids.push(acpSessionId);
716
+ }
717
+ }
718
+ for (const acpSessionId of ids) {
719
+ void this.cancel(acpSessionId).catch((err) => {
720
+ log.warn(
721
+ { acpSessionId, parentConversationId, err },
722
+ "Failed to cancel ACP session on parent cancel",
723
+ );
724
+ });
725
+ }
726
+ return ids.length;
727
+ }
728
+
694
729
  /**
695
730
  * Kills the agent process and removes the session from tracking.
696
731
  *