@vellumai/assistant 0.9.0-dev.202606191617.bbf9de9 → 0.9.0-dev.202606191743.3f533cf

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
@@ -19315,9 +19315,37 @@ paths:
19315
19315
  additionalProperties: {}
19316
19316
  description: Pending confirmation details or null
19317
19317
  pendingSecret:
19318
- type: object
19319
- properties: {}
19320
- additionalProperties: {}
19318
+ anyOf:
19319
+ - type: object
19320
+ properties:
19321
+ requestId:
19322
+ type: string
19323
+ service:
19324
+ type: string
19325
+ field:
19326
+ type: string
19327
+ label:
19328
+ type: string
19329
+ description:
19330
+ type: string
19331
+ placeholder:
19332
+ type: string
19333
+ purpose:
19334
+ type: string
19335
+ allowedTools:
19336
+ type: array
19337
+ items:
19338
+ type: string
19339
+ allowedDomains:
19340
+ type: array
19341
+ items:
19342
+ type: string
19343
+ allowOneTimeSend:
19344
+ type: boolean
19345
+ required:
19346
+ - requestId
19347
+ additionalProperties: {}
19348
+ - type: "null"
19321
19349
  description: Pending secret request or null
19322
19350
  interactions:
19323
19351
  type: array
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606191617.bbf9de9",
3
+ "version": "0.9.0-dev.202606191743.3f533cf",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -689,6 +689,82 @@ describe("standalone approval endpoints — HTTP layer", () => {
689
689
  await stopServer();
690
690
  });
691
691
 
