@arcote.tech/arc-chat 0.5.7 → 0.5.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.5.7",
4
+ "version": "0.5.8",
5
5
  "private": false,
6
6
  "description": "Chat module with AI integration for Arc framework",
7
7
  "main": "./src/index.ts",
@@ -10,11 +10,11 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc": "^0.5.7",
14
- "@arcote.tech/arc-ai": "^0.5.7",
15
- "@arcote.tech/arc-auth": "^0.5.7",
16
- "@arcote.tech/arc-ds": "^0.5.7",
17
- "@arcote.tech/platform": "^0.5.7",
13
+ "@arcote.tech/arc": "^0.5.8",
14
+ "@arcote.tech/arc-ai": "^0.5.8",
15
+ "@arcote.tech/arc-auth": "^0.5.8",
16
+ "@arcote.tech/arc-ds": "^0.5.8",
17
+ "@arcote.tech/platform": "^0.5.8",
18
18
  "lucide-react": ">=0.400.0",
19
19
  "react": ">=18.0.0",
20
20
  "typescript": "^5.0.0"
@@ -107,36 +107,55 @@ export const createMessageAggregate = <
107
107
  },
108
108
  )
109
109
 
110
- // ─── assistantRespondedAI generates one turn ─────────────
111
- // The `blocks` field is the JSON-serialized AssistantContentBlock[]
112
- // produced by the provider for this single turn text and tool_call
113
- // blocks interleaved in the order the model emitted them.
110
+ // ─── assistantTurnStartednew assistant row, isGenerating=true
111
+ // Created at the start of each LLM turn. The row exists in DB without
112
+ // blocks; the frontend detects `isGenerating: true` and subscribes to the
113
+ // SSE stream identified by `sessionId`.
114
114
  .publicEvent(
115
- "assistantResponded",
115
+ "assistantTurnStarted",
116
116
  {
117
117
  messageId,
118
118
  scopeId,
119
119
  sessionId: string(),
120
- blocks: string(),
121
120
  model: string().optional(),
122
- previousResponseId: string().optional(),
123
- isGenerating: boolean().optional(),
124
121
  },
125
122
  async (ctx, event) => {
126
123
  const p = event.payload;
127
124
  await ctx.set(p.messageId, {
128
125
  scopeId: p.scopeId,
129
126
  role: "assistant",
130
- blocks: p.blocks,
131
127
  model: p.model,
132
128
  sessionId: p.sessionId,
133
- previousResponseId: p.previousResponseId,
134
- isGenerating: p.isGenerating,
129
+ isGenerating: true,
135
130
  createdAt: event.createdAt,
136
131
  });
137
132
  },
138
133
  )
139
134
 
135
+ // ─── assistantTurnCompleted — finalize an in-progress turn row ───
136
+ // Partial update on the SAME row — fills `blocks`, flips
137
+ // `isGenerating` to false, optionally records `previousResponseId`,
138
+ // `usage`, or `error`.
139
+ .publicEvent(
140
+ "assistantTurnCompleted",
141
+ {
142
+ messageId,
143
+ blocks: string(),
144
+ previousResponseId: string().optional(),
145
+ usage: string().optional(),
146
+ error: string().optional(),
147
+ },
148
+ async (ctx, event) => {
149
+ const p = event.payload;
150
+ await ctx.modify(p.messageId, {
151
+ blocks: p.blocks,
152
+ previousResponseId: p.previousResponseId,
153
+ usage: p.usage,
154
+ isGenerating: false,
155
+ } as any);
156
+ },
157
+ )
158
+
140
159
  // ─── toolExecuted — server tool returns result ──────────────
141
160
  .publicEvent(
142
161
  "toolExecuted",
@@ -188,25 +207,6 @@ export const createMessageAggregate = <
188
207
  },
189
208
  )
190
209
 
191
- // ─── generationCompleted — AI loop finished ─────────────────
192
- .publicEvent(
193
- "generationCompleted",
194
- {
195
- messageId,
196
- sessionId: string(),
197
- usage: string().optional(),
198
- },
199
- async (ctx, event) => {
200
- const p = event.payload;
201
- // PARTIAL update — `ctx.set` replaces the whole row and would null
202
- // out scopeId/role/blocks. Use `modify` to only flip isGenerating.
203
- await ctx.modify(p.messageId, {
204
- isGenerating: false,
205
- usage: p.usage,
206
- } as any);
207
- },
208
- )
209
-
210
210
  // ─── sendMessage — user sends message, creates session ──────
211
211
  .mutateMethod(
212
212
  "sendMessage",
@@ -233,36 +233,55 @@ export const createMessageAggregate = <
233
233
  ),
234
234
  )
235
235
 
236
- // ─── saveAssistantMessage ───────────────────────────────────
237
- // `blocks` is the JSON-serialized AssistantContentBlock[] from the
238
- // provider's response single source of truth for the model's output.
236
+ // ─── startAssistantTurn — open an in-progress assistant row ────
237
+ // Generates a fresh messageId, emits `assistantTurnStarted`. The row
238
+ // exists with `isGenerating: true` and no `blocks` until
239
+ // `completeAssistantTurn` fills them in.
239
240
  .mutateMethod(
240
- "saveAssistantMessage",
241
+ "startAssistantTurn",
241
242
  (fn) => fn.withParams({
242
243
  scopeId,
243
244
  sessionId: string(),
244
- blocks: string(),
245
245
  model: string().optional(),
246
- previousResponseId: string().optional(),
247
- isGenerating: boolean().optional(),
248
246
  }).handle(
249
247
  ONLY_SERVER &&
250
248
  (async (ctx, params) => {
251
249
  const msgId = messageId.generate();
252
- await ctx.assistantResponded.emit({
250
+ await ctx.assistantTurnStarted.emit({
253
251
  messageId: msgId,
254
252
  scopeId: params.scopeId,
255
253
  sessionId: params.sessionId,
256
- blocks: params.blocks,
257
254
  model: params.model,
258
- previousResponseId: params.previousResponseId,
259
- isGenerating: params.isGenerating,
260
255
  });
261
256
  return { messageId: msgId };
262
257
  }),
263
258
  ),
264
259
  )
265
260
 
261
+ // ─── completeAssistantTurn — partial update of the open turn row ─
262
+ .mutateMethod(
263
+ "completeAssistantTurn",
264
+ (fn) => fn.withParams({
265
+ messageId,
266
+ blocks: string(),
267
+ previousResponseId: string().optional(),
268
+ usage: string().optional(),
269
+ error: string().optional(),
270
+ }).handle(
271
+ ONLY_SERVER &&
272
+ (async (ctx, params) => {
273
+ await ctx.assistantTurnCompleted.emit({
274
+ messageId: params.messageId,
275
+ blocks: params.blocks,
276
+ previousResponseId: params.previousResponseId,
277
+ usage: params.usage,
278
+ error: params.error,
279
+ });
280
+ return { ok: true };
281
+ }),
282
+ ),
283
+ )
284
+
266
285
  // ─── saveToolResult — server tool executed ──────────────────
267
286
  .mutateMethod(
268
287
  "saveToolResult",
@@ -291,26 +310,6 @@ export const createMessageAggregate = <
291
310
  ),
292
311
  )
293
312
 
294
- // ─── completeGeneration ─────────────────────────────────────
295
- .mutateMethod(
296
- "completeGeneration",
297
- (fn) => fn.withParams({
298
- generationMessageId: messageId,
299
- sessionId: string(),
300
- usage: string().optional(),
301
- }).handle(
302
- ONLY_SERVER &&
303
- (async (ctx, params) => {
304
- await ctx.generationCompleted.emit({
305
- messageId: params.generationMessageId,
306
- sessionId: params.sessionId,
307
- usage: params.usage,
308
- });
309
- return { ok: true };
310
- }),
311
- ),
312
- )
313
-
314
313
  // ─── respondToTool — user answers interactive tool ──────────
315
314
  .mutateMethod(
316
315
  "respondToTool",
package/src/index.ts CHANGED
@@ -7,12 +7,7 @@ export { createMessageAggregate, createMessageId } from "./aggregates/message";
7
7
  export type { MessageAggregate, MessageId } from "./aggregates/message";
8
8
 
9
9
  // --- Streaming ---
10
- export {
11
- createStreamSession,
12
- getStreamSession,
13
- deleteStreamSession,
14
- } from "./streaming/stream-registry";
15
- export type { StreamSession } from "./streaming/stream-registry";
10
+ export { broadcast, endStream, hasActiveStream, subscribe } from "./streaming/stream-registry";
16
11
 
17
12
  // --- Listener ---
18
13
  export { createAiGenerationListener } from "./listeners/ai-generation-listener";
@@ -182,7 +182,6 @@ interface RunLoopConfig {
182
182
  toolDefs: any[] | undefined;
183
183
  serverToolsMap: Map<string, ArcToolAny>;
184
184
  interactiveToolNames: Set<string>;
185
- generationMessageId: string;
186
185
  scopeId: string;
187
186
  sessionId: string;
188
187
  maxExecutionCount: number;
@@ -199,7 +198,6 @@ async function runGenerationLoop(config: RunLoopConfig) {
199
198
  toolDefs,
200
199
  serverToolsMap,
201
200
  interactiveToolNames,
202
- generationMessageId,
203
201
  scopeId,
204
202
  sessionId,
205
203
  maxExecutionCount,
@@ -210,6 +208,11 @@ async function runGenerationLoop(config: RunLoopConfig) {
210
208
  let history = config.history;
211
209
  let newTurnsStartIdx = config.initialNewTurnsStartIdx;
212
210
  let executionCount = 0;
211
+ /** The in-progress assistant row for the CURRENT iteration. Set at the top
212
+ * of every iteration via `startAssistantTurn`; closed at the bottom via
213
+ * `completeAssistantTurn`. The error handler uses it to mark the open turn
214
+ * as failed. */
215
+ let currentTurnId: string | undefined;
213
216
 
214
217
  try {
215
218
  while (executionCount <= maxExecutionCount) {
@@ -230,6 +233,14 @@ async function runGenerationLoop(config: RunLoopConfig) {
230
233
  newTurnsStartIdx,
231
234
  );
232
235
 
236
+ // Open a new in-progress assistant row before the stream starts. The
237
+ // frontend detects `isGenerating: true` on this row and subscribes to
238
+ // the SSE stream identified by `sessionId`.
239
+ const turnStart = await ctx
240
+ .mutate(messageElement)
241
+ .startAssistantTurn({ scopeId, sessionId, model });
242
+ currentTurnId = turnStart.messageId;
243
+
233
244
  const result = await provider.streamComplete(
234
245
  {
235
246
  model,
@@ -255,17 +266,6 @@ async function runGenerationLoop(config: RunLoopConfig) {
255
266
  },
256
267
  );
257
268
 
258
- // Persist this turn's assistant blocks as a single message row.
259
- if (result.blocks.length > 0) {
260
- await ctx.mutate(messageElement).saveAssistantMessage({
261
- scopeId,
262
- sessionId,
263
- blocks: JSON.stringify(result.blocks),
264
- model,
265
- previousResponseId: result.responseId,
266
- });
267
- }
268
-
269
269
  // Append to local history so the next iteration sees this turn.
270
270
  const assistantTurn: ConversationTurn = {
271
271
  role: "assistant",
@@ -285,12 +285,18 @@ async function runGenerationLoop(config: RunLoopConfig) {
285
285
  const hasToolCalls =
286
286
  result.finishReason === "tool_call" && toolCalls.length > 0;
287
287
 
288
+ // Close the turn row — same row that was opened above. The final turn
289
+ // (no tool calls) carries the usage; intermediate turns carry only the
290
+ // blocks + responseId.
291
+ await ctx.mutate(messageElement).completeAssistantTurn({
292
+ messageId: currentTurnId!,
293
+ blocks: JSON.stringify(result.blocks),
294
+ previousResponseId: result.responseId,
295
+ usage: hasToolCalls ? undefined : JSON.stringify(result.usage),
296
+ });
297
+ currentTurnId = undefined;
298
+
288
299
  if (!hasToolCalls) {
289
- await ctx.mutate(messageElement).completeGeneration({
290
- generationMessageId,
291
- sessionId,
292
- usage: JSON.stringify(result.usage),
293
- });
294
300
  broadcast(sessionId, {
295
301
  type: "done",
296
302
  sessionId,
@@ -390,18 +396,22 @@ async function runGenerationLoop(config: RunLoopConfig) {
390
396
  executionCount++;
391
397
  }
392
398
  } catch (err) {
399
+ const errorMsg = `AI error: ${err instanceof Error ? err.message : String(err)}`;
393
400
  broadcast(sessionId, {
394
401
  type: "error",
395
402
  sessionId,
396
- error: `AI error: ${err instanceof Error ? err.message : String(err)}`,
403
+ error: errorMsg,
397
404
  executionCount,
398
405
  });
399
- try {
400
- await ctx.mutate(messageElement).completeGeneration({
401
- generationMessageId,
402
- sessionId,
403
- });
404
- } catch {}
406
+ if (currentTurnId) {
407
+ try {
408
+ await ctx.mutate(messageElement).completeAssistantTurn({
409
+ messageId: currentTurnId,
410
+ blocks: "[]",
411
+ error: errorMsg,
412
+ });
413
+ } catch {}
414
+ }
405
415
  endStream(sessionId);
406
416
  }
407
417
  }
@@ -458,18 +468,6 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
458
468
  const newTurnsStartIdx = history.length;
459
469
  history.push({ role: "user", content: userContent });
460
470
 
461
- // Placeholder assistant message so the UI can render "AI is typing".
462
- // Empty blocks; the real one is saved by the loop after streaming.
463
- const generationResult = await ctx
464
- .mutate(messageElement)
465
- .saveAssistantMessage({
466
- scopeId,
467
- sessionId,
468
- blocks: "[]",
469
- model,
470
- isGenerating: true,
471
- });
472
-
473
471
  await runGenerationLoop({
474
472
  ctx,
475
473
  messageElement,
@@ -480,7 +478,6 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
480
478
  toolDefs,
481
479
  serverToolsMap,
482
480
  interactiveToolNames,
483
- generationMessageId: generationResult.messageId,
484
481
  scopeId,
485
482
  sessionId,
486
483
  maxExecutionCount,
@@ -557,17 +554,6 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
557
554
  const provider = resolveProvider(model, scopeId);
558
555
  if (!provider) return;
559
556
 
560
- // Placeholder assistant message for "AI is typing"
561
- const generationResult = await ctx
562
- .mutate(messageElement)
563
- .saveAssistantMessage({
564
- scopeId,
565
- sessionId,
566
- blocks: "[]",
567
- model,
568
- isGenerating: true,
569
- });
570
-
571
557
  void toolName;
572
558
  void toolResult;
573
559
 
@@ -581,7 +567,6 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
581
567
  toolDefs,
582
568
  serverToolsMap,
583
569
  interactiveToolNames,
584
- generationMessageId: generationResult.messageId,
585
570
  scopeId,
586
571
  sessionId,
587
572
  maxExecutionCount,
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
1
+ import { useState, useCallback, useMemo, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
2
2
  import type { ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
3
3
  import type { ChatLabels, ChatMessageData, SendMessageOptions } from "@arcote.tech/arc-ds";
4
4
  import { Chat, ChatMessage, ChatInputProvider, ChatLabelsProvider, ChatToolLog, useChatLabels } from "@arcote.tech/arc-ds";
@@ -53,7 +53,6 @@ export function createChatComponent(
53
53
  const [isStreaming, setIsStreaming] = useState(false);
54
54
  const sessionIdRef = useRef<string | null>(null);
55
55
  const currentAssistantIdRef = useRef<string | null>(null);
56
- const lastHistoryLenRef = useRef(0);
57
56
  const resumedSessionRef = useRef<string | null>(null);
58
57
 
59
58
  const queries = scope.useQuery();
@@ -68,11 +67,24 @@ export function createChatComponent(
68
67
  const historyData = historyResult?.[0];
69
68
  const historyLen = historyData?.length ?? 0;
70
69
 
70
+ // Stable signature of all messages — `[id]:[isGenerating]:[hasBlocks]:[contentLen]`.
71
+ // Changes on insert AND on partial update (e.g. ctx.modify flipping
72
+ // isGenerating to false), so the timeline-rebuild effect refires for
73
+ // both cases. `historyLen` alone misses updates that don't change count.
74
+ const historySig = useMemo(
75
+ () =>
76
+ historyData
77
+ ?.map(
78
+ (m: any) =>
79
+ `${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}`,
80
+ )
81
+ .join("|") ?? "",
82
+ [historyData],
83
+ );
84
+
71
85
  // ─── Restore timeline from DB history ───────────────────────
72
86
  useEffect(() => {
73
87
  if (isStreaming || !historyData || historyLen === 0) return;
74
- if (historyLen === lastHistoryLenRef.current) return;
75
- lastHistoryLenRef.current = historyLen;
76
88
 
77
89
  const resultIds = new Set<string>();
78
90
  const resultMap = new Map<string, { content: string; isError?: boolean }>();
@@ -103,22 +115,30 @@ export function createChatComponent(
103
115
  }
104
116
 
105
117
  if (msg.role === "assistant") {
106
- // Placeholder row created at the start of generation. We track its
107
- // session for SSE reconnect, but don't render it yet — the loop
108
- // will save the real assistant row with `blocks` once streaming
109
- // completes.
110
- const blocksStr = msg.blocks ?? "";
111
- if (msg.isGenerating && !blocksStr) {
118
+ // Open turn (in progress). The row exists with `isGenerating: true`
119
+ // and no blocks; the SSE stream identified by `sessionId` will
120
+ // populate this bubble live. Use msg._id as the bubble id so the
121
+ // next rebuild (after `assistantTurnCompleted` flips the flag and
122
+ // sets blocks) replaces this bubble naturally.
123
+ if (msg.isGenerating === true) {
112
124
  if (msg.sessionId) sessionIdRef.current = msg.sessionId;
113
125
  hasActiveGeneration = true;
126
+ items.push({
127
+ type: "message",
128
+ id: msg._id,
129
+ role: "assistant",
130
+ content: "",
131
+ isStreaming: true,
132
+ });
133
+ currentAssistantIdRef.current = msg._id;
114
134
  continue;
115
135
  }
116
136
 
117
- // Walk the assistant's blocks in order each TextBlock becomes a
137
+ // Closed turn render from blocks. Each TextBlock becomes a
118
138
  // message item, each ToolCallBlock becomes a tool item paired with
119
139
  // its result row.
120
140
  const blocks =
121
- (tryParseJson(blocksStr) as Array<
141
+ (tryParseJson(msg.blocks ?? "") as Array<
122
142
  | { type: "text"; text: string }
123
143
  | {
124
144
  type: "tool_call";
@@ -154,8 +174,6 @@ export function createChatComponent(
154
174
  }
155
175
  blockIdx++;
156
176
  }
157
-
158
- if (msg.isGenerating === true) hasActiveGeneration = true;
159
177
  }
160
178
  }
161
179
 
@@ -163,7 +181,7 @@ export function createChatComponent(
163
181
  if (!isStreaming && hasActiveGeneration) {
164
182
  setIsStreaming(true);
165
183
  }
166
- }, [historyLen, isStreaming]);
184
+ }, [historySig, isStreaming]);
167
185
 
168
186
  // ─── SSE stream consumer ────────────────────────────────────
169
187
  // Reusable: handles fetch + read loop + processEvent dispatch.
@@ -1,11 +1,24 @@
1
1
  import type { ChatStreamEvent } from "@arcote.tech/arc-ai";
2
2
 
3
- // ─── ChatStreamManager — per message ID streaming ──────────────
3
+ // ─── ChatStreamManager — per session SSE registry with replay buffer ───
4
+ //
5
+ // Per-session state:
6
+ // - `streams[sessionId]` — live controllers currently subscribed
7
+ // - `buffers[sessionId]` — every event broadcast since the session started,
8
+ // so a late subscriber (e.g. after a page refresh mid-generation) gets
9
+ // the full prefix replayed before going live
10
+ // - `keepAliveIntervals[sessionId]` — heartbeat ping interval
4
11
 
5
12
  const streams = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>();
13
+ const buffers = new Map<string, ChatStreamEvent[]>();
6
14
  const keepAliveIntervals = new Map<string, ReturnType<typeof setInterval>>();
7
15
  const encoder = new TextEncoder();
8
16
 
17
+ /** Hard cap on per-session buffer size. Each typical generation produces a
18
+ * few hundred chunks; 5000 is generous but bounds memory if a stream
19
+ * somehow runs without `endStream`. */
20
+ const MAX_BUFFER = 5000;
21
+
9
22
  function encode(event: ChatStreamEvent): Uint8Array {
10
23
  return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
11
24
  }
@@ -14,9 +27,18 @@ function encodePing(): Uint8Array {
14
27
  return encoder.encode(`: ping\n\n`);
15
28
  }
16
29
 
17
- export function broadcast(messageId: string, event: ChatStreamEvent): void {
18
- const controllers = streams.get(messageId);
19
- if (!controllers) return;
30
+ export function broadcast(sessionId: string, event: ChatStreamEvent): void {
31
+ // Append to the replay buffer first — even if no client is currently
32
+ // subscribed (initial connect race) the event survives for replay.
33
+ let buf = buffers.get(sessionId);
34
+ if (!buf) {
35
+ buf = [];
36
+ buffers.set(sessionId, buf);
37
+ }
38
+ if (buf.length < MAX_BUFFER) buf.push(event);
39
+
40
+ const controllers = streams.get(sessionId);
41
+ if (!controllers || controllers.size === 0) return;
20
42
  const data = encode(event);
21
43
  for (const controller of controllers) {
22
44
  try {
@@ -27,42 +49,64 @@ export function broadcast(messageId: string, event: ChatStreamEvent): void {
27
49
  }
28
50
  }
29
51
 
30
- export function subscribe(messageId: string): ReadableStream<Uint8Array> {
52
+ export function subscribe(sessionId: string): ReadableStream<Uint8Array> {
31
53
  return new ReadableStream<Uint8Array>({
32
54
  start(controller) {
33
- let set = streams.get(messageId);
55
+ // Replay any buffered events before going live, so a client that
56
+ // connects mid-stream sees the full prefix.
57
+ const buf = buffers.get(sessionId);
58
+ if (buf) {
59
+ for (const e of buf) {
60
+ try {
61
+ controller.enqueue(encode(e));
62
+ } catch {
63
+ return;
64
+ }
65
+ }
66
+ }
67
+
68
+ let set = streams.get(sessionId);
34
69
  if (!set) {
35
70
  set = new Set();
36
- streams.set(messageId, set);
71
+ streams.set(sessionId, set);
37
72
  }
38
73
  set.add(controller);
39
74
 
40
75
  // Start keep-alive if not running
41
- if (!keepAliveIntervals.has(messageId)) {
76
+ if (!keepAliveIntervals.has(sessionId)) {
42
77
  const interval = setInterval(() => {
43
- const s = streams.get(messageId);
78
+ const s = streams.get(sessionId);
44
79
  if (s && s.size > 0) {
45
80
  const ping = encodePing();
46
81
  for (const c of s) {
47
82
  try { c.enqueue(ping); } catch { s.delete(c); }
48
83
  }
49
- } else {
50
- cleanup(messageId);
84
+ } else if (!buffers.has(sessionId)) {
85
+ // Stream truly inactive: no live clients AND no buffer. Stop
86
+ // pinging. We never proactively drop the buffer here — that
87
+ // happens in `endStream` so a late re-subscribe still gets the
88
+ // full replay.
89
+ cleanup(sessionId);
51
90
  }
52
91
  }, 5000);
53
- keepAliveIntervals.set(messageId, interval);
92
+ keepAliveIntervals.set(sessionId, interval);
54
93
  }
55
94
  },
56
95
  cancel() {
57
- // One client disconnected — don't cleanup everything
96
+ // One client disconnected — don't tear down session state. The buffer
97
+ // and other subscribers (if any) remain.
58
98
  },
59
99
  });
60
100
  }
61
101
 
62
- export function endStream(messageId: string): void {
63
- const controllers = streams.get(messageId);
102
+ /** Called by the AI generation listener when a turn finishes (success or
103
+ * error). Closes all live SSE streams and drops the replay buffer. After
104
+ * this, a fresh `subscribe(sessionId)` returns an empty stream — the
105
+ * client should fall back to reading the final `blocks` from DB. */
106
+ export function endStream(sessionId: string): void {
107
+ const controllers = streams.get(sessionId);
64
108
  if (controllers) {
65
- const done = encode({ type: "done", sessionId: messageId } as any);
109
+ const done = encode({ type: "done", sessionId } as any);
66
110
  for (const controller of controllers) {
67
111
  try {
68
112
  controller.enqueue(done);
@@ -70,45 +114,20 @@ export function endStream(messageId: string): void {
70
114
  } catch {}
71
115
  }
72
116
  }
73
- cleanup(messageId);
117
+ cleanup(sessionId);
74
118
  }
75
119
 
76
- export function hasActiveStream(messageId: string): boolean {
77
- const s = streams.get(messageId);
120
+ export function hasActiveStream(sessionId: string): boolean {
121
+ const s = streams.get(sessionId);
78
122
  return !!s && s.size > 0;
79
123
  }
80
124
 
81
- function cleanup(messageId: string): void {
82
- const interval = keepAliveIntervals.get(messageId);
125
+ function cleanup(sessionId: string): void {
126
+ const interval = keepAliveIntervals.get(sessionId);
83
127
  if (interval) {
84
128
  clearInterval(interval);
85
- keepAliveIntervals.delete(messageId);
129
+ keepAliveIntervals.delete(sessionId);
86
130
  }
87
- streams.delete(messageId);
88
- }
89
-
90
- // ─── Legacy exports (for respondToTool compatibility) ───────────
91
- // TODO: remove after full migration
92
-
93
- export interface StreamSession {
94
- readonly sessionId: string;
95
- push(event: ChatStreamEvent): void;
96
- close(): void;
97
- isClosed(): boolean;
98
- }
99
-
100
- export function createStreamSession(sessionId: string): StreamSession {
101
- let closed = false;
102
- return {
103
- sessionId,
104
- push(event) { broadcast(sessionId, event); },
105
- close() { closed = true; },
106
- isClosed() { return closed; },
107
- };
131
+ streams.delete(sessionId);
132
+ buffers.delete(sessionId);
108
133
  }
109
-
110
- export function getStreamSession(sessionId: string): StreamSession | undefined {
111
- return undefined;
112
- }
113
-
114
- export function deleteStreamSession(sessionId: string): void {}