@vellumai/assistant 0.10.6-staging.1 → 0.10.6

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.
Files changed (58) hide show
  1. package/openapi.yaml +43 -6
  2. package/package.json +1 -1
  3. package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
  4. package/src/__tests__/attachments-store.test.ts +37 -1
  5. package/src/__tests__/attachments.test.ts +53 -0
  6. package/src/__tests__/channel-reply-delivery.test.ts +214 -5
  7. package/src/__tests__/config-loader-backfill.test.ts +16 -36
  8. package/src/__tests__/image-source-path-reinject.test.ts +155 -14
  9. package/src/__tests__/managed-profile-guard.test.ts +2 -0
  10. package/src/__tests__/mtime-cache.test.ts +265 -80
  11. package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
  12. package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
  13. package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
  14. package/src/agent/attachments.ts +55 -13
  15. package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
  16. package/src/cli/commands/platform/index.ts +86 -4
  17. package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
  18. package/src/cli/lib/diff-plugin.ts +7 -3
  19. package/src/cli/lib/inspect-plugin.ts +28 -10
  20. package/src/cli/lib/install-from-github.ts +69 -24
  21. package/src/cli/lib/ipc-params.ts +3 -3
  22. package/src/cli/lib/list-installed-plugins.ts +11 -5
  23. package/src/cli/lib/merge-plugin-tree.ts +15 -9
  24. package/src/cli/lib/plugin-fingerprint.ts +0 -6
  25. package/src/cli/lib/publish-plugin.ts +1 -1
  26. package/src/cli/lib/unknown-command.ts +3 -1
  27. package/src/cli/lib/upgrade-plugin.ts +2 -5
  28. package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
  29. package/src/config/seed-inference-profiles.ts +5 -4
  30. package/src/daemon/conversation-lifecycle.ts +37 -15
  31. package/src/daemon/conversation-messaging.ts +56 -28
  32. package/src/daemon/conversation.ts +2 -2
  33. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  34. package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
  35. package/src/hooks/hook-loader.ts +57 -144
  36. package/src/hooks/registry.ts +13 -9
  37. package/src/persistence/attachments-store.ts +2 -1
  38. package/src/persistence/conversation-crud.ts +24 -0
  39. package/src/plugin-api/types.ts +4 -1
  40. package/src/plugins/mtime-cache.ts +347 -33
  41. package/src/plugins/pipeline.ts +3 -2
  42. package/src/providers/__tests__/inference.test.ts +42 -31
  43. package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
  44. package/src/providers/inference/auth.ts +1 -2
  45. package/src/providers/inference/backfill.ts +7 -5
  46. package/src/providers/inference/connections.ts +54 -30
  47. package/src/runtime/channel-reply-delivery.ts +83 -24
  48. package/src/runtime/channel-retry-sweep.ts +4 -0
  49. package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
  50. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
  51. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
  52. package/src/runtime/routes/conversation-query-routes.ts +18 -1
  53. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
  54. package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
  55. package/src/runtime/routes/platform-routes.ts +82 -1
  56. package/src/runtime/slack-reply-session.ts +1 -1
  57. package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
  58. package/src/workspace/migrations/registry.ts +2 -0
package/openapi.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  openapi: 3.1.0
4
4
  info:
5
5
  title: Vellum Assistant API
6
- version: 0.10.5
6
+ version: 0.10.6
7
7
  description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
8
8
  servers:
9
9
  - url: http://127.0.0.1:7821
@@ -13810,9 +13810,8 @@ paths:
13810
13810
  operationId: inference_providerconnections_by_name_delete
13811
13811
  summary: Delete a provider connection
13812
13812
  description:
13813
- Delete a provider connection. Fails with 400 for managed connections (anthropic-managed, openai-managed,
13814
- gemini-managed) which are re-seeded on boot. Fails with 409 if any profile or call-site references the
13815
- connection.
13813
+ Delete a provider connection. Fails with 400 for the Vellum-managed connection (vellum) which is re-seeded
13814
+ on boot. Fails with 409 if any profile or call-site references the connection.
13816
13815
  tags:
