@vellumai/assistant 0.10.5-staging.2 → 0.10.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.5-staging.2",
3
+ "version": "0.10.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,177 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ function makeLoggerStub(): Record<string, unknown> {
4
+ const stub: Record<string, unknown> = {};
5
+ for (const m of [
6
+ "info",
7
+ "warn",
8
+ "error",
9
+ "debug",
10
+ "trace",
11
+ "fatal",
12
+ "silent",
13
+ "child",
14
+ ]) {
15
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
16
+ }
17
+ return stub;
18
+ }
19
+
20
+ mock.module("../util/logger.js", () => ({
21
+ getLogger: () => makeLoggerStub(),
22
+ }));
23
+
24
+ mock.module("../persistence/conversation-crud.js", () => ({
25
+ setConversationProcessingStartedAt: () => {},
26
+ isConversationProcessing: () => false,
27
+ getMessages: () => [],
28
+ reserveMessage: mock(async () => ({ id: "msg-reserve" })),
29
+ }));
30
+
31
+ mock.module("../persistence/attachments-store.js", () => ({
32
+ getAttachmentMetadataForMessage: () => [],
33
+ getAttachmentContent: () => null,
34
+ }));
35
+
36
+ import { runAssistantDrivenCompaction } from "../context/compactor.js";
37
+ import type { Message, Provider } from "../providers/types.js";
38
+
39
+ const TAIL_TIMESTAMP =
40
+ "2026-05-21 (Thursday) 10:00:00 -05:00 (America/Chicago)";
41
+
42
+ const compactionResponse = `
43
+ <compaction_result>
44
+ <summary>
45
+ Earlier turns summarized here.
46
+ </summary>
47
+
48
+ <key_state>
49
+ - Nothing critical pending.
50
+ </key_state>
51
+
52
+ <tail_start timestamp="${TAIL_TIMESTAMP}" preview="tail anchor message" />
53
+ </compaction_result>
54
+ `;
55
+
56
+ const OLD_SCREENSHOT_DATA = "b64-old-screenshot-bytes";
57
+ const LATEST_SCREENSHOT_DATA = "b64-latest-screenshot-bytes";
58
+ const USER_ATTACHED_IMAGE_DATA = "b64-user-attached-bytes";
59
+
60
+ const userTextWithTurnContext = (text: string, timestamp: string): Message => ({
61
+ role: "user",
62
+ content: [
63
+ {
64
+ type: "text",
65
+ text: `<turn_context>\ncurrent_time: ${timestamp}\n</turn_context>\n${text}`,
66
+ },
67
+ ],
68
+ });
69
+
70
+ const assistantToolUse = (id: string): Message => ({
71
+ role: "assistant",
72
+ content: [{ type: "tool_use", id, name: "screenshot", input: {} }],
73
+ });
74
+
75
+ const toolResultWithImage = (id: string, data: string): Message => ({
76
+ role: "user",
77
+ content: [
78
+ {
79
+ type: "tool_result",
80
+ tool_use_id: id,
81
+ content: "captured",
82
+ contentBlocks: [
83
+ {
84
+ type: "image",
85
+ source: { type: "base64", media_type: "image/png", data },
86
+ },
87
+ ],
88
+ },
89
+ ],
90
+ });
91
+
92
+ const userWithAttachedImage = (): Message => ({
93
+ role: "user",
94
+ content: [
95
+ { type: "text", text: "here is a mockup" },
96
+ {
97
+ type: "image",
98
+ source: {
99
+ type: "base64",
100
+ media_type: "image/png",
101
+ data: USER_ATTACHED_IMAGE_DATA,
102
+ },
103
+ },
104
+ ],
105
+ });
106
+
107
+ function serializeBlocks(messages: Message[]): string {
108
+ return JSON.stringify(messages);
109
+ }
110
+
111
+ // Records the exact message list the provider is asked to summarize.
112
+ function recordingProvider(sink: { sent: Message[] }): Provider {
113
+ return {
114
+ name: "mock-provider",
115
+ sendMessage: async (msgs: Message[]) => {
116
+ sink.sent = msgs;
117
+ return {
118
+ content: [{ type: "text", text: compactionResponse }],
119
+ model: "mock-model",
120
+ usage: { inputTokens: 100, outputTokens: 50 },
121
+ stopReason: "end_turn",
122
+ };
123
+ },
124
+ };
125
+ }
126
+
127
+ // The summary request must carry the same media-stripped projection the agent
128
+ // loop sends on its own model calls. An unsanitized history resends every
129
+ // screenshot in the conversation; enough of them cross Anthropic's many-image
130
+ // threshold, where a stricter per-image dimension cap rejects the whole
131
+ // summary call (and trips the compaction circuit breaker).
132
+ describe("compaction summary calls — old tool-result media stripping", () => {
133
+ test("runAssistantDrivenCompaction strips images from older tool results, keeps the latest turn's image and user-attached images, and leaves durable history untouched", async () => {
134
+ // GIVEN a screenshot-heavy history: older tool results with images, a
135
+ // user-attached image, and a most-recent tool result with an image
136
+ const messages: Message[] = [
137
+ userWithAttachedImage(),
138
+ assistantToolUse("tu_old_1"),
139
+ toolResultWithImage("tu_old_1", OLD_SCREENSHOT_DATA),
140
+ userTextWithTurnContext("tail anchor message", TAIL_TIMESTAMP),
141
+ assistantToolUse("tu_latest"),
142
+ toolResultWithImage("tu_latest", LATEST_SCREENSHOT_DATA),
143
+ ];
144
+
145
+ // AND a snapshot of the caller's array so we can assert it is not mutated
146
+ const inputSnapshot = serializeBlocks(messages);
147
+ const sink = { sent: [] as Message[] };
148
+
149
+ // WHEN compaction runs over that history
150
+ const result = await runAssistantDrivenCompaction({
151
+ conversationId: "conv-media-strip",
152
+ messages,
153
+ provider: recordingProvider(sink),
154
+ systemPrompt: "system",
155
+ compaction: { enabled: true, autoThreshold: 0.7 },
156
+ // Large budget so the request is not front-truncated — this test
157
+ // exercises the media-strip sanitizer, not the truncation fallback.
158
+ maxInputTokens: 100000,
159
+ previousEstimatedInputTokens: 90000,
160
+ });
161
+
162
+ // THEN older tool-result screenshots are replaced with a text marker
163
+ const sentSerialized = serializeBlocks(sink.sent);
164
+ expect(sentSerialized).not.toContain(OLD_SCREENSHOT_DATA);
165
+ expect(sentSerialized).toContain("binary data removed to save context");
166
+
167
+ // AND the most recent tool-result turn keeps its image (the model may
168
+ // still need it), as do images the user attached directly
169
+ expect(sentSerialized).toContain(LATEST_SCREENSHOT_DATA);
170
+ expect(sentSerialized).toContain(USER_ATTACHED_IMAGE_DATA);
171
+
172
+ // AND the caller's durable history array is left byte-for-byte untouched,
173
+ // so persisted messages keep the original rich blocks
174
+ expect(serializeBlocks(messages)).toBe(inputSnapshot);
175
+ expect(result.compacted).toBe(true);
176
+ });
177
+ });
@@ -622,6 +622,13 @@ describe("web_search_tool_result structural guard", () => {
622
622
  "context/post-turn-tool-result-truncation.ts",
623
623
  "context/tool-result-spool.ts",
624
624
 
625
+ // Outbound sanitize bundle: the media-strip and AX-tree transforms
626
+ // operate on locally-executed tool results (media contentBlocks and
627
+ // <ax-tree> text), which web_search_tool_result blocks never carry.
628
+ // Web-search blocks are handled by the bundle's own third transform
629
+ // (stripHistoricalWebSearchResults), so nothing is silently dropped.
630
+ "context/outbound-sanitize.ts",
631
+
625
632
  // Anthropic provider type guards define API-specific discriminants.
626
633
  // It has a separate isWebSearchToolResultBlock for the other type.
627
634
  "providers/anthropic/client.ts",
@@ -9,10 +9,12 @@ import {
9
9
  buildGuardianRequestCodeInstruction,
10
10
  hasGuardianRequestCodeInstruction,
11
11
  parseGuardianQuestionPayload,
12
+ parseInteractiveApprovalPayload,
12
13
  resolveGuardianInstructionModeForRequest,
13
14
  resolveGuardianInstructionModeFromFields,
14
15
  resolveGuardianQuestionInstructionMode,
15
16
  stripConflictingGuardianRequestInstructions,
17
+ stripGuardianRequestCodeInstructions,
16
18
  } from "../notifications/guardian-question-mode.js";
17
19
 
18
20
  describe("guardian-question-mode", () => {
@@ -228,4 +230,59 @@ describe("guardian-question-mode", () => {
228
230
  ),
229
231
  ).toBe("");
230
232
  });
233
+
234
+ test("stripGuardianRequestCodeInstructions removes both-mode directives and bare code mentions", () => {
235
+ const text = [
236
+ "Alice is asking to run ls /tmp.",
237
+ "Approval code: A1B2C3",
238
+ 'Reference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".',
239
+ 'Reply "A1B2C3 <your answer>".',
240
+ ].join("\n");
241
+
242
+ expect(stripGuardianRequestCodeInstructions(text, "A1B2C3")).toBe(
243
+ "Alice is asking to run ls /tmp.",
244
+ );
245
+ });
246
+
247
+ test("stripGuardianRequestCodeInstructions leaves unrelated text and other codes intact", () => {
248
+ const text = 'Approval code: ZZZZZZ. Reply "A1B2C3 approve" if you agree.';
249
+ expect(stripGuardianRequestCodeInstructions(text, "A1B2C3")).toBe(text);
250
+ });
251
+
252
+ test("parseInteractiveApprovalPayload accepts approval-mode payloads with a requestId", () => {
253
+ expect(
254
+ parseInteractiveApprovalPayload({
255
+ requestKind: "tool_grant_request",
256
+ requestId: "req-1",
257
+ requestCode: "A1B2C3",
258
+ questionText: "Allow host bash?",
259
+ toolName: "host_bash",
260
+ }),
261
+ ).not.toBeNull();
262
+ });
263
+
264
+ test("parseInteractiveApprovalPayload rejects answer-mode and unparseable payloads", () => {
265
+ // Answer mode: free-text questions get no Approve/Reject buttons.
266
+ expect(
267
+ parseInteractiveApprovalPayload({
268
+ requestKind: "pending_question",
269
+ requestId: "req-2",
270
+ requestCode: "A1B2C3",
271
+ questionText: "What time works?",
272
+ callSessionId: "call-1",
273
+ activeGuardianRequestCount: 1,
274
+ }),
275
+ ).toBeNull();
276
+
277
+ // Strict-parse failure: missing required pending_question fields.
278
+ expect(
279
+ parseInteractiveApprovalPayload({
280
+ requestKind: "pending_question",
281
+ requestId: "req-3",
282
+ requestCode: "A1B2C3",
283
+ questionText: "Allow send_email?",
284
+ toolName: "send_email",
285
+ }),
286
+ ).toBeNull();
287
+ });
231
288
  });
