fix: isolate chat SSE broadcasts per generation

After stopping a streaming chat reply, the next message would appear
sent but show no Stop button or "Connecting…" indicator. The cancel
broadcast from the prior generation was leaking into the new SSE
subscription and immediately marking it as errored.

Each `chatManager.sendMessage` now allocates a per-generation id;
`ChatStreamManager` only delivers tagged broadcasts to subscribers
from the matching generation. `sendMessage`'s cleanup also stops
deleting a newer generation's `activeGenerations` slot when an older
one finally unwinds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 07:35:18 -07:00
parent f57a692eeb
commit f49ec30c54
6 changed files with 267 additions and 39 deletions

View File

@@ -536,7 +536,13 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
}
}
// Subscribe to session events
// Allocate a generation up front so subscription and sendMessage broadcasts
// share the same id. This filters out stragglers from a prior, just-cancelled
// generation that would otherwise hit this fresh subscriber and falsely look
// like an error/done for this request.
const { generationId } = chatManager.beginGeneration(sessionId);
// Subscribe to session events for this generation only.
const unsubscribe = chatStreamManager.subscribe(sessionId, (event, eventId) => {
const data = (event as { data?: unknown }).data;
if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) {
@@ -549,7 +555,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
unsubscribe();
res.end();
}
});
}, { generationId });
// Handle client disconnect
req.on("close", () => {
@@ -577,7 +583,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
chatStreamManager.broadcast(sessionId, {
type: "error",
data: "modelProvider and modelId must both be provided or neither",
});
}, { generationId });
unsubscribe();
res.end();
return;
@@ -590,6 +596,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
normalizedProvider,
normalizedModelId,
Array.isArray(attachments) ? attachments : undefined,
{ generationId },
).catch((err: Error) => {
chatLogger.error("Error in sendMessage", {
error: err.message,
@@ -597,7 +604,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
chatStreamManager.broadcast(sessionId, {
type: "error",
data: err.message || "Failed to process message",
});
}, { generationId });
});
} catch (err: unknown) {
if (err instanceof ApiError) {