13817
13816
  - inference
13818
13817
  parameters:
@@ -13866,8 +13865,8 @@ paths:
13866
13865
  operationId: inference_providerconnections_by_name_patch
13867
13866
  summary: Update a provider connection
13868
13867
  description:
13869
- Update an existing connection. Cannot rename or change the provider. For managed connections
13870
- (anthropic-managed, openai-managed, gemini-managed) the auth is locked to platform; label remains editable.
13868
+ Update an existing connection. Cannot rename or change the provider. For the Vellum-managed connection
13869
+ (vellum) the auth is locked to platform; label remains editable.
13871
13870
  tags:
13872
13871
  - inference
13873
13872
  parameters:
@@ -21080,6 +21079,44 @@ paths:
21080
21079
  showPlatformLogin:
21081
21080
  type: boolean
21082
21081
  additionalProperties: false
21082
+ /v1/platform/credits:
21083
+ get:
21084
+ operationId: platform_credits_get
21085
+ summary: Get the organization's remaining credit balance
21086
+ description:
21087
+ Fetches the org's settled, pending, and effective (remaining) credit balance in USD from the platform
21088
+ billing summary.
21089
+ tags:
21090
+ - platform
21091
+ responses:
21092
+ "200":
21093
+ description: Successful response
21094
+ content:
21095
+ application/json:
21096
+ schema:
21097
+ type: object
21098
+ properties:
21099
+ remaining:
21100
+ type: number
21101
+ settled:
21102
+ type: number
21103
+ pending:
21104
+ type: number
21105
+ unit:
21106
+ type: string
21107
+ const: USD
21108
+ stale:
21109
+ type: boolean
21110
+ as_of:
21111
+ type: string
21112
+ required:
21113
+ - remaining
21114
+ - settled
21115
+ - pending
21116
+ - unit
21117
+ - stale
21118
+ - as_of
21119
+ additionalProperties: false
21083
21120
  /v1/platform/disconnect:
21084
21121
  post:
21085
21122
  operationId: platform_disconnect_post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.6-staging.1",
