@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606272129.e87b552",
3
+ "version": "0.10.3-dev.202606272328.b705e83",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- expect(lifecycleSource).toContain(
239
- [
240
- "heartbeat.start();",
241
- "registerBackgroundWakeRuntime({ scheduler, heartbeat });",
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
- ctx.setProcessing(false);
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 { QueueDrainReason } from "./conversation-queue-manager.js";
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
  }