@@ -285,6 +285,100 @@ describe("notification decision fallback copy", () => {
285
285
  expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
286
286
  expect(decision.renderedCopy.vellum?.body).not.toContain("<your answer>");
287
287
  });
288
+
289
+ test("slack approval copy is stripped of request-code instructions instead of enforced", async () => {
290
+ const signal = makeSignal({
291
+ contextPayload: {
292
+ requestId: "req-grant-slack-1",
293
+ questionText: "Approve tool: bash — ls /tmp (requested by Alice)",
294
+ requestCode: "A1B2C3",
295
+ requestKind: "tool_grant_request",
296
+ toolName: "bash",
297
+ },
298
+ });
299
+
300
+ const decision = await evaluateSignal(signal, [
301
+ "vellum",
302
+ "slack",
303
+ ] as NotificationChannel[]);
304
+
305
+ expect(decision.fallbackUsed).toBe(true);
306
+ // Vellum keeps the code-reply directive.
307
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
308
+ // Slack renders Approve/Reject buttons — no code anywhere in its copy.
309
+ expect(decision.renderedCopy.slack?.body).toBe(
310
+ "Approve tool: bash — ls /tmp (requested by Alice)",
311
+ );
312
+ expect(decision.renderedCopy.slack?.body).not.toContain("A1B2C3");
313
+ });
314
+
315
+ test("slack copy strips LLM-authored approval-code phrasing for approval requests", async () => {
316
+ configuredProvider = {
317
+ sendMessage: async () => ({ content: [] }),
318
+ };
319
+ extractedToolUse = {
320
+ name: "record_notification_decision",
321
+ input: {
322
+ shouldNotify: true,
323
+ selectedChannels: ["slack"],
324
+ reasoningSummary: "LLM decision",
325
+ renderedCopy: {
326
+ slack: {
327
+ title: "Tool Grant Request",
328
+ body: "Alice is asking to run ls /tmp.",
329
+ deliveryText:
330
+ 'Alice is asking to run ls /tmp.\nApproval code: A1B2C3\nReference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".',
331
+ },
332
+ },
333
+ dedupeKey: "guardian-question-slack-llm-test",
334
+ confidence: 0.9,
335
+ },
336
+ };
337
+
338
+ const signal = makeSignal({
339
+ contextPayload: {
340
+ requestId: "req-grant-slack-2",
341
+ questionText: "Approve tool: bash — ls /tmp (requested by Alice)",
342
+ requestCode: "A1B2C3",
343
+ requestKind: "tool_grant_request",
344
+ toolName: "bash",
345
+ },
346
+ });
347
+
348
+ const decision = await evaluateSignal(signal, [
349
+ "slack",
350
+ ] as NotificationChannel[]);
351
+
352
+ expect(decision.fallbackUsed).toBe(false);
353
+ expect(decision.renderedCopy.slack?.deliveryText).toBe(
354
+ "Alice is asking to run ls /tmp.",
355
+ );
356
+ expect(decision.renderedCopy.slack?.body).toBe(
357
+ "Alice is asking to run ls /tmp.",
358
+ );
359
+ });
360
+
361
+ test("slack answer-mode questions keep code instructions (no buttons render)", async () => {
362
+ const signal = makeSignal({
363
+ contextPayload: {
364
+ requestId: "req-pending-slack-1",
365
+ questionText: "What is the gate code?",
366
+ requestCode: "A1B2C3",
367
+ requestKind: "pending_question",
368
+ callSessionId: "call-1",
369
+ activeGuardianRequestCount: 1,
370
+ },
371
+ });
372
+
373
+ const decision = await evaluateSignal(signal, [
374
+ "slack",
375
+ ] as NotificationChannel[]);
376
+
377
+ expect(decision.fallbackUsed).toBe(true);
378
+ expect(decision.renderedCopy.slack?.body).toContain(
379
+ '"A1B2C3 <your answer>"',
380
+ );
381
+ });
288
382
  });
