@vellumai/assistant 0.12.3-dev.202609221821.7bf89b6 → 0.12.3-dev.202609222113.8a21e65

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 (73) hide show
  1. package/openapi.yaml +6 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  4. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  5. package/src/__tests__/conversation-pairing.test.ts +10 -0
  6. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  7. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  8. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  9. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  10. package/src/__tests__/pricing.test.ts +33 -0
  11. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  12. package/src/__tests__/skill-load-tool.test.ts +42 -11
  13. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  14. package/src/__tests__/validate-input.test.ts +73 -0
  15. package/src/api/ask-question-tool.ts +50 -0
  16. package/src/api/index.ts +5 -0
  17. package/src/calls/voice-escalation-judge.ts +1 -1
  18. package/src/cli/commands/platform/index.help.ts +2 -1
  19. package/src/cli/commands/platform/index.ts +8 -2
  20. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +60 -0
  21. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  22. package/src/cli/lib/plugin-catalog-cache.ts +17 -2
  23. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  24. package/src/config/__tests__/default-profile-catalog.test.ts +28 -0
  25. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  26. package/src/config/call-site-defaults.ts +6 -0
  27. package/src/config/default-profile-catalog.ts +30 -1
  28. package/src/config/default-profile-names.ts +36 -2
  29. package/src/config/feature-flag-registry.json +8 -0
  30. package/src/config/schemas/call-site-catalog.ts +7 -0
  31. package/src/config/schemas/llm.ts +21 -5
  32. package/src/config/sync-gated-profiles.ts +96 -44
  33. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  34. package/src/daemon/auto-profile-router.ts +220 -0
  35. package/src/daemon/conversation-agent-loop.ts +77 -4
  36. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  37. package/src/daemon/pkb-reminder-builder.ts +1 -1
  38. package/src/home/feed-types.ts +2 -0
  39. package/src/inbound/platform-callback-registration.ts +19 -6
  40. package/src/live-voice/continuation-judge.ts +1 -1
  41. package/src/notifications/README.md +11 -3
  42. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  43. package/src/notifications/conversation-pairing.ts +23 -8
  44. package/src/notifications/home-feed-mirror.ts +39 -0
  45. package/src/notifications/home-feed-side-effect.ts +46 -19
  46. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +85 -14
  47. package/src/persistence/jobs-store.ts +229 -0
  48. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  49. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  50. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  51. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  52. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  53. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  54. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  55. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  56. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  57. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  58. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  59. package/src/providers/anthropic/client.ts +2 -2
  60. package/src/{calls/__tests__/typesafe-noul.test.ts → providers/jev/ask.test.ts} +4 -4
  61. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +67 -34
  62. package/src/providers/model-catalog.ts +234 -0
  63. package/src/providers/openai/codex-models.ts +2 -0
  64. package/src/runtime/routes/platform-routes.ts +6 -1
  65. package/src/skills/validate-input.ts +75 -11
  66. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  67. package/src/tools/skills/execute.ts +1 -1
  68. package/src/tools/skills/format-input-schema.ts +15 -0
  69. package/src/tools/skills/load.ts +2 -26
  70. package/src/tools/skills/scaffold-managed.ts +86 -71
  71. package/src/tools/skills/skill-tool-factory.ts +2 -1
  72. package/src/util/worker-pipe.test.ts +56 -10
  73. package/src/util/worker-pipe.ts +9 -4
package/openapi.yaml CHANGED
@@ -26971,7 +26971,12 @@ paths:
26971
26971
  assistantId:
26972
26972
  type: string
26973
26973
  hasAssistantApiKey:
26974
- type: boolean
26974
+ anyOf:
26975
+ - type: boolean
26976
+ - type: "null"
26977
+ description:
26978
+ Whether a managed assistant API key is stored. Null when the credential store could not be read, which is
26979
+ not evidence that no key is stored.
26975
26980
  hasWebhookSecret:
26976
26981
  type: boolean
26977
26982
  clientInstallationId:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.3-dev.202609221821.7bf89b6",
3
+ "version": "0.12.3-dev.202609222113.8a21e65",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -4,8 +4,16 @@ import { credentialKey } from "../security/credential-key.js";
4
4
 
5
5
  let mockSecureKeys: Record<string, string>;
6
6
 
7
+ // Spread the real module: mocks are shared across files in a combined run,
8
+ // and a wholesale replacement drops the exports peer modules import.
9
+ const actualSecureKeys = await import("../security/secure-keys.js");
7
10
  mock.module("../security/secure-keys.js", () => ({
11
+ ...actualSecureKeys,
8
12
  getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
13
+ getSecureKeyResultAsync: async (key: string) => ({
14
+ value: mockSecureKeys[key] ?? undefined,
15
+ unreachable: false,
16
+ }),
9
17
  setSecureKeyAsync: async (key: string, value: string) => {
10
18
  mockSecureKeys[key] = value;
11
19
  return true;
@@ -5,8 +5,16 @@ import { setConfig } from "./helpers/set-config.js";
5
5
 
6
6
  let mockSecureKeys: Record<string, string>;
7
7
 
8
+ // Spread the real module: mocks are shared across files in a combined run,
9
+ // and a wholesale replacement drops the exports peer modules import.
10
+ const actualSecureKeys = await import("../security/secure-keys.js");
8
11
  mock.module("../security/secure-keys.js", () => ({
12
+ ...actualSecureKeys,
9
13
  getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
14
+ getSecureKeyResultAsync: async (key: string) => ({
15
+ value: mockSecureKeys[key] ?? undefined,
16
+ unreachable: false,
17
+ }),
10
18
  setSecureKeyAsync: async (key: string, value: string) => {
11
19
  mockSecureKeys[key] = value;
12
20
  return true;
@@ -996,6 +996,12 @@ describe("pairDeliveryWithConversation", () => {
996
996
  expect(createConversationMock).not.toHaveBeenCalled();
997
997
  expect(addMessageMock).toHaveBeenCalledTimes(1);
998
998
  expect(messagesInvalidated).toContain("conv-foreground");
999
+ // The transcript is this notification's only home, so a chat marked Done
1000
+ // comes back for it.
1001
+ const options = addMessageMock.mock.calls[0]![3] as {
1002
+ skipResurface?: boolean;
1003
+ };
1004
+ expect(options.skipResurface).toBe(false);
999
1005
  });
1000
1006
 
1001
1007
  test("appended notification body skips indexing", async () => {
@@ -1017,8 +1023,12 @@ describe("pairDeliveryWithConversation", () => {
1017
1023
 
1018
1024
  const options = addMessageMock.mock.calls[0]![3] as {
1019
1025
  skipIndexing?: boolean;
1026
+ skipResurface?: boolean;
1020
1027
  };
1021
1028
  expect(options.skipIndexing).toBe(true);
1029
+ // The bell carries this one (async-background), so the append is
1030
+ // bookkeeping and never resurfaces a chat the user marked Done.
1031
+ expect(options.skipResurface).toBe(true);
1022
1032
  });
1023
1033
 
1024
1034
  test("passive vellum signal appends regardless of the producing conversation's source", async () => {
@@ -35,6 +35,7 @@ const MEMORY_JOB_TYPES = [
35
35
  "memory_retrospective",
36
36
  "memory_retrospective_sweep",
37
37
  "skill_card_insert",
38
+ "skill_update_receipt",
38
39
  "pkb_filing",
39
40
  "pkb_compaction",
40
41
  ].sort();
@@ -446,7 +446,7 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
446
446
  expect(JSON.stringify(messages)).not.toContain("prompt_cache_breakpoint");
447
447
  });
448
448
 
449
- test("catalog flags GPT-5.6 and GPT-6 Astra direct-openai rows", () => {
449
+ test("catalog flags GPT-5.6 and GPT-6 direct-openai rows", () => {
450
450
  const openai = PROVIDER_CATALOG.find((p) => p.id === "openai");
451
451
  const flagged = (openai?.models ?? [])
452
452
  .filter((m) => m.supportsPromptCacheBreakpoints)
@@ -457,6 +457,8 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
457
457
  "gpt-5.6-sol",
458
458
  "gpt-5.6-terra",
459
459
  "gpt-6-astra",
460
+ "gpt-6-luna",
461
+ "gpt-6-sol",
460
462
  ]);
461
463
  });
462
464
  });
@@ -11,6 +11,7 @@ let mockVelayWebhooksEnabled = false;
11
11
  let mockPlatformBaseUrl = "";
12
12
  let mockPlatformAssistantId = "";
13
13
  let mockSecureKeys: Record<string, string> = {};
14
+ let mockSecureKeysUnreachable = false;
14
15
  let mockConfig: { ingress?: { publicBaseUrl?: string; enabled?: boolean } } =
15
16
  {};
16
17
 
@@ -65,7 +66,12 @@ mock.module("../ipc/gateway-client.js", () => ({
65
66
  const actualSecureKeys = await import("../security/secure-keys.js");
66
67
  mock.module("../security/secure-keys.js", () => ({
67
68
  ...actualSecureKeys,
68
- getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? undefined,
69
+ getSecureKeyAsync: async (key: string) =>
70
+ mockSecureKeysUnreachable ? undefined : (mockSecureKeys[key] ?? undefined),
71
+ getSecureKeyResultAsync: async (key: string) =>
72
+ mockSecureKeysUnreachable
73
+ ? { value: undefined, unreachable: true }
74
+ : { value: mockSecureKeys[key] ?? undefined, unreachable: false },
69
75
  }));
70
76
 
71
77
  const actualLoader = await import("../config/loader.js");
@@ -92,6 +98,7 @@ describe("platform callback registration", () => {
92
98
  mockPlatformBaseUrl = "";
93
99
  mockPlatformAssistantId = "";
94
100
  mockSecureKeys = {};
101
+ mockSecureKeysUnreachable = false;
95
102
  mockConfig = {};
96
103
  setIngressPublicBaseUrl(undefined);
97
104
  delete process.env.ASSISTANT_API_KEY;
@@ -156,6 +163,43 @@ describe("platform callback registration", () => {
156
163
  expect(context.authHeader).toBe("Api-Key env-key");
157
164
  });
158
165
 
166
+ test("reports the key as missing when the store answers and holds none", async () => {
167
+ mockPlatformBaseUrl = "https://platform.example.com";
168
+ mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
169
+
170
+ const context = await resolvePlatformCallbackRegistrationContext();
171
+
172
+ expect(context.hasAssistantApiKey).toBe(false);
173
+ expect(context.authHeader).toBeNull();
174
+ expect(context.enabled).toBe(false);
175
+ });
176
+
177
+ test("reports the key state as unknown when the store did not answer", async () => {
178
+ // A client that provisions a key when it sees none must not be told
179
+ // "none" by a daemon whose store did not answer.
180
+ mockSecureKeysUnreachable = true;
181
+ mockPlatformBaseUrl = "https://platform.example.com";
182
+ mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
183
+
184
+ const context = await resolvePlatformCallbackRegistrationContext();
185
+
186
+ expect(context.hasAssistantApiKey).toBeNull();
187
+ expect(context.authHeader).toBeNull();
188
+ expect(context.enabled).toBe(false);
189
+ });
190
+
191
+ test("an environment key counts as present even when the store did not answer", async () => {
192
+ mockSecureKeysUnreachable = true;
193
+ process.env.ASSISTANT_API_KEY = "env-key";
194
+ mockPlatformBaseUrl = "https://platform.example.com";
195
+ mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
196
+
197
+ const context = await resolvePlatformCallbackRegistrationContext();
198
+
199
+ expect(context.hasAssistantApiKey).toBe(true);
200
+ expect(context.authHeader).toBe("Api-Key env-key");
201
+ });
202
+
159
203
  test("registerCallbackRoute falls back to assistant API key auth", async () => {
160
204
  mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
161
205
  "https://platform.example.com";
@@ -402,6 +446,7 @@ describe("resolveCallbackUrl resolution order", () => {
402
446
  mockPlatformBaseUrl = "";
403
447
  mockPlatformAssistantId = "";
404
448
  mockSecureKeys = {};
449
+ mockSecureKeysUnreachable = false;
405
450
  mockConfig = {};
406
451
  setIngressPublicBaseUrl(undefined);
407
452
  delete process.env.ASSISTANT_API_KEY;
@@ -196,6 +196,9 @@ const BASELINE: Record<string, readonly string[]> = {
196
196
  "../../../daemon/trust-context.js",
197
197
  "../../../home/job-handlers/conversation-starters.js",
198
198
  "../../../media/job-handlers/media-processing.js",
199
+ // The skill-update receipt job announces a burst through the
200
+ // notification pipeline's single entry point. No plugin-api equivalent.
201
+ "../../../notifications/emit-signal.js",
199
202
  "../../../permissions/types.js",
200
203
  "../../../persistence/auto-analysis-constants.js",
201
204
  "../../../persistence/checkpoints.js",
@@ -135,6 +135,39 @@ describe("resolvePricing", () => {
135
135
  expect(result.estimatedCostUsd).toBeCloseTo(0.5 + 0.625 + 0.05, 10);
136
136
  });
137
137
 
138
+ test("bills GPT-6 Sol at published short-context rates", () => {
139
+ const result = resolvePricingForUsage("openai", "gpt-6-sol", {
140
+ directInputTokens: 50_000,
141
+ outputTokens: 50_000,
142
+ cacheCreationInputTokens: 50_000,
143
+ cacheReadInputTokens: 50_000,
144
+ anthropicCacheCreation: null,
145
+ });
146
+
147
+ expect(result.pricingStatus).toBe("priced");
148
+ // 0.05M x $2 direct + 0.05M x $2.5 write + 0.05M x $0.2 read
149
+ // + 0.05M x $10 output.
150
+ expect(result.estimatedCostUsd).toBeCloseTo(0.1 + 0.125 + 0.01 + 0.5, 10);
151
+ });
152
+
153
+ test("bills GPT-6 Luna at the long-context tier above 272k", () => {
154
+ const result = resolvePricingForUsage("openai", "gpt-6-luna", {
155
+ directInputTokens: 500_000,
156
+ outputTokens: 1_000_000,
157
+ cacheCreationInputTokens: 500_000,
158
+ cacheReadInputTokens: 1_000_000,
159
+ anthropicCacheCreation: null,
160
+ });
161
+
162
+ expect(result.pricingStatus).toBe("priced");
163
+ // Tier rates: 0.5M x $0.2 direct + 0.5M x $0.25 write + 1M x $0.02 read
164
+ // + 1M x $0.75 output.
165
+ expect(result.estimatedCostUsd).toBeCloseTo(
166
+ 0.1 + 0.125 + 0.02 + 0.75,
167
+ 10,
168
+ );
169
+ });
170
+
138
171
  test("bills GPT-6 Astra at the long-context tier above 272k", () => {
139
172
  const result = resolvePricingForUsage("openai", "gpt-6-astra", {
140
173
  directInputTokens: 500_000,
@@ -32,25 +32,24 @@ mock.module("../daemon/skill-memory-refresh.js", () => ({
32
32
  refreshSkillCapabilityMemories: mockRefreshSkillCapabilityMemories,
33
33
  }));
34
34
 
35
- // Background skill updates route through the notification pipeline; record
36
- // the signals rather than standing up delivery.
37
- let emittedSignals: Array<{
38
- sourceEventName: string;
39
- sourceContextId: string;
40
- dedupeKey?: string;
41
- contextPayload?: Record<string, unknown>;
42
- }> = [];
43
- mock.module("../notifications/emit-signal.js", () => ({
44
- emitNotificationSignal: async (params: {
45
- sourceEventName: string;
46
- sourceContextId: string;
47
- dedupeKey?: string;
48
- contextPayload?: Record<string, unknown>;
49
- }) => {
50
- emittedSignals.push(params);
51
- return { signalId: "test-signal" };
52
- },
53
- }));
35
+ // Receipt recorder: the executor's one side effect for a background
36
+ // overwrite, captured at the job module's boundary so the tests read what
37
+ // was recorded rather than standing up the memory job queue.
38
+ import type * as ReceiptJob from "../plugins/defaults/memory/skill-update-receipt-job.js";
39
+
40
+ let recordedUpdates: Parameters<typeof ReceiptJob.recordSkillUpdate>[0][] = [];
41
+ let recordUpdateFails = false;
42
+ mock.module(
43
+ "../plugins/defaults/memory/skill-update-receipt-job.js",
44
+ (): Partial<typeof ReceiptJob> => ({
45
+ recordSkillUpdate: (input) => {
46
+ recordedUpdates.push(input);
47
+ if (recordUpdateFails) {
48
+ throw new Error("memory database unavailable");
49
+ }
50
+ },
51
+ }),
52
+ );
54
53
 
55
54
  // Skill-card enqueue recorder. Snapshot + override (rather than a full module
56
55
  // replacement) because other modules in this import graph (e.g. the managed
@@ -152,7 +151,7 @@ beforeEach(() => {
152
151
  skillCardJobUpserts = [];
153
152
  skillCardUpsertThrows = false;
154
153
  watchdogEvents.length = 0;
155
- emittedSignals = [];
154
+ recordedUpdates = [];
156
155
  });
157
156
 
158
157
  afterEach(() => {
@@ -1771,7 +1770,8 @@ describe("background skill update notification", () => {
1771
1770
  });
1772
1771
  watchdogEvents.length = 0;
1773
1772
  skillCardJobUpserts = [];
1774
- emittedSignals = [];
1773
+ recordedUpdates = [];
1774
+ recordUpdateFails = false;
1775
1775
  }
1776
1776
 
1777
1777
  const lineage = () => ({
@@ -1781,9 +1781,73 @@ describe("background skill update notification", () => {
1781
1781
  : null,
1782
1782
  });
1783
1783
 
1784
- test("a background overwrite of an existing skill notifies, keyed to the source conversation", async () => {
1784
+ test("a background overwrite of an existing skill records a receipt entry keyed to the tool call", async () => {
1785
1785
  await seedAssistantSkill("weekly-export", "Old body.");
1786
1786
 
1787
+ const result = await executeScaffoldManagedSkill(
1788
+ {
1789
+ skill_id: "weekly-export",
1790
+ name: "Weekly Report Export",
1791
+ description: "export the weekly usage report",
1792
+ body_markdown: "1. Refined steps.",
1793
+ activation_hints: HINTS,
1794
+ overwrite: true,
1795
+ change_summary: "Refined the steps.",
1796
+ },
1797
+ makeRetrospectiveContext({
1798
+ conversationId: "retro-run-conv",
1799
+ toolUseId: "toolu_1",
1800
+ }),
1801
+ lineage(),
1802
+ );
1803
+
1804
+ expect(result.isError).toBe(false);
1805
+ // Nothing is announced here: the entry joins the pending receipt job,
1806
+ // which announces the burst once it has settled.
1807
+ expect(recordedUpdates).toEqual([
1808
+ {
1809
+ // The producing tool call, so a re-executed call records nothing new.
1810
+ entryId: "retro-run-conv:toolu_1",
1811
+ skillId: "weekly-export",
1812
+ name: "Weekly Report Export",
1813
+ changeSummary: "Refined the steps.",
1814
+ runConversationId: "retro-run-conv",
1815
+ // The receipt's link for this entry resolves through this id, so it
1816
+ // must be the conversation the work came from, not the hidden fork.
1817
+ sourceConversationId: "source-conv",
1818
+ },
1819
+ ]);
1820
+ });
1821
+
1822
+ test("an entry without a tool call id gets a fresh key, so a second rewrite of the same skill is still recorded", async () => {
1823
+ await seedAssistantSkill("weekly-export", "Old body.");
1824
+
1825
+ for (const body of ["1. Refined steps.", "1. Refined again."]) {
1826
+ await executeScaffoldManagedSkill(
1827
+ {
1828
+ skill_id: "weekly-export",
1829
+ name: "Weekly Report Export",
1830
+ description: "export the weekly usage report",
1831
+ body_markdown: body,
1832
+ activation_hints: HINTS,
1833
+ overwrite: true,
1834
+ change_summary: `Refined: ${body}`,
1835
+ },
1836
+ makeRetrospectiveContext({ conversationId: "retro-run-conv" }),
1837
+ );
1838
+ }
1839
+
1840
+ expect(recordedUpdates).toHaveLength(2);
1841
+ const [first, second] = recordedUpdates;
1842
+ expect(first?.entryId.startsWith("retro-run-conv:")).toBe(true);
1843
+ expect(second?.entryId.startsWith("retro-run-conv:")).toBe(true);
1844
+ expect(first?.entryId).not.toBe(second?.entryId);
1845
+ });
1846
+
1847
+ test("a receipt that cannot be recorded is logged and counted, and the skill write stands", async () => {
1848
+ await seedAssistantSkill("weekly-export", "Old body.");
1849
+ recordUpdateFails = true;
1850
+
1787
1851
  const result = await executeScaffoldManagedSkill(
1788
1852
  {
1789
1853
  skill_id: "weekly-export",
@@ -1799,29 +1863,20 @@ describe("background skill update notification", () => {
1799
1863
  );
1800
1864
 
1801
1865
  expect(result.isError).toBe(false);
1802
- expect(emittedSignals).toHaveLength(1);
1803
- const signal = emittedSignals[0]!;
1804
- expect(signal.sourceEventName).toBe("activity.complete");
1805
- // The feed item's "Go to Convo" target resolves through this id, so it
1806
- // must be the conversation the work came from, not the hidden fork.
1807
- expect(signal.sourceContextId).toBe("source-conv");
1808
- expect(signal.contextPayload?.skillId).toBe("weekly-export");
1809
- expect(signal.contextPayload?.summary).toBe("Refined the steps.");
1810
- // The home feed falls back to `title`/`body` when no channel copy was
1811
- // rendered, which is the intended quiet shape for this signal. Without
1812
- // them a suppressed delivery would leave the feed item unwritten.
1813
- // Named so the row is scannable in a feed several entries deep.
1814
- expect(signal.contextPayload?.title).toBe(
1815
- "Skill updated: Weekly Report Export",
1816
- );
1817
- expect(signal.contextPayload?.body).toBe("Refined the steps.");
1818
- // Deduped per skill per day so repeated refinements cannot flood the feed.
1819
- expect(signal.dedupeKey).toBe(
1820
- `skill-updated:weekly-export:${new Date().toISOString().slice(0, 10)}`,
1821
- );
1822
- });
1823
-
1824
- test("the notice body is the pass's change_summary, so the feed says what changed", async () => {
1866
+ expect(
1867
+ readFileSync(
1868
+ join(TEST_DIR, "skills", "weekly-export", "SKILL.md"),
1869
+ "utf-8",
1870
+ ),
1871
+ ).toContain("Refined steps.");
1872
+ expect(watchdogEvents).toContainEqual(
1873
+ expect.objectContaining({
1874
+ checkName: "skill_update_receipt_record_failed",
1875
+ }),
1876
+ );
1877
+ });
1878
+
1879
+ test("the entry body is the pass's change_summary, so the receipt says what changed", async () => {
1825
1880
  await seedAssistantSkill("weekly-export", "Old body.");
1826
1881
 
1827
1882
  const result = await executeScaffoldManagedSkill(
@@ -1840,14 +1895,9 @@ describe("background skill update notification", () => {
1840
1895
  );
1841
1896
 
1842
1897
  expect(result.isError).toBe(false);
1843
- expect(emittedSignals).toHaveLength(1);
1844
- const payload = emittedSignals[0]!.contextPayload;
1845
- // The title still names the skill; the body is the change itself.
1846
- expect(payload?.title).toBe("Skill updated: Weekly Report Export");
1847
- expect(payload?.body).toBe(
1898
+ expect(recordedUpdates[0]?.changeSummary).toBe(
1848
1899
  "Added the retry after an expired session and the export endpoint that held steady.",
1849
1900
  );
1850
- expect(payload?.summary).toBe(payload?.body);
1851
1901
  });
1852
1902
 
1853
1903
  test("a background overwrite without a change_summary is refused before the write", async () => {
@@ -1881,7 +1931,7 @@ describe("background skill update notification", () => {
1881
1931
  'Error: change_summary is required when updating an existing skill: pass one or two short sentences (under 200 characters) for the person who reads the "Skill updated" notice, naming what you changed and what in the trace prompted it (for example "Added the retry after an expired session and the export endpoint that held steady.").',
1882
1932
  );
1883
1933
  expect(readFileSync(skillFile, "utf-8")).toBe(before);
1884
- expect(emittedSignals).toHaveLength(0);
1934
+ expect(recordedUpdates).toHaveLength(0);
1885
1935
  }
1886
1936
  });
1887
1937
 
@@ -1905,7 +1955,7 @@ describe("background skill update notification", () => {
1905
1955
  expect(result.isError).toBe(true);
1906
1956
  expect(result.content).toContain("Set overwrite=true to replace it.");
1907
1957
  expect(result.content).not.toContain("change_summary");
1908
- expect(emittedSignals).toHaveLength(0);
1958
+ expect(recordedUpdates).toHaveLength(0);
1909
1959
  });
1910
1960
 
1911
1961
  test("the ownership backstop speaks before the change_summary requirement", async () => {
@@ -1937,7 +1987,7 @@ describe("background skill update notification", () => {
1937
1987
  expect(result.content).toContain("not verifiably assistant-authored");
1938
1988
  });
1939
1989
 
1940
- test("a user-directed overwrite needs no change_summary: it never notifies", async () => {
1990
+ test("a user-directed overwrite needs no change_summary: it records no entry", async () => {
1941
1991
  await executeScaffoldManagedSkill(
1942
1992
  {
1943
1993
  skill_id: "user-skill",
@@ -1964,7 +2014,7 @@ describe("background skill update notification", () => {
1964
2014
  expect(result.isError).toBe(false);
1965
2015
  });
1966
2016
 
1967
- test("change_summary is sanitized like any other notification body", async () => {
2017
+ test("change_summary is sanitized like any notification body", async () => {
1968
2018
  await seedAssistantSkill("weekly-export", "Old body.");
1969
2019
 
1970
2020
  await executeScaffoldManagedSkill(
@@ -1981,7 +2031,7 @@ describe("background skill update notification", () => {
1981
2031
  lineage(),
1982
2032
  );
1983
2033
 
1984
- const body = String(emittedSignals[0]!.contextPayload?.body);
2034
+ const body = String(recordedUpdates[0]?.changeSummary);
1985
2035
  // Control characters go, horizontal whitespace collapses, blank-line runs
1986
2036
  // collapse to one paragraph break, and the whole thing is clamped to the
1987
2037
  // shared notification preview budget.
@@ -2012,7 +2062,7 @@ describe("background skill update notification", () => {
2012
2062
 
2013
2063
  expect(result.isError).toBe(true);
2014
2064
  expect(result.content).toBe("Error: change_summary must be a string");
2015
- expect(emittedSignals).toHaveLength(0);
2065
+ expect(recordedUpdates).toHaveLength(0);
2016
2066
  });
2017
2067
 
2018
2068
  test("the registered tool's schema declares change_summary as an optional string", () => {
@@ -2044,7 +2094,7 @@ describe("background skill update notification", () => {
2044
2094
  expect(result.ok).toBe(true);
2045
2095
  });
2046
2096
 
2047
- test("it falls back to the run conversation when fork lineage does not resolve", async () => {
2097
+ test("an entry whose fork lineage does not resolve carries no source, never the run", async () => {
2048
2098
  await seedAssistantSkill("weekly-export", "Old body.");
2049
2099
 
2050
2100
  await executeScaffoldManagedSkill(
@@ -2061,10 +2111,10 @@ describe("background skill update notification", () => {
2061
2111
  { getConversation: () => null },
2062
2112
  );
2063
2113
 
2064
- // Still a real conversation id, so the deep link resolves rather than
2065
- // falling through to an unrelated target.
2066
- expect(emittedSignals).toHaveLength(1);
2067
- expect(emittedSignals[0]!.sourceContextId).toBe("retro-run-conv");
2114
+ // The run is an ephemeral fork; naming it as the source would have the
2115
+ // receipt job drop the entry once the fork is garbage collected.
2116
+ expect(recordedUpdates).toHaveLength(1);
2117
+ expect(recordedUpdates[0]).not.toHaveProperty("sourceConversationId");
2068
2118
  });
2069
2119
 
2070
2120
  test("a background CREATE gets the skill card instead, with no duplicate signal", async () => {
@@ -2082,10 +2132,10 @@ describe("background skill update notification", () => {
2082
2132
 
2083
2133
  expect(result.isError).toBe(false);
2084
2134
  expect(skillCardJobUpserts).toHaveLength(1);
2085
- expect(emittedSignals).toHaveLength(0);
2135
+ expect(recordedUpdates).toHaveLength(0);
2086
2136
  });
2087
2137
 
2088
- test("a user-directed overwrite does not notify: it is not unattended work", async () => {
2138
+ test("a user-directed overwrite records no receipt entry: it is not unattended work", async () => {
2089
2139
  await executeScaffoldManagedSkill(
2090
2140
  {
2091
2141
  skill_id: "user-skill",
@@ -2096,7 +2146,7 @@ describe("background skill update notification", () => {
2096
2146
  },
2097
2147
  makeContext(),
2098
2148
  );
2099
- emittedSignals = [];
2149
+ recordedUpdates = [];
2100
2150
 
2101
2151
  await executeScaffoldManagedSkill(
2102
2152
  {
@@ -2113,6 +2163,6 @@ describe("background skill update notification", () => {
2113
2163
  expect(
2114
2164
  readFileSync(join(TEST_DIR, "skills", "user-skill", "SKILL.md"), "utf-8"),
2115
2165
  ).toContain("V2.");
2116
- expect(emittedSignals).toHaveLength(0);
2166
+ expect(recordedUpdates).toHaveLength(0);
2117
2167
  });
2118
2168
  });
@@ -1,4 +1,4 @@
1
- import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
@@ -781,16 +781,48 @@ describe("skill_load tool", () => {
781
781
  expect(result.content).toContain("Deploy the application to production");
782
782
  expect(result.content).toContain("Rollback to previous version");
783
783
 
784
- // Should list parameters with types and required/optional markers
785
- expect(result.content).toContain(
786
- "- environment (string, required): Target environment",
784
+ const schemas = [
785
+ ...result.content.matchAll(/```json\n([\s\S]*?)\n```/g),
786
+ ].map((match) => JSON.parse(match[1]));
787
+ expect(schemas[0].required).toEqual(["environment"]);
788
+ expect(schemas[0].properties.environment).toEqual({
789
+ type: "string",
790
+ description: "Target environment",
791
+ });
792
+ expect(schemas[0].properties.force.type).toBe("boolean");
793
+ expect(schemas[1].required).toEqual(["version"]);
794
+ });
795
+
796
+ test("discloses the complete annotation mark schema before execution", async () => {
797
+ const manifest = JSON.parse(
798
+ readFileSync(
799
+ join(
800
+ import.meta.dir,
801
+ "../config/bundled-skills/screen-annotation/TOOLS.json",
802
+ ),
803
+ "utf-8",
804
+ ),
787
805
  );
788
- expect(result.content).toContain(
789
- "- force (boolean, optional): Force deploy even if checks fail",
806
+ writeSkill(
807
+ "annotation-schema",
808
+ "Annotation Schema",
809
+ "Point at a control",
810
+ "Name the control.",
790
811
  );
791
- expect(result.content).toContain(
792
- "- version (string, required): Version to rollback to",
812
+ writeToolsJson("annotation-schema", manifest.tools);
813
+
814
+ const result = await executeSkillLoad(
815
+ { skill: "annotation-schema" },
816
+ "macos",
793
817
  );
818
+ expect(result.isError).toBe(false);
819
+ const schemaMatch = result.content.match(/```json\n([\s\S]*?)\n```/);
820
+ expect(schemaMatch).not.toBeNull();
821
+ const schema = JSON.parse(schemaMatch![1]);
822
+ expect(schema).toEqual(manifest.tools[0].input_schema);
823
+ expect(schema.properties.marks.items.oneOf).toHaveLength(2);
824
+ expect(schema.properties.marks.items.properties.target.minLength).toBe(1);
825
+ expect(schema.properties.marks.items.properties.caption.maxLength).toBe(80);
794
826
  });
795
827
 
796
828
  test("skill without TOOLS.json does not include tool schemas section", async () => {
@@ -862,9 +894,8 @@ describe("skill_load tool", () => {
862
894
  // The child skill's tool schemas should appear (#### level under ### Tools from …)
863
895
  expect(result.content).toContain("#### child_action");
864
896
  expect(result.content).toContain("A child tool action");
865
- expect(result.content).toContain(
866
- "- target (string, required): Action target",
867
- );
897
+ const schemaMatch = result.content.match(/```json\n([\s\S]*?)\n```/);
898
+ expect(JSON.parse(schemaMatch![1]).required).toEqual(["target"]);
868
899
  expect(result.content).toContain(
869
900
  "Use `skill_execute` to call these tools.",
870
901
  );