@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 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.202606272229.2ffe9f3",
3
+ "version": "0.10.3-dev.202606280110.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
+ });
@@ -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
 
@@ -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
  }