289
383
 
290
384
  // ── Access-request instruction enforcement ──────────────────────────────
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { preModelCallSanitize } from "../agent/loop.js";
3
+ import { preModelCallSanitize } from "../context/outbound-sanitize.js";
4
4
  import type { Message } from "../providers/types.js";
5
5
 
6
6
  /**
@@ -10,7 +10,7 @@ import type { ApprovalUIMetadata } from "../runtime/channel-approval-types.js";
10
10
  * quoted-preview body, action callback ids, source/permalink and requester-id
11
11
  * context blocks, the security-warning context block, and guardian
12
12
  * verification note that `buildApprovalNotificationBlocks` emits for an
13
- * access request.
13
+ * access request — plus the tool-approval card body/continuation split.
14
14
  */
15
15
 
16
16
  const APPROVAL: ApprovalUIMetadata = {
@@ -39,7 +39,9 @@ type Block = Record<string, unknown>;
39
39
 
40
40
  function card(blocks: unknown[]): Block {
41
41
  const c = (blocks as Block[]).find((b) => b.type === "card");
42
- if (!c) throw new Error("no card block");
42
+ if (!c) {
43
+ throw new Error("no card block");
44
+ }
43
45
  return c;
44
46
  }
45
47
 
@@ -174,3 +176,88 @@ describe("Slack access-request card blocks", () => {
174
176
  ).toBe(true);
175
177
  });
