@vellumai/assistant 0.12.2-dev.202609211115.1844039 → 0.12.2-dev.202609211515.9bea579

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -12949,6 +12949,62 @@ paths:
12949
12949
  schema:
12950
12950
  type: string
12951
12951
  format: binary
12952
+ /v1/documents/create:
12953
+ post:
12954
+ operationId: documents_create_post
12955
+ summary: Create a document
12956
+ description: Create an empty document owned by a conversation and return it with its new surface ID.
12957
+ tags:
12958
+ - documents
12959
+ requestBody:
12960
+ required: true
12961
+ content:
12962
+ application/json:
12963
+ schema:
12964
+ type: object
12965
+ properties:
12966
+ conversationId:
12967
+ type: string
12968
+ description: Owning conversation
12969
+ title:
12970
+ description: Document title. Defaults to "Untitled Document".
12971
+ type: string
12972
+ required:
12973
+ - conversationId
12974
+ responses:
12975
+ "200":
12976
+ description: Successful response
12977
+ content:
12978
+ application/json:
12979
+ schema:
12980
+ type: object
12981
+ properties:
12982
+ success:
12983
+ type: boolean
12984
+ surfaceId:
12985
+ type: string
12986
+ conversationId:
12987
+ type: string
12988
+ title:
12989
+ type: string
12990
+ content:
12991
+ type: string
12992
+ wordCount:
12993
+ type: number
12994
+ createdAt:
12995
+ type: number
12996
+ updatedAt:
12997
+ type: number
12998
+ required:
12999
+ - success
13000
+ - surfaceId
13001
+ - conversationId
13002
+ - title
13003
+ - content
13004
+ - wordCount
13005
+ - createdAt
13006
+ - updatedAt
13007
+ additionalProperties: false
12952
13008
  /v1/domain/register:
12953
13009
  post:
12954
13010
  operationId: domain_register_post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609211115.1844039",
3
+ "version": "0.12.2-dev.202609211515.9bea579",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,88 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ DEFAULT_DOCUMENT_TITLE,
5
+ getDocumentsForConversation,
6
+ } from "../documents/document-store.js";
7
+ import { createConversation } from "../persistence/conversation-crud.js";
8
+ import { getDb } from "../persistence/db-connection.js";
9
+ import { initializeDb } from "../persistence/db-init.js";
10
+ import { ROUTES as DOCUMENT_ROUTES } from "../runtime/routes/documents-routes.js";
11
+ import type { RouteDefinition } from "../runtime/routes/types.js";
12
+ import { resetDbForTesting } from "./db-test-helpers.js";
13
+
14
+ await initializeDb();
15
+
16
+ const CONVERSATION_ID = "conv-doc-create";
17
+
18
+ async function create(body: Record<string, unknown>): Promise<unknown> {
19
+ const route = DOCUMENT_ROUTES.find(
20
+ (r: RouteDefinition) => r.operationId === "createDocument",
21
+ )!;
22
+ return await route.handler({ body });
23
+ }
24
+
25
+ beforeEach(() => {
26
+ const db = getDb();
27
+ db.run("DELETE FROM document_conversations");
28
+ db.run("DELETE FROM documents");
29
+ db.run("DELETE FROM conversations");
30
+ createConversation({ id: CONVERSATION_ID });
31
+ });
32
+
33
+ afterAll(() => {
34
+ resetDbForTesting();
35
+ });
36
+
37
+ describe("createDocument route", () => {
38
+ test("creates an empty document under a new surface ID and returns it", async () => {
39
+ const result = await create({
40
+ conversationId: CONVERSATION_ID,
41
+ title: "Trip plan",
42
+ });
43
+
44
+ expect(result).toMatchObject({
45
+ success: true,
46
+ conversationId: CONVERSATION_ID,
47
+ title: "Trip plan",
48
+ content: "",
49
+ wordCount: 0,
50
+ });
51
+ const { surfaceId } = result as { surfaceId: string };
52
+ expect(surfaceId).toMatch(/^doc-[0-9a-f-]{36}$/);
53
+ expect(
54
+ getDocumentsForConversation(CONVERSATION_ID).map((d) => d.surfaceId),
55
+ ).toEqual([surfaceId]);
56
+ });
57
+
58
+ test("a missing or blank title falls back to the default", async () => {
59
+ await expect(
60
+ create({ conversationId: CONVERSATION_ID }),
61
+ ).resolves.toMatchObject({ title: DEFAULT_DOCUMENT_TITLE });
62
+ await expect(
63
+ create({ conversationId: CONVERSATION_ID, title: " " }),
64
+ ).resolves.toMatchObject({ title: DEFAULT_DOCUMENT_TITLE });
65
+ });
66
+
67
+ test("each create mints a distinct document", async () => {
68
+ const first = (await create({ conversationId: CONVERSATION_ID })) as {
69
+ surfaceId: string;
70
+ };
71
+ const second = (await create({ conversationId: CONVERSATION_ID })) as {
72
+ surfaceId: string;
73
+ };
74
+
75
+ expect(first.surfaceId).not.toBe(second.surfaceId);
76
+ expect(getDocumentsForConversation(CONVERSATION_ID)).toHaveLength(2);
77
+ });
78
+
79
+ test("rejects a request without a conversation", async () => {
80
+ await expect(create({})).rejects.toThrow(/conversationId is required/);
81
+ });
82
+
83
+ test("rejects a conversation that does not exist", async () => {
84
+ await expect(create({ conversationId: "conv-missing" })).rejects.toThrow(
85
+ /Conversation not found/,
86
+ );
87
+ });
88
+ });
@@ -203,6 +203,22 @@ describe("document write routes publish documents:list", () => {
203
203
  });