3
+ "version": "0.10.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Regression: re-uploading a document with the same filename must not leave
3
+ * the model pointing at the older upload. Storage resolves the collision with
4
+ * a -2/-3 suffix in the conversation's attachments/ directory, so the
5
+ * LLM-facing user message (and the metadata used to rebuild it on history
6
+ * reload) must carry the resolved stored path of every linked attachment —
7
+ * otherwise the model's only on-disk handle is the original filename, which
8
+ * stays bound to the oldest upload.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
12
+
13
+ mock.module("../util/logger.js", () => ({
14
+ getLogger: () =>
15
+ new Proxy({} as Record<string, unknown>, {
16
+ get: () => () => {},
17
+ }),
18
+ }));
19
+
20
+ mock.module("../config/loader.js", () => ({
21
+ getConfig: () => ({
22
+ ui: {},
23
+
24
+ model: "test",
25
+ provider: "test",
26
+ memory: { enabled: false },
27
+ rateLimit: { maxRequestsPerMinute: 0 },
28
+ }),
29
+ }));
30
+
31
+ import { reinjectAttachmentPathAnnotations } from "../daemon/conversation-lifecycle.js";
32
+ import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
33
+ import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
34
+ import { createConversation } from "../persistence/conversation-crud.js";
35
+ import { getDb } from "../persistence/db-connection.js";
36
+ import { initializeDb } from "../persistence/db-init.js";
37
+ import { rawGet } from "../persistence/raw-query.js";
38
+ import type { ContentBlock } from "../providers/types.js";
39
+
40
+ await initializeDb();
41
+
42
+ function resetTables() {
43
+ const db = getDb();
44
+ db.run("DELETE FROM message_attachments");
45
+ db.run("DELETE FROM attachments");
46
+ db.run("DELETE FROM messages");
47
+ db.run("DELETE FROM conversations");
48
+ }
49
+
50
+ function makeCtx(conversationId: string): MessagingConversationContext {
51
+ return {
52
+ conversationId,
53
+ messages: [],
54
+ abortController: null,
55
+ currentRequestId: undefined,
56
+ queue: {} as never,
57
+ isProcessing: () => false,
58
+ setProcessing: () => {},
59
+ getTurnChannelContext: () => null,
60
+ getTurnInterfaceContext: () => null,
61
+ } as unknown as MessagingConversationContext;
62
+ }
63
+
64
+ function lastAnnotationBlock(ctx: MessagingConversationContext): {
65
+ type: string;
66
+ text: string;
67
+ } {
68
+ const llmMessage = ctx.messages.at(-1)!;
69
+ return (llmMessage.content as ContentBlock[]).at(-1) as {
70
+ type: string;
71
+ text: string;
72
+ };
73
+ }
74
+
75
+ describe("persistQueuedMessageBody stored path annotations", () => {
76
+ beforeEach(resetTables);
77
+
78
+ test("same-named re-upload annotates the resolved -2 path", async () => {
79
+ const conv = createConversation();
80
+ const ctx = makeCtx(conv.id);
81
+
82
+ // "aGVsbG8=" = "hello", "d29ybGQ=" = "world"
83
+ const first = await persistQueuedMessageBody(ctx, {
84
+ content: "here is my file",
85
+ attachments: [
86
+ { filename: "report.csv", mimeType: "text/csv", data: "aGVsbG8=" },
87
+ ],
88
+ });
89
+ const second = await persistQueuedMessageBody(ctx, {
90
+ content: "I edited it, take another look",
91
+ attachments: [
92
+ { filename: "report.csv", mimeType: "text/csv", data: "d29ybGQ=" },
93
+ ],
94
+ });
95
+
96
+ // Second turn's persisted metadata records the collision-suffixed copy.
97
+ const row = rawGet<{ metadata: string }>(
98
+ "test:secondMessageMetadata",
99
+ "SELECT metadata FROM messages WHERE id = ?",
100
+ second.id,
101
+ );
102
+ const meta = JSON.parse(row!.metadata) as {
103
+ attachmentStoredPaths: Record<string, string>;
104
+ };
105
+ const storedPath = meta.attachmentStoredPaths["0:report.csv"];
106
+ expect(storedPath.endsWith("report-2.csv")).toBe(true);
107
+ expect(readFileSync(storedPath).toString()).toBe("world");
108
+
109
+ // The in-memory LLM message points at that copy, not the original name.
110
+ const annotation = lastAnnotationBlock(ctx);
111
+ expect(annotation.type).toBe("text");
112
+ expect(annotation.text).toBe(
113
+ `[Attachment "report.csv" is stored at: ${storedPath}]`,
114
+ );
115
+
116
+ // The first turn's annotation still points at the unsuffixed original.
117
+ const firstRow = rawGet<{ metadata: string }>(
118
+ "test:firstMessageMetadata",
119
+ "SELECT metadata FROM messages WHERE id = ?",
120
+ first.id,
121
+ );
122
+ const firstMeta = JSON.parse(firstRow!.metadata) as {
123
+ attachmentStoredPaths: Record<string, string>;
124
+ };
125
+ const firstStoredPath = firstMeta.attachmentStoredPaths["0:report.csv"];
126
+ expect(firstStoredPath.endsWith("report.csv")).toBe(true);
127
+ expect(firstStoredPath.endsWith("report-2.csv")).toBe(false);
128
+ expect(readFileSync(firstStoredPath).toString()).toBe("hello");
129
+
130
+ // History-reload parity: reinjection rebuilds the identical block.
131
+ const rebuilt = reinjectAttachmentPathAnnotations(
132
+ [{ type: "text", text: "I edited it, take another look" }],
133
+ "user",
134
+ row!.metadata,
135
+ );
136
+ const rebuiltBlock = rebuilt.at(-1) as { type: "text"; text: string };
137
+ expect(rebuiltBlock.text).toBe(annotation.text);
138
+ });
139
+
140
+ test("messages without attachments get no annotation block", async () => {
141
+ const conv = createConversation();
142
+ const ctx = makeCtx(conv.id);
143
+
144
+ await persistQueuedMessageBody(ctx, { content: "just text" });
145
+
146
+ expect(ctx.messages).toHaveLength(1);
147
+ const content = ctx.messages[0].content as ContentBlock[];
148
+ expect(content).toHaveLength(1);
149
+ expect(content[0].type).toBe("text");
150
+ });
151
+ });
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, rmSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
@@ -21,6 +21,7 @@ mock.module("../config/loader.js", () => ({
21
21
  }));