176
178
  });
179
+
180
+ const TOOL_APPROVAL: ApprovalUIMetadata = {
181
+ requestId: "req-456",
182
+ actions: [
183
+ { id: "approve_once", label: "Approve once" },
184
+ { id: "reject", label: "Reject" },
185
+ ],
186
+ plainTextFallback: 'Reply "ABC123 approve" or "ABC123 reject"',
187
+ permissionDetails: {
188
+ toolName: "bash",
189
+ riskLevel: "medium",
190
+ toolInput: { command: "ls /tmp" },
191
+ requesterIdentifier: "Alice",
192
+ },
193
+ };
194
+
195
+ function buildToolApprovalPayload(): ChannelDeliveryPayload {
196
+ return {
197
+ sourceEventName: "guardian.question",
198
+ copy: { title: "Guardian Question", body: "Approve tool: bash" },
199
+ urgency: "high",
200
+ approvalContext: TOOL_APPROVAL,
201
+ };
202
+ }
203
+
204
+ function sectionTexts(blocks: unknown[]): string[] {
205
+ return (blocks as Block[])
206
+ .filter((b) => b.type === "section")
207
+ .map((b) => text(b.text));
208
+ }
209
+
210
+ describe("Slack tool-approval card blocks", () => {
211
+ test("short message renders entirely in the card body with no companion section", () => {
212
+ const message = "Alice is requesting approval to run: ls /tmp";
213
+ const blocks = buildApprovalNotificationBlocks(
214
+ buildToolApprovalPayload(),
215
+ message,
216
+ );
217
+ expect(text(card(blocks).body)).toBe(message);
218
+ expect(sectionTexts(blocks)).toHaveLength(0);
219
+ });
220
+
221
+ test("card carries tool title and tool/requester subtitle", () => {
222
+ const c = card(
223
+ buildApprovalNotificationBlocks(buildToolApprovalPayload(), "msg"),
224
+ );
225
+ expect(text(c.title)).toBe("Tool Approval");
226
+ expect(text(c.subtitle)).toBe("bash — requested by Alice");
227
+ });
228
+
229
+ test("long message continues in a section without repeating the card body", () => {
230
+ const message = Array.from(
231
+ { length: 40 },
232
+ (_, i) => `word${String(i).padStart(2, "0")}`,
233
+ ).join(" "); // 279 chars of distinct words
234
+ const blocks = buildApprovalNotificationBlocks(
235
+ buildToolApprovalPayload(),
236
+ message,
237
+ );
238
+
239
+ const body = text(card(blocks).body);
240
+ expect(body.length).toBeLessThanOrEqual(200);
241
+ expect(body.endsWith(" ↓")).toBe(true);
242
+
243
+ const sections = sectionTexts(blocks);
244
+ expect(sections).toHaveLength(1);
245
+ const continuation = sections[0];
246
+
247
+ // Body + continuation reassemble the full message with nothing repeated
248
+ // and nothing lost — the guardian reads each word exactly once.
249
+ const bodyWords = body.replace(/ ↓$/, "").split(" ");
250
+ const continuationWords = continuation.replace(/^… /, "").split(" ");
251
+ expect([...bodyWords, ...continuationWords].join(" ")).toBe(message);
252
+ });
253
+
254
+ test("split lands on a word boundary", () => {
255
+ const message = `${"a".repeat(190)} ${"b".repeat(60)}`;
256
+ const blocks = buildApprovalNotificationBlocks(
257
+ buildToolApprovalPayload(),
258
+ message,
259
+ );
260
+ expect(text(card(blocks).body)).toBe(`${"a".repeat(190)} ↓`);
261
+ expect(sectionTexts(blocks)[0]).toBe(`… ${"b".repeat(60)}`);
262
+ });
263
+ });
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import {
4
+ compactAxTreeHistory,
5
+ escapeAxTreeContent,
6
+ } from "../context/outbound-sanitize.js";
3
7
  import type { Message } from "../providers/types.js";
4
- import { compactAxTreeHistory, escapeAxTreeContent } from "./loop.js";
5
8
 
6
9
  // ---------------------------------------------------------------------------
7
10
  // Helpers