204
204
  });
205
205
 
206
+ test("creating a document publishes with the caller's client id", async () => {
207
+ const events = await captureSyncEvents(() =>
208
+ invoke("createDocument", {
209
+ body: { conversationId: CONVERSATION_ID },
210
+ headers: { "x-vellum-client-id": "client-abc" },
211
+ }),
212
+ );
213
+
214
+ expect(events).toHaveLength(1);
215
+ expect(events[0].message).toMatchObject({
216
+ type: "sync_changed",
217
+ tags: [SYNC_TAGS.documentsList],
218
+ originClientId: "client-abc",
219
+ });
220
+ });
221
+
206
222
  test("linking a document to a conversation publishes", async () => {
207
223
  await saveViaRoute();
208
224
 
@@ -251,6 +267,17 @@ describe("document routes that change nothing the list shows stay silent", () =>
251
267
  expect(events).toEqual([]);
252
268
  });
253
269
 
270
+ test("a create for a conversation that does not exist publishes nothing", async () => {
271
+ const events = await captureSyncEvents(async () => {
272
+ await expect(
273
+ invoke("createDocument", {
274
+ body: { conversationId: MISSING_CONVERSATION_ID },
275
+ }),
276
+ ).rejects.toThrow(/Conversation not found/);
277
+ });
278
+ expect(events).toEqual([]);
279
+ });
280
+
254
281
  test("a link to a document that does not exist publishes nothing", async () => {
255
282
  const events = await captureSyncEvents(async () => {
256
283
  await expect(
@@ -5,6 +5,8 @@
5
5
  * background jobs (e.g. proactive artifact generation) can persist documents
6
6
  * without going through the HTTP layer.
7
7
  */
8
+ import { randomUUID } from "node:crypto";
9
+
8
10
  import { rawAll, rawGet, rawRun } from "../persistence/raw-query.js";
9
11
  import { getLogger } from "../util/logger.js";
10
12
 
@@ -456,6 +458,27 @@ export function saveDocument(params: {
456
458
  }
457
459
  }
458
460
 
461
+ export const DEFAULT_DOCUMENT_TITLE = "Untitled Document";
462
+
463
+ /**
464
+ * Create a new document owned by `conversationId` under a freshly minted
465
+ * surface ID. A missing or empty title falls back to `DEFAULT_DOCUMENT_TITLE`.
466
+ */
467
+ export function createDocument(params: {
468
+ conversationId: string;
469
+ title?: string;
470
+ content?: string;
471
+ }): ReturnType<typeof saveDocument> {
472
+ const content = params.content ?? "";
473
+ return saveDocument({
474
+ surfaceId: `doc-${randomUUID()}`,
475
+ conversationId: params.conversationId,
476
+ title: params.title || DEFAULT_DOCUMENT_TITLE,
477
+ content,
478
+ wordCount: countWords(content),
479
+ });
480
+ }
481
+
459
482
  // ---------------------------------------------------------------------------
460
483
  // Find-and-replace
461
484
  // ---------------------------------------------------------------------------
@@ -8,10 +8,13 @@ import { z } from "zod";
8
8
 
9
9
  import {
10
10
  addDocumentConversation,
11
+ createDocument,
12
+ DEFAULT_DOCUMENT_TITLE,
11
13
  getDocumentById,
12
14
  getDocumentsForConversation,
13
15
  saveDocument,
14
16
  } from "../../documents/document-store.js";
17
+ import { getConversation } from "../../persistence/conversation-crud.js";
15
18
  import { rawAll } from "../../persistence/raw-query.js";
16
19
  import { getLogger } from "../../util/logger.js";
17
20
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
@@ -214,6 +217,54 @@ export const ROUTES: RouteDefinition[] = [
214
217
  },
215
218
  },
216
219
 
220
+ {
221
+ operationId: "createDocument",
222
+ endpoint: "documents/create",
223
+ method: "POST",
224
+ policy: {
225
+ requiredScopes: ["settings.write"],
226
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
227
+ },
228
+ summary: "Create a document",
229
+ description:
230
+ "Create an empty document owned by a conversation and return it with its new surface ID.",
231
+ tags: ["documents"],
232
+ requestBody: z.object({
233
+ conversationId: z.string().describe("Owning conversation"),
234
+ title: z
235
+ .string()
236
+ .optional()
237
+ .describe(`Document title. Defaults to "${DEFAULT_DOCUMENT_TITLE}".`),
238
+ }),
239
+ responseBody: documentPayloadSchema,
240
+ handler: ({ body, headers }) => {
241
+ const { conversationId, title } = (body ?? {}) as {
242
+ conversationId?: string;
243
+ title?: string;
244
+ };
245
+ if (!conversationId || typeof conversationId !== "string") {
246
+ throw new BadRequestError("conversationId is required");
247
+ }
248
+ if (title !== undefined && typeof title !== "string") {
249
+ throw new BadRequestError("title must be a string");
250
+ }
251
+ if (!getConversation(conversationId)) {
252
+ throw new NotFoundError("Conversation not found");
253
+ }
254
+
255
+ const result = createDocument({ conversationId, title: title?.trim() });
256
+ if (!result.success) {
257
+ throw new InternalError(result.error);
258
+ }
259
+ const doc = getDocumentById(result.surfaceId);
260
+ if (!doc) {
261
+ throw new InternalError("Created document could not be read back");
262
+ }
263
+ publishDocumentsChanged(getOriginClientId(headers));
264
+ return { success: true, ...doc };
265
+ },
266
+ },
267
+
217
268
  {
218
269
  operationId: "linkDocumentConversation",
219
270
  endpoint: "documents/:id/conversations",
@@ -1,9 +1,9 @@
1
- import { randomUUID } from "node:crypto";
2
-
3
1
  import { z } from "zod";
4
2
 
5
3
  import {
6
4
  addDocumentConversation,
5
+ createDocument,
6
+ DEFAULT_DOCUMENT_TITLE,
7
7
  deleteDocument,
8
8
  findInDocument,
9
9
  findRecentEmptyDocumentByTitle,
@@ -11,7 +11,6 @@ import {
11
11
  getDocumentsForConversation,
12
12
  isDocumentAssociatedWithConversation,
13
13
  replaceInDocument,
14
- saveDocument,
15
14
  searchDocumentsByTitle,
16
15
  updateDocumentContent,
17
16
  } from "../../documents/document-store.js";
@@ -283,7 +282,7 @@ export function executeDocumentCreate(
283
282
  return invalidToolInputResult("document_create", parsedInput.error);
284
283
  }
285
284
  throwIfCancelled(context);
286
- const title = parsedInput.data.title || "Untitled Document";
285
+ const title = parsedInput.data.title || DEFAULT_DOCUMENT_TITLE;
287
286
  const initialContent = parsedInput.data.initial_content || "";
288
287
 
289
288
  const reused = maybeReuseEmptyDocument(title, initialContent, context);
@@ -291,21 +290,21 @@ export function executeDocumentCreate(
291
290
  return reused;
292
291
  }
293
292
 
294
- const surfaceId = `doc-${randomUUID()}`;
295
-
296
293
  // Persist the document so any client (web or macOS) can fetch it via
297
294
  // GET /v1/documents/:id. The macOS client may later update the row
298
295
  // via document_save; ON CONFLICT DO UPDATE handles that.
299
- const wordCount = initialContent
300
- .split(/\s+/)
301
- .filter((w) => w.length > 0).length;
302
- saveDocument({
303
- surfaceId,
296
+ const created = createDocument({
304
297
  conversationId: context.conversationId,
305
298
  title,
306
299
  content: initialContent,
307
- wordCount,
308
300
  });
301
+ if (!created.success) {
302
+ return {
303
+ content: JSON.stringify({ success: false, error: created.error }),
304
+ isError: true,
305
+ };
306
+ }
307
+ const { surfaceId } = created;
309
308
 
310
309
  // Send document_editor_show message to open the built-in RTE
311
310
  if (context.sendToClient) {