@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.
- package/package.json +1 -1
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +1 -40
- package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
- package/src/__tests__/app-source-watcher.test.ts +30 -10
- package/src/__tests__/config-schema.test.ts +34 -0
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop.test.ts +3 -0
- package/src/__tests__/conversation-process-callsite.test.ts +0 -14
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
- package/src/__tests__/heartbeat-service.test.ts +6 -0
- package/src/__tests__/list-messages-attachments.test.ts +41 -0
- package/src/__tests__/plugin-source-watcher.test.ts +33 -1
- package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
- package/src/acp/__tests__/session-manager.test.ts +72 -1
- package/src/acp/index.ts +10 -0
- package/src/acp/session-manager.ts +35 -0
- package/src/agent/loop.ts +28 -22
- package/src/config/schemas/memory-lifecycle.ts +5 -3
- package/src/config/schemas/timeouts.ts +24 -0
- package/src/daemon/app-source-watcher.ts +31 -18
- package/src/daemon/conversation-agent-loop.ts +8 -5
- package/src/daemon/conversation.ts +30 -41
- package/src/daemon/handlers/conversations.ts +7 -0
- package/src/daemon/plugin-source-watcher.ts +5 -0
- package/src/daemon/workspace-tools-watcher.ts +4 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
- package/src/heartbeat/heartbeat-service.ts +3 -4
- package/src/memory/__tests__/db-maintenance.test.ts +27 -35
- package/src/memory/conversation-crud.ts +9 -3
- package/src/memory/db-init.ts +33 -5
- package/src/memory/db-maintenance.ts +43 -38
- package/src/memory/job-handlers/cleanup.ts +6 -0
- package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
- package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/plugin-api/index.ts +7 -0
- package/src/plugin-api/vision-support.ts +75 -0
- package/src/prompts/system-prompt.ts +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +6 -4
- package/src/runtime/agent-wake.ts +15 -7
- package/src/runtime/routes/conversation-routes.ts +24 -3
- package/src/runtime/routes/migration-routes.ts +35 -39
- package/src/schedule/scheduler.ts +5 -9
- package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
- package/src/tools/ask-question/ask-question-tool.ts +14 -73
- package/src/util/fs-watcher-error.ts +36 -0
package/package.json
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
* for these knobs is silently ignored.
|
|
10
10
|
*
|
|
11
11
|
* Precedence (highest wins):
|
|
12
|
-
* 1. Per-
|
|
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:
|
|
23
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
*
|