@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.
- package/openapi.yaml +43 -6
- package/package.json +1 -1
- package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
- package/src/__tests__/attachments-store.test.ts +37 -1
- package/src/__tests__/attachments.test.ts +53 -0
- package/src/__tests__/channel-reply-delivery.test.ts +214 -5
- package/src/__tests__/config-loader-backfill.test.ts +16 -36
- package/src/__tests__/image-source-path-reinject.test.ts +155 -14
- package/src/__tests__/managed-profile-guard.test.ts +2 -0
- package/src/__tests__/mtime-cache.test.ts +265 -80
- package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
- package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
- package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
- package/src/agent/attachments.ts +55 -13
- package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
- package/src/cli/commands/platform/index.ts +86 -4
- package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
- package/src/cli/lib/diff-plugin.ts +7 -3
- package/src/cli/lib/inspect-plugin.ts +28 -10
- package/src/cli/lib/install-from-github.ts +69 -24
- package/src/cli/lib/ipc-params.ts +3 -3
- package/src/cli/lib/list-installed-plugins.ts +11 -5
- package/src/cli/lib/merge-plugin-tree.ts +15 -9
- package/src/cli/lib/plugin-fingerprint.ts +0 -6
- package/src/cli/lib/publish-plugin.ts +1 -1
- package/src/cli/lib/unknown-command.ts +3 -1
- package/src/cli/lib/upgrade-plugin.ts +2 -5
- package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
- package/src/config/seed-inference-profiles.ts +5 -4
- package/src/daemon/conversation-lifecycle.ts +37 -15
- package/src/daemon/conversation-messaging.ts +56 -28
- package/src/daemon/conversation.ts +2 -2
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
- package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
- package/src/hooks/hook-loader.ts +57 -144
- package/src/hooks/registry.ts +13 -9
- package/src/persistence/attachments-store.ts +2 -1
- package/src/persistence/conversation-crud.ts +24 -0
- package/src/plugin-api/types.ts +4 -1
- package/src/plugins/mtime-cache.ts +347 -33
- package/src/plugins/pipeline.ts +3 -2
- package/src/providers/__tests__/inference.test.ts +42 -31
- package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
- package/src/providers/inference/auth.ts +1 -2
- package/src/providers/inference/backfill.ts +7 -5
- package/src/providers/inference/connections.ts +54 -30
- package/src/runtime/channel-reply-delivery.ts +83 -24
- package/src/runtime/channel-retry-sweep.ts +4 -0
- package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
- package/src/runtime/routes/conversation-query-routes.ts +18 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
- package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
- package/src/runtime/routes/platform-routes.ts +82 -1
- package/src/runtime/slack-reply-session.ts +1 -1
- package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
- 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.
|
|
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
|
|
13814
|
-
|
|
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
|
|
13870
|
-
(
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" },
|