@vellumai/assistant 0.10.3-dev.202606272229.2ffe9f3 → 0.10.3-dev.202606280110.b705e83
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 +7 -0
- package/package.json +1 -1
- package/src/__tests__/list-messages-queued.test.ts +220 -0
- package/src/api/responses/conversation-message.ts +13 -0
- package/src/background-wake/wake-intent-hooks.test.ts +4 -6
- package/src/daemon/conversation-queue-manager.ts +6 -0
- package/src/daemon/conversation.ts +10 -1
- package/src/daemon/lifecycle.ts +1024 -1047
- package/src/memory/attachments-store.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +70 -0
package/openapi.yaml
CHANGED
|
@@ -17577,6 +17577,13 @@ paths:
|
|
|
17577
17577
|
- channelId
|
|
17578
17578
|
- channelTs
|
|
17579
17579
|
additionalProperties: false
|
|
17580
|
+
queueStatus:
|
|
17581
|
+
type: string
|
|
17582
|
+
enum:
|
|
17583
|
+
- queued
|
|
17584
|
+
- processing
|
|
17585
|
+
queuePosition:
|
|
17586
|
+
type: number
|
|
17580
17587
|
required:
|
|
17581
17588
|
- id
|
|
17582
17589
|
- role
|
package/package.json
CHANGED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for handleListMessages surfacing the in-memory message queue.
|
|
3
|
+
*
|
|
4
|
+
* Messages enqueued while the agent is mid-turn live only in the live
|
|
5
|
+
* conversation's in-memory queue until the queue drains and persists them.
|
|
6
|
+
* The messages snapshot appends them (carrying `queueStatus: "queued"` and a
|
|
7
|
+
* 1-based `queuePosition`, mirroring the client `DisplayMessage` shape) to the
|
|
8
|
+
* newest page so a cold reload restores the queued rows that the
|
|
9
|
+
* `message_queued` SSE events would otherwise be the only source of.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
mock.module("../util/logger.js", () => ({
|
|
15
|
+
getLogger: () =>
|
|
16
|
+
new Proxy({} as Record<string, unknown>, {
|
|
17
|
+
get: () => () => {},
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module("../config/loader.js", () => ({
|
|
22
|
+
getConfig: () => ({
|
|
23
|
+
ui: {},
|
|
24
|
+
model: "test",
|
|
25
|
+
provider: "test",
|
|
26
|
+
memory: { enabled: false },
|
|
27
|
+
rateLimit: { maxRequestsPerMinute: 0 },
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import type { Conversation } from "../daemon/conversation.js";
|
|
32
|
+
import type { QueuedMessage } from "../daemon/conversation-queue-manager.js";
|
|
33
|
+
import {
|
|
34
|
+
clearConversations,
|
|
35
|
+
setConversation,
|
|
36
|
+
} from "../daemon/conversation-registry.js";
|
|
37
|
+
import { addMessage, createConversation } from "../memory/conversation-crud.js";
|
|
38
|
+
import { getDb } from "../memory/db-connection.js";
|
|
39
|
+
import { initializeDb } from "../memory/db-init.js";
|
|
40
|
+
import { handleListMessages } from "../runtime/routes/conversation-routes.js";
|
|
41
|
+
|
|
42
|
+
await initializeDb();
|
|
43
|
+
|
|
44
|
+
function resetTables() {
|
|
45
|
+
const db = getDb();
|
|
46
|
+
db.run("DELETE FROM messages");
|
|
47
|
+
db.run("DELETE FROM conversations");
|
|
48
|
+
clearConversations();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface MessagePayload {
|
|
52
|
+
id: string;
|
|
53
|
+
role: string;
|
|
54
|
+
content?: string;
|
|
55
|
+
clientMessageId?: string;
|
|
56
|
+
queueStatus?: "queued" | "processing";
|
|
57
|
+
queuePosition?: number;
|
|
58
|
+
attachments?: Array<{ id: string; filename: string; kind: string }>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Register a live conversation whose in-memory queue holds `queued`. */
|
|
62
|
+
function registerLiveConversation(
|
|
63
|
+
conversationId: string,
|
|
64
|
+
queued: QueuedMessage[],
|
|
65
|
+
): void {
|
|
66
|
+
const stub = {
|
|
67
|
+
snapshotQueuedMessages: () => queued,
|
|
68
|
+
// A conversation only holds a queue while it is mid-turn; mirror that so
|
|
69
|
+
// `isConversationProcessing` (which prefers the live instance) reports busy.
|
|
70
|
+
isProcessing: () => true,
|
|
71
|
+
} as unknown as Conversation;
|
|
72
|
+
setConversation(conversationId, stub);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeQueued(overrides: Partial<QueuedMessage>): QueuedMessage {
|
|
76
|
+
return {
|
|
77
|
+
content: "queued body",
|
|
78
|
+
attachments: [],
|
|
79
|
+
requestId: "req-1",
|
|
80
|
+
onEvent: () => {},
|
|
81
|
+
sentAt: 1_700_000_000_000,
|
|
82
|
+
...overrides,
|
|
83
|
+
} as QueuedMessage;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("handleListMessages in-memory queue", () => {
|
|
87
|
+
beforeEach(resetTables);
|
|
88
|
+
|
|
89
|
+
test("appends queued rows in FIFO order with 1-based queuePosition", async () => {
|
|
90
|
+
// GIVEN a persisted user turn and two messages still waiting in the queue
|
|
91
|
+
const conv = createConversation();
|
|
92
|
+
await addMessage(
|
|
93
|
+
conv.id,
|
|
94
|
+
"user",
|
|
95
|
+
JSON.stringify([{ type: "text", text: "first" }]),
|
|
96
|
+
{ skipIndexing: true },
|
|
97
|
+
);
|
|
98
|
+
registerLiveConversation(conv.id, [
|
|
99
|
+
makeQueued({
|
|
100
|
+
requestId: "req-queued-1",
|
|
101
|
+
content: "please also do this",
|
|
102
|
+
clientMessageId: "nonce-queued",
|
|
103
|
+
}),
|
|
104
|
+
makeQueued({ requestId: "req-queued-2", content: "and then this" }),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// WHEN the messages snapshot is built for the newest page
|
|
108
|
+
const response = handleListMessages({
|
|
109
|
+
queryParams: { conversationId: conv.id },
|
|
110
|
+
});
|
|
111
|
+
const body = response as { messages: MessagePayload[] };
|
|
112
|
+
|
|
113
|
+
// THEN the queued messages are appended after the persisted row, carrying
|
|
114
|
+
// queue state and keyed by their requestId for the delete/steer endpoints
|
|
115
|
+
expect(body.messages).toHaveLength(3);
|
|
116
|
+
expect(body.messages[0].queueStatus).toBeUndefined();
|
|
117
|
+
|
|
118
|
+
const first = body.messages[1];
|
|
119
|
+
expect(first.queueStatus).toBe("queued");
|
|
120
|
+
expect(first.queuePosition).toBe(1);
|
|
121
|
+
expect(first.role).toBe("user");
|
|
122
|
+
expect(first.id).toBe("req-queued-1");
|
|
123
|
+
expect(first.content).toBe("please also do this");
|
|
124
|
+
expect(first.clientMessageId).toBe("nonce-queued");
|
|
125
|
+
|
|
126
|
+
const second = body.messages[2];
|
|
127
|
+
expect(second.queueStatus).toBe("queued");
|
|
128
|
+
expect(second.queuePosition).toBe(2);
|
|
129
|
+
expect(second.id).toBe("req-queued-2");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("prefers displayContent over the model-facing content", async () => {
|
|
133
|
+
const conv = createConversation();
|
|
134
|
+
registerLiveConversation(conv.id, [
|
|
135
|
+
makeQueued({
|
|
136
|
+
requestId: "req-display",
|
|
137
|
+
content: "stripped recording intent",
|
|
138
|
+
displayContent: "what the user actually typed",
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const response = handleListMessages({
|
|
143
|
+
queryParams: { conversationId: conv.id },
|
|
144
|
+
});
|
|
145
|
+
const body = response as { messages: MessagePayload[] };
|
|
146
|
+
|
|
147
|
+
expect(body.messages).toHaveLength(1);
|
|
148
|
+
expect(body.messages[0].content).toBe("what the user actually typed");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("renders queued attachments with a derived kind", async () => {
|
|
152
|
+
const conv = createConversation();
|
|
153
|
+
registerLiveConversation(conv.id, [
|
|
154
|
+
makeQueued({
|
|
155
|
+
requestId: "req-att",
|
|
156
|
+
content: "see attached",
|
|
157
|
+
attachments: [
|
|
158
|
+
{
|
|
159
|
+
filename: "diagram.png",
|
|
160
|
+
mimeType: "image/png",
|
|
161
|
+
data: "AAAA",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
}),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const response = handleListMessages({
|
|
168
|
+
queryParams: { conversationId: conv.id },
|
|
169
|
+
});
|
|
170
|
+
const body = response as { messages: MessagePayload[] };
|
|
171
|
+
|
|
172
|
+
const att = body.messages[0].attachments?.[0];
|
|
173
|
+
expect(att?.filename).toBe("diagram.png");
|
|
174
|
+
expect(att?.kind).toBe("image");
|
|
175
|
+
expect(att?.id).toBe("req-att:attachment:0");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("does not append queued messages to an older-history page", async () => {
|
|
179
|
+
// GIVEN history requested with beforeTimestamp (older page)
|
|
180
|
+
const conv = createConversation();
|
|
181
|
+
await addMessage(
|
|
182
|
+
conv.id,
|
|
183
|
+
"user",
|
|
184
|
+
JSON.stringify([{ type: "text", text: "old" }]),
|
|
185
|
+
{ skipIndexing: true },
|
|
186
|
+
);
|
|
187
|
+
registerLiveConversation(conv.id, [makeQueued({ requestId: "req-old" })]);
|
|
188
|
+
|
|
189
|
+
// WHEN paging older history
|
|
190
|
+
const response = handleListMessages({
|
|
191
|
+
queryParams: {
|
|
192
|
+
conversationId: conv.id,
|
|
193
|
+
beforeTimestamp: String(Date.now() + 1_000),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const body = response as { messages: MessagePayload[] };
|
|
197
|
+
|
|
198
|
+
// THEN the queued message is not mixed into the older page
|
|
199
|
+
expect(body.messages.every((m) => m.queueStatus == null)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("returns only persisted rows when the conversation is not live", async () => {
|
|
203
|
+
// GIVEN a conversation with no live in-memory instance (cold / aged out)
|
|
204
|
+
const conv = createConversation();
|
|
205
|
+
await addMessage(
|
|
206
|
+
conv.id,
|
|
207
|
+
"user",
|
|
208
|
+
JSON.stringify([{ type: "text", text: "only persisted" }]),
|
|
209
|
+
{ skipIndexing: true },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const response = handleListMessages({
|
|
213
|
+
queryParams: { conversationId: conv.id },
|
|
214
|
+
});
|
|
215
|
+
const body = response as { messages: MessagePayload[] };
|
|
216
|
+
|
|
217
|
+
expect(body.messages).toHaveLength(1);
|
|
218
|
+
expect(body.messages[0].queueStatus).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -522,5 +522,18 @@ export const ConversationMessageSchema = z.object({
|
|
|
522
522
|
contentBlocks: z.array(ConversationContentBlockSchema).optional(),
|
|
523
523
|
subagentNotification: ConversationSubagentNotificationSchema.optional(),
|
|
524
524
|
slackMessage: ConversationSlackMessageSchema.optional(),
|
|
525
|
+
/**
|
|
526
|
+
* Queue state for a user message that is still waiting in the daemon's
|
|
527
|
+
* in-memory queue (enqueued while the agent was mid-turn, not yet drained or
|
|
528
|
+
* persisted to the database). Derived at render time from the live
|
|
529
|
+
* conversation's queue, so it is present only while the message is genuinely
|
|
530
|
+
* pending and lets a cold reload restore the queued rows the live
|
|
531
|
+
* `message_queued` SSE events would otherwise be the only source of. Absent
|
|
532
|
+
* on already-persisted rows. Mirrors the client `DisplayMessage` fields so
|
|
533
|
+
* the wire and display shapes converge.
|
|
534
|
+
*/
|
|
535
|
+
queueStatus: z.enum(["queued", "processing"]).optional(),
|
|
536
|
+
/** 1-based position in the queue, mirroring the `message_queued` SSE event. */
|
|
537
|
+
queuePosition: z.number().optional(),
|
|
525
538
|
});
|
|
526
539
|
export type ConversationMessage = z.infer<typeof ConversationMessageSchema>;
|
|
@@ -235,12 +235,10 @@ describe("background wake intent publisher hooks", () => {
|
|
|
235
235
|
"utf-8",
|
|
236
236
|
);
|
|
237
237
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
'refreshBackgroundWakeIntent("daemon-startup");',
|
|
243
|
-
].join("\n "),
|
|
238
|
+
// Match the three calls in order, tolerant of indentation, so the
|
|
239
|
+
// assertion survives reformatting/re-indentation of lifecycle.ts.
|
|
240
|
+
expect(lifecycleSource).toMatch(
|
|
241
|
+
/heartbeat\.start\(\);\n[ \t]*registerBackgroundWakeRuntime\(\{ scheduler, heartbeat \}\);\n[ \t]*refreshBackgroundWakeIntent\("daemon-startup"\);/,
|
|
244
242
|
);
|
|
245
243
|
});
|
|
246
244
|
|
|
@@ -130,6 +130,12 @@ export class MessageQueue {
|
|
|
130
130
|
return this.items[index];
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/** FIFO snapshot of the queued messages. The array is a copy; the items are
|
|
134
|
+
* the live references (callers must treat them as read-only). */
|
|
135
|
+
snapshot(): QueuedMessage[] {
|
|
136
|
+
return [...this.items];
|
|
137
|
+
}
|
|
138
|
+
|
|
133
139
|
/**
|
|
134
140
|
* Pop up to `count` messages FIFO and return them in order.
|
|
135
141
|
* Decrements the byte budget for each popped item using the same
|
|
@@ -129,7 +129,10 @@ import {
|
|
|
129
129
|
drainQueue as drainQueueImpl,
|
|
130
130
|
processMessage as processMessageImpl,
|
|
131
131
|
} from "./conversation-process.js";
|
|
132
|
-
import type {
|
|
132
|
+
import type {
|
|
133
|
+
QueuedMessage,
|
|
134
|
+
QueueDrainReason,
|
|
135
|
+
} from "./conversation-queue-manager.js";
|
|
133
136
|
import { MessageQueue } from "./conversation-queue-manager.js";
|
|
134
137
|
import {
|
|
135
138
|
type ChannelCapabilities,
|
|
@@ -1466,6 +1469,12 @@ export class Conversation {
|
|
|
1466
1469
|
return !this.queue.isEmpty;
|
|
1467
1470
|
}
|
|
1468
1471
|
|
|
1472
|
+
/** FIFO snapshot of the messages currently waiting in the in-memory queue.
|
|
1473
|
+
* Read-only — used to surface queued user messages in history responses. */
|
|
1474
|
+
snapshotQueuedMessages(): QueuedMessage[] {
|
|
1475
|
+
return this.queue.snapshot();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1469
1478
|
removeQueuedMessage(requestId: string): boolean {
|
|
1470
1479
|
return this.queue.removeByRequestId(requestId) !== undefined;
|
|
1471
1480
|
}
|