@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321

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 (209) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
  47. package/src/calls/voice-escalation-judge.ts +6 -5
  48. package/src/calls/voice-screen-annotation.ts +44 -0
  49. package/src/calls/voice-session-bridge.ts +22 -0
  50. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  51. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  52. package/src/cli/commands/platform/index.help.ts +2 -1
  53. package/src/cli/commands/platform/index.ts +8 -2
  54. package/src/cli/commands/plugins.ts +16 -0
  55. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  56. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  57. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  58. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  59. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  60. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  62. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  63. package/src/cli/lib/bundled-marketplace.json +4 -4
  64. package/src/cli/lib/inspect-plugin.ts +5 -0
  65. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  66. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  67. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  68. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  69. package/src/cli/lib/plugin-details.ts +10 -7
  70. package/src/cli/lib/search-plugins.ts +2 -0
  71. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  72. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  73. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  74. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  75. package/src/config/call-site-defaults.ts +17 -11
  76. package/src/config/default-profile-catalog.ts +118 -19
  77. package/src/config/default-profile-names.ts +69 -0
  78. package/src/config/feature-flag-registry.json +25 -9
  79. package/src/config/inference-profile-validation.ts +7 -6
  80. package/src/config/llm-resolver.ts +16 -0
  81. package/src/config/profile-text-generation.test.ts +9 -0
  82. package/src/config/schemas/call-site-catalog.ts +8 -1
  83. package/src/config/schemas/llm.ts +70 -9
  84. package/src/config/sidebar-done-gate.ts +27 -0
  85. package/src/config/sync-gated-profiles.ts +96 -44
  86. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  87. package/src/daemon/auto-profile-router.ts +220 -0
  88. package/src/daemon/conversation-agent-loop.ts +77 -4
  89. package/src/daemon/conversation-slash.ts +2 -2
  90. package/src/daemon/conversation-turn-finalize.ts +12 -0
  91. package/src/daemon/dictation-text-processing.ts +4 -4
  92. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  93. package/src/daemon/handlers/config-channels.ts +3 -1
  94. package/src/daemon/handlers/conversation-history.ts +11 -1
  95. package/src/daemon/handlers/conversations.ts +118 -10
  96. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  97. package/src/daemon/pkb-reminder-builder.ts +1 -1
  98. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  99. package/src/home/feed-types.ts +2 -0
  100. package/src/inbound/platform-callback-registration.ts +19 -6
  101. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  102. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  103. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  104. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  105. package/src/live-voice/continuation-judge.ts +1 -1
  106. package/src/live-voice/live-voice-session.ts +7 -6
  107. package/src/live-voice/protocol.ts +16 -5
  108. package/src/messaging/providers/gmail/client.ts +4 -0
  109. package/src/notifications/README.md +11 -3
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  111. package/src/notifications/conversation-pairing.ts +23 -8
  112. package/src/notifications/home-feed-mirror.ts +39 -0
  113. package/src/notifications/home-feed-side-effect.ts +46 -19
  114. package/src/oauth/AGENTS.md +7 -1
  115. package/src/permissions/question-prompter.ts +14 -4
  116. package/src/persistence/conversation-crud.ts +114 -3
  117. package/src/persistence/conversation-queries.ts +48 -3
  118. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  119. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  120. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  121. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  122. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  123. package/src/persistence/embeddings/embedding-local.ts +72 -17
  124. package/src/persistence/embeddings/embedding-types.ts +0 -19
  125. package/src/persistence/jobs-store.ts +229 -0
  126. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  127. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  128. package/src/persistence/steps.ts +12 -0
  129. package/src/platform/sync-avatar.test.ts +29 -0
  130. package/src/plugin-api/model-profiles.ts +5 -3
  131. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  132. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  133. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  134. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  135. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  136. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  137. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  138. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  139. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  140. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  141. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  142. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  143. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  144. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  145. package/src/providers/anthropic/client.ts +2 -2
  146. package/src/providers/gemini/client.ts +17 -1
  147. package/src/providers/inference/auth.ts +25 -0
  148. package/src/providers/jev/ask.test.ts +66 -0
  149. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  150. package/src/providers/malformed-tool-call.ts +55 -0
  151. package/src/providers/model-catalog.ts +257 -4
  152. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  153. package/src/providers/openai/chat-completions-provider.ts +30 -0
  154. package/src/providers/openai/codex-models.ts +2 -0
  155. package/src/providers/platform-proxy/constants.ts +5 -0
  156. package/src/providers/retry.ts +68 -36
  157. package/src/providers/search-provider-catalog.ts +10 -0
  158. package/src/providers/vellum-model-routing.test.ts +1 -0
  159. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  160. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  161. package/src/runtime/agent-wake.ts +9 -2
  162. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  163. package/src/runtime/auth/middleware.ts +15 -11
  164. package/src/runtime/middleware/auth.ts +7 -1
  165. package/src/runtime/pending-interactions.ts +8 -8
  166. package/src/runtime/question-resolution.ts +54 -8
  167. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  168. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  169. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  170. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  171. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  172. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  173. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  174. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  175. package/src/runtime/routes/conversation-routes.ts +4 -2
  176. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  177. package/src/runtime/routes/global-search-routes.ts +22 -7
  178. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  179. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  180. package/src/runtime/routes/platform-routes.ts +6 -1
  181. package/src/runtime/routes/plugins-routes.ts +5 -2
  182. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  183. package/src/runtime/sync/resource-sync-events.ts +11 -0
  184. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  185. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  186. package/src/runtime/verification-outbound-actions.ts +2 -2
  187. package/src/skills/validate-input.ts +75 -11
  188. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  189. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  190. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  191. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  192. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  193. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  194. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  195. package/src/tools/network/web-fetch.ts +30 -9
  196. package/src/tools/network/web-search.ts +235 -4
  197. package/src/tools/skills/execute.ts +1 -1
  198. package/src/tools/skills/format-input-schema.ts +15 -0
  199. package/src/tools/skills/load.ts +15 -43
  200. package/src/tools/skills/scaffold-managed.ts +86 -71
  201. package/src/tools/skills/skill-tool-factory.ts +2 -1
  202. package/src/tools/workflows/manage-workflows.ts +4 -2
  203. package/src/util/worker-pipe.test.ts +146 -0
  204. package/src/util/worker-pipe.ts +49 -0
  205. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  206. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  207. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -321,6 +321,11 @@ async function insertOrDeferSkillCard(
321
321
  metadata: { kind: SKILL_CARD_MESSAGE_KIND, automated: true },
322
322
  skipIndexing: true,
323
323
  clientMessageId: surfaceId,
324
+ // The card is what the pass learned about this conversation, not new
325
+ // activity in it, and a pass runs once the conversation has gone idle.
326
+ // That is exactly when the user has just marked it done, so a card that
327
+ // resurfaced would bounce fresh done chats back into the sidebar.
328
+ skipResurface: true,
324
329
  },
325
330
  );
326
331
  if (persisted.deduplicated) {
@@ -0,0 +1,334 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Memory retrospective: skill-update receipts.
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // A background pass can rewrite several managed skills within minutes. A
6
+ // receipt collapses one such burst into one announcement: every rewrite is
7
+ // appended to the one pending `skill_update_receipt` job
8
+ // (`upsertSkillUpdateReceiptJob`), and this handler announces the burst once
9
+ // it has settled, through `emitNotificationSignal`, the single entry point
10
+ // every notification producer uses. The same shape as the skill card's
11
+ // delivery job, keyed to a burst rather than to a run.
12
+ //
13
+ // Settled means every run that contributed an entry has finished (its fork
14
+ // conversation is no longer processing, or is gone) and no entry has landed
15
+ // for the quiet window. A burst that never goes quiet is announced at the
16
+ // cap, measured from its earliest entry. Until then the handler re-upserts
17
+ // the payload for a later check, the way the card defers until its run ends,
18
+ // so no attempt counter accumulates.
19
+ //
20
+ // The pending-only match in the upsert is what makes a burst boundary
21
+ // possible without a table of its own, and also what bounds it. The claimed
22
+ // row's payload is frozen, so an entry landing during evaluation opens a
23
+ // pending sibling; the handler checks for one right before announcing and,
24
+ // if it finds one, merges its own entries into it and announces nothing,
25
+ // leaving the sibling to carry the whole burst. Two windows stay open, and
26
+ // both surface as a second bell row rather than a lost entry: an entry
27
+ // landing between that check and the announcement's event row, and a
28
+ // sibling claimed by another worker between the check and the merge, in
29
+ // which case the merge opens a third row. Both are accepted.
30
+ //
31
+ // One announcement per row: the dedupe key is the row id, so a retry after a
32
+ // crash past the event row meets the key and completes. A pipeline failure
33
+ // before that is reported as retryable and the worker's own budget covers
34
+ // it; a row that exhausts it loses its entries, as the per-update notice it
35
+ // replaces would have.
36
+
37
+ import {
38
+ getConversation,
39
+ isConversationProcessing,
40
+ } from "@vellumai/plugin-api";
41
+
42
+ import { emitNotificationSignal } from "../../../notifications/emit-signal.js";
43
+ import {
44
+ hasPendingJobOfType,
45
+ type MemoryJob,
46
+ parseSkillUpdateReceiptPayload,
47
+ type SkillUpdateReceiptEntry,
48
+ type SkillUpdateReceiptJobPayload,
49
+ upsertSkillUpdateReceiptJob,
50
+ } from "../../../persistence/jobs-store.js";
51
+ import type { JobQueueResolution } from "../../types.js";
52
+ import { getLogger } from "./logging.js";
53
+
54
+ const log = getLogger("skill-update-receipt-job");
55
+
56
+ /**
57
+ * How long a receipt stays open after its last entry before it is announced.
58
+ * Equal to the retrospective's default interval trigger, so runs a
59
+ * conversation's message count keeps triggering (at most every five minutes)
60
+ * chain into one receipt, while interval-driven runs of a slow conversation
61
+ * each get their own.
62
+ */
63
+ export const SKILL_UPDATE_RECEIPT_QUIET_MS = 30 * 60 * 1000;
64
+
65
+ /**
66
+ * The longest a receipt stays open, measured from its earliest entry. A
67
+ * burst that never goes quiet is announced at the cap and a later entry
68
+ * opens the next receipt.
69
+ */
70
+ export const SKILL_UPDATE_RECEIPT_CAP_MS = 2 * 60 * 60 * 1000;
71
+
72
+ /**
73
+ * How soon the handler looks again at a receipt whose contributing run is
74
+ * still live. The run's end is what it waits for, and a run is one wake, so a
75
+ * short cadence catches it without polling hard.
76
+ */
77
+ export const SKILL_UPDATE_RECEIPT_LIVE_RUN_RECHECK_MS = 30_000;
78
+
79
+ /**
80
+ * Record one rewrite on the open receipt and make sure a check is queued.
81
+ * Called by the scaffold tool after a background overwrite lands. Throws on
82
+ * a store failure; the caller logs it and lets the skill write stand.
83
+ */
84
+ export function recordSkillUpdate(
85
+ entry: Omit<SkillUpdateReceiptEntry, "createdAt">,
86
+ now: number = Date.now(),
87
+ ): void {
88
+ upsertSkillUpdateReceiptJob(
89
+ {
90
+ firstEntryAt: now,
91
+ lastEntryAt: now,
92
+ entries: [{ ...entry, createdAt: now }],
93
+ },
94
+ now + SKILL_UPDATE_RECEIPT_QUIET_MS,
95
+ );
96
+ }
97
+
98
+ /** The dedupe key a receipt row is announced under. */
99
+ export function skillUpdateReceiptDedupeKey(jobId: string): string {
100
+ return `skill-update-receipt:${jobId}`;
101
+ }
102
+
103
+ /**
104
+ * The `sourceContextId` a receipt is announced with. The home feed's "Go to
105
+ * Conversation" and the vellum delivery's body append both resolve it to a
106
+ * conversation, so a receipt whose rewrites all came from one conversation
107
+ * names it; one spanning several, or with a rewrite of unknown lineage,
108
+ * passes a sentinel that resolves to nothing, and the receipt's own entries
109
+ * carry the links.
110
+ */
111
+ export function skillUpdateReceiptSourceContextId(
112
+ jobId: string,
113
+ entries: SkillUpdateReceiptEntry[],
114
+ ): string {
115
+ // An entry with no lineage counts as its own source: linking the whole
116
+ // receipt to the one known conversation would attribute that rewrite
117
+ // there too.
118
+ const sources = new Set(entries.map((entry) => entry.sourceConversationId));
119
+ const [only] = sources;
120
+ return sources.size === 1 && only ? only : `skill-update-receipt:${jobId}`;
121
+ }
122
+
123
+ /**
124
+ * The English fallback title and body, for a surface that renders the item
125
+ * as it arrived: a bundle without the receipt panel, a channel post, a
126
+ * banner. The web client derives its own title from the entries.
127
+ */
128
+ export function composeSkillUpdateReceiptCopy(
129
+ entries: SkillUpdateReceiptEntry[],
130
+ ): { title: string; body: string } {
131
+ const skills = new Map<string, string>();
132
+ for (const entry of entries) {
133
+ skills.set(entry.skillId, entry.name);
134
+ }
135
+ const names = [...skills.values()];
136
+ const title =
137
+ names.length === 1
138
+ ? `Skill updated: ${names[0]}`
139
+ : `${names.length} skills updated`;
140
+ const body = entries
141
+ .map((entry) => `- ${entry.name}: ${entry.changeSummary}`)
142
+ .join("\n");
143
+ return { title, body };
144
+ }
145
+
146
+ export type SkillUpdateReceiptSealDecision =
147
+ | { seal: "quiet" | "cap" }
148
+ | { seal: null; nextCheckAt: number };
149
+
150
+ /**
151
+ * Whether the receipt is due to be announced, and if not, when to look
152
+ * again. Pure, so the boundary can be asserted without a clock or a queue.
153
+ */
154
+ export function decideSkillUpdateReceiptSeal(args: {
155
+ receipt: Pick<SkillUpdateReceiptJobPayload, "firstEntryAt" | "lastEntryAt">;
156
+ anyRunLive: boolean;
157
+ now: number;
158
+ }): SkillUpdateReceiptSealDecision {
159
+ const capAt = args.receipt.firstEntryAt + SKILL_UPDATE_RECEIPT_CAP_MS;
160
+ if (args.now >= capAt) {
161
+ return { seal: "cap" };
162
+ }
163
+ const quietAt = args.receipt.lastEntryAt + SKILL_UPDATE_RECEIPT_QUIET_MS;
164
+ if (args.anyRunLive) {
165
+ return {
166
+ seal: null,
167
+ nextCheckAt: Math.min(
168
+ args.now + SKILL_UPDATE_RECEIPT_LIVE_RUN_RECHECK_MS,
169
+ capAt,
170
+ ),
171
+ };
172
+ }
173
+ if (args.now >= quietAt) {
174
+ return { seal: "quiet" };
175
+ }
176
+ return { seal: null, nextCheckAt: Math.min(quietAt, capAt) };
177
+ }
178
+
179
+ /**
180
+ * Whether the run that produced an entry has finished. A fork conversation
181
+ * that no longer exists counts as finished: superseded-fork GC deletes the
182
+ * row, and a run cannot be processing without one. Same reading as the
183
+ * skill card's gate.
184
+ */
185
+ async function isRunFinished(runConversationId: string): Promise<boolean> {
186
+ const conversation = await getConversation(runConversationId);
187
+ if (!conversation) {
188
+ return true;
189
+ }
190
+ return !(await isConversationProcessing(runConversationId));
191
+ }
192
+
193
+ /**
194
+ * The entries whose source conversation still exists. An entry with no
195
+ * lineage has nothing to check and stays.
196
+ */
197
+ async function dropEntriesOfDeletedSources(
198
+ entries: SkillUpdateReceiptEntry[],
199
+ ): Promise<SkillUpdateReceiptEntry[]> {
200
+ const sources = new Set(
201
+ entries.flatMap((entry) =>
202
+ entry.sourceConversationId ? [entry.sourceConversationId] : [],
203
+ ),
204
+ );
205
+ const gone = new Set<string>();
206
+ for (const source of sources) {
207
+ if (!(await getConversation(source))) {
208
+ gone.add(source);
209
+ }
210
+ }
211
+ return entries.filter(
212
+ (entry) =>
213
+ !entry.sourceConversationId || !gone.has(entry.sourceConversationId),
214
+ );
215
+ }
216
+
217
+ /**
218
+ * Job handler for `skill_update_receipt` (registered in `job-handlers.ts`).
219
+ * A malformed payload is dropped with a warning. A pipeline failure returns
220
+ * `retryable` so the worker's budget covers it.
221
+ */
222
+ export async function skillUpdateReceiptJob(
223
+ job: MemoryJob,
224
+ ): Promise<JobQueueResolution | undefined> {
225
+ const payload = parseSkillUpdateReceiptPayload(job.payload);
226
+ if (!payload || payload.entries.length === 0) {
227
+ log.warn(
228
+ { jobId: job.id },
229
+ "skill-update receipt: dropping job with malformed or empty payload",
230
+ );
231
+ return undefined;
232
+ }
233
+
234
+ const runIds = [
235
+ ...new Set(payload.entries.map((entry) => entry.runConversationId)),
236
+ ];
237
+ let anyRunLive = false;
238
+ for (const runId of runIds) {
239
+ if (!(await isRunFinished(runId))) {
240
+ anyRunLive = true;
241
+ break;
242
+ }
243
+ }
244
+ const decision = decideSkillUpdateReceiptSeal({
245
+ receipt: payload,
246
+ anyRunLive,
247
+ now: Date.now(),
248
+ });
249
+ if (decision.seal === null) {
250
+ // Not settled. The claimed row completes and the payload moves to a
251
+ // pending row for the next check, merging with any entries that landed
252
+ // meanwhile.
253
+ upsertSkillUpdateReceiptJob(payload, decision.nextCheckAt);
254
+ return undefined;
255
+ }
256
+
257
+ if (hasPendingJobOfType("skill_update_receipt")) {
258
+ // An entry landed while this row was being evaluated and opened a
259
+ // sibling. The sibling carries the burst: merge into it and announce
260
+ // nothing, so the two rows do not become two bell rows.
261
+ upsertSkillUpdateReceiptJob(payload, Date.now());
262
+ log.info(
263
+ { jobId: job.id, entryCount: payload.entries.length },
264
+ "skill-update receipt: merged into the sibling that opened during evaluation",
265
+ );
266
+ return undefined;
267
+ }
268
+
269
+ // The claimed payload is a snapshot: a source conversation deleted while
270
+ // this row was evaluated was purged from pending rows only. Announce
271
+ // nothing distilled from a conversation that is gone.
272
+ const entries = await dropEntriesOfDeletedSources(payload.entries);
273
+ if (entries.length === 0) {
274
+ log.info(
275
+ { jobId: job.id },
276
+ "skill-update receipt: every entry's source conversation was deleted; nothing to announce",
277
+ );
278
+ return undefined;
279
+ }
280
+
281
+ const { title, body } = composeSkillUpdateReceiptCopy(entries);
282
+ const skillIds = new Set(entries.map((entry) => entry.skillId));
283
+ const [onlySkillId] = skillIds;
284
+ const result = await emitNotificationSignal({
285
+ // A tool's work reported after the fact, not the scheduler's:
286
+ // `home-feed-side-effect` derives `fromAssistant` from the channel.
287
+ sourceChannel: "assistant_tool",
288
+ sourceContextId: skillUpdateReceiptSourceContextId(job.id, entries),
289
+ sourceEventName: "activity.complete",
290
+ dedupeKey: skillUpdateReceiptDedupeKey(job.id),
291
+ contextPayload: {
292
+ summary: body,
293
+ title,
294
+ body,
295
+ // The typed list the receipt panel renders; the feed maps it onto the
296
+ // item and strips it from the free-form metadata.
297
+ updates: entries.map((entry) => ({
298
+ skillId: entry.skillId,
299
+ name: entry.name,
300
+ summary: entry.changeSummary,
301
+ ...(entry.sourceConversationId
302
+ ? { conversationId: entry.sourceConversationId }
303
+ : {}),
304
+ })),
305
+ // A receipt naming one skill links to it from the detail's footer the
306
+ // way a single-skill notification does.
307
+ ...(skillIds.size === 1 && onlySkillId ? { skillId: onlySkillId } : {}),
308
+ },
309
+ attentionHints: {
310
+ requiresAction: false,
311
+ urgency: "low",
312
+ isAsyncBackground: true,
313
+ visibleInSourceNow: false,
314
+ },
315
+ });
316
+ if (result.pipelineFailed) {
317
+ return {
318
+ queueResolution: "retryable",
319
+ errorMessage: `skill-update receipt announcement failed: ${result.reason}`,
320
+ };
321
+ }
322
+ log.info(
323
+ {
324
+ jobId: job.id,
325
+ sealedBy: decision.seal,
326
+ entryCount: entries.length,
327
+ skillCount: skillIds.size,
328
+ deduplicated: result.deduplicated,
329
+ dispatched: result.dispatched,
330
+ },
331
+ "skill-update receipt announced",
332
+ );
333
+ return undefined;
334
+ }
@@ -34,7 +34,7 @@ export const DEFAULT_CARD_CHARS = 500;
34
34
  * only, so a missing product is not evidence it is unsupported.
35
35
  */
36
36
  export const SKILLS_INJECTION_CATALOG_HINT =
37
- "Injected skills are only ones currently in the workspace. Run `assistant plugins search <name>` and `assistant skills search <name>` before concluding a given integration or skill is unsupported";
37
+ "These skill cards describe availability, not tool instructions. When skill_load is available, use it to load a skill's current instructions and input schemas before calling its tools; follow those schemas over remembered arguments. Injected skills are only ones currently in the workspace. Run `assistant plugins search <name>` and `assistant skills search <name>` before concluding a given integration or skill is unsupported";
38
38
 
39
39
  /**
40
40
  * Render the prose-style capability statement embedded into the unified
@@ -451,6 +451,23 @@ describe("rememberTool definition", () => {
451
451
  finish_turn: { type: "boolean" },
452
452
  });
453
453
  });
454
+
455
+ test("says later capture is conditional and never covers scheduled work", () => {
456
+ const description = rememberTool.description;
457
+ expect(description).toContain("not guaranteed");
458
+ expect(description).toContain("never runs over scheduled work");
459
+ expect(description).toContain("save it now");
460
+ // A pass that reviews every conversation on a cadence is not what runs:
461
+ // the enqueue gate skips scheduled conversations.
462
+ expect(description).not.toContain("after each message-count");
463
+ expect(description).not.toContain("saves what you didn't capture");
464
+ // The provider reads the definition after JSON serialization, so the
465
+ // contract has to survive that round trip unchanged.
466
+ const serialized = z
467
+ .object({ description: z.string() })
468
+ .parse(JSON.parse(JSON.stringify(rememberTool)));
469
+ expect(serialized.description).toBe(description);
470
+ });
454
471
  });
455
472
 
456
473
  describe("rememberTool definition — page-hint guidance gating", () => {
@@ -31,6 +31,9 @@ describe("renderInjectionBlockInner", () => {
31
31
  expect(inner).toContain("assistant plugins search <name>");
32
32
  expect(inner).toContain("assistant skills search <name>");
33
33
  expect(inner).toContain("currently in the workspace");
34
+ expect(inner).toContain("When skill_load is available, use it");
35
+ expect(inner).toContain("before calling its tools");
36
+ expect(inner).toContain("over remembered arguments");
34
37
  expect(inner.indexOf("# Skills")).toBeLessThan(inner.indexOf("# Skill:"));
35
38
  expect(inner.startsWith(`${V3_INJECTION_HEADER}\n\n# Skills\n`)).toBe(true);
36
39
  });
@@ -57,6 +57,8 @@ describe("isConnectionCompatibleWithModel", () => {
57
57
  test("oauth_subscription connection is compatible with a Codex model", () => {
58
58
  const conn = { auth: oauthAuth };
59
59
  expect(isConnectionCompatibleWithModel(conn, "gpt-6-astra")).toBe(true);
60
+ expect(isConnectionCompatibleWithModel(conn, "gpt-6-sol")).toBe(true);
61
+ expect(isConnectionCompatibleWithModel(conn, "gpt-6-luna")).toBe(true);
60
62
  expect(isConnectionCompatibleWithModel(conn, "gpt-5.6-sol")).toBe(true);
61
63
  expect(isConnectionCompatibleWithModel(conn, "gpt-5.6-terra")).toBe(true);
62
64
  expect(isConnectionCompatibleWithModel(conn, "gpt-5.6-luna")).toBe(true);
@@ -104,4 +104,12 @@ describe("search-provider-catalog", () => {
104
104
  expect(tinyfish?.defaultApiBase).toBe("https://api.search.tinyfish.ai");
105
105
  expect(tinyfish?.envVar).toBe("TINYFISH_API_KEY");
106
106
  });
107
+
108
+ test("Exa is a keyed BYOK provider without a custom API base", () => {
109
+ const exa = getSearchProvider("exa");
110
+ expect(exa?.kind).toBe("byok");
111
+ expect(exa?.keyless).toBeUndefined();
112
+ expect(exa?.supportsApiBase).toBeUndefined();
113
+ expect(exa?.envVar).toBe("EXA_API_KEY");
114
+ });
107
115
  });
@@ -1004,7 +1004,7 @@ export class AnthropicProvider implements Provider {
1004
1004
  const isHaiku = effectiveModel.includes("haiku");
1005
1005
  // Effort support is per-model: Haiku and Sonnet 4.5 reject the param (see isEffortSupported).
1006
1006
  const supportsEffort = isEffortSupported(effectiveModel);
1007
- // opus-4-7 / opus-4-8 / opus-5 and sonnet-5 reject `temperature`,
1007
+ // opus-4-7 / opus-4-8 / opus-5 / opus-5-5 and sonnet-5 reject `temperature`,
1008
1008
  // `top_p`, and `top_k` with a 400 "`temperature`/`top_p` is deprecated
1009
1009
  // for this model" — model-wide, not effort-conditional (verified
1010
1010
  // 2026-06-23). opus-4-6 / sonnet-4-6 / haiku-4-5 still accept them.
@@ -1016,7 +1016,7 @@ export class AnthropicProvider implements Provider {
1016
1016
  // suffix is what matches.
1017
1017
  const deprecatesSamplingParams =
1018
1018
  /claude-opus-4-[78]\b/.test(effectiveModel) ||
1019
- /claude-opus-5\b/.test(effectiveModel) ||
1019
+ /claude-opus-5(-5)?\b/.test(effectiveModel) ||
1020
1020
  /claude-sonnet-5\b/.test(effectiveModel) ||
1021
1021
  /claude-fable-/.test(effectiveModel);
1022
1022
  const mergedOutputConfig = {
@@ -16,6 +16,10 @@ import {
16
16
  } from "../content-block-size.js";
17
17
  import { fileBlockToProviderText } from "../file-block-text.js";
18
18
  import { unsignedThoughtSignatureFallback } from "../gemini-thought-signature.js";
19
+ import {
20
+ isMalformedToolCallFinishReason,
21
+ malformedToolCallError,
22
+ } from "../malformed-tool-call.js";
19
23
  import { base64Source, resolveMediaReferences } from "../media-resolve.js";
20
24
  import { PROVIDER_CATALOG } from "../model-catalog.js";
21
25
  import { recordProviderRequestDiagnostics } from "../request-diagnostics.js";
@@ -577,6 +581,18 @@ export class GeminiProvider implements Provider {
577
581
  cleanupTimeout();
578
582
  }
579
583
 
584
+ if (isMalformedToolCallFinishReason(finishReason)) {
585
+ log.warn(
586
+ {
587
+ model: responseModel,
588
+ finishReason,
589
+ streamedTextLength: fullText.length,
590
+ },
591
+ "Gemini response ended on a malformed tool call",
592
+ );
593
+ throw malformedToolCallError("gemini", finishReason);
594
+ }
595
+
580
596
  // Build content blocks
581
597
  const content: ContentBlock[] = [];
582
598
  if (fullText) {
@@ -649,7 +665,7 @@ export class GeminiProvider implements Provider {
649
665
  error.status,
650
666
  // Skip reason on caller-abort: abortReason already carries the intent
651
667
  // and short-circuits classification/retry (mirrors the Anthropic client).
652
- abortReason
668
+ abortReason
653
669
  ? { abortReason, rawRequest: inspectableRequest }
654
670
  : {
655
671
  reason: deriveGeminiReason(error),
@@ -176,6 +176,31 @@ export type ConnectionModel = z.infer<typeof ConnectionModelSchema>;
176
176
  * `models` list (openai-compatible endpoints have no fixed upstream, so the
177
177
  * user must supply both).
178
178
  */
179
+ /**
180
+ * An openai-compatible connection whose endpoint is opencode.ai is an
181
+ * OpenCode connection: zen/go rejects requests without the session headers
182
+ * only the opencode adapter and probe send, so the generic adapter can never
183
+ * dispatch against it. Applied on create so the row is stored as provider
184
+ * "opencode" and every provider-keyed path treats it as one.
185
+ */
186
+ export function normalizeConnectionProvider(
187
+ provider: string,
188
+ baseUrl: unknown,
189
+ ): string {
190
+ if (provider !== "openai-compatible" || typeof baseUrl !== "string") {
191
+ return provider;
192
+ }
193
+ let host: string;
194
+ try {
195
+ host = new URL(baseUrl).hostname.toLowerCase();
196
+ } catch {
197
+ return provider;
198
+ }
199
+ return host === "opencode.ai" || host.endsWith(".opencode.ai")
200
+ ? "opencode"
201
+ : provider;
202
+ }
203
+
179
204
  export const PROVIDERS_REQUIRING_BASE_URL_AND_MODELS: ReadonlySet<string> =
180
205
  new Set(["openai-compatible"]);
181
206
 
@@ -0,0 +1,66 @@
1
+ import { afterAll, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { Provider } from "../types.js";
4
+
5
+ let configured: {
6
+ provider: Provider;
7
+ configuredProviderName: string;
8
+ entryRouted: boolean;
9
+ } | null = null;
10
+
11
+ const actual = await import("../provider-send-message.js");
12
+ mock.module("../provider-send-message.js", () => ({
13
+ ...actual,
14
+ resolveConfiguredProvider: async () => configured,
15
+ }));
16
+
17
+ const { resolveTypesafeProvider } = await import("./ask.js");
18
+
19
+ afterAll(() => {
20
+ mock.restore();
21
+ });
22
+
23
+ function stub(name: string): Provider {
24
+ return {
25
+ name,
26
+ defaultModel: "jev-latest",
27
+ sendMessage: async () => {
28
+ throw new Error("not dispatched in this test");
29
+ },
30
+ } as unknown as Provider;
31
+ }
32
+
33
+ describe("resolveTypesafeProvider", () => {
34
+ test("a BYOK TypeSafe profile resolves", async () => {
35
+ configured = {
36
+ provider: stub("typesafe"),
37
+ configuredProviderName: "typesafe",
38
+ entryRouted: false,
39
+ };
40
+ expect((await resolveTypesafeProvider("voiceEscalationJudge"))?.name).toBe(
41
+ "typesafe",
42
+ );
43
+ });
44
+
45
+ test("a managed Jev profile resolves although it is configured as vellum", async () => {
46
+ configured = {
47
+ provider: stub("typesafe"),
48
+ configuredProviderName: "vellum",
49
+ entryRouted: false,
50
+ };
51
+ expect((await resolveTypesafeProvider("voiceEscalationJudge"))?.name).toBe(
52
+ "typesafe",
53
+ );
54
+ });
55
+
56
+ test("any other dispatched provider is unavailable", async () => {
57
+ configured = {
58
+ provider: stub("anthropic"),
59
+ configuredProviderName: "vellum",
60
+ entryRouted: false,
61
+ };
62
+ expect(await resolveTypesafeProvider("voiceEscalationJudge")).toBeNull();
63
+ configured = null;
64
+ expect(await resolveTypesafeProvider("voiceEscalationJudge")).toBeNull();
65
+ });
66
+ });