@vellumai/assistant 0.10.5-staging.2 → 0.10.5-staging.3
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 +1 -1
- package/src/__tests__/compactor-media-strip.test.ts +177 -0
- package/src/__tests__/conversation-history-web-search.test.ts +7 -0
- package/src/__tests__/guardian-question-mode.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +94 -0
- package/src/__tests__/pre-model-call-sanitize.test.ts +1 -1
- package/src/__tests__/slack-notification-approval-card.test.ts +89 -2
- package/src/agent/ax-tree-compaction.test.ts +4 -1
- package/src/agent/loop.ts +1 -176
- package/src/context/compactor.ts +29 -15
- package/src/context/outbound-sanitize.ts +202 -0
- package/src/daemon/handlers/shared.ts +1 -23
- package/src/daemon/host-cu-proxy.ts +1 -1
- package/src/messaging/providers/slack/send.test.ts +79 -0
- package/src/messaging/providers/slack/send.ts +48 -12
- package/src/notifications/README.md +1 -1
- package/src/notifications/adapters/slack.ts +44 -13
- package/src/notifications/broadcaster.ts +21 -15
- package/src/notifications/decision-engine.ts +121 -31
- package/src/notifications/guardian-question-mode.ts +58 -10
- package/src/prompts/templates/SOUL.md +40 -14
- package/src/runtime/slack-reply-session.test.ts +81 -0
- package/src/runtime/slack-reply-session.ts +13 -0
- package/src/util/__tests__/text-spacing.test.ts +53 -0
- package/src/util/text-spacing.ts +53 -0
package/package.json
CHANGED
|
@@ -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 ──────────────────────────────
|
|
@@ -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)
|
|
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
|