@vellumai/assistant 0.10.3-dev.202606252046.9075fd5 → 0.10.3-dev.202606252138.db46320

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
@@ -17462,6 +17462,14 @@ paths:
17462
17462
  anyOf:
17463
17463
  - type: number
17464
17464
  - type: "null"
17465
+ processing:
17466
+ description:
17467
+ "Whether the agent is currently mid-turn for this conversation, sourced authoritatively from the persisted
17468
+ `processing_started_at` column. `true` means a turn is in flight; `false` means the conversation
17469
+ is idle. Clients use this to recover from a dropped SSE stream: if a turn appears to be running
17470
+ locally but the server reports `processing: false`, the turn has ended (or died) and the UI should
17471
+ stop waiting rather than spin indefinitely. Absent on older daemons that predate this field."
17472
+ type: boolean
17465
17473
  required:
17466
17474
  - messages
17467
17475
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606252046.9075fd5",
3
+ "version": "0.10.3-dev.202606252138.db46320",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,9 +12,7 @@ import {
12
12
  _resetStreamStateForTesting,
13
13
  _simulateRestartForTesting,
14
14
  getCurrentSeq,
15
- getPersistedSeq,
16
15
  getReplayWindow,
17
- recordPersistedSeq,
18
16
  stampAndBuffer,
19
17
  } from "../runtime/assistant-stream-state.js";
20
18
 
@@ -571,80 +569,9 @@ describe("assistant-stream-state", () => {
571
569
  });
572
570
  });
573
571
 
574
- describe("persisted seq", () => {
575
- test("getPersistedSeq is null for an unknown conversation", () => {
576
- expect(getPersistedSeq("conv_unknown")).toBeNull();
577
- });
578
-
579
- test("records and retrieves a per-conversation value", () => {
580
- recordPersistedSeq("conv_a", 7);
581
- expect(getPersistedSeq("conv_a")).toBe(7);
582
- expect(getPersistedSeq("conv_b")).toBeNull();
583
- });
584
-
585
- test("tracks conversations independently", () => {
586
- recordPersistedSeq("conv_a", 3);
587
- recordPersistedSeq("conv_b", 9);
588
- expect(getPersistedSeq("conv_a")).toBe(3);
589
- expect(getPersistedSeq("conv_b")).toBe(9);
590
- });
591
-
592
- test("advances monotonically and never regresses", () => {
593
- recordPersistedSeq("conv_a", 5);
594
- recordPersistedSeq("conv_a", 12);
595
- expect(getPersistedSeq("conv_a")).toBe(12);
596
-
597
- // A lower seq (e.g. an out-of-order async commit) is clamped.
598
- recordPersistedSeq("conv_a", 8);
599
- expect(getPersistedSeq("conv_a")).toBe(12);
600
- });
601
-
602
- test("ignores non-positive and non-finite seq values", () => {
603
- recordPersistedSeq("conv_a", 0);
604
- recordPersistedSeq("conv_a", -3);
605
- recordPersistedSeq("conv_a", Number.NaN);
606
- recordPersistedSeq("conv_a", Number.POSITIVE_INFINITY);
607
- expect(getPersistedSeq("conv_a")).toBeNull();
608
- });
609
-
610
- test("is cleared by reset", () => {
611
- recordPersistedSeq("conv_a", 4);
612
- _resetStreamStateForTesting();
613
- expect(getPersistedSeq("conv_a")).toBeNull();
614
- });
615
-
616
- test("evicts the least-recently-recorded conversation past the cap", () => {
617
- // The map is LRU-bounded at 1024 conversations. Fill to the cap,
618
- // then one more insert evicts the oldest key.
619
- const CAP = 1024;
620
- for (let i = 0; i < CAP; i++) {
621
- recordPersistedSeq(`conv_${i}`, i + 1);
622
- }
623
- // All present at the cap.
624
- expect(getPersistedSeq("conv_0")).toBe(1);
625
- expect(getPersistedSeq(`conv_${CAP - 1}`)).toBe(CAP);
626
-
627
- // One more distinct conversation evicts the oldest (conv_0).
628
- recordPersistedSeq("conv_overflow", 9999);
629
- expect(getPersistedSeq("conv_0")).toBeNull();
630
- expect(getPersistedSeq("conv_1")).toBe(2);
631
- expect(getPersistedSeq("conv_overflow")).toBe(9999);
632
- });
633
-
634
- test("re-recording refreshes recency so a kept key is not evicted first", () => {
635
- const CAP = 1024;
636
- for (let i = 0; i < CAP; i++) {
637
- recordPersistedSeq(`conv_${i}`, i + 1);
638
- }
639
- // Touch the oldest key so it moves to the most-recent end.
640
- recordPersistedSeq("conv_0", 5000);
641
-
642
- // The next insert now evicts conv_1 (the new oldest), not conv_0.
643
- recordPersistedSeq("conv_overflow", 9999);
644
- expect(getPersistedSeq("conv_0")).toBe(5000);
645
- expect(getPersistedSeq("conv_1")).toBeNull();
646
- });
647
- });
572
+ // Per-conversation persisted seq now lives on the `conversations.seq`
573
+ // column (see conversation-crud `getConversationPersistedSeq` /
574
+ // `recordConversationPersistedSeq`); its tests live with that module.
648
575
 