692
+ test("cancels a secret request when value is omitted", async () => {
693
+ let secretRequestId: string | undefined;
694
+ let secretValue: string | undefined;
695
+
696
+ const session = makeIdleSession({
697
+ onSecret: (reqId, val) => {
698
+ secretRequestId = reqId;
699
+ secretValue = val;
700
+ },
701
+ });
702
+
703
+ await startServer(() => session);
704
+
705
+ pendingInteractions.register("secret-cancel-1", {
706
+ conversationId: "conv-1",
707
+ kind: "secret",
708
+ });
709
+
710
+ const res = await fetch(url("secret"), {
711
+ method: "POST",
712
+ headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
713
+ body: JSON.stringify({ requestId: "secret-cancel-1" }),
714
+ });
715
+ const body = (await res.json()) as { accepted: boolean };
716
+
717
+ expect(res.status).toBe(200);
718
+ expect(body.accepted).toBe(true);
719
+ expect(secretRequestId).toBe("secret-cancel-1");
720
+ expect(secretValue).toBeUndefined();
721
+ expect(pendingInteractions.get("secret-cancel-1")).toBeUndefined();
722
+
723
+ await stopServer();
724
+ });
725
+
726
+ test('legacy delivery "none" cancels the request without 400', async () => {
727
+ let secretRequestId: string | undefined;
728
+ let secretValue: string | undefined;
729
+ let secretDelivery: string | undefined;
730
+
731
+ const session = makeIdleSession({
732
+ onSecret: (reqId, val, del) => {
733
+ secretRequestId = reqId;
734
+ secretValue = val;
735
+ secretDelivery = del;
736
+ },
737
+ });
738
+
739
+ await startServer(() => session);
740
+
741
+ pendingInteractions.register("secret-legacy-cancel-1", {
742
+ conversationId: "conv-1",
743
+ kind: "secret",
744
+ });
745
+
746
+ const res = await fetch(url("secret"), {
747
+ method: "POST",
748
+ headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
749
+ body: JSON.stringify({
750
+ requestId: "secret-legacy-cancel-1",
751
+ value: "ignored-by-cancel",
752
+ delivery: "none",
753
+ }),
754
+ });
755
+ const body = (await res.json()) as { accepted: boolean };
756
+
757
+ expect(res.status).toBe(200);
758
+ expect(body.accepted).toBe(true);
759
+ expect(secretRequestId).toBe("secret-legacy-cancel-1");
760
+ // delivery "none" normalizes to the cancellation path: value/delivery dropped.
761
+ expect(secretValue).toBeUndefined();
762
+ expect(secretDelivery).toBeUndefined();
763
+ expect(pendingInteractions.get("secret-legacy-cancel-1")).toBeUndefined();
764
+
765
+ await stopServer();
766
+ });
767
+
692
768
  test("rejects a non-secret requestId without consuming it", async () => {
693
769
  /**
694
770
  * /v1/secret only settles secret prompts. A confirmation (or any other
@@ -790,6 +866,58 @@ describe("standalone approval endpoints — HTTP layer", () => {
790
866
  });
791
867
  });
792
868
 
869
+ // ── GET /v1/pending-interactions ─────────────────────────────────────
870
+
871
+ describe("GET /v1/pending-interactions", () => {
872
+ test("returns full secret prompt metadata for a registered secret", async () => {
873
+ const session = makeIdleSession();
874
+ await startServer(() => session);
875
+
876
+ pendingInteractions.register("secret-meta-1", {
877
+ conversationId: "conv-meta",
878
+ kind: "secret",
879
+ secretDetails: {
880
+ service: "github",
881
+ field: "token",
882
+ label: "GitHub Token",
883
+ description: "Personal access token",
884
+ placeholder: "ghp_...",
885
+ purpose: "Push commits",
886
+ allowedTools: ["git_push"],
887
+ allowedDomains: ["github.com"],
888
+ allowOneTimeSend: true,
889
+ },
890
+ });
891
+
892
+ const res = await fetch(
893
+ url("pending-interactions?conversationId=conv-meta"),
894
+ {
895
+ method: "GET",
896
+ headers: { ...AUTH_HEADERS },
897
+ },
898
+ );
899
+ const body = (await res.json()) as {
900
+ pendingSecret: Record<string, unknown> | null;
901
+ };
902
+
903
+ expect(res.status).toBe(200);
904
+ expect(body.pendingSecret).toEqual({
905
+ requestId: "secret-meta-1",
906
+ service: "github",
907
+ field: "token",
908
+ label: "GitHub Token",
909
+ description: "Personal access token",
910
+ placeholder: "ghp_...",
911
+ purpose: "Push commits",
912
+ allowedTools: ["git_push"],
913
+ allowedDomains: ["github.com"],
914
+ allowOneTimeSend: true,
915
+ });
916
+
917
+ await stopServer();
918
+ });
919
+ });
920
+
793
921
  // ── getByConversation ────────────────────────────────────────────────
794
922
 
795
923
  describe("getByConversation", () => {
@@ -264,12 +264,15 @@ describe("executeDocumentUpdate — input validation", () => {
264
264
  seedFixtureDocuments();
265
265
  });
266
266
 
267
- test("returns Invalid input when surface_id is missing", () => {
268
- const result = executeDocumentUpdate({}, makeContext());
269
- expect(result.isError).toBe(true);
270
- const body = parseResult<{ error: string }>(result);
271
- expect(body.error).toContain("Invalid input: surface_id is required");
272
- expect(body.error).not.toContain("Document not found");
267
+ test("resolves to the conversation's document when surface_id is omitted", () => {
268
+ const result = executeDocumentUpdate(
269
+ { content: "appended chunk" },
270
+ makeContext({ sendToClient: () => {} }),
271
+ );
272
+ expect(result.isError).toBe(false);
273
+ const body = parseResult<{ surface_id: string; success: boolean }>(result);
274
+ expect(body.success).toBe(true);
275
+ expect(body.surface_id).toBe("doc-current");
273
276
  });
274
277
 
275
278
  test("returns Invalid input when content is missing", () => {
@@ -0,0 +1,202 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { getDocumentById } from "../documents/document-store.js";
4
+ import { getSqlite } from "../memory/db-connection.js";
5
+ import { executeDocumentUpdate } from "../tools/document/document-tool.js";
6
+ import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
7
+ import { resetDbForTesting } from "./db-test-helpers.js";
8
+
9
+ function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
10
+ return {
11
+ workingDir: "/tmp/project",
12
+ conversationId: "conv-current",
13
+ trustClass: "trusted_contact",
14
+ executionChannel: "slack",
15
+ sendToClient: () => {},
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ function parseResult<T>(result: ToolExecutionResult): T {
21
+ return JSON.parse(result.content) as T;
22
+ }
23
+
24
+ function bootstrapDocumentTables(): void {
25
+ resetDbForTesting();
26
+ const raw = getSqlite();
27
+ raw.exec(/*sql*/ `
28
+ DROP TABLE IF EXISTS document_conversations;
29
+ DROP TABLE IF EXISTS documents;
30
+ DROP TABLE IF EXISTS conversations;
31
+
32
+ CREATE TABLE conversations (
33
+ id TEXT PRIMARY KEY,
34
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
35
+ );
36
+
37
+ CREATE TABLE documents (
38
+ surface_id TEXT PRIMARY KEY,
39
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
40
+ title TEXT NOT NULL,
41
+ content TEXT NOT NULL,
42
+ word_count INTEGER NOT NULL DEFAULT 0,
43
+ created_at INTEGER NOT NULL,
44
+ updated_at INTEGER NOT NULL
45
+ );
46
+
47
+ CREATE TABLE document_conversations (
48
+ surface_id TEXT NOT NULL,
49
+ conversation_id TEXT NOT NULL,
50
+ created_at INTEGER NOT NULL,
51
+ PRIMARY KEY (surface_id, conversation_id),
52
+ FOREIGN KEY (surface_id) REFERENCES documents(surface_id) ON DELETE CASCADE
53
+ );
54
+ `);
55
+ }
56
+
57
+ function seedDocument(params: {
58
+ surfaceId: string;
59
+ conversationId: string;
60
+ title: string;
61
+ content: string;
62
+ createdAt: number;
63
+ updatedAt?: number;
64
+ }): void {
65
+ const raw = getSqlite();
66
+ raw
67
+ .query(`INSERT OR IGNORE INTO conversations (id, created_at) VALUES (?, ?)`)
68
+ .run(params.conversationId, params.createdAt);
69
+ raw
70
+ .query(
71
+ `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
72
+ )
73
+ .run(
74
+ params.surfaceId,
75
+ params.conversationId,
76
+ params.title,
77
+ params.content,
78
+ params.content.split(/\s+/).filter(Boolean).length,
79
+ params.createdAt,
80
+ params.updatedAt ?? params.createdAt,
81
+ );
82
+ raw
83
+ .query(
84
+ `INSERT OR IGNORE INTO document_conversations (surface_id, conversation_id, created_at) VALUES (?, ?, ?)`,
85
+ )
86
+ .run(params.surfaceId, params.conversationId, params.createdAt);
87
+ }
88
+
89
+ describe("executeDocumentUpdate — default surface_id resolution", () => {
90
+ beforeEach(() => {
91
+ bootstrapDocumentTables();
92
+ });
93
+
94
+ test("appends to the conversation's only document when surface_id is omitted", () => {
95
+ const surfaceId = "doc-only";
96
+ seedDocument({
97
+ surfaceId,
98
+ conversationId: "conv-current",
99
+ title: "Dating in 2026",
100
+ content: "# Dating in 2026\n\nIntro.",
101
+ createdAt: Date.now(),
102
+ });
103
+
104
+ const result = executeDocumentUpdate(
105
+ { content: "## Section two", mode: "append" },
106
+ makeContext(),
107
+ );
108
+
109
+ expect(result.isError).toBe(false);
110
+ const body = parseResult<{ surface_id: string; success: boolean }>(result);
111
+ expect(body.success).toBe(true);
112
+ expect(body.surface_id).toBe(surfaceId);
113
+ expect(getDocumentById(surfaceId)?.content).toBe(
114
+ "# Dating in 2026\n\nIntro.\n\n## Section two",
115
+ );
116
+ });
117
+
118
+ test("targets the most recently updated document when several exist", () => {
119
+ const now = Date.now();
120
+ seedDocument({
121
+ surfaceId: "doc-old",
122
+ conversationId: "conv-current",
123
+ title: "Old",
124
+ content: "old",
125
+ createdAt: now - 10_000,
126
+ updatedAt: now - 10_000,
127
+ });
128
+ seedDocument({
129
+ surfaceId: "doc-fresh",
130
+ conversationId: "conv-current",
131
+ title: "Fresh",
132
+ content: "fresh",
133
+ createdAt: now,
134
+ updatedAt: now,
135
+ });
136
+
137
+ const result = executeDocumentUpdate({ content: "more" }, makeContext());
138
+
139
+ expect(result.isError).toBe(false);
140
+ const body = parseResult<{ surface_id: string }>(result);
141
+ expect(body.surface_id).toBe("doc-fresh");
142
+ expect(getDocumentById("doc-fresh")?.content).toBe("fresh\n\nmore");
143
+ expect(getDocumentById("doc-old")?.content).toBe("old");
144
+ });
145
+
146
+ test("an explicit surface_id still wins over the default", () => {
147
+ const now = Date.now();
148
+ seedDocument({
149
+ surfaceId: "doc-target",
150
+ conversationId: "conv-current",
151
+ title: "Target",
152
+ content: "target",
153
+ createdAt: now - 10_000,
154
+ updatedAt: now - 10_000,
155
+ });
156
+ seedDocument({
157
+ surfaceId: "doc-fresh",
158
+ conversationId: "conv-current",
159
+ title: "Fresh",
160
+ content: "fresh",
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ });
164
+
165
+ const result = executeDocumentUpdate(
166
+ { surface_id: "doc-target", content: "hit" },
167
+ makeContext(),
168
+ );
169
+
170
+ expect(result.isError).toBe(false);
171
+ expect(parseResult<{ surface_id: string }>(result).surface_id).toBe(
172
+ "doc-target",
173
+ );
174
+ expect(getDocumentById("doc-target")?.content).toBe("target\n\nhit");
175
+ expect(getDocumentById("doc-fresh")?.content).toBe("fresh");
176
+ });
177
+
178
+ test("errors helpfully when the conversation has no document", () => {
179
+ const result = executeDocumentUpdate(
180
+ { content: "orphan chunk" },
181
+ makeContext(),
182
+ );
183
+
184
+ expect(result.isError).toBe(true);
185
+ expect(result.content).toContain("no document is open");
186
+ expect(result.content).toContain("document_create");
187
+ });
188
+
189
+ test("still requires content", () => {
190
+ seedDocument({
191
+ surfaceId: "doc-only",
192
+ conversationId: "conv-current",
193
+ title: "X",
194
+ content: "x",
195
+ createdAt: Date.now(),
196
+ });
197
+
198
+ const result = executeDocumentUpdate({ mode: "append" }, makeContext());
199
+ expect(result.isError).toBe(true);
200
+ expect(result.content).toContain("content is required");
201
+ });
202
+ });