@vellumai/assistant 0.10.3-dev.202606301343.ebf8f0b → 0.10.3-dev.202606301559.bafb7ae
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.yaml +5 -16
- package/package.json +1 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -0
- package/src/__tests__/cancel-resolves-conversation-key.test.ts +0 -1
- package/src/__tests__/conversation-clear-safety.test.ts +0 -1
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -1
- package/src/__tests__/conversation-error.test.ts +0 -18
- package/src/__tests__/conversation-history-web-search.test.ts +0 -137
- package/src/__tests__/conversation-process-callsite.test.ts +37 -0
- package/src/__tests__/conversation-queue.test.ts +43 -0
- package/src/__tests__/conversation-routes-slash-commands.test.ts +46 -1
- package/src/__tests__/job-handler-registry-guard.test.ts +1 -0
- package/src/__tests__/managed-store.test.ts +75 -1
- package/src/__tests__/mtime-cache.test.ts +19 -0
- package/src/__tests__/plugin-bootstrap.test.ts +27 -13
- package/src/__tests__/plugin-injector-contribution.test.ts +4 -20
- package/src/__tests__/plugin-route-contribution.test.ts +62 -115
- package/src/__tests__/plugin-tool-contribution.test.ts +0 -22
- package/src/__tests__/prune-old-tool-invocations-job.test.ts +97 -0
- package/src/__tests__/skill-script-runner-host.test.ts +53 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +5 -2
- package/src/__tests__/skill-tool-factory.test.ts +20 -0
- package/src/__tests__/unified-turn-context-client-os.test.ts +45 -0
- package/src/api/events/conversation-error.ts +0 -1
- package/src/channels/__tests__/types.test.ts +28 -0
- package/src/channels/types.ts +23 -0
- package/src/cli/program.ts +1 -1
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
- package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +9 -9
- package/src/daemon/conversation-agent-loop.ts +7 -0
- package/src/daemon/conversation-error.ts +1 -15
- package/src/daemon/conversation-history.ts +2 -228
- package/src/daemon/conversation-process.ts +7 -0
- package/src/daemon/conversation-runtime-assembly.ts +8 -0
- package/src/daemon/conversation-store.ts +1 -0
- package/src/daemon/conversation.ts +23 -0
- package/src/daemon/external-plugins-bootstrap.ts +36 -77
- package/src/daemon/handlers/conversations.ts +0 -52
- package/src/daemon/lifecycle.ts +1 -13
- package/src/daemon/message-types/conversations.ts +10 -0
- package/src/daemon/shutdown-handlers.ts +24 -3
- package/src/jobs/register-job-handlers.ts +4 -0
- package/src/persistence/job-handlers/cleanup.ts +27 -0
- package/src/persistence/jobs-store.ts +50 -0
- package/src/persistence/jobs-worker.ts +9 -0
- package/src/plugin-api/types.ts +21 -9
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +4 -0
- package/src/plugins/mtime-cache.ts +26 -46
- package/src/plugins/types.ts +8 -0
- package/src/runtime/routes/conversation-management-routes.ts +0 -37
- package/src/runtime/routes/conversation-routes.ts +20 -0
- package/src/skills/managed-store.ts +25 -6
- package/src/tools/skills/skill-script-runner.ts +17 -0
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +0 -203
package/openapi.yaml
CHANGED
|
@@ -7213,22 +7213,6 @@ paths:
|
|
|
7213
7213
|
responses:
|
|
7214
7214
|
"200":
|
|
7215
7215
|
description: Successful response
|
|
7216
|
-
/v1/conversations/{id}/regenerate:
|
|
7217
|
-
post:
|
|
7218
|
-
operationId: conversations_by_id_regenerate_post
|
|
7219
|
-
summary: Regenerate response
|
|
7220
|
-
description: Re-run the assistant for the last user message in a conversation.
|
|
7221
|
-
tags:
|
|
7222
|
-
- conversations
|
|
7223
|
-
parameters:
|
|
7224
|
-
- name: id
|
|
7225
|
-
in: path
|
|
7226
|
-
required: true
|
|
7227
|
-
schema:
|
|
7228
|
-
type: string
|
|
7229
|
-
responses:
|
|
7230
|
-
"202":
|
|
7231
|
-
description: Successful response
|
|
7232
7216
|
/v1/conversations/{id}/slash:
|
|
7233
7217
|
post:
|
|
7234
7218
|
operationId: conversations_by_id_slash_post
|
|
@@ -17894,6 +17878,11 @@ paths:
|
|
|
17894
17878
|
type: string
|
|
17895
17879
|
clientTimezone:
|
|
17896
17880
|
type: string
|
|
17881
|
+
clientOs:
|
|
17882
|
+
description:
|
|
17883
|
+
Client OS surface ("web" | "ios" | "macos" | "android"), reported separately from `interface`. Drives the
|
|
17884
|
+
per-turn `client_os` context only; does not affect transport/host-proxy capabilities.
|
|
17885
|
+
type: string
|
|
17897
17886
|
clientMessageId:
|
|
17898
17887
|
type: string
|
|
17899
17888
|
description:
|
package/package.json
CHANGED
|
@@ -186,6 +186,7 @@ mock.module("../persistence/jobs-store.js", () => ({
|
|
|
186
186
|
enqueuePruneOldConversationsJob: mock(() => "job-prune-conv"),
|
|
187
187
|
enqueuePruneOldLlmRequestLogsJob: mock(() => "job-prune-llm"),
|
|
188
188
|
enqueuePruneOldTraceEventsJob: mock(() => "job-prune-trace"),
|
|
189
|
+
enqueuePruneOldToolInvocationsJob: mock(() => "job-prune-tool"),
|
|
189
190
|
failMemoryJob: mock(() => {}),
|
|
190
191
|
failStalledJobs: mockFailStalledJobs,
|
|
191
192
|
getMemoryJobCounts: mock(() => ({})),
|
|
@@ -51,7 +51,6 @@ mock.module("../daemon/handlers/conversations.js", () => ({
|
|
|
51
51
|
switchConversation: async () => null,
|
|
52
52
|
clearAllConversations: async () => 0,
|
|
53
53
|
undoLastMessage: async () => null,
|
|
54
|
-
regenerateResponse: async () => null,
|
|
55
54
|
}));
|
|
56
55
|
|
|
57
56
|
import { getOrCreateConversation } from "../persistence/conversation-key-store.js";
|
|
@@ -26,7 +26,6 @@ mock.module("../daemon/handlers/conversations.js", () => ({
|
|
|
26
26
|
clearAllConversations: async () => 0,
|
|
27
27
|
switchConversation: async () => null,
|
|
28
28
|
undoLastMessage: async () => null,
|
|
29
|
-
regenerateResponse: async () => null,
|
|
30
29
|
}));
|
|
31
30
|
|
|
32
31
|
import type { Database } from "bun:sqlite";
|
|
@@ -520,24 +520,6 @@ describe("classifyConversationError", () => {
|
|
|
520
520
|
});
|
|
521
521
|
});
|
|
522
522
|
|
|
523
|
-
describe("regenerate phase", () => {
|
|
524
|
-
it("returns REGENERATE_FAILED with nested classification info", () => {
|
|
525
|
-
const ctx: ErrorContext = { phase: "regenerate" };
|
|
526
|
-
const result = classifyConversationError(new Error("ECONNREFUSED"), ctx);
|
|
527
|
-
expect(result.code).toBe("REGENERATE_FAILED");
|
|
528
|
-
expect(result.retryable).toBe(true);
|
|
529
|
-
expect(result.userMessage).toContain("regenerate");
|
|
530
|
-
expect(result.errorCategory).toContain("regenerate:");
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it("returns REGENERATE_FAILED for generic errors", () => {
|
|
534
|
-
const ctx: ErrorContext = { phase: "regenerate" };
|
|
535
|
-
const result = classifyConversationError(new Error("unknown issue"), ctx);
|
|
536
|
-
expect(result.code).toBe("REGENERATE_FAILED");
|
|
537
|
-
expect(result.retryable).toBe(true);
|
|
538
|
-
});
|
|
539
|
-
});
|
|
540
|
-
|
|
541
523
|
describe("generic errors", () => {
|
|
542
524
|
it("classifies unknown errors as CONVERSATION_PROCESSING_FAILED with error summary", () => {
|
|
543
525
|
const result = classifyConversationError(
|
|
@@ -82,8 +82,6 @@ mock.module("../persistence/embeddings/qdrant-client.js", () => ({
|
|
|
82
82
|
import {
|
|
83
83
|
consolidateAssistantMessages,
|
|
84
84
|
findLastUndoableUserMessageIndex,
|
|
85
|
-
type HistoryConversationContext,
|
|
86
|
-
regenerate,
|
|
87
85
|
} from "../daemon/conversation-history.js";
|
|
88
86
|
import type { ContentBlock, Message } from "../providers/types.js";
|
|
89
87
|
|
|
@@ -347,141 +345,6 @@ describe("isUndoableUserMessage with web_search_tool_result", () => {
|
|
|
347
345
|
});
|
|
348
346
|
});
|
|
349
347
|
|
|
350
|
-
// ── Test 4: regenerate handles conversations with web_search_tool_result ─
|
|
351
|
-
|
|
352
|
-
describe("regenerate with web_search_tool_result", () => {
|
|
353
|
-
beforeEach(() => {
|
|
354
|
-
dbMessages = [];
|
|
355
|
-
deletedMessageIds = [];
|
|
356
|
-
updatedMessages = [];
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test("regenerate skips web_search_tool_result-only user messages when finding last real user message", async () => {
|
|
360
|
-
const conversationId = "conv-ws-regen";
|
|
361
|
-
|
|
362
|
-
// DB messages: user → assistant(server_tool_use) → user(web_search_tool_result) → assistant(text)
|
|
363
|
-
dbMessages = [
|
|
364
|
-
makeDbMessage(
|
|
365
|
-
"msg-u1",
|
|
366
|
-
conversationId,
|
|
367
|
-
"user",
|
|
368
|
-
[{ type: "text", text: "search for X" }],
|
|
369
|
-
1000,
|
|
370
|
-
),
|
|
371
|
-
makeDbMessage(
|
|
372
|
-
"msg-a1",
|
|
373
|
-
conversationId,
|
|
374
|
-
"assistant",
|
|
375
|
-
[
|
|
376
|
-
{
|
|
377
|
-
type: "server_tool_use",
|
|
378
|
-
id: "srvtoolu_regen",
|
|
379
|
-
name: "web_search",
|
|
380
|
-
input: { query: "X" },
|
|
381
|
-
},
|
|
382
|
-
],
|
|
383
|
-
2000,
|
|
384
|
-
),
|
|
385
|
-
makeDbMessage(
|
|
386
|
-
"msg-ws",
|
|
387
|
-
conversationId,
|
|
388
|
-
"user",
|
|
389
|
-
[
|
|
390
|
-
{
|
|
391
|
-
type: "web_search_tool_result",
|
|
392
|
-
tool_use_id: "srvtoolu_regen",
|
|
393
|
-
content: [],
|
|
394
|
-
},
|
|
395
|
-
],
|
|
396
|
-
3000,
|
|
397
|
-
),
|
|
398
|
-
makeDbMessage(
|
|
399
|
-
"msg-a2",
|
|
400
|
-
conversationId,
|
|
401
|
-
"assistant",
|
|
402
|
-
[{ type: "text", text: "Results here." }],
|
|
403
|
-
4000,
|
|
404
|
-
),
|
|
405
|
-
];
|
|
406
|
-
|
|
407
|
-
// In-memory messages matching DB
|
|
408
|
-
const inMemoryMessages: Message[] = [
|
|
409
|
-
{
|
|
410
|
-
role: "user",
|
|
411
|
-
content: [{ type: "text", text: "search for X" }],
|
|
412
|
-
},
|
|
413
|
-
{
|
|
414
|
-
role: "assistant",
|
|
415
|
-
content: [
|
|
416
|
-
{
|
|
417
|
-
type: "server_tool_use",
|
|
418
|
-
id: "srvtoolu_regen",
|
|
419
|
-
name: "web_search",
|
|
420
|
-
input: { query: "X" },
|
|
421
|
-
},
|
|
422
|
-
],
|
|
423
|
-
},
|
|
424
|
-
{
|
|
425
|
-
role: "user",
|
|
426
|
-
content: [
|
|
427
|
-
{
|
|
428
|
-
type: "web_search_tool_result",
|
|
429
|
-
tool_use_id: "srvtoolu_regen",
|
|
430
|
-
content: [],
|
|
431
|
-
},
|
|
432
|
-
],
|
|
433
|
-
},
|
|
434
|
-
{
|
|
435
|
-
role: "assistant",
|
|
436
|
-
content: [{ type: "text", text: "Results here." }],
|
|
437
|
-
},
|
|
438
|
-
];
|
|
439
|
-
|
|
440
|
-
let agentLoopCalled = false;
|
|
441
|
-
let agentLoopContent = "";
|
|
442
|
-
let agentLoopUserMessageId = "";
|
|
443
|
-
|
|
444
|
-
const events: Array<{ type: string; message?: string }> = [];
|
|
445
|
-
|
|
446
|
-
let processing = false;
|
|
447
|
-
const session: HistoryConversationContext = {
|
|
448
|
-
conversationId,
|
|
449
|
-
traceEmitter: {
|
|
450
|
-
emit: () => {},
|
|
451
|
-
} as unknown as HistoryConversationContext["traceEmitter"],
|
|
452
|
-
sendToClient: (msg) => events.push(msg),
|
|
453
|
-
messages: [...inMemoryMessages],
|
|
454
|
-
isProcessing: () => processing,
|
|
455
|
-
setProcessing: (value: boolean) => {
|
|
456
|
-
processing = value;
|
|
457
|
-
},
|
|
458
|
-
abortController: null,
|
|
459
|
-
async runAgentLoop(content, userMessageId) {
|
|
460
|
-
agentLoopCalled = true;
|
|
461
|
-
agentLoopContent = content;
|
|
462
|
-
agentLoopUserMessageId = userMessageId;
|
|
463
|
-
},
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
await regenerate(session);
|
|
467
|
-
|
|
468
|
-
// regenerate should find the real user message (msg-u1) and skip the
|
|
469
|
-
// web_search_tool_result-only message (msg-ws).
|
|
470
|
-
// BUG: Currently, regenerate only checks for tool_result in the
|
|
471
|
-
// `parsed.every(b => b.type === "tool_result")` check, so msg-ws
|
|
472
|
-
// is treated as a real user message, and regenerate gets confused.
|
|
473
|
-
|
|
474
|
-
expect(agentLoopCalled).toBe(true);
|
|
475
|
-
expect(agentLoopUserMessageId).toBe("msg-u1");
|
|
476
|
-
expect(agentLoopContent).toBe("search for X");
|
|
477
|
-
|
|
478
|
-
// Messages after the user message should be deleted
|
|
479
|
-
expect(deletedMessageIds).toContain("msg-a1");
|
|
480
|
-
expect(deletedMessageIds).toContain("msg-ws");
|
|
481
|
-
expect(deletedMessageIds).toContain("msg-a2");
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
348
|
// ── Test 5: ensureToolPairing preserves server_tool_use / web_search_tool_result pairs ─
|
|
486
349
|
|
|
487
350
|
describe("ensureToolPairing with server_tool_use / web_search_tool_result", () => {
|
|
@@ -431,4 +431,41 @@ describe("processMessage callSite threading", () => {
|
|
|
431
431
|
|
|
432
432
|
expect(conversation.clientTimezone).toBe("Europe/London");
|
|
433
433
|
});
|
|
434
|
+
|
|
435
|
+
test("applies clientOs in the create and reuse transport metadata path", async () => {
|
|
436
|
+
mockConversation = {
|
|
437
|
+
id: "conv-store-client-os",
|
|
438
|
+
contextSummary: null,
|
|
439
|
+
contextCompactedMessageCount: 0,
|
|
440
|
+
totalInputTokens: 0,
|
|
441
|
+
totalOutputTokens: 0,
|
|
442
|
+
totalEstimatedCost: 0,
|
|
443
|
+
};
|
|
444
|
+
mockDbMessages = [];
|
|
445
|
+
clearCaptured();
|
|
446
|
+
clearAllActiveConversations();
|
|
447
|
+
|
|
448
|
+
// `interface` is always the transport surface (here "web"); the OS is
|
|
449
|
+
// carried separately as `clientOs` and must be applied on both the create
|
|
450
|
+
// and reuse paths so each turn reflects its own surface.
|
|
451
|
+
const conversation = await getOrCreateConversation("conv-store-client-os", {
|
|
452
|
+
transport: {
|
|
453
|
+
channelId: "vellum",
|
|
454
|
+
interfaceId: "web",
|
|
455
|
+
clientOs: "macos",
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
expect(conversation.clientOs).toBe("macos");
|
|
460
|
+
|
|
461
|
+
await getOrCreateConversation("conv-store-client-os", {
|
|
462
|
+
transport: {
|
|
463
|
+
channelId: "vellum",
|
|
464
|
+
interfaceId: "web",
|
|
465
|
+
clientOs: "ios",
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
expect(conversation.clientOs).toBe("ios");
|
|
470
|
+
});
|
|
434
471
|
});
|
|
@@ -681,6 +681,49 @@ describe("Conversation message queue", () => {
|
|
|
681
681
|
expect(events3.some((e) => e.type === "message_complete")).toBe(true);
|
|
682
682
|
});
|
|
683
683
|
|
|
684
|
+
test("[experimental] queued passthrough siblings from different client OS do NOT batch", async () => {
|
|
685
|
+
// Post-decouple, web/iOS/macOS all report interfaceId "web", so the
|
|
686
|
+
// interface-based batch split no longer separates them. A batched turn
|
|
687
|
+
// applies only the head's clientOs, so messages from different OS surfaces
|
|
688
|
+
// must split into separate runs rather than coalesce under one OS.
|
|
689
|
+
const conversation = makeConversation();
|
|
690
|
+
await conversation.loadFromDb();
|
|
691
|
+
|
|
692
|
+
const p1 = conversation.processMessage({
|
|
693
|
+
content: "msg-1",
|
|
694
|
+
attachments: [],
|
|
695
|
+
onEvent: () => {},
|
|
696
|
+
requestId: "req-1",
|
|
697
|
+
});
|
|
698
|
+
await waitForPendingRun(1);
|
|
699
|
+
|
|
700
|
+
// Two siblings on the same transport interface ("web") but different OS.
|
|
701
|
+
conversation.enqueueMessage({
|
|
702
|
+
content: "msg-2",
|
|
703
|
+
onEvent: () => {},
|
|
704
|
+
requestId: "req-2",
|
|
705
|
+
transport: { channelId: "vellum", interfaceId: "web", clientOs: "macos" },
|
|
706
|
+
});
|
|
707
|
+
conversation.enqueueMessage({
|
|
708
|
+
content: "msg-3",
|
|
709
|
+
onEvent: () => {},
|
|
710
|
+
requestId: "req-3",
|
|
711
|
+
transport: { channelId: "vellum", interfaceId: "web", clientOs: "ios" },
|
|
712
|
+
});
|
|
713
|
+
expect(conversation.getQueueDepth()).toBe(2);
|
|
714
|
+
|
|
715
|
+
// Drain: msg-2 (macos) is the batch head; msg-3 (ios) has a different
|
|
716
|
+
// clientOs, so it must NOT join the batch.
|
|
717
|
+
await resolveRun(0);
|
|
718
|
+
await p1;
|
|
719
|
+
await waitForPendingRun(2);
|
|
720
|
+
await resolveRun(1);
|
|
721
|
+
await waitForPendingRun(3);
|
|
722
|
+
|
|
723
|
+
// Three runs total (msg-1, msg-2, msg-3) — msg-3 was not batched with msg-2.
|
|
724
|
+
expect(pendingRuns.length).toBe(3);
|
|
725
|
+
});
|
|
726
|
+
|
|
684
727
|
test("message_queued and message_dequeued events are emitted", async () => {
|
|
685
728
|
const conversation = makeConversation();
|
|
686
729
|
await conversation.loadFromDb();
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* do NOT trigger the agent loop.
|
|
7
7
|
* - Regular messages pass through to the agent loop unchanged.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
12
|
|
|
11
13
|
mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
|
|
12
14
|
|
|
@@ -302,6 +304,7 @@ function makeConversation() {
|
|
|
302
304
|
forceCompact,
|
|
303
305
|
setPreactivatedSkillIds,
|
|
304
306
|
drainQueue: async () => {},
|
|
307
|
+
warmPromptCache: () => {},
|
|
305
308
|
getMessages: () => messages,
|
|
306
309
|
assistantId: "self",
|
|
307
310
|
trustContext: undefined,
|
|
@@ -545,3 +548,45 @@ describe("handleSendMessage slash command interception", () => {
|
|
|
545
548
|
expect(ipcCallMock).not.toHaveBeenCalled();
|
|
546
549
|
});
|
|
547
550
|
});
|
|
551
|
+
|
|
552
|
+
// The first-message wake-up greeting ("Wake up, my friend!") is served as a
|
|
553
|
+
// canned response that skips the agent loop, just like a slash command. Its
|
|
554
|
+
// user row must still carry the client-generated idempotency nonce so the web
|
|
555
|
+
// client can reconcile its optimistic row against the persisted one — otherwise
|
|
556
|
+
// the greeting renders twice (see the "two wakeup messages" staging report).
|
|
557
|
+
describe("handleSendMessage canned wake-up greeting", () => {
|
|
558
|
+
// `isWakeUpGreeting` resolves BOOTSTRAP.md via getWorkspacePromptPath, which
|
|
559
|
+
// is rooted at VELLUM_WORKSPACE_DIR (the per-test temp workspace).
|
|
560
|
+
const bootstrapPath = join(process.env.VELLUM_WORKSPACE_DIR!, "BOOTSTRAP.md");
|
|
561
|
+
|
|
562
|
+
beforeEach(() => {
|
|
563
|
+
addMessageMock.mockClear();
|
|
564
|
+
// `isWakeUpGreeting` only treats the message as the wake-up greeting when
|
|
565
|
+
// BOOTSTRAP.md exists at the workspace prompt path (i.e. a first run).
|
|
566
|
+
writeFileSync(bootstrapPath, "# Bootstrap\n\nFirst run.");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
afterEach(() => {
|
|
570
|
+
if (existsSync(bootstrapPath)) rmSync(bootstrapPath, { force: true });
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("persists the clientMessageId on the user row", async () => {
|
|
574
|
+
const { conversation, runAgentLoop } = makeConversation();
|
|
575
|
+
const res = await callHandler(
|
|
576
|
+
(args) => handleSendMessage(args, makeDeps(conversation)),
|
|
577
|
+
makeRequest("Wake up, my friend!", {
|
|
578
|
+
clientMessageId: "nonce-wake-123",
|
|
579
|
+
}),
|
|
580
|
+
undefined,
|
|
581
|
+
202,
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
expect(res.status).toBe(202);
|
|
585
|
+
// Canned greeting path: user + assistant rows persisted, agent loop skipped.
|
|
586
|
+
expect(runAgentLoop).not.toHaveBeenCalled();
|
|
587
|
+
const userCall = addMessageMock.mock.calls.find((c) => c[1] === "user");
|
|
588
|
+
expect(userCall).toBeDefined();
|
|
589
|
+
const options = userCall?.[3] as { clientMessageId?: string } | undefined;
|
|
590
|
+
expect(options?.clientMessageId).toBe("nonce-wake-123");
|
|
591
|
+
});
|
|
592
|
+
});
|
|
@@ -453,7 +453,12 @@ describe("validateCompanionPath", () => {
|
|
|
453
453
|
});
|
|
454
454
|
|
|
455
455
|
test("rejects store-owned top-level files", () => {
|
|
456
|
-
for (const reserved of [
|
|
456
|
+
for (const reserved of [
|
|
457
|
+
"SKILL.md",
|
|
458
|
+
"install-meta.json",
|
|
459
|
+
"version.json",
|
|
460
|
+
"TOOLS.json",
|
|
461
|
+
]) {
|
|
457
462
|
expect(validateCompanionPath(skillDir, reserved).error).toContain(
|
|
458
463
|
"store-owned",
|
|
459
464
|
);
|
|
@@ -463,6 +468,38 @@ describe("validateCompanionPath", () => {
|
|
|
463
468
|
validateCompanionPath(skillDir, "references/SKILL.md").error,
|
|
464
469
|
).toBeUndefined();
|
|
465
470
|
});
|
|
471
|
+
|
|
472
|
+
test("rejects a top-level TOOLS.json companion to block planting executable tools", () => {
|
|
473
|
+
// TOOLS.json is the manifest the skill loader scans to register (and
|
|
474
|
+
// dynamically import) executable tools. A scaffold author must never be
|
|
475
|
+
// able to plant one — otherwise an instruction-only managed skill becomes a
|
|
476
|
+
// code-injection surface.
|
|
477
|
+
expect(validateCompanionPath(skillDir, "TOOLS.json").error).toContain(
|
|
478
|
+
"store-owned",
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("rejects case variants of reserved names (case-insensitive filesystems)", () => {
|
|
483
|
+
// On macOS (case-insensitive FS), `tools.json` resolves to the same file
|
|
484
|
+
// the scanner reads as `TOOLS.json`, so a varied-case name must be rejected
|
|
485
|
+
// too — otherwise the guard is trivially bypassed. Same for SKILL.md.
|
|
486
|
+
for (const variant of [
|
|
487
|
+
"tools.json",
|
|
488
|
+
"Tools.json",
|
|
489
|
+
"TOOLS.JSON",
|
|
490
|
+
"skill.md",
|
|
491
|
+
"Skill.MD",
|
|
492
|
+
"INSTALL-META.JSON",
|
|
493
|
+
]) {
|
|
494
|
+
expect(validateCompanionPath(skillDir, variant).error).toContain(
|
|
495
|
+
"store-owned",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
// Nested case variants remain allowed — only top-level is scanned.
|
|
499
|
+
expect(
|
|
500
|
+
validateCompanionPath(skillDir, "references/tools.json").error,
|
|
501
|
+
).toBeUndefined();
|
|
502
|
+
});
|
|
466
503
|
});
|
|
467
504
|
|
|
468
505
|
describe("createManagedSkill companion files", () => {
|
|
@@ -604,6 +641,43 @@ describe("createManagedSkill companion files", () => {
|
|
|
604
641
|
).toBe(false);
|
|
605
642
|
});
|
|
606
643
|
|
|
644
|
+
test("rejects a TOOLS.json companion and writes nothing", () => {
|
|
645
|
+
// Planting a TOOLS.json declaring execution_target/risk would let a
|
|
646
|
+
// scaffolded skill register attacker-controlled tools. The reserved-name
|
|
647
|
+
// check must reject it before any write, leaving no SKILL.md or manifest.
|
|
648
|
+
const result = createManagedSkill({
|
|
649
|
+
id: "tools-manifest",
|
|
650
|
+
name: "Tools Manifest",
|
|
651
|
+
description: "Tries to plant a tool manifest",
|
|
652
|
+
bodyMarkdown: "Body.",
|
|
653
|
+
files: [
|
|
654
|
+
{
|
|
655
|
+
path: "TOOLS.json",
|
|
656
|
+
content: JSON.stringify({
|
|
657
|
+
version: 1,
|
|
658
|
+
tools: [
|
|
659
|
+
{
|
|
660
|
+
name: "pwn",
|
|
661
|
+
description: "x",
|
|
662
|
+
category: "x",
|
|
663
|
+
risk: "low",
|
|
664
|
+
input_schema: { type: "object" },
|
|
665
|
+
executor: "tools/pwn.ts",
|
|
666
|
+
execution_target: "host",
|
|
667
|
+
},
|
|
668
|
+
],
|
|
669
|
+
}),
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
expect(result.created).toBe(false);
|
|
675
|
+
expect(result.error).toContain("store-owned");
|
|
676
|
+
const skillDir = join(TEST_DIR, "skills", "tools-manifest");
|
|
677
|
+
expect(existsSync(join(skillDir, "TOOLS.json"))).toBe(false);
|
|
678
|
+
expect(existsSync(join(skillDir, "SKILL.md"))).toBe(false);
|
|
679
|
+
});
|
|
680
|
+
|
|
607
681
|
test("overwrite re-writes companion files", () => {
|
|
608
682
|
createManagedSkill({
|
|
609
683
|
id: "overwrite-files",
|
|
@@ -629,6 +629,25 @@ describe("plugin runtime activation", () => {
|
|
|
629
629
|
expect(existsSync(shutdownMarker)).toBe(true);
|
|
630
630
|
});
|
|
631
631
|
|
|
632
|
+
test("a user plugin's shutdown hook is surfaced through the unified hook lookup", async () => {
|
|
633
|
+
// Plugin `shutdown` hooks fire at daemon shutdown through the same
|
|
634
|
+
// getHooksFor/runHook pipeline as every other lifecycle hook. Prove the
|
|
635
|
+
// user-land side is discoverable that way: the plugin's shutdown hook is
|
|
636
|
+
// returned by the unified per-name lookup and runs when invoked.
|
|
637
|
+
const dir = freshPluginDir("shutdown-hook-plugin");
|
|
638
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "shutdown-hook-plugin" });
|
|
639
|
+
const shutdownMarker = join(ROOT, "user-shutdown.log");
|
|
640
|
+
writeMarkerHook(dir, "shutdown", shutdownMarker, "bye");
|
|
641
|
+
|
|
642
|
+
await populateCacheAtBoot();
|
|
643
|
+
|
|
644
|
+
const shutdownHooks = await getUserHooksFor("shutdown");
|
|
645
|
+
expect(shutdownHooks).toHaveLength(1);
|
|
646
|
+
|
|
647
|
+
await shutdownHooks[0]!({ assistantVersion: "test", reason: "shutdown" });
|
|
648
|
+
expect(existsSync(shutdownMarker)).toBe(true);
|
|
649
|
+
});
|
|
650
|
+
|
|
632
651
|
test("disabling a plugin at runtime tears down its tools", async () => {
|
|
633
652
|
const dir = freshPluginDir("disable-plugin");
|
|
634
653
|
writePackageJson(dir, { ...SIMPLE_PKG, name: "disable-plugin" });
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* - Version-mismatch registration fails with an error that names the plugin
|
|
8
8
|
* (the registry enforces this at `registerPlugin` time, so bootstrap never
|
|
9
9
|
* sees the malformed plugin).
|
|
10
|
-
* -
|
|
10
|
+
* - Plugins' `shutdown` hooks fire through the unified `runHook(HOOKS.SHUTDOWN)`
|
|
11
|
+
* pipeline; flag-gated and `.disabled` plugins are excluded from that dispatch.
|
|
11
12
|
*
|
|
12
13
|
* `resetPluginRegistryForTests()` isolates registry state between cases.
|
|
13
14
|
*/
|
|
@@ -20,9 +21,10 @@ import { beforeEach, describe, expect, test } from "bun:test";
|
|
|
20
21
|
|
|
21
22
|
import { clearFeatureFlagOverridesCache } from "../config/assistant-feature-flags.js";
|
|
22
23
|
import { bootstrapPlugins } from "../daemon/external-plugins-bootstrap.js";
|
|
23
|
-
import { runShutdownHooks } from "../daemon/shutdown-registry.js";
|
|
24
24
|
import { RiskLevel } from "../permissions/types.js";
|
|
25
|
+
import { HOOKS } from "../plugin-api/constants.js";
|
|
25
26
|
import { registerDefaultPlugins } from "../plugins/defaults/index.js";
|
|
27
|
+
import { runHook } from "../plugins/pipeline.js";
|
|
26
28
|
import {
|
|
27
29
|
closeRegistration,
|
|
28
30
|
getRegisteredPlugins,
|
|
@@ -233,7 +235,7 @@ describe("plugin bootstrap", () => {
|
|
|
233
235
|
expect(names).toContain("default-title-generate");
|
|
234
236
|
});
|
|
235
237
|
|
|
236
|
-
test("shutdown
|
|
238
|
+
test("shutdown: onShutdown fires through the runHook pipeline in registration order", async () => {
|
|
237
239
|
const callOrder: string[] = [];
|
|
238
240
|
registerPlugin(
|
|
239
241
|
buildPlugin("first-registered", {
|
|
@@ -251,12 +253,15 @@ describe("plugin bootstrap", () => {
|
|
|
251
253
|
);
|
|
252
254
|
|
|
253
255
|
await bootstrapPlugins();
|
|
254
|
-
|
|
256
|
+
// The plugins' `shutdown` hooks fire through the unified pipeline — the same
|
|
257
|
+
// dispatch path (and registration order) as every other lifecycle hook, so
|
|
258
|
+
// the first plugin to register runs first.
|
|
259
|
+
await runHook(HOOKS.SHUTDOWN, {
|
|
260
|
+
assistantVersion: APP_VERSION,
|
|
261
|
+
reason: "shutdown",
|
|
262
|
+
});
|
|
255
263
|
|
|
256
|
-
|
|
257
|
-
// shuts down last. Symmetric tear-down around registration order is the
|
|
258
|
-
// whole point of the reverse walk.
|
|
259
|
-
expect(callOrder).toEqual(["second-registered", "first-registered"]);
|
|
264
|
+
expect(callOrder).toEqual(["first-registered", "second-registered"]);
|
|
260
265
|
});
|
|
261
266
|
|
|
262
267
|
test("empty registry: bootstrap seeds the first-party defaults without throwing", async () => {
|
|
@@ -463,11 +468,14 @@ describe("plugin bootstrap", () => {
|
|
|
463
468
|
registerPlugin(plugin);
|
|
464
469
|
|
|
465
470
|
await bootstrapPlugins();
|
|
466
|
-
await
|
|
471
|
+
await runHook(HOOKS.SHUTDOWN, {
|
|
472
|
+
assistantVersion: APP_VERSION,
|
|
473
|
+
reason: "shutdown",
|
|
474
|
+
});
|
|
467
475
|
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
476
|
+
// A flag-gated plugin is dropped from the registry at bootstrap
|
|
477
|
+
// (`unregisterPlugin`), so it never appears in the `getHooksFor("shutdown")`
|
|
478
|
+
// the pipeline dispatches — its `onShutdown` must never fire.
|
|
471
479
|
expect(shutdownFired).toBe(false);
|
|
472
480
|
});
|
|
473
481
|
|
|
@@ -549,8 +557,14 @@ describe("plugin bootstrap", () => {
|
|
|
549
557
|
await writeFile(join(sentinelDir, ".disabled"), "");
|
|
550
558
|
|
|
551
559
|
await bootstrapPlugins();
|
|
552
|
-
await
|
|
560
|
+
await runHook(HOOKS.SHUTDOWN, {
|
|
561
|
+
assistantVersion: APP_VERSION,
|
|
562
|
+
reason: "shutdown",
|
|
563
|
+
});
|
|
553
564
|
|
|
565
|
+
// A `.disabled` plugin keeps its hooks registered but they are filtered out
|
|
566
|
+
// at read time by `isPluginDisabled` inside `getHooksFor`, so the pipeline
|
|
567
|
+
// dispatch skips its `onShutdown` too.
|
|
554
568
|
expect(shutdownFired).toBe(false);
|
|
555
569
|
|
|
556
570
|
await rm(sentinelDir, { recursive: true, force: true });
|