649
576
  describe("seq persistence across restarts", () => {
650
577
  test("counter resumes above the persisted reservation after a restart", () => {
@@ -100,8 +100,10 @@ mock.module("../daemon/process-message.js", () => ({
100
100
 
101
101
  const createdConversations: Array<{ conversationType: string }> = [];
102
102
  mock.module("../memory/conversation-crud.js", () => ({
103
- setConversationProcessingStartedAt: () => {},
104
- isConversationProcessing: () => false,
103
+ setConversationProcessingStartedAt: () => {},
104
+ isConversationProcessing: () => false,
105
+ recordConversationPersistedSeq: () => {},
106
+ getConversationPersistedSeq: () => null,
105
107
  addMessage: mock(() => ({ id: "msg-1" })),
106
108
  archiveConversation: mock(() => true),
107
109
  batchSetDisplayOrders: mock(() => {}),
@@ -257,8 +257,8 @@ mock.module("../plugins/defaults/compaction/overflow-policy.js", () => ({
257
257
  }));
258
258
 
259
259
  mock.module("../memory/conversation-crud.js", () => ({
260
- setConversationProcessingStartedAt: () => {},
261
- isConversationProcessing: () => false,
260
+ setConversationProcessingStartedAt: () => {},
261
+ isConversationProcessing: () => false,
262
262
  setConversationOriginChannelIfUnset: () => {},
263
263
  setConversationHistoryStrippedAt: () => {},
264
264
  updateConversationUsage: () => {},
@@ -289,6 +289,8 @@ mock.module("../memory/conversation-crud.js", () => ({
289
289
  getLastUserTimestampBefore: () => 0,
290
290
  resolveOverrideProfile: () => undefined,
291
291
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
292
+ recordConversationPersistedSeq: () => {},
293
+ getConversationPersistedSeq: () => null,
292
294
  }));
293
295
 
294
296
  afterAll(() => {
@@ -303,6 +303,8 @@ mock.module("../memory/conversation-crud.js", () => ({
303
303
  getLastUserTimestampBefore: () => 0,
304
304
  reserveMessage: reserveMessageMock,
305
305
  updateMessageContent: updateMessageContentMock,
306
+ recordConversationPersistedSeq: () => {},
307
+ getConversationPersistedSeq: () => null,
306
308
  // The real schema is a Zod object; tests don't exercise validation,
307
309
  // so a passthrough is sufficient — the production code at
308
310
  // `handleMessageComplete` only branches on `success` and reads two
@@ -37,14 +37,20 @@ mock.module("../daemon/identity-helpers.js", () => ({
37
37
  getAssistantName: () => mockAssistantName,
38
38
  }));
39
39
 
40
- import { createConversation } from "../memory/conversation-crud.js";
40
+ import {
41
+ createConversation,
42
+ recordConversationPersistedSeq,
43
+ setConversationProcessingStartedAt,
44
+ } from "../memory/conversation-crud.js";
41
45
  import { getDb } from "../memory/db-connection.js";
42
46
  import { initializeDb } from "../memory/db-init.js";
43
47
  import { messages } from "../memory/schema.js";
44
48
  import { writeSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
49
+ import type { AssistantEvent } from "../runtime/assistant-event.js";
45
50
  import {
46
51
  _resetStreamStateForTesting,
47
- recordPersistedSeq,
52
+ getCurrentSeq,
53
+ stampAndBuffer,
48
54
  } from "../runtime/assistant-stream-state.js";
49
55
  import { handleListMessages } from "../runtime/routes/conversation-routes.js";
50
56
  import { BadRequestError } from "../runtime/routes/errors.js";
@@ -112,6 +118,28 @@ interface ListResponse {
112
118
  oldestTimestamp?: number | null;
113
119
  oldestMessageId?: string | null;
114
120
  seq?: number | null;
121
+ processing?: boolean;
122
+ }
123
+
124
+ /**
125
+ * Stamp `count` conversation-scoped events to advance the global `seq`
126
+ * counter, mirroring `mkEvent` in assistant-stream-state.test.ts. The
127
+ * conversationId is irrelevant — `getCurrentSeq()` is process-global — so
128
+ * any value works for nudging the high-water mark before a creation.
129
+ */
130
+ function advanceGlobalSeq(count: number): void {
131
+ for (let i = 0; i < count; i++) {
132
+ stampAndBuffer({
133
+ id: `seq-bump-${i}`,
134
+ conversationId: "seq-bump-conv",
135
+ emittedAt: new Date().toISOString(),
136
+ message: {
137
+ type: "assistant_text_delta",
138
+ conversationId: "seq-bump-conv",
139
+ text: "x",
140
+ },
141
+ } as AssistantEvent);
142
+ }
115
143
  }
116
144
 
117
145
  function callList(query: Record<string, string>): ListResponse {
@@ -138,7 +166,7 @@ describe("handleListMessages page=latest", () => {
138
166
  const conv = createConversation();
139
167
  seedMessages(conv.id, 3);
140
168
  // AND the daemon has recorded a persisted seq for it
141
- recordPersistedSeq(conv.id, 42);
169
+ recordConversationPersistedSeq(conv.id, 42);
142
170
 
143
171
  // WHEN the snapshot is fetched
144
172
  const body = callList({ conversationId: conv.id, page: "latest" });
@@ -171,7 +199,7 @@ describe("handleListMessages page=latest", () => {
171
199
  // GIVEN a conversation with a recorded persisted seq
172
200
  const conv = createConversation();
173
201
  seedMessages(conv.id, 2);
174
- recordPersistedSeq(conv.id, 7);
202
+ recordConversationPersistedSeq(conv.id, 7);
175
203
 
176
204
  // WHEN fetched with no pagination params
177
205
  const body = callList({ conversationId: conv.id });
@@ -179,6 +207,138 @@ describe("handleListMessages page=latest", () => {
179
207
  // THEN the seq still rides along
180
208
  expect(body.seq).toBe(7);
181
209
  });
210
+
211
+ test("recording advances the seq monotonically and never regresses", () => {
212
+ /**
213
+ * Out-of-order async commits must not lower the high-water mark — the
214
+ * `WHERE seq < ?` guard on the persist UPDATE keeps it monotonic.
215
+ */
216
+
217
+ // GIVEN a conversation whose persisted seq has been recorded
218
+ const conv = createConversation();
219
+ recordConversationPersistedSeq(conv.id, 12);
220
+
221
+ // WHEN a lower seq is recorded (a late, out-of-order commit)
222
+ recordConversationPersistedSeq(conv.id, 8);
223
+
224
+ // THEN the high-water seq is unchanged
225
+ expect(callList({ conversationId: conv.id }).seq).toBe(12);
226
+
227
+ // AND a higher seq still advances it
228
+ recordConversationPersistedSeq(conv.id, 20);
229
+ expect(callList({ conversationId: conv.id }).seq).toBe(20);
230
+ });
231
+
232
+ test("recording ignores non-positive and non-finite seq values", () => {
233
+ // GIVEN a freshly created conversation (no stream activity → null seed)
234
+ const conv = createConversation();
235
+
236
+ // WHEN non-positive / non-finite seqs are recorded
237
+ recordConversationPersistedSeq(conv.id, 0);
238
+ recordConversationPersistedSeq(conv.id, -3);
239
+ recordConversationPersistedSeq(conv.id, Number.NaN);
240
+ recordConversationPersistedSeq(conv.id, Number.POSITIVE_INFINITY);
241
+
242
+ // THEN none take effect and seq stays null
243
+ expect(callList({ conversationId: conv.id }).seq).toBeNull();
244
+ });
245
+
246
+ test("a freshly created conversation is anchored to the current global seq", () => {
247
+ /**
248
+ * Conversations created after some stream activity must not report a
249
+ * null `seq` — that would force the client to cold-start with no
250
+ * alignment baseline. They inherit the current high-water global seq
251
+ * at creation time so the first snapshot can align with the stream.
252
+ */
253
+
254
+ // GIVEN the global stream has already advanced past seq 0
255
+ advanceGlobalSeq(5);
256
+ expect(getCurrentSeq()).toBe(5);
257
+
258
+ // WHEN a brand-new conversation is created and its snapshot fetched
259
+ const conv = createConversation();
260
+ const body = callList({ conversationId: conv.id, page: "latest" });
261
+
262
+ // THEN the snapshot is anchored to the global seq at creation time
263
+ expect(body.seq).toBe(5);
264
+ });
265
+
266
+ test("a conversation created before any stream activity stays null", () => {
267
+ /**
268
+ * With nothing stamped this process, `getCurrentSeq()` is 0 and the
269
+ * creation seed stores NULL (non-positive seqs aren't recorded), so the
270
+ * snapshot legitimately reports null and the client cold-starts.
271
+ */
272
+
273
+ // GIVEN no events have been stamped (stream reset in beforeEach)
274
+ expect(getCurrentSeq()).toBe(0);
275
+
276
+ // WHEN a conversation is created and its snapshot fetched
277
+ const conv = createConversation();
278
+ const body = callList({ conversationId: conv.id, page: "latest" });
279
+
280
+ // THEN seq remains null
281
+ expect(body.seq).toBeNull();
282
+ });
283
+ });
284
+
285
+ describe("processing state", () => {
286
+ test("reports processing=true while the conversation is mid-turn", () => {
287
+ /**
288
+ * The authoritative processing flag lets a client recover from a
289
+ * dropped stream: it can ask the server whether a turn is still
290
+ * running rather than spinning forever.
291
+ */
292
+
293
+ // GIVEN a conversation marked as processing
294
+ const conv = createConversation();
295
+ seedMessages(conv.id, 1);
296
+ setConversationProcessingStartedAt(conv.id, Date.now());
297
+
298
+ // WHEN the snapshot is fetched
299
+ const body = callList({ conversationId: conv.id, page: "latest" });
300
+
301
+ // THEN it reports the conversation is processing
302
+ expect(body.processing).toBe(true);
303
+ });
304
+
305
+ test("reports processing=false when the conversation is idle", () => {
306
+ // GIVEN an idle conversation (processing_started_at is null)
307
+ const conv = createConversation();
308
+ seedMessages(conv.id, 2);
309
+
310
+ // WHEN the snapshot is fetched
311
+ const body = callList({ conversationId: conv.id, page: "latest" });
312
+
313
+ // THEN it reports the conversation is not processing
314
+ expect(body.processing).toBe(false);
315
+ });
316
+
317
+ test("the no-pagination path also reports processing state", () => {
318
+ /** `processing` is present on every resolved-conversation shape. */
319
+
320
+ // GIVEN a processing conversation
321
+ const conv = createConversation();
322
+ seedMessages(conv.id, 1);
323
+ setConversationProcessingStartedAt(conv.id, Date.now());
324
+
325
+ // WHEN fetched with no pagination params
326
+ const body = callList({ conversationId: conv.id });
327
+
328
+ // THEN processing rides along
329
+ expect(body.processing).toBe(true);
330
+ });
331
+
332
+ test("an unresolved conversationKey reports processing=false (page=latest)", () => {
333
+ // WHEN a never-created key is fetched with the stable contract
334
+ const body = callList({
335
+ conversationKey: "no-such-key",
336
+ page: "latest",
337
+ });
338
+
339
+ // THEN it cannot be processing
340
+ expect(body.processing).toBe(false);
341
+ });
182
342
  });
183
343
 
184
344
  test("page=latest with no limit returns all messages chronologically", () => {
@@ -59,14 +59,27 @@ const reserveMessageMock = mock(
59
59
  );
60
60
  const updateMessageContentMock = mock((_id: string, _content: string) => {});
61
61
 
62
+ // Stand-in for the `conversations.seq` column. The DB-backed
63
+ // `recordConversationPersistedSeq` / `getConversationPersistedSeq` are mocked
64
+ // over this map with the same monotonic, ignore-non-positive semantics so the
65
+ // handler's persisted-seq writes are observable without a real database.
66
+ const persistedSeqByConversation = new Map<string, number>();
67
+
62
68
  mock.module("../memory/conversation-crud.js", () => ({
63
- setConversationProcessingStartedAt: () => {},
64
- isConversationProcessing: () => false,
69
+ setConversationProcessingStartedAt: () => {},
70
+ isConversationProcessing: () => false,
65
71
  getConversation: () => null,
66
72
  getMessageById: () => null,
67
73
  updateMessageContent: updateMessageContentMock,
68
74
  provenanceFromTrustContext: () => ({}),
69
75
  reserveMessage: reserveMessageMock,
76
+ recordConversationPersistedSeq: (id: string, seq: number) => {
77
+ if (!Number.isFinite(seq) || seq <= 0) return;
78
+ const prev = persistedSeqByConversation.get(id);
79
+ if (prev == null || prev < seq) persistedSeqByConversation.set(id, seq);
80
+ },
81
+ getConversationPersistedSeq: (id: string) =>
82
+ persistedSeqByConversation.get(id) ?? null,
70
83
  }));
71
84
 
72
85
  mock.module("../memory/conversation-disk-view.js", () => ({
@@ -102,11 +115,11 @@ import {
102
115
  handleToolUsePreviewStart,
103
116
  } from "../daemon/conversation-agent-loop-handlers.js";
104
117
  import type { ServerMessage } from "../daemon/message-protocol.js";
118
+ import { getConversationPersistedSeq } from "../memory/conversation-crud.js";
105
119
  import type { AssistantEvent } from "../runtime/assistant-event.js";
106
120
  import {
107
121
  _resetStreamStateForTesting,
108
122
  getCurrentSeq,
109
- getPersistedSeq,
110
123
  stampAndBuffer,
111
124
  } from "../runtime/assistant-stream-state.js";
112
125
 
@@ -324,6 +337,7 @@ describe("tool preview lifecycle", () => {
324
337
  describe("persisted seq advances on tool_use_start", () => {
325
338
  beforeEach(() => {
326
339
  _resetStreamStateForTesting();
340
+ persistedSeqByConversation.clear();
327
341
  });
328
342
 
329
343
  test("advances the conversation's persisted seq to the tool_use_start seq", () => {
@@ -374,8 +388,8 @@ describe("tool preview lifecycle", () => {
374
388
  (e) => e.type === "tool_use_start",
375
389
  );
376
390
  expect(toolUseStart).toBeDefined();
377
- expect(getPersistedSeq(conversationId)).toBe(getCurrentSeq());
378
- expect(getPersistedSeq(conversationId)).toBe(
391
+ expect(getConversationPersistedSeq(conversationId)).toBe(getCurrentSeq());
392
+ expect(getConversationPersistedSeq(conversationId)).toBe(
379
393
  (toolUseStart as unknown as AssistantEvent).seq ?? null,
380
394
  );
381
395
  });
@@ -386,6 +400,7 @@ describe("tool preview lifecycle", () => {
386
400
 
387
401
  beforeEach(() => {
388
402
  _resetStreamStateForTesting();
403
+ persistedSeqByConversation.clear();
389
404
  });
390
405
 
391
406
  /** onEvent that stamps conversation-scoped events like the runtime hub. */
@@ -474,8 +489,8 @@ describe("tool preview lifecycle", () => {
474
489
  (e) => e.type === "assistant_thinking_delta",
475
490
  );
476
491
  expect(thinkingDelta).toBeDefined();
477
- expect(getPersistedSeq(conversationId)).toBe(getCurrentSeq());
478
- expect(getPersistedSeq(conversationId)).toBe(
492
+ expect(getConversationPersistedSeq(conversationId)).toBe(getCurrentSeq());
493
+ expect(getConversationPersistedSeq(conversationId)).toBe(
479
494
  (thinkingDelta as unknown as AssistantEvent).seq ?? null,
480
495
  );
481
496
  });
@@ -505,8 +520,8 @@ describe("tool preview lifecycle", () => {
505
520
  // THEN the persisted seq equals the just-stamped tool_result seq
506
521
  const toolResult = events.find((e) => e.type === "tool_result");
507
522
  expect(toolResult).toBeDefined();
508
- expect(getPersistedSeq(conversationId)).toBe(getCurrentSeq());
509
- expect(getPersistedSeq(conversationId)).toBe(
523
+ expect(getConversationPersistedSeq(conversationId)).toBe(getCurrentSeq());
524
+ expect(getConversationPersistedSeq(conversationId)).toBe(
510
525
  (toolResult as unknown as AssistantEvent).seq ?? null,
511
526
  );
512
527
  });
@@ -539,7 +554,7 @@ describe("tool preview lifecycle", () => {
539
554
  events.find((e) => e.type === "assistant_thinking_delta"),
540
555
  ).toBeUndefined();
541
556
  expect(state.lastPersistedContentSeq).toBeUndefined();
542
- expect(getPersistedSeq(conversationId)).toBeNull();
557
+ expect(getConversationPersistedSeq(conversationId)).toBeNull();
543
558
  });
544
559
  });
545
560
 
@@ -548,6 +563,7 @@ describe("tool preview lifecycle", () => {
548
563
 
549
564
  beforeEach(() => {
550
565
  _resetStreamStateForTesting();
566
+ persistedSeqByConversation.clear();
551
567
  reserveMessageMock.mockClear();
552
568
  updateMessageContentMock.mockClear();
553
569
  });
@@ -43,8 +43,8 @@ let mockedRowContent = "";
43
43
  const updates: Array<{ id: string; content: string }> = [];
44
44
 
45
45
  mock.module("../memory/conversation-crud.js", () => ({
46
- setConversationProcessingStartedAt: () => {},
47
- isConversationProcessing: () => false,
46
+ setConversationProcessingStartedAt: () => {},
47
+ isConversationProcessing: () => false,
48
48
  addMessage: () => ({ id: "mock-msg-id" }),
49
49
  getMessageById: (id: string) =>
50
50
  mockedRowContent ? { id, content: mockedRowContent } : null,
@@ -53,6 +53,8 @@ mock.module("../memory/conversation-crud.js", () => ({
53
53
  },
54
54
  provenanceFromTrustContext: () => ({}),
55
55
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
56
+ recordConversationPersistedSeq: () => {},
57
+ getConversationPersistedSeq: () => null,
56
58
  }));
57
59
 
58
60
  mock.module("../memory/llm-request-log-store.js", () => ({
@@ -62,7 +64,6 @@ mock.module("../memory/llm-request-log-store.js", () => ({
62
64
 
63
65
  mock.module("../runtime/assistant-stream-state.js", () => ({
64
66
  getCurrentSeq: () => 0,
65
- recordPersistedSeq: () => {},
66
67
  }));
67
68
 
68
69
  // ── Imports (after mocks) ─────────────────────────────────────────────────────
@@ -29,6 +29,7 @@ import {
29
29
  getMessageById,
30
30
  messageMetadataSchema,
31
31
  provenanceFromTrustContext,
32
+ recordConversationPersistedSeq,
32
33
  reserveMessage,
33
34
  setConversationHistoryStrippedAt,
34
35
  setLastNotifiedInferenceProfile,
@@ -57,10 +58,7 @@ import type {
57
58
  ImageContent,
58
59
  Message,
59
60
  } from "../providers/types.js";
60
- import {
61
- getCurrentSeq,
62
- recordPersistedSeq,
63
- } from "../runtime/assistant-stream-state.js";
61
+ import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
64
62
  import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
65
63
  import { redactSecrets } from "../security/secret-scanner.js";
66
64
  import { extractDomain } from "../tools/network/domain-normalize.js";
@@ -530,7 +528,7 @@ async function flushAccumulatedContent(
530
528
  // Record only after the write commits, so the snapshot seq never
531
529
  // claims content that is not yet durable.
532
530
  if (flushedSeq != null) {
533
- recordPersistedSeq(deps.ctx.conversationId, flushedSeq);
531
+ recordConversationPersistedSeq(deps.ctx.conversationId, flushedSeq);
534
532
  }
535
533
  } catch (err) {
536
534
  deps.rlog.warn(
@@ -918,7 +916,7 @@ export function handleToolUse(
918
916
  // persisted seq to it. Without this the snapshot would advertise a seq below
919
917
  // an event it already incorporates, and a client applying `seq > snapshot.seq`
920
918
  // would replay this tool start.
921
- recordPersistedSeq(deps.ctx.conversationId, getCurrentSeq());
919
+ recordConversationPersistedSeq(deps.ctx.conversationId, getCurrentSeq());
922
920
  }
923
921
 
924
922
  export function handleToolUsePreviewStart(
@@ -1136,7 +1134,7 @@ async function persistPendingToolResultRow(
1136
1134
  rowId,
1137
1135
  JSON.stringify(buildToolResultBlocks(state.pendingToolResults)),
1138
1136
  );
1139
- recordPersistedSeq(deps.ctx.conversationId, seq);
1137
+ recordConversationPersistedSeq(deps.ctx.conversationId, seq);
1140
1138
  const conv = getConversation(deps.ctx.conversationId);
1141
1139
  if (conv != null) {
1142
1140
  syncMessageToDisk(deps.ctx.conversationId, rowId, conv.createdAt);
@@ -1816,10 +1814,14 @@ export async function handleMessageComplete(
1816
1814
  // delta's seq -- the highest stamped content event this row reflects -- so
1817
1815
  // recording it is honest. A drained tool result was stamped earlier in the
1818
1816
  // turn, so this seq already covers it; a call that streams no content (a
1819
- // pure tool call) advances instead via `tool_use_start`. `recordPersistedSeq`
1820
- // clamps monotonically, so a lower value here never regresses the seq.
1817
+ // pure tool call) advances instead via `tool_use_start`.
1818
+ // `recordConversationPersistedSeq` clamps monotonically, so a lower value
1819
+ // here never regresses the seq.
1821
1820
  if (state.lastPersistedContentSeq != null) {
1822
- recordPersistedSeq(deps.ctx.conversationId, state.lastPersistedContentSeq);
1821
+ recordConversationPersistedSeq(
1822
+ deps.ctx.conversationId,
1823
+ state.lastPersistedContentSeq,
1824
+ );
1823
1825
  }
1824
1826
  // Reset the partial-persist mirror so subsequent calls in this turn
1825
1827
  // start with an empty running view.
@@ -34,6 +34,7 @@ import {
34
34
  MEMORY_V3_INJECTED_BLOCK_METADATA_KEY,
35
35
  seedEverInjectedFromSlugs,
36
36
  } from "../plugins/defaults/memory-v3-shadow/ever-injected-store.js";
37
+ import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
37
38
  import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
38
39
  import { UserError } from "../util/errors.js";
39
40
  import { safeParseRecord } from "../util/json.js";
@@ -596,6 +597,7 @@ export function createConversation(
596
597
  ) {
597
598
  const db = getDb();
598
599
  const now = Date.now();
600
+ const initialSeq = getCurrentSeq();
599
601
  const opts =
600
602
  typeof titleOrOpts === "string"
601
603
  ? { title: titleOrOpts }
@@ -634,6 +636,10 @@ export function createConversation(
634
636
  memoryScopeId,
635
637
  scheduleJobId: opts.scheduleJobId ?? null,
636
638
  forkParentConversationId: opts.forkParentConversationId ?? null,
639
+ // Snapshot↔stream alignment baseline, captured at the creation instant.
640
+ // 0 (nothing stamped yet this process) is stored as NULL so `/messages`
641
+ // reports null and the client cold-starts rather than aligning to seq 0.
642
+ seq: initialSeq > 0 ? initialSeq : null,
637
643
  };
638
644
 
639
645
  // Retry on SQLITE_BUSY and SQLITE_IOERR — transient disk I/O errors or WAL
@@ -2248,6 +2254,45 @@ export function isConversationProcessing(id: string): boolean {
2248
2254
  return row?.processing_started_at != null;
2249
2255
  }
2250
2256
 
2257
+ /**
2258
+ * Highest stream `seq` whose content is durably persisted to this
2259
+ * conversation's message rows, read from the `conversations.seq` column. This
2260
+ * is the snapshot↔stream alignment baseline `/messages` returns so a client
2261
+ * applies only stream events with a higher `seq`. `null` when none was
2262
+ * recorded (created before any stream activity, row predates the column, or
2263
+ * the conversation row is absent), in which case the client cold-starts.
2264
+ *
2265
+ * Seeded at creation with the global high-water seq and advanced on each
2266
+ * persistence flush by {@link recordConversationPersistedSeq}.
2267
+ */
2268
+ export function getConversationPersistedSeq(id: string): number | null {
2269
+ const row = rawGet<{ seq: number | null }>(
2270
+ "SELECT seq FROM conversations WHERE id = ?",
2271
+ id,
2272
+ );
2273
+ return row?.seq ?? null;
2274
+ }
2275
+
2276
+ /**
2277
+ * Record that conversation `id` has durably persisted all of its events
2278
+ * through `seq`, writing the `conversations.seq` column. Called at each
2279
+ * persistence flush with the `seq` of the last event whose content the write
2280
+ * committed.
2281
+ *
2282
+ * Monotonic: the `WHERE seq IS NULL OR seq < ?` guard makes the update raise
2283
+ * the high-water mark only, so out-of-order async commits never regress it.
2284
+ * Non-positive or non-finite `seq` values are ignored.
2285
+ */
2286
+ export function recordConversationPersistedSeq(id: string, seq: number): void {
2287
+ if (!Number.isFinite(seq) || seq <= 0) return;
2288
+ rawRun(
2289
+ "UPDATE conversations SET seq = ? WHERE id = ? AND (seq IS NULL OR seq < ?)",
2290
+ seq,
2291
+ id,
2292
+ seq,
2293
+ );
2294
+ }
2295
+
2251
2296
  /**
2252
2297
  * Set or clear the `surfaced_at` promotion marker for a conversation.
2253
2298
  *
@@ -0,0 +1,33 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
3
+
4
+ const COLUMN_NAME = "seq";
5
+ const COLUMN_DEFINITION = "seq INTEGER";
6
+
7
+ /**
8
+ * Add the `seq` column to the `conversations` table.
9
+ *
10
+ * The column holds the highest stream `seq` whose content is durably persisted
11
+ * to the conversation's message rows: seeded with the global high-water seq
12
+ * when the row is inserted and advanced on each persistence flush. It is the
13
+ * durable snapshot↔stream alignment baseline returned by `/messages` — a
14
+ * client aligns its snapshot with the `/events` stream by applying only events
15
+ * with a higher `seq`.
16
+ *
17
+ * Nullable: NULL means the conversation was created before any stream activity
18
+ * (global seq was 0) or predates this column, in which case the client
19
+ * cold-starts.
20
+ *
21
+ * No backfill is needed — existing rows default to NULL, which correctly maps
22
+ * to "no recorded baseline, cold-start".
23
+ *
24
+ * Idempotent: guarded with `tableHasColumn` so a crash between the `ALTER
25
+ * TABLE` and the checkpoint write doesn't cause a duplicate-column error on
26
+ * the next boot.
27
+ */
28
+ export function migrateAddConversationCreationSeq(database: DrizzleDb): void {
29
+ if (tableHasColumn(database, "conversations", COLUMN_NAME)) {
30
+ return;
31
+ }
32
+ database.run(`ALTER TABLE conversations ADD COLUMN ${COLUMN_DEFINITION}`);
33
+ }
@@ -61,6 +61,17 @@ export const conversations = sqliteTable(
61
61
  * callers read this column directly.
62
62
  */
63
63
  processingStartedAt: integer("processing_started_at"),
64
+ /**
65
+ * Highest stream `seq` whose content is durably persisted to this
66
+ * conversation's message rows. Seeded with the global high-water seq when
67
+ * the row is inserted and advanced on each persistence flush
68
+ * (`recordConversationPersistedSeq`). Returned by `/messages` as the
69
+ * snapshot↔stream alignment baseline so a client applies only stream
70
+ * events with a higher `seq`. NULL means the conversation was created
71
+ * before any stream activity (global seq 0) or predates this column — the
72
+ * client cold-starts in that case.
73
+ */
74
+ seq: integer("seq"),
64
75
  },
65
76
  (table) => [
66
77
  index("idx_conversations_updated_at").on(table.updatedAt),
@@ -410,6 +410,7 @@ import { migrateCanonicalGuardianDeliveriesConversationIndex } from "./migration
410
410
  import { migrateAddProcessingStartedAt } from "./migrations/300-add-processing-started-at.js";
411
411
  import { createWatchdogEventsTable } from "./migrations/301-create-watchdog-events.js";
412
412
  import { migrateCreateCompactionEvents } from "./migrations/302-create-compaction-events.js";
413
+ import { migrateAddConversationCreationSeq } from "./migrations/303-add-conversation-creation-seq.js";
413
414
  import type { MigrationStep } from "./migrations/run-migrations.js";
414
415
 
415
416
  export const migrationSteps: MigrationStep[] = [
@@ -1291,4 +1292,5 @@ export const migrationSteps: MigrationStep[] = [
1291
1292
  migrateAddProcessingStartedAt,
1292
1293
  createWatchdogEventsTable,
1293
1294
  migrateCreateCompactionEvents,
1295
+ migrateAddConversationCreationSeq,
1294
1296
  ];
@@ -31,20 +31,18 @@
31
31
  * sized generously enough that a typical refresh round-trip (~1-3s)
32
32
  * is well within window.
33
33
  *
34
- * Persisted-seq map: alongside the live counter and ring, this module
35
- * tracks, per conversation, the `seq` of the last event whose content is
36
- * durably committed to the message rows (`persistedSeqByConversation`).
37
- * The `/messages` snapshot returns this value so a client can align the
38
- * snapshot with the stream: "these rows reflect all of this
39
- * conversation's events through `seq = S`." It is recorded at each
40
- * persistence flush (assistant rows persist incrementally, debounced, so
41
- * the snapshot can lag the live counter) -- never the live counter
42
- * itself, which would over-claim events that have streamed but not yet
43
- * been written. The map is in-memory and clears on restart; because the
44
- * counter resumes above the persisted reservation, a value recorded by
45
- * a previous process could only ever be lower than any seq the new
46
- * process assigns -- never ambiguous against it. The map is LRU-bounded; an
47
- * evicted conversation reports no seq and the client cold-starts.
34
+ * Persisted seq: alongside the live counter and ring, the `seq` of the last
35
+ * event whose content is durably committed to a conversation's message rows
36
+ * is stored on the `conversations.seq` column (see `conversation-crud`). The
37
+ * `/messages` snapshot returns it so a client can align the snapshot with the
38
+ * stream: "these rows reflect all of this conversation's events through
39
+ * `seq = S`." It is written at each persistence flush (assistant rows persist
40
+ * incrementally, debounced, so the snapshot can lag the live counter) -- never
41
+ * the live counter itself, which would over-claim events that have streamed
42
+ * but not yet been written. Because it lives in the database it survives a
43
+ * restart; and because the counter resumes above the persisted reservation, a
44
+ * value written by a previous process could only ever be lower than any seq
45
+ * the new process assigns -- never ambiguous against it.
48
46
  */
49
47
 
50
48
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -66,16 +64,6 @@ const RING_COUNT_LIMIT = SSE_REPLAY_RING_COUNT_LIMIT;
66
64
  const RING_SIZE_LIMIT_BYTES = 256 * 1024;
67
65
  const RING_AGE_LIMIT_MS = SSE_REPLAY_RING_AGE_LIMIT_MS;
68
66
 
69
- /**
70
- * Cap on how many conversations retain a persisted-seq entry. Unlike the
71
- * ring (which the live stream needs only briefly), the persisted-seq map
72
- * grows with the number of conversations that have ever streamed in this
73
- * process. Bound it LRU so it can't grow without limit; an evicted
74
- * conversation simply reports no seq on its next `/messages` and the
75
- * client cold-starts, which is harmless.
76
- */
77
- const PERSISTED_SEQ_CONVERSATION_LIMIT = 1024;
78
-
79
67
  /**
80
68
  * How many seq values are reserved per persisted write. The counter can
81
69
  * hand out seqs up to the persisted ceiling without touching disk, so
@@ -141,13 +129,6 @@ interface AssistantStreamState {
141
129
  firstStampedSeq: number;
142
130
  ring: RingEntry[];
143
131
  totalSizeBytes: number;
144
- /**
145
- * Per-conversation `seq` of the last event durably committed to the
146
- * message rows. Insertion order is maintained as an LRU recency list:
147
- * the oldest key is evicted first once the map exceeds
148
- * {@link PERSISTED_SEQ_CONVERSATION_LIMIT}.
149
- */
150
- persistedSeqByConversation: Map<string, number>;
151
132
  }
152
133
 
153
134
  // ── State ────────────────────────────────────────────────────────────
@@ -159,7 +140,6 @@ const state: AssistantStreamState = {
159
140
  firstStampedSeq: 0,
160
141
  ring: [],
161
142
  totalSizeBytes: 0,
162
- persistedSeqByConversation: new Map(),
163
143
  };
164
144
 
165
145
  // ── Public API ───────────────────────────────────────────────────────
@@ -270,46 +250,6 @@ export function getCurrentSeq(): number {
270
250
  return state.nextSeq - 1;
271
251
  }
272
252
 
273
- /**
274
- * Record that conversation `conversationId` has durably persisted all of
275
- * its events through `seq`. Called at each persistence flush with the
276
- * `seq` of the last event whose content the write committed.
277
- *
278
- * Monotonic: a lower `seq` never regresses a higher one (out-of-order
279
- * async commits are clamped). LRU-bounded by
280
- * {@link PERSISTED_SEQ_CONVERSATION_LIMIT}: re-recording refreshes
281
- * recency, and the oldest conversation is evicted once the cap is
282
- * exceeded. Non-positive or non-finite `seq` values are ignored.
283
- */
284
- export function recordPersistedSeq(conversationId: string, seq: number): void {
285
- if (!Number.isFinite(seq) || seq <= 0) return;
286
-
287
- const map = state.persistedSeqByConversation;
288
- const prev = map.get(conversationId);
289
- if (prev !== undefined) {
290
- // Re-insert to move this key to the most-recently-used end.
291
- map.delete(conversationId);
292
- map.set(conversationId, Math.max(prev, seq));
293
- return;
294
- }
295
-
296
- map.set(conversationId, seq);
297
- if (map.size > PERSISTED_SEQ_CONVERSATION_LIMIT) {
298
- const oldestKey = map.keys().next().value;
299
- if (oldestKey !== undefined) map.delete(oldestKey);
300
- }
301
- }
302
-
303
- /**
304
- * Highest `seq` durably persisted for `conversationId`, or `null` when
305
- * none has been recorded in this process (cold conversation, or evicted
306
- * from the LRU map). Returned by `/messages` so a client can align the
307
- * snapshot with the live stream.
308
- */
309
- export function getPersistedSeq(conversationId: string): number | null {
310
- return state.persistedSeqByConversation.get(conversationId) ?? null;
311
- }
312
-
313
253
  /**
314
254
  * Reset all stream state. Test-only.
315
255
  */
@@ -323,7 +263,6 @@ export function _resetStreamStateForTesting(): void {
323
263
  state.firstStampedSeq = 0;
324
264
  state.ring = [];
325
265
  state.totalSizeBytes = 0;
326
- state.persistedSeqByConversation.clear();
327
266
  }
328
267
 
329
268
  /**
@@ -337,7 +276,6 @@ export function _simulateRestartForTesting(): void {
337
276
  state.firstStampedSeq = 0;
338
277
  state.ring = [];
339
278
  state.totalSizeBytes = 0;
340
- state.persistedSeqByConversation.clear();
341
279
  }
342
280
 
343
281
  /**
@@ -90,9 +90,11 @@ import {
90
90
  addMessage,
91
91
  extractImageSourcePaths,
92
92
  getConversation,
93
+ getConversationPersistedSeq,
93
94
  getMessages,
94
95
  getMessagesPaginated,
95
96
  hasMessages,
97
+ isConversationProcessing,
96
98
  type MessageRow,
97
99
  provenanceFromTrustContext,
98
100
  setConversationInferenceProfile,
@@ -122,7 +124,6 @@ import {
122
124
  } from "../../util/platform.js";
123
125
  import { silentlyWithLog } from "../../util/silently.js";
124
126
  import { assistantEventHub, broadcastMessage } from "../assistant-event-hub.js";
125
- import { getPersistedSeq } from "../assistant-stream-state.js";
126
127
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
127
128
  import {
128
129
  type GuardianPendingScope,
@@ -657,6 +658,7 @@ export function handleListMessages({
657
658
  oldestTimestamp: null,
658
659
  oldestMessageId: null,
659
660
  seq: null,
661
+ processing: false,
660
662
  };
661
663
  }
662
664
  return { messages: [] };
@@ -1006,11 +1008,19 @@ export function handleListMessages({
1006
1008
  });
1007
1009
 
1008
1010
  // Snapshot↔stream alignment token: the `seq` of the last event whose
1009
- // content is durably persisted for this conversation in the current
1010
- // daemon process. Returned on every resolved-conversation response so a
1011
- // client can apply only stream events with a higher `seq`. Null when
1012
- // nothing has been persisted in-process (cold/aged-out/post-restart).
1013
- const persistedSeq = getPersistedSeq(resolvedConversationId);
1011
+ // content is durably persisted for this conversation, read from the
1012
+ // `conversations.seq` column. Returned on every resolved-conversation
1013
+ // response so a client can apply only stream events with a higher `seq`.
1014
+ // Null when nothing has been persisted (the conversation was created before
1015
+ // any stream activity, or predates the column) -- the client cold-starts.
1016
+ const persistedSeq = getConversationPersistedSeq(resolvedConversationId);
1017
+
1018
+ // Authoritative "is the agent mid-turn?" signal, sourced from the
1019
+ // `processing_started_at` column (persisted, survives daemon restarts).
1020
+ // Clients use this to distinguish a live turn still in flight from a
1021
+ // turn that silently died — without it, a dropped SSE stream leaves the
1022
+ // UI spinning forever with no way to learn the server is actually idle.
1023
+ const processing = isConversationProcessing(resolvedConversationId);
1014
1024
 
1015
1025
  if (isPaginated) {
1016
1026
  // Prefer the page's oldest visible row (the documented cursor semantic).
@@ -1034,6 +1044,7 @@ export function handleListMessages({
1034
1044
  oldestTimestamp: oldestTimestamp ?? null,
1035
1045
  oldestMessageId: oldestMessageId ?? null,
1036
1046
  seq: persistedSeq,
1047
+ processing,
1037
1048
  };
1038
1049
  }
1039
1050
 
@@ -1043,10 +1054,11 @@ export function handleListMessages({
1043
1054
  ...(oldestTimestamp != null ? { oldestTimestamp } : {}),
1044
1055
  ...(oldestMessageId != null ? { oldestMessageId } : {}),
1045
1056
  seq: persistedSeq,
1057
+ processing,
1046
1058
  };
1047
1059
  }
1048
1060
 
1049
- return { messages, seq: persistedSeq };
1061
+ return { messages, seq: persistedSeq, processing };
1050
1062
  }
1051
1063
 
1052
1064
  /**
@@ -2688,6 +2700,12 @@ export const ROUTES: RouteDefinition[] = [
2688
2700
  .describe(
2689
2701
  "Global SSE `seq` of the last event whose content is durably persisted for this conversation in the current daemon process. A client can align this snapshot with the `/events` stream by applying only events with `seq` greater than this value. Null when no events have been persisted in this process (cold conversation, after a daemon restart, or when the conversation has aged out of the in-memory map) — clients should cold-start in that case. Absent on older daemons that predate this field.",
2690
2702
  ),
2703
+ processing: z
2704
+ .boolean()
2705
+ .optional()
2706
+ .describe(
2707
+ "Whether the agent is currently mid-turn for this conversation, sourced authoritatively from the persisted `processing_started_at` column. `true` means a turn is in flight; `false` means the conversation is idle. Clients use this to recover from a dropped SSE stream: if a turn appears to be running locally but the server reports `processing: false`, the turn has ended (or died) and the UI should stop waiting rather than spin indefinitely. Absent on older daemons that predate this field.",
2708
+ ),
2691
2709
  }),
2692
2710
  handler: (args) => handleListMessages(args),
2693
2711
  },