22
22
 
23
23
  import {
24
+ attachInlineAttachmentToMessage,
24
25
  AttachmentUploadError,
25
26
  deleteAttachment,
26
27
  deleteOrphanAttachments,
@@ -389,6 +390,41 @@ describe("getAttachmentById", () => {
389
390
  });
390
391
  });
391
392
 
393
+ // ---------------------------------------------------------------------------
394
+ // attachInlineAttachmentToMessage — filename collisions
395
+ // ---------------------------------------------------------------------------
396
+
397
+ describe("attachInlineAttachmentToMessage filename collisions", () => {
398
+ beforeEach(resetTables);
399
+
400
+ test("returns the resolved -N path when a same-named file already exists", async () => {
401
+ const conv = createConversation();
402
+ const msg1 = await addMessage(conv.id, "user", "first upload");
403
+ const msg2 = await addMessage(conv.id, "user", "re-upload after editing");
404
+
405
+ // "aGVsbG8=" = "hello", "d29ybGQ=" = "world"
406
+ const first = attachInlineAttachmentToMessage(
407
+ msg1.id,
408
+ 0,
409
+ "report.csv",
410
+ "text/csv",
411
+ "aGVsbG8=",
412
+ );
413
+ const second = attachInlineAttachmentToMessage(
414
+ msg2.id,
415
+ 0,
416
+ "report.csv",
417
+ "text/csv",
418
+ "d29ybGQ=",
419
+ );
420
+
421
+ expect(first.filePath.endsWith("report.csv")).toBe(true);
422
+ expect(second.filePath.endsWith("report-2.csv")).toBe(true);
423
+ expect(readFileSync(first.filePath).toString()).toBe("hello");
424
+ expect(readFileSync(second.filePath).toString()).toBe("world");
425
+ });
426
+ });
427
+
392
428
  // ---------------------------------------------------------------------------
393
429
  // linkAttachmentToMessage + getAttachmentsForMessage
394
430
  // ---------------------------------------------------------------------------
@@ -254,4 +254,57 @@ describe("enrichMessageWithSourcePaths", () => {
254
254
  const annotation = enriched.content[3] as { type: "text"; text: string };
255
255
  expect(annotation.text).toBe("[Attached image source: /path/to/a.jpg]");
256
256
  });
257
+
258
+ test("annotates non-image attachments that have storedPath", () => {
259
+ const attachments = [
260
+ {
261
+ filename: "report.pdf",
262
+ mimeType: "application/pdf",
263
+ data: "pdfdata",
264
+ filePath: "/staging/123-report.pdf",
265
+ storedPath: "/conv/attachments/report-2.pdf",
266
+ },
267
+ ];
268
+ const original = createUserMessage("review my edits", attachments);
269
+ const enriched = enrichMessageWithSourcePaths(original, attachments);
270
+
271
+ expect(enriched).not.toBe(original);
272
+ // text + file = 2 blocks; enriched adds 1 annotation = 3
273
+ expect(enriched.content).toHaveLength(3);
274
+ const annotation = enriched.content[2] as { type: "text"; text: string };
275
+ expect(annotation.type).toBe("text");
276
+ expect(annotation.text).toBe(
277
+ '[Attachment "report.pdf" is stored at: /conv/attachments/report-2.pdf]',
278
+ );
279
+ });
280
+
281
+ test("emits image source lines before stored path lines in one block", () => {
282
+ const attachments = [
283
+ {
284
+ filename: "data.csv",
285
+ mimeType: "text/csv",
286
+ data: "csvdata",
287
+ storedPath: "/conv/attachments/data-2.csv",
288
+ },
289
+ {
290
+ filename: "photo.jpg",
291
+ mimeType: "image/jpeg",
292
+ data: "img",
293
+ filePath: "/Users/me/Desktop/photo.jpg",
294
+ storedPath: "/conv/attachments/photo.jpg",
295
+ },
296
+ ];
297
+ const original = createUserMessage("compare", attachments);
298
+ const enriched = enrichMessageWithSourcePaths(original, attachments);
299
+
300
+ const annotation = enriched.content.at(-1) as {
301
+ type: "text";
302
+ text: string;
303
+ };
304
+ expect(annotation.text).toBe(
305
+ "[Attached image source: /Users/me/Desktop/photo.jpg]\n" +
306
+ '[Attachment "data.csv" is stored at: /conv/attachments/data-2.csv]\n' +
307
+ '[Attachment "photo.jpg" is stored at: /conv/attachments/photo.jpg]',
308
+ );
309
+ });
257
310
  });
@@ -435,7 +435,10 @@ describe("channel-reply-delivery", () => {
435
435
  expect(deliveryCalls).toHaveLength(0);
436
436
  });
437
437
 
438
- it("treats a current-turn no_response marker as terminal instead of falling back", async () => {
438
+ // Silence means the turn produced no real reply text anywhere not "the
439
+ // last row was a sentinel". A trailing bare <no_response/> row must not
440
+ // swallow the real reply written earlier in the same turn.
441
+ it("delivers the earlier real reply when the turn ends with a bare no_response row", async () => {
439
442
  conversationMessages.push(
440
443
  { id: "msg-current-user", role: "user", content: "current prompt" },
441
444
  {
@@ -449,7 +452,7 @@ describe("channel-reply-delivery", () => {
449
452
  content: '[{"type":"text","text":"<no_response/>"}]',
450
453
  },
451
454
  );
452
- renderedHistoryContentQueue.push({
455
+ const silentStub = {
453
456
  text: "<no_response/>",
454
457
  textSegments: ["<no_response/>"],
455
458
  toolCalls: [],
@@ -457,8 +460,42 @@ describe("channel-reply-delivery", () => {
457
460
  contentOrder: ["text:0"],
458
461
  surfaces: [],
459
462
  thinkingSegments: [],
460
- });
461
- renderedHistoryContentQueue.push({
463
+ };
464
+ const answerStub = {
465
+ text: "current answer",
466
+ textSegments: ["current answer"],
467
+ toolCalls: [],
468
+ toolCallsBeforeText: false,
469
+ contentOrder: ["text:0"],
470
+ surfaces: [],
471
+ thinkingSegments: [],
472
+ };
473
+ // Turn scan reads the silent row then the text row; delivery re-reads
474
+ // the chosen text row.
475
+ renderedHistoryContentQueue.push(silentStub, answerStub, answerStub);
476
+
477
+ await deliverReplyViaCallback(
478
+ "conv-1",
479
+ "chat-current",
480
+ "http://gateway/deliver/slack",
481
+ "assistant-current",
482
+ { sinceMessageId: "msg-current-user" },
483
+ );
484
+
485
+ expect(deliveryCalls).toHaveLength(1);
486
+ expect(deliveryCalls[0].payload.text).toBe("current answer");
487
+ });
488
+
489
+ it("stays silent when a no_response turn has no real reply text anywhere", async () => {
490
+ conversationMessages.push(
491
+ { id: "msg-current-user", role: "user", content: "current prompt" },
492
+ {
493
+ id: "msg-current-silent",
494
+ role: "assistant",
495
+ content: '[{"type":"text","text":"<no_response/>"}]',
496
+ },
497
+ );
498
+ const silentStub = {
462
499
  text: "<no_response/>",
463
500
  textSegments: ["<no_response/>"],
464
501
  toolCalls: [],
@@ -466,7 +503,10 @@ describe("channel-reply-delivery", () => {
466
503
  contentOrder: ["text:0"],
467
504
  surfaces: [],
468
505
  thinkingSegments: [],
469
- });
506
+ };
507
+ // Turn scan reads the silent row; delivery re-reads it as the terminal
508
+ // deliberate-silence target.
509
+ renderedHistoryContentQueue.push(silentStub, silentStub);
470
510
 
471
511
  await deliverReplyViaCallback(
472
512
  "conv-1",
@@ -479,6 +519,124 @@ describe("channel-reply-delivery", () => {
479
519
  expect(deliveryCalls).toHaveLength(0);
480
520
  });
481
521
 
522
+ it("falls through a messageId-targeted bare no_response row to the turn's real reply", async () => {
523
+ conversationMessages.push(
524
+ { id: "msg-current-user", role: "user", content: "current prompt" },
525
+ {
526
+ id: "msg-current-text",
527
+ role: "assistant",
528
+ content: '[{"type":"text","text":"current answer"}]',
529
+ },
530
+ {
531
+ id: "msg-current-silent",
532
+ role: "assistant",
533
+ content: '[{"type":"text","text":"<no_response/>"}]',
534
+ },
535
+ );
536
+ const silentStub = {
537
+ text: "<no_response/>",
538
+ textSegments: ["<no_response/>"],
539
+ toolCalls: [],
540
+ toolCallsBeforeText: false,
541
+ contentOrder: ["text:0"],
542
+ surfaces: [],
543
+ thinkingSegments: [],
544
+ };
545
+ const answerStub = {
546
+ text: "current answer",
547
+ textSegments: ["current answer"],
548
+ toolCalls: [],
549
+ toolCallsBeforeText: false,
550
+ contentOrder: ["text:0"],
551
+ surfaces: [],
552
+ thinkingSegments: [],
553
+ };
554
+ // messageId branch reads the targeted silent row, the turn scan reads
555
+ // the silent row then the text row, and delivery re-reads the text row.
556
+ renderedHistoryContentQueue.push(
557
+ silentStub,
558
+ silentStub,
559
+ answerStub,
560
+ answerStub,
561
+ );
562
+
563
+ await deliverReplyViaCallback(
564
+ "conv-1",
565
+ "chat-current",
566
+ "http://gateway/deliver/slack",
567
+ "assistant-current",
568
+ { messageId: "msg-current-silent", sinceMessageId: "msg-current-user" },
569
+ );
570
+
571
+ expect(deliveryCalls).toHaveLength(1);
572
+ expect(deliveryCalls[0].payload.text).toBe("current answer");
573
+ });
574
+
575
+ // A bare-sentinel row never delivers its attachments (marker rows suppress
576
+ // attachment delivery), so attachments alone must not make the row count
577
+ // as the turn's real reply and stop the fall-through.
578
+ it("falls through a bare no_response row with attachments to the turn's real reply", async () => {
579
+ conversationMessages.push(
580
+ { id: "msg-current-user", role: "user", content: "current prompt" },
581
+ {
582
+ id: "msg-current-text",
583
+ role: "assistant",
584
+ content: '[{"type":"text","text":"current answer"}]',
585
+ },
586
+ {
587
+ id: "msg-current-silent",
588
+ role: "assistant",
589
+ content: '[{"type":"text","text":"<no_response/>"}]',
590
+ },
591
+ );
592
+ attachmentsByMessageId.set("msg-current-silent", [
593
+ {
594
+ id: "att-silent",
595
+ originalFilename: "chart.png",
596
+ mimeType: "image/png",
597
+ sizeBytes: 10,
598
+ kind: "generated",
599
+ },
600
+ ]);
601
+ const silentStub = {
602
+ text: "<no_response/>",
603
+ textSegments: ["<no_response/>"],
604
+ toolCalls: [],
605
+ toolCallsBeforeText: false,
606
+ contentOrder: ["text:0"],
607
+ surfaces: [],
608
+ thinkingSegments: [],
609
+ };
610
+ const answerStub = {
611
+ text: "current answer",
612
+ textSegments: ["current answer"],
613
+ toolCalls: [],
614
+ toolCallsBeforeText: false,
615
+ contentOrder: ["text:0"],
616
+ surfaces: [],
617
+ thinkingSegments: [],
618
+ };
619
+ // messageId branch reads the targeted silent row, the turn scan reads
620
+ // the silent row then the text row, and delivery re-reads the text row.
621
+ renderedHistoryContentQueue.push(
622
+ silentStub,
623
+ silentStub,
624
+ answerStub,
625
+ answerStub,
626
+ );
627
+
628
+ await deliverReplyViaCallback(
629
+ "conv-1",
630
+ "chat-current",
631
+ "http://gateway/deliver/slack",
632
+ "assistant-current",
633
+ { messageId: "msg-current-silent", sinceMessageId: "msg-current-user" },
634
+ );
635
+
636
+ expect(deliveryCalls).toHaveLength(1);
637
+ expect(deliveryCalls[0].payload.text).toBe("current answer");
638
+ });
639
+
482
640
  it("skips already-delivered segments when startFromSegment is set", async () => {
483
641
  await deliverRenderedReplyViaCallback({
484
642
  callbackUrl: "http://gateway/deliver/telegram",
@@ -703,6 +861,57 @@ describe("channel-reply-delivery", () => {
703
861
  expect(deliveryCalls[0].payload.text).toBe("Real response.");
704
862
  });
705
863
 
864
+ it("strips a prefixed inline <no_response/> and delivers the rest of the segment", async () => {
865
+ await deliverRenderedReplyViaCallback({
866
+ callbackUrl: "http://gateway/deliver/telegram",
867
+ chatId: "chat-inline-prefix",
868
+ textSegments: ["<no_response/>\n\nReal reply."],
869
+ interSegmentDelayMs: 0,
870
+ });
871
+
872
+ expect(deliveryCalls).toHaveLength(1);
873
+ expect(deliveryCalls[0].payload.text).toBe("Real reply.");
874
+ });
875
+
876
+ it("strips a trailing inline <no_response/> and delivers the rest of the segment", async () => {
877
+ await deliverRenderedReplyViaCallback({
878
+ callbackUrl: "http://gateway/deliver/telegram",
879
+ chatId: "chat-inline-trailing",
880
+ textSegments: ["Real reply.\n\n<no_response/>"],
881
+ interSegmentDelayMs: 0,
882
+ });
883
+
884
+ expect(deliveryCalls).toHaveLength(1);
885
+ expect(deliveryCalls[0].payload.text).toBe("Real reply.");
886
+ });
887
+
888
+ it("never leaks the sentinel into delivered text, including the fallback path", async () => {
889
+ await deliverRenderedReplyViaCallback({
890
+ callbackUrl: "http://gateway/deliver/telegram",
891
+ chatId: "chat-fallback-strip",
892
+ textSegments: [],
893
+ fallbackText: "Fallback reply. <no_response/>",
894
+ interSegmentDelayMs: 0,
895
+ });
896
+
897
+ expect(deliveryCalls).toHaveLength(1);
898
+ expect(deliveryCalls[0].payload.text).toBe("Fallback reply.");
899
+ for (const call of deliveryCalls) {
900
+ expect(String(call.payload.text)).not.toContain("<no_response");
901
+ }
902
+ });
903
+
904
+ it("suppresses delivery for a case-insensitive bare sentinel", async () => {
905
+ await deliverRenderedReplyViaCallback({
906
+ callbackUrl: "http://gateway/deliver/slack",
907
+ chatId: "chat-silent-case",
908
+ textSegments: ["<NO_RESPONSE/>"],
909
+ interSegmentDelayMs: 0,
910
+ });
911
+
912
+ expect(deliveryCalls).toHaveLength(0);
913
+ });
914
+
706
915
  it("passes startFromSegment through deliverReplyViaCallback options", async () => {
707
916
  conversationMessages.push(
708
917
  { id: "msg-u", role: "user", content: "hi" },