@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 +8 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-stream-state.test.ts +3 -76
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/list-messages-page-latest.test.ts +164 -4
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
- package/src/__tests__/tool-start-timestamp.test.ts +4 -3
- package/src/daemon/conversation-agent-loop-handlers.ts +12 -10
- package/src/memory/conversation-crud.ts +45 -0
- package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
- package/src/memory/schema/conversations.ts +11 -0
- package/src/memory/steps.ts +2 -0
- package/src/runtime/assistant-stream-state.ts +12 -74
- package/src/runtime/routes/conversation-routes.ts +25 -7
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
|
@@ -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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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(
|
|
378
|
-
expect(
|
|
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(
|
|
478
|
-
expect(
|
|
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(
|
|
509
|
-
expect(
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
1820
|
-
// clamps monotonically, so a lower value
|
|
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
|
-
|
|
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),
|
package/src/memory/steps.ts
CHANGED
|
@@ -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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* the
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
1010
|
-
//
|
|
1011
|
-
// client can apply only stream events with a higher `seq`.
|
|
1012
|
-
// nothing has been persisted
|
|
1013
|
-
|
|
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
|
},
|