@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 +31 -3
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +128 -0
- package/src/__tests__/document-tool-security.test.ts +9 -6
- package/src/__tests__/document-update-default-surface.test.ts +202 -0
- package/src/__tests__/exploration-drift-empty-input-escalation.test.ts +320 -0
- package/src/__tests__/secret-response-routing.test.ts +35 -0
- package/src/__tests__/skill-execute-input.test.ts +5 -0
- package/src/__tests__/subagent-tools.test.ts +150 -0
- package/src/cli/commands/oauth/status.test.ts +36 -0
- package/src/cli/commands/oauth/status.ts +23 -3
- package/src/config/bundled-skills/document-editor/SKILL.md +3 -3
- package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/SKILL.md +6 -4
- package/src/daemon/conversation-tool-setup.ts +1 -0
- package/src/permissions/secret-prompter.ts +15 -1
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +223 -3
- package/src/runtime/pending-interactions.ts +20 -0
- package/src/runtime/routes/approval-routes.ts +40 -9
- package/src/subagent/manager.ts +11 -4
- package/src/tools/document/document-tool.ts +28 -3
- package/src/tools/skills/execute.ts +1 -2
- package/src/tools/subagent/spawn.ts +37 -13
- package/src/tools/terminal/shell.ts +5 -0
- package/src/tools/types.ts +9 -0
package/openapi.yaml
CHANGED
|
@@ -19315,9 +19315,37 @@ paths:
|
|
|
19315
19315
|
additionalProperties: {}
|
|
19316
19316
|
description: Pending confirmation details or null
|
|
19317
19317
|
pendingSecret:
|
|
19318
|
-
|
|
19319
|
-
|
|
19320
|
-
|
|
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
|
@@ -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("
|
|
268
|
-
const result = executeDocumentUpdate(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
expect(
|
|
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
|
+
});
|