@vellumai/assistant 0.11.10 → 0.11.11-staging.1

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 (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -1,487 +0,0 @@
1
- /**
2
- * Tests for POST /v1/conversations/:id/sight-frame, the HTTP door the text
3
- * chat's camera tile persists a kept frame through.
4
- *
5
- * Exercises the guards the handler owns (an id that names no conversation, a
6
- * body with no attachment), the actor the row is attributed to, and the
7
- * persist behaviour it inherits from the live-voice path, including the one
8
- * difference: a keep taken beside the composer is not a voice session turn.
9
- */
10
-
11
- import { describe, expect, mock, spyOn, test } from "bun:test";
12
-
13
- import {
14
- createMockProvider,
15
- textResponse,
16
- } from "../../../__tests__/helpers/mock-provider.js";
17
- import { setConfig } from "../../../__tests__/helpers/set-config.js";
18
-
19
- setConfig("memory", { enabled: false });
20
-
21
- import { Conversation } from "../../../daemon/conversation.js";
22
- import {
23
- deleteConversation,
24
- setConversation,
25
- } from "../../../daemon/conversation-registry.js";
26
- import { destroyActiveConversation } from "../../../daemon/conversation-store.js";
27
- import { _setProcessingWaitMsForTests } from "../../../live-voice/live-voice-photo.js";
28
- import {
29
- getAttachmentById,
30
- uploadAttachment,
31
- } from "../../../persistence/attachments-store.js";
32
- import {
33
- createConversation,
34
- deleteConversation as deleteConversationRows,
35
- getConversation,
36
- getMessages,
37
- type MessageRow,
38
- } from "../../../persistence/conversation-crud.js";
39
- import { sightFrameAttachmentIdsFromMetadata } from "../../../persistence/conversation-types.js";
40
- import { getDb } from "../../../persistence/db-connection.js";
41
- import { initializeDb } from "../../../persistence/db-init.js";
42
- import { conversations } from "../../../persistence/schema/index.js";
43
- import { BadRequestError, NotFoundError } from "../errors.js";
44
- import type { RouteDefinition } from "../types.js";
45
- import * as vellumActorTrust from "../vellum-actor-trust.js";
46
-
47
- /**
48
- * Run something in the window the handler opens between accepting the request
49
- * and persisting the frame.
50
- *
51
- * Resolving the request's actor is the only thing the handler awaits in
52
- * between, so a hook here lands inside that window without a timer to race.
53
- * Armed per test and cleared as it fires; every other test leaves it null and
54
- * gets the real resolver.
55
- */
56
- let duringTrustResolution: (() => void) | null = null;
57
- const realVellumActorTrust = { ...vellumActorTrust };
58
- mock.module("../vellum-actor-trust.js", () => ({
59
- ...realVellumActorTrust,
60
- resolveVellumActorTrustContext: (
61
- ...args: Parameters<
62
- typeof realVellumActorTrust.resolveVellumActorTrustContext
63
- >
64
- ) => {
65
- const hook = duringTrustResolution;
66
- duringTrustResolution = null;
67
- hook?.();
68
- return realVellumActorTrust.resolveVellumActorTrustContext(...args);
69
- },
70
- }));
71
-
72
- const { ROUTES: SIGHT_FRAME_ROUTES } = await import("../sight-frame-routes.js");
73
-
74
- await initializeDb();
75
-
76
- const IMAGE_BASE64 =
77
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk";
78
-
79
- function findHandler(routes: RouteDefinition[], operationId: string) {
80
- const route = routes.find((r) => r.operationId === operationId);
81
- if (!route) {
82
- throw new Error(`Route ${operationId} not found`);
83
- }
84
- return route.handler;
85
- }
86
-
87
- const sightFrameHandler = findHandler(
88
- SIGHT_FRAME_ROUTES,
89
- "conversationSightFrame",
90
- );
91
-
92
- interface SightFrameResponse {
93
- persisted: boolean;
94
- messageId?: string;
95
- }
96
-
97
- function persist(
98
- id: string,
99
- body: Record<string, unknown>,
100
- headers?: Record<string, string>,
101
- ): Promise<SightFrameResponse> {
102
- return sightFrameHandler({
103
- pathParams: { id },
104
- body,
105
- ...(headers ? { headers } : {}),
106
- }) as Promise<SightFrameResponse>;
107
- }
108
-
109
- /** Tear the conversation down the way DELETE /v1/conversations/:id does. */
110
- function deleteEverywhere(id: string): void {
111
- destroyActiveConversation(id, { keepSubagentRecords: true });
112
- deleteConversationRows(id);
113
- }
114
-
115
- /** A registered conversation the persist path can reach, plus its teardown. */
116
- function liveConversation(title: string) {
117
- const row = createConversation(title);
118
- const { provider } = createMockProvider([textResponse("")]);
119
- const activeConversation = new Conversation(
120
- row.id,
121
- provider,
122
- "system prompt",
123
- () => {},
124
- "/tmp",
125
- { maxTokens: 4096 },
126
- );
127
- activeConversation.setTrustContext({
128
- trustClass: "guardian",
129
- sourceChannel: "vellum",
130
- });
131
- setConversation(row.id, activeConversation);
132
- return {
133
- id: row.id,
134
- activeConversation,
135
- dispose: () => {
136
- deleteConversation(row.id);
137
- activeConversation.dispose();
138
- },
139
- };
140
- }
141
-
142
- function metadataOf(row: MessageRow): Record<string, unknown> {
143
- return JSON.parse(row.metadata ?? "{}") as Record<string, unknown>;
144
- }
145
-
146
- function conversationCount(): number {
147
- return getDb().select({ id: conversations.id }).from(conversations).all()
148
- .length;
149
- }
150
-
151
- function uploadFrame(name: string): Promise<string> {
152
- return uploadAttachment(name, "image/png", IMAGE_BASE64).then((a) => a.id);
153
- }
154
-
155
- /** True while the attachment's row is still in the store. */
156
- function frameStored(attachmentId: string): boolean {
157
- return getAttachmentById(attachmentId) !== null;
158
- }
159
-
160
- function sleep(ms: number): Promise<void> {
161
- return new Promise((resolve) => setTimeout(resolve, ms));
162
- }
163
-
164
- describe("POST /v1/conversations/:id/sight-frame", () => {
165
- test("404s for an unknown id without minting a conversation for it", async () => {
166
- // The persist calls `getOrCreateConversation`, so an id the handler lets
167
- // through becomes a conversation holding nothing but camera frames.
168
- const before = conversationCount();
169
-
170
- await expect(
171
- persist(crypto.randomUUID(), { attachmentId: "att-1" }),
172
- ).rejects.toThrow(NotFoundError);
173
-
174
- expect(conversationCount()).toBe(before);
175
- });
176
-
177
- test("400s when the body carries no attachment", async () => {
178
- const live = liveConversation("Chat keep bad body");
179
- try {
180
- await expect(persist(live.id, {})).rejects.toThrow(BadRequestError);
181
- await expect(persist(live.id, { attachmentId: "" })).rejects.toThrow(
182
- BadRequestError,
183
- );
184
- await expect(persist(live.id, { attachmentId: 7 })).rejects.toThrow(
185
- BadRequestError,
186
- );
187
- expect(getMessages(live.id)).toHaveLength(0);
188
- } finally {
189
- live.dispose();
190
- }
191
- });
192
-
193
- test("persists the frame as its own tagged row and names it", async () => {
194
- const live = liveConversation("Chat keep happy path");
195
- try {
196
- const attachmentId = await uploadFrame("frame.png");
197
-
198
- const response = await persist(live.id, { attachmentId });
199
-
200
- const [row] = getMessages(live.id);
201
- expect(response).toEqual({ persisted: true, messageId: row.id });
202
- expect(row.content.filter((block) => block.type === "text")).toEqual([
203
- { type: "text", text: "(camera frame)" },
204
- ]);
205
- // Retention reads the tag and the memory-privacy guard reads the pair of
206
- // `scripted` and the tag, so a chat keep has to carry both.
207
- expect(metadataOf(row).scripted).toBe(true);
208
- expect(sightFrameAttachmentIdsFromMetadata(metadataOf(row))).toEqual([
209
- attachmentId,
210
- ]);
211
- } finally {
212
- live.dispose();
213
- }
214
- });
215
-
216
- test("attributes the row to the verified actor, not the conversation's trust", async () => {
217
- // The conversation rests on the trust of whoever used it last, so a row
218
- // stamped from the slot names that actor rather than the one this request
219
- // was verified as. Actor-scoped history reads the stamp, so the wrong one
220
- // either hides the frame from its owner or lends guardian standing to
221
- // content that has none.
222
- const live = liveConversation("Chat keep actor attribution");
223
- try {
224
- const attachmentId = await uploadFrame("frame.png");
225
- // A principal no guardian binding names, which the vellum-channel
226
- // resolver fails closed to `unknown`.
227
- const headers = { "x-vellum-actor-principal-id": "actor-not-bound" };
228
- expect(live.activeConversation.trustContext?.trustClass).toBe("guardian");
229
-
230
- expect(await persist(live.id, { attachmentId }, headers)).toMatchObject({
231
- persisted: true,
232
- });
233
-
234
- const metadata = metadataOf(getMessages(live.id)[0]);
235
- expect(metadata.provenanceTrustClass).toBe("unknown");
236
- expect(metadata.provenanceSourceChannel).toBe("vellum");
237
- } finally {
238
- live.dispose();
239
- }
240
- });
241
-
242
- test("attributes a caller with no actor header to the guardian", async () => {
243
- // A local or IPC caller carries no actor header and is the guardian by
244
- // construction, the same reading canonical ingress gives it.
245
- const live = liveConversation("Chat keep local caller");
246
- try {
247
- const attachmentId = await uploadFrame("frame.png");
248
-
249
- expect(await persist(live.id, { attachmentId })).toMatchObject({
250
- persisted: true,
251
- });
252
-
253
- const metadata = metadataOf(getMessages(live.id)[0]);
254
- expect(metadata.provenanceTrustClass).toBe("guardian");
255
- expect(metadata.provenanceSourceChannel).toBe("vellum");
256
- } finally {
257
- live.dispose();
258
- }
259
- });
260
-
261
- test("does not mark a composer keep as a voice session turn", async () => {
262
- // The mark says a reply reaches the user over a session that is still
263
- // open. Nothing is open here, so a reply that follows this row has to be
264
- // pushed like any other.
265
- const live = liveConversation("Chat keep voice mark");
266
- try {
267
- const attachmentId = await uploadFrame("frame.png");
268
-
269
- expect(await persist(live.id, { attachmentId })).toMatchObject({
270
- persisted: true,
271
- });
272
-
273
- expect(
274
- metadataOf(getMessages(live.id)[0]).voiceSessionTurn,
275
- ).toBeUndefined();
276
- } finally {
277
- live.dispose();
278
- }
279
- });
280
-
281
- test("a frame from another actor makes the resident history reload", async () => {
282
- // The persist stamps the ROW for the actor that sent it, but the message
283
- // it appends to the resident history carries no scope of its own. A
284
- // conversation resident under one actor would go on sending that array to
285
- // the model, image included, while a reload would have filtered it out.
286
- const live = liveConversation("Chat keep cross trust");
287
- // Scope the resident history for the conversation's own actor.
288
- await live.activeConversation.ensureActorScopedHistory();
289
- const reload = spyOn(Conversation.prototype, "loadFromDb");
290
- try {
291
- const attachmentId = await uploadFrame("cross-trust.png");
292
- // A principal no guardian binding names, which the vellum-channel
293
- // resolver fails closed to `unknown`: not the class this history holds.
294
- expect(
295
- await persist(
296
- live.id,
297
- { attachmentId },
298
- { "x-vellum-actor-principal-id": "actor-not-bound" },
299
- ),
300
- ).toMatchObject({ persisted: true });
301
-
302
- await live.activeConversation.ensureActorScopedHistory();
303
-
304
- // Reloaded and re-filtered rather than reusing the array the frame was
305
- // appended to.
306
- expect(reload).toHaveBeenCalled();
307
- } finally {
308
- reload.mockRestore();
309
- live.dispose();
310
- }
311
- });
312
-
313
- test("a frame from the resident actor does not force a reload", async () => {
314
- // The control on the check above. Frames arrive on a camera's cadence, so
315
- // a reload apiece is a real cost and only a differing scope earns one.
316
- const live = liveConversation("Chat keep same trust");
317
- await live.activeConversation.ensureActorScopedHistory();
318
- const reload = spyOn(Conversation.prototype, "loadFromDb");
319
- try {
320
- const attachmentId = await uploadFrame("same-trust.png");
321
- // No actor header, which the resolver reads as the guardian: the class
322
- // the resident history is already scoped for.
323
- expect(await persist(live.id, { attachmentId })).toMatchObject({
324
- persisted: true,
325
- });
326
-
327
- await live.activeConversation.ensureActorScopedHistory();
328
-
329
- expect(reload).not.toHaveBeenCalled();
330
- } finally {
331
- reload.mockRestore();
332
- live.dispose();
333
- }
334
- });
335
-
336
- test("reports a frame whose attachment does not resolve", async () => {
337
- const live = liveConversation("Chat keep missing attachment");
338
- try {
339
- expect(await persist(live.id, { attachmentId: "att-missing" })).toEqual({
340
- persisted: false,
341
- });
342
- expect(getMessages(live.id)).toHaveLength(0);
343
- } finally {
344
- live.dispose();
345
- }
346
- });
347
-
348
- test("two overlapping keeps write one at a time", async () => {
349
- // The processing flag is a boolean rather than a counted lock, so two
350
- // persists in flight at once would both read it free and both take it.
351
- const live = liveConversation("Chat keep serialized");
352
- try {
353
- const first = await uploadFrame("first.png");
354
- const second = await uploadFrame("second.png");
355
-
356
- live.activeConversation.setProcessing(true);
357
- const firstKeep = persist(live.id, { attachmentId: first });
358
- // Long enough for the first keep to reach the idle wait, so the second
359
- // queues behind a job that has already begun rather than replacing it.
360
- await sleep(30);
361
- const secondKeep = persist(live.id, { attachmentId: second });
362
- await sleep(30);
363
- live.activeConversation.setProcessing(false);
364
-
365
- expect(await firstKeep).toMatchObject({ persisted: true });
366
- expect(await secondKeep).toMatchObject({ persisted: true });
367
- expect(getMessages(live.id)).toHaveLength(2);
368
- } finally {
369
- live.dispose();
370
- }
371
- });
372
-
373
- test("a newer keep replaces one still waiting, and its upload is reclaimed", async () => {
374
- // What bounds the chain: keeps arrive every few seconds while each job can
375
- // wait out a turn for far longer. The displaced upload is the daemon's to
376
- // collect, because the client's own abandon-delete fires on a refused
377
- // send and this send was accepted.
378
- const live = liveConversation("Chat keep superseded");
379
- try {
380
- const running = await uploadFrame("running.png");
381
- const stale = await uploadFrame("stale.png");
382
- const newest = await uploadFrame("newest.png");
383
-
384
- live.activeConversation.setProcessing(true);
385
- const runningKeep = persist(live.id, { attachmentId: running });
386
- await sleep(30);
387
- const staleKeep = persist(live.id, { attachmentId: stale });
388
- const newestKeep = persist(live.id, { attachmentId: newest });
389
- await sleep(30);
390
- live.activeConversation.setProcessing(false);
391
-
392
- expect(await runningKeep).toMatchObject({ persisted: true });
393
- expect(await staleKeep).toEqual({ persisted: false });
394
- expect(await newestKeep).toMatchObject({ persisted: true });
395
-
396
- expect(getMessages(live.id)).toHaveLength(2);
397
- expect(frameStored(stale)).toBe(false);
398
- expect(frameStored(running)).toBe(true);
399
- expect(frameStored(newest)).toBe(true);
400
- } finally {
401
- live.dispose();
402
- }
403
- });
404
-
405
- test("a deletion is final for a keep still queued behind another", async () => {
406
- // The route's 404 speaks only for the moment it ran. A keep queued behind
407
- // another one starts later, so the job re-reads the conversation before it
408
- // touches anything: the persist reaches it through
409
- // `getOrCreateConversation`, which writes the row back for an id it finds
410
- // missing, and a deleted conversation must not return carrying nothing but
411
- // camera frames.
412
- const restoreWait = _setProcessingWaitMsForTests(150);
413
- const live = liveConversation("Chat keep deleted mid-queue");
414
- const holder = await uploadFrame("holder.png");
415
- const queued = await uploadFrame("queued.png");
416
- try {
417
- // The first keep holds the chain by waiting out a turn that never ends,
418
- // so the second is still queued and has touched nothing.
419
- live.activeConversation.setProcessing(true);
420
- const holderKeep = persist(live.id, { attachmentId: holder });
421
- await sleep(30);
422
- const queuedKeep = persist(live.id, { attachmentId: queued });
423
-
424
- deleteEverywhere(live.id);
425
- const countAfterDelete = conversationCount();
426
-
427
- expect(await holderKeep).toEqual({ persisted: false });
428
- expect(await queuedKeep).toEqual({ persisted: false });
429
-
430
- expect(getConversation(live.id)).toBeNull();
431
- expect(conversationCount()).toBe(countAfterDelete);
432
- expect(getMessages(live.id)).toHaveLength(0);
433
- expect(frameStored(queued)).toBe(false);
434
- } finally {
435
- _setProcessingWaitMsForTests(restoreWait);
436
- }
437
- });
438
-
439
- test("a frame does not land in a conversation replaced while the actor resolves", async () => {
440
- // The 404 gate is where the frame is accepted, and resolving the request's
441
- // actor is awaited after it. A delete and recreate under this id inside
442
- // that await leaves a row the gate never saw, and a persist that read the
443
- // id for itself afterwards would take the replacement for the conversation
444
- // the client addressed.
445
- const live = liveConversation("Chat keep replaced mid-trust");
446
- try {
447
- const frame = await uploadFrame("mid-trust.png");
448
-
449
- duringTrustResolution = () => {
450
- deleteConversationRows(live.id);
451
- createConversation({ id: live.id, title: "Recreated" });
452
- };
453
-
454
- expect(await persist(live.id, { attachmentId: frame })).toEqual({
455
- persisted: false,
456
- });
457
-
458
- expect(getMessages(live.id)).toHaveLength(0);
459
- // Nothing else would ever collect it: the client's abandon-delete fires
460
- // on a refused send, and this send was accepted.
461
- expect(frameStored(frame)).toBe(false);
462
- } finally {
463
- duringTrustResolution = null;
464
- live.dispose();
465
- }
466
- });
467
-
468
- test("reports a keep that outwaited the turn ahead of it", async () => {
469
- const restoreWait = _setProcessingWaitMsForTests(120);
470
- const live = liveConversation("Chat keep wait timeout");
471
- try {
472
- const frame = await uploadFrame("timed-out.png");
473
-
474
- live.activeConversation.setProcessing(true);
475
- expect(await persist(live.id, { attachmentId: frame })).toEqual({
476
- persisted: false,
477
- });
478
-
479
- expect(frameStored(frame)).toBe(false);
480
- expect(getMessages(live.id)).toHaveLength(0);
481
- } finally {
482
- live.activeConversation.setProcessing(false);
483
- live.dispose();
484
- _setProcessingWaitMsForTests(restoreWait);
485
- }
486
- });
487
- });
@@ -1,136 +0,0 @@
1
- /**
2
- * Transport-agnostic route definition for ambient camera keeps.
3
- *
4
- * POST /v1/conversations/:id/sight-frame persists one already-uploaded camera
5
- * frame into a conversation as its own sight-tagged user message. It runs no
6
- * turn and starts no agent loop: it is the same standalone-image persist the
7
- * live-voice `sight_frame` socket frame performs, reached over HTTP by the
8
- * clients that have a camera tile but no voice socket. The rationale for
9
- * persisting a kept frame on its own rather than attaching it to a turn is at
10
- * the top of `live-voice/live-voice-photo.ts`.
11
- *
12
- * The response is the client's per-frame acknowledgement, which is why the
13
- * handler waits on the persist rather than accepting and reporting later.
14
- */
15
-
16
- import { z } from "zod";
17
-
18
- import { persistAmbientSightFrame } from "../../live-voice/live-voice-photo.js";
19
- import { getConversation } from "../../persistence/conversation-crud.js";
20
- import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
21
- import { resolveOrThrow } from "./conversation-management-routes.js";
22
- import { BadRequestError, NotFoundError } from "./errors.js";
23
- import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
24
- import { resolveVellumActorTrustContext } from "./vellum-actor-trust.js";
25
-
26
- // ---------------------------------------------------------------------------
27
- // Handlers
28
- // ---------------------------------------------------------------------------
29
-
30
- /**
31
- * Persist a kept camera frame into an existing conversation.
32
- *
33
- * The DB existence check comes before the persist because the persist calls
34
- * `getOrCreateConversation`, which mints a conversation for an id it has never
35
- * seen. Without the gate a stale client id would silently create a phantom
36
- * conversation holding nothing but camera frames.
37
- *
38
- * That read is also where the frame is accepted, so the row's `created_at` is
39
- * taken from it and handed to the persist. Resolving the request's actor is
40
- * awaited below, and a conversation deleted and recreated under this id inside
41
- * that await leaves a row the gate never saw: the persist reading the id for
42
- * itself afterwards would take the replacement for the conversation the client
43
- * addressed. Nothing awaits between the check and the capture, so the two are
44
- * one moment.
45
- *
46
- * The body is hand-validated because the HTTP adapter does not run a route's
47
- * zod schema against the request and swallows a JSON parse failure, so a
48
- * malformed send arrives here as an empty bag.
49
- */
50
- async function handleConversationSightFrame({
51
- pathParams = {},
52
- body = {},
53
- headers,
54
- }: RouteHandlerArgs) {
55
- const rawId = pathParams.id!;
56
- const conversationId = resolveOrThrow(rawId);
57
- const accepted = getConversation(conversationId);
58
- if (!accepted) {
59
- throw new NotFoundError(`Conversation ${rawId} not found`);
60
- }
61
-
62
- const attachmentId = body.attachmentId;
63
- if (typeof attachmentId !== "string" || attachmentId.length === 0) {
64
- throw new BadRequestError("Missing attachmentId");
65
- }
66
-
67
- // The row is attributed to the actor this request was verified as, resolved
68
- // the way every other vellum-channel route resolves it. Left to the
69
- // conversation's resting trust, a frame would be stamped for whoever used
70
- // the conversation last.
71
- const trustContext = await resolveVellumActorTrustContext(
72
- headers?.["x-vellum-actor-principal-id"],
73
- { healResetDrift: true },
74
- );
75
-
76
- // Every non-fatal outcome the persist folds into `ok` is reported as a
77
- // refusal rather than an error status: the frame is gone either way, the
78
- // client's only move is to keep sampling, and the upload behind each one is
79
- // the persist's to settle. It gives the bytes up as it refuses, or, where
80
- // the store would not say whether a row landed, holds them until it answers
81
- // and gives them up then. Either way the client is done with the frame,
82
- // which is what the two-state response means.
83
- const result = await persistAmbientSightFrame(
84
- conversationId,
85
- attachmentId,
86
- "chat",
87
- trustContext,
88
- accepted.createdAt,
89
- );
90
- return {
91
- persisted: result.ok,
92
- ...(result.messageId === undefined ? {} : { messageId: result.messageId }),
93
- };
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // Route definitions
98
- // ---------------------------------------------------------------------------
99
-
100
- export const ROUTES: RouteDefinition[] = [
101
- {
102
- operationId: "conversationSightFrame",
103
- endpoint: "conversations/:id/sight-frame",
104
- method: "POST",
105
- policy: {
106
- requiredScopes: ["chat.write"],
107
- allowedPrincipalTypes: ACTOR_PRINCIPALS,
108
- },
109
- summary: "Persist an ambient camera frame",
110
- description:
111
- "Add an already-uploaded camera frame to a conversation as its own " +
112
- "message, tagged so retention can age the image out of the model's " +
113
- "context while the transcript keeps it. No reply is generated. " +
114
- "Returns persisted=false when the frame was dropped, in which case the " +
115
- "assistant owns the upload and there is nothing to clean up.",
116
- tags: ["conversations"],
117
- pathParams: [{ name: "id", type: "uuid" }],
118
- requestBody: z.object({
119
- attachmentId: z
120
- .string()
121
- .describe("Id of the uploaded attachment holding the frame."),
122
- }),
123
- responseBody: z.object({
124
- persisted: z.boolean(),
125
- messageId: z
126
- .string()
127
- .optional()
128
- .describe("The message the frame landed on, when one was written."),
129
- }),
130
- additionalResponses: {
131
- "400": { description: "Missing or empty attachmentId" },
132
- "404": { description: "Conversation not found" },
133
- },
134
- handler: handleConversationSightFrame,
135
- },
136
- ];