@vellumai/assistant 0.10.3-dev.202606272129.e87b552 → 0.10.3-dev.202606272328.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/__tests__/persist-user-message-set-processing-failure.test.ts +70 -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-messaging.ts +16 -2
- 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
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: `persistUserMessage` calls `ctx.setProcessing(true)`, which
|
|
3
|
+
* persists the flag to the DB and can throw (e.g. SQLITE_BUSY). That call must
|
|
4
|
+
* run inside the function's try/catch so a failure unwinds the request-id and
|
|
5
|
+
* abort-controller bookkeeping instead of stranding it on the conversation.
|
|
6
|
+
* A later failure while clearing the flag must not mask the original error.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
|
|
11
|
+
import { persistUserMessage } from "../daemon/conversation-messaging.js";
|
|
12
|
+
|
|
13
|
+
type SetProcessingBehavior = (value: boolean) => void;
|
|
14
|
+
|
|
15
|
+
function makeContext(
|
|
16
|
+
setProcessing: SetProcessingBehavior,
|
|
17
|
+
): MessagingConversationContext & {
|
|
18
|
+
processing: boolean;
|
|
19
|
+
} {
|
|
20
|
+
let processing = false;
|
|
21
|
+
const ctx = {
|
|
22
|
+
conversationId: "conv-set-processing-failure",
|
|
23
|
+
messages: [],
|
|
24
|
+
abortController: null,
|
|
25
|
+
currentRequestId: undefined,
|
|
26
|
+
queue: {} as never,
|
|
27
|
+
get processing() {
|
|
28
|
+
return processing;
|
|
29
|
+
},
|
|
30
|
+
isProcessing: () => processing,
|
|
31
|
+
setProcessing: (value: boolean) => {
|
|
32
|
+
setProcessing(value);
|
|
33
|
+
processing = value;
|
|
34
|
+
},
|
|
35
|
+
getTurnChannelContext: () => null,
|
|
36
|
+
getTurnInterfaceContext: () => null,
|
|
37
|
+
} as unknown as MessagingConversationContext & { processing: boolean };
|
|
38
|
+
return ctx;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("persistUserMessage setProcessing failure", () => {
|
|
42
|
+
test("clears request-id and abort bookkeeping when setProcessing(true) throws", async () => {
|
|
43
|
+
const ctx = makeContext((value) => {
|
|
44
|
+
if (value) throw new Error("database is locked (SQLITE_BUSY)");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await expect(
|
|
48
|
+
persistUserMessage(ctx, { content: "hello", requestId: "req-1" }),
|
|
49
|
+
).rejects.toThrow("database is locked");
|
|
50
|
+
|
|
51
|
+
expect(ctx.currentRequestId).toBeUndefined();
|
|
52
|
+
expect(ctx.abortController).toBeNull();
|
|
53
|
+
expect(ctx.isProcessing()).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a failing clear does not mask the original persist error", async () => {
|
|
57
|
+
// setProcessing throws on both the set and the clear; the original error
|
|
58
|
+
// must still propagate and the bookkeeping must still be reset.
|
|
59
|
+
const ctx = makeContext(() => {
|
|
60
|
+
throw new Error("database is locked (SQLITE_BUSY)");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await expect(
|
|
64
|
+
persistUserMessage(ctx, { content: "hello", requestId: "req-2" }),
|
|
65
|
+
).rejects.toThrow("database is locked");
|
|
66
|
+
|
|
67
|
+
expect(ctx.currentRequestId).toBeUndefined();
|
|
68
|
+
expect(ctx.abortController).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -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
|
|
|
@@ -412,10 +412,13 @@ export async function persistUserMessage(
|
|
|
412
412
|
|
|
413
413
|
const reqId = options.requestId ?? uuid();
|
|
414
414
|
ctx.currentRequestId = reqId;
|
|
415
|
-
ctx.setProcessing(true);
|
|
416
415
|
ctx.abortController = new AbortController();
|
|
417
416
|
|
|
418
417
|
try {
|
|
418
|
+
// `setProcessing(true)` persists the flag and can throw (e.g.
|
|
419
|
+
// SQLITE_BUSY). Keeping it inside the try ensures a failure here unwinds
|
|
420
|
+
// the request-id/abort bookkeeping below rather than stranding it.
|
|
421
|
+
ctx.setProcessing(true);
|
|
419
422
|
const result = await persistQueuedMessageBody(ctx, {
|
|
420
423
|
...options,
|
|
421
424
|
attachments,
|
|
@@ -428,7 +431,18 @@ export async function persistUserMessage(
|
|
|
428
431
|
}
|
|
429
432
|
return result;
|
|
430
433
|
} catch (err) {
|
|
431
|
-
|
|
434
|
+
// Clear the flag, but never let a clear failure mask the original error
|
|
435
|
+
// or skip the bookkeeping reset. `setProcessing` reverts its own
|
|
436
|
+
// in-memory flag when its persist throws, so the conversation is left
|
|
437
|
+
// consistent either way.
|
|
438
|
+
try {
|
|
439
|
+
ctx.setProcessing(false);
|
|
440
|
+
} catch (clearErr) {
|
|
441
|
+
log.error(
|
|
442
|
+
{ err: clearErr, conversationId: ctx.conversationId },
|
|
443
|
+
"Failed to clear processing flag after persistUserMessage failure",
|
|
444
|
+
);
|
|
445
|
+
}
|
|
432
446
|
ctx.abortController = null;
|
|
433
447
|
ctx.currentRequestId = undefined;
|
|
434
448
|
throw err;
|
|
@@ -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
|
}
|