@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.
Files changed (55) hide show
  1. package/openapi.yaml +5 -16
  2. package/package.json +1 -1
  3. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -0
  4. package/src/__tests__/cancel-resolves-conversation-key.test.ts +0 -1
  5. package/src/__tests__/conversation-clear-safety.test.ts +0 -1
  6. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -1
  7. package/src/__tests__/conversation-error.test.ts +0 -18
  8. package/src/__tests__/conversation-history-web-search.test.ts +0 -137
  9. package/src/__tests__/conversation-process-callsite.test.ts +37 -0
  10. package/src/__tests__/conversation-queue.test.ts +43 -0
  11. package/src/__tests__/conversation-routes-slash-commands.test.ts +46 -1
  12. package/src/__tests__/job-handler-registry-guard.test.ts +1 -0
  13. package/src/__tests__/managed-store.test.ts +75 -1
  14. package/src/__tests__/mtime-cache.test.ts +19 -0
  15. package/src/__tests__/plugin-bootstrap.test.ts +27 -13
  16. package/src/__tests__/plugin-injector-contribution.test.ts +4 -20
  17. package/src/__tests__/plugin-route-contribution.test.ts +62 -115
  18. package/src/__tests__/plugin-tool-contribution.test.ts +0 -22
  19. package/src/__tests__/prune-old-tool-invocations-job.test.ts +97 -0
  20. package/src/__tests__/skill-script-runner-host.test.ts +53 -0
  21. package/src/__tests__/skill-script-runner-sandbox.test.ts +5 -2
  22. package/src/__tests__/skill-tool-factory.test.ts +20 -0
  23. package/src/__tests__/unified-turn-context-client-os.test.ts +45 -0
  24. package/src/api/events/conversation-error.ts +0 -1
  25. package/src/channels/__tests__/types.test.ts +28 -0
  26. package/src/channels/types.ts +23 -0
  27. package/src/cli/program.ts +1 -1
  28. package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
  29. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +9 -9
  30. package/src/daemon/conversation-agent-loop.ts +7 -0
  31. package/src/daemon/conversation-error.ts +1 -15
  32. package/src/daemon/conversation-history.ts +2 -228
  33. package/src/daemon/conversation-process.ts +7 -0
  34. package/src/daemon/conversation-runtime-assembly.ts +8 -0
  35. package/src/daemon/conversation-store.ts +1 -0
  36. package/src/daemon/conversation.ts +23 -0
  37. package/src/daemon/external-plugins-bootstrap.ts +36 -77
  38. package/src/daemon/handlers/conversations.ts +0 -52
  39. package/src/daemon/lifecycle.ts +1 -13
  40. package/src/daemon/message-types/conversations.ts +10 -0
  41. package/src/daemon/shutdown-handlers.ts +24 -3
  42. package/src/jobs/register-job-handlers.ts +4 -0
  43. package/src/persistence/job-handlers/cleanup.ts +27 -0
  44. package/src/persistence/jobs-store.ts +50 -0
  45. package/src/persistence/jobs-worker.ts +9 -0
  46. package/src/plugin-api/types.ts +21 -9
  47. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  48. package/src/plugins/defaults/turn-context/unified-turn-context.ts +4 -0
  49. package/src/plugins/mtime-cache.ts +26 -46
  50. package/src/plugins/types.ts +8 -0
  51. package/src/runtime/routes/conversation-management-routes.ts +0 -37
  52. package/src/runtime/routes/conversation-routes.ts +20 -0
  53. package/src/skills/managed-store.ts +25 -6
  54. package/src/tools/skills/skill-script-runner.ts +17 -0
  55. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606301343.ebf8f0b",
3
+ "version": "0.10.3-dev.202606301559.bafb7ae",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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";
@@ -33,7 +33,6 @@ mock.module("../daemon/handlers/conversations.js", () => ({
33
33
  },
34
34
  switchConversation: async () => null,
35
35
  undoLastMessage: async () => null,
36
- regenerateResponse: async () => null,
37
36
  }));
38
37
 
39
38
  import {
@@ -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 { beforeEach, describe, expect, mock, test } from "bun:test";
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
+ });
@@ -53,6 +53,7 @@ const NON_PLUGIN_JOB_TYPES = [
53
53
  "prune_old_conversations",
54
54
  "prune_old_llm_request_logs",
55
55
  "prune_old_trace_events",
56
+ "prune_old_tool_invocations",
56
57
  "build_conversation_summary",
57
58
  "media_processing",
58
59
  "conversation_analyze",
@@ -453,7 +453,12 @@ describe("validateCompanionPath", () => {
453
453
  });
454
454
 
455
455
  test("rejects store-owned top-level files", () => {
456
- for (const reserved of ["SKILL.md", "install-meta.json", "version.json"]) {
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
- * - Shutdown hook walks plugins in reverse registration order.
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 order: onShutdown fires in reverse registration order", async () => {
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
- await runShutdownHooks("test-shutdown");
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
- // The last plugin to register must shut down first; the first to register
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 runShutdownHooks("test-shutdown");
471
+ await runHook(HOOKS.SHUTDOWN, {
472
+ assistantVersion: APP_VERSION,
473
+ reason: "shutdown",
474
+ });
467
475
 
468
- // The shutdown hook is a single registered callback that walks a
469
- // snapshot taken at bootstrap. A skipped plugin should never appear in
470
- // that snapshot, so its `onShutdown` must never fire.
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 runShutdownHooks("test-shutdown");
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 });