From b9c5df04af5b8fd08ccf2cd395b1c255f8ac5da4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 4 Aug 2026 19:15:20 -0700 Subject: [PATCH] FN-8791: prevent NUL-containing chat checkpoints from crashing Prevent malformed tool output from breaking chat checkpoint persistence. - Sanitize NUL characters at chat JSONB persistence boundaries without mutating clean values. - Observe rejected best-effort checkpoint writes so streaming sessions do not emit unhandled rejections. - Add PostgreSQL, dashboard, and sanitizer regression coverage with operator-facing release notes. Files changed: .changeset/fn-8791-chat-nul-checkpoint.md | 7 ++ .../reliability/chat-jsonb-nul-sanitization.md | 44 +++++++++++ packages/core/src/__tests__/nul-sanitize.test.ts | 21 ++++- .../chat-store-content-search-edit.pg.test.ts | 91 +++++++++++++++++++++- packages/core/src/async-stores/async-chat-store.ts | 61 ++++++++++----- packages/core/src/postgres/nul-sanitize.ts | 17 ++-- .../dashboard/src/__tests__/chat-manager.test.ts | 90 +++++++++++++++++++++ packages/dashboard/src/chat.ts | 21 ++++- 8 files changed, 322 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-8791 Fusion-Task-Lineage: 51e13634-04cc-4827-b72f-f33e40760940 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8791-chat-nul-checkpoint.md | 7 ++ .../chat-jsonb-nul-sanitization.md | 44 +++++++++ .../core/src/__tests__/nul-sanitize.test.ts | 21 ++++- .../chat-store-content-search-edit.pg.test.ts | 91 ++++++++++++++++++- .../core/src/async-stores/async-chat-store.ts | 61 +++++++++---- packages/core/src/postgres/nul-sanitize.ts | 17 ++-- .../src/__tests__/chat-manager.test.ts | 90 ++++++++++++++++++ packages/dashboard/src/chat.ts | 21 ++++- 8 files changed, 322 insertions(+), 30 deletions(-) create mode 100644 .changeset/fn-8791-chat-nul-checkpoint.md create mode 100644 docs/solutions/reliability/chat-jsonb-nul-sanitization.md diff --git a/.changeset/fn-8791-chat-nul-checkpoint.md b/.changeset/fn-8791-chat-nul-checkpoint.md new file mode 100644 index 0000000000..7736560c76 --- /dev/null +++ b/.changeset/fn-8791-chat-nul-checkpoint.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent chat checkpoints from failing on NUL-containing tool output. +category: fix +dev: Sanitizes chat JSONB persistence boundaries and observes best-effort checkpoint failures. diff --git a/docs/solutions/reliability/chat-jsonb-nul-sanitization.md b/docs/solutions/reliability/chat-jsonb-nul-sanitization.md new file mode 100644 index 0000000000..a27b990708 --- /dev/null +++ b/docs/solutions/reliability/chat-jsonb-nul-sanitization.md @@ -0,0 +1,44 @@ +--- +title: "Chat JSONB persistence must strip U+0000 at every PostgreSQL boundary" +date: 2026-08-05 +problem_type: reliability +module: "@fusion/core" +component: chat-persistence +tags: + - postgres + - jsonb + - chat + - nul + - checkpoints +symptoms: + - "unsupported Unicode escape sequence during chat session checkpointing" + - "unhandled rejection after a tool reads NUL-bearing output" +root_cause: "Raw model and tool text reached PostgreSQL jsonb bindings without U+0000 sanitization, while fire-and-forget checkpoint promises had no rejection observer." +resolution_type: code_fix +--- + +## Problem + +PostgreSQL rejects U+0000 in `text` and `jsonb`. Chat tool results are arbitrary environment-controlled output, so a tool reading redirected Fusion logs can include the logger marker `\u0000fnlvl=info\u0000`. Persisting that result in an in-flight generation snapshot caused the checkpoint write to reject; because checkpoints are intentionally fire-and-forget, the dashboard then emitted an unhandled rejection. + +## Protected surfaces + +The invariant belongs at PostgreSQL persistence boundaries, not provider callbacks. `sanitizeJsonbValue` now recursively strips U+0000 from JSONB values and keys before chat-session snapshot creation/checkpoint writes, message and room-message inserts, attachment appends, and message metadata merges. This covers direct chat, QuickChat, all provider callbacks, and future tool providers without mutating the caller's value. Clean values retain identity; `null` and `undefined` retain their existing semantics. + +## Degradation behavior + +In-flight checkpoints are crash-recovery state, not turn control flow. `ChatManager` routes debounced timer writes and immediate flushes through one observing helper. A failed checkpoint emits a concise session-scoped warning without payload or database-error text, drops that attempt, and allows streaming, completion, cancellation, and later clears to continue. It does not retry, wait, or create another scheduler. + +## Why strip U+0000 + +Stripping is deterministic, preserves the readable surrounding tool output, and is already the established PostgreSQL sanitizer behavior. Escaping into a literal sequence would change tool text semantics and still require every reader to understand a special encoding. + +## Regression commands + +```bash +pnpm --filter @fusion/core exec vitest run src/__tests__/nul-sanitize.test.ts --silent=passed-only --reporter=default +pnpm --filter @fusion/core exec vitest run src/__tests__/postgres/chat-store-content-search-edit.pg.test.ts --silent=passed-only --reporter=default +pnpm --filter @fusion/dashboard exec vitest run src/__tests__/chat-manager.test.ts --project dashboard-api-quality --reporter=default --silent=passed-only +``` + +The PostgreSQL test persists the reported full in-flight tool-result shape and reads it back; dashboard tests exercise both debounced and flush rejection paths with no unhandled rejection. diff --git a/packages/core/src/__tests__/nul-sanitize.test.ts b/packages/core/src/__tests__/nul-sanitize.test.ts index 2853593277..c6d5acff87 100644 --- a/packages/core/src/__tests__/nul-sanitize.test.ts +++ b/packages/core/src/__tests__/nul-sanitize.test.ts @@ -59,8 +59,25 @@ describe("sanitizeTextValue", () => { }); describe("sanitizeJsonbValue", () => { - it("deep-strips NUL from an object destined for a jsonb column", () => { - expect(sanitizeJsonbValue({ note: "tail\u0000end" })).toEqual({ note: "tailend" }); + it("deep-strips NUL from nested values, keys, and repeated logger markers", () => { + const snapshot = { + ["tool\u0000result"]: { + content: ["\u0000fnlvl=info\u0000first", { text: "\u0000fnlvl=warn\u0000second" }], + }, + }; + expect(sanitizeJsonbValue(snapshot)).toEqual({ + toolresult: { + content: ["fnlvl=infofirst", { text: "fnlvl=warnsecond" }], + }, + }); + expect(snapshot["tool\u0000result"].content[0]).toBe("\u0000fnlvl=info\u0000first"); + }); + + it("preserves clean JSON identity while leaving null and undefined intact", () => { + const clean = { nested: ["clean", { value: true }] }; + expect(sanitizeJsonbValue(clean)).toBe(clean); + expect(sanitizeJsonbValue(null)).toBe(null); + expect(sanitizeJsonbValue(undefined)).toBe(undefined); }); it("passes through null/undefined unchanged", () => { diff --git a/packages/core/src/__tests__/postgres/chat-store-content-search-edit.pg.test.ts b/packages/core/src/__tests__/postgres/chat-store-content-search-edit.pg.test.ts index 35bfd56a66..3bff381d2c 100644 --- a/packages/core/src/__tests__/postgres/chat-store-content-search-edit.pg.test.ts +++ b/packages/core/src/__tests__/postgres/chat-store-content-search-edit.pg.test.ts @@ -19,13 +19,16 @@ import { execSync } from "node:child_process"; import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js"; import { addChatMessage, + addChatMessageAttachment, createChatSession, deleteChatMessagesFrom, getChatMessages, + getChatSession, + setInFlightGeneration, searchChatSessionsByMessageContent, updateChatMessageMetadata, } from "../../async-stores/async-chat-store.js"; -import type { ChatMessage, ChatSession } from "../../chat/chat-types.js"; +import type { ChatInFlightGenerationState, ChatMessage, ChatSession } from "../../chat/chat-types.js"; const PG_TEST_URL_BASE = process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432"; @@ -216,6 +219,40 @@ pgDescribe("async chat store content search + edit primitives (PostgreSQL)", () expect((await getChatMessages(ctx.layer.db, sessionA.id)).length).toBe(1); }); + /* + FNXC:ChatPersistence 2026-08-05-01:54: + Attachment appends and metadata merges are separate jsonb update paths from + initial inserts. They must independently enforce the arbitrary-text NUL + invariant so a later tool-derived mutation cannot poison a chat row. + */ + it("sanitizes attachment appends and metadata merge updates at their jsonb boundaries", async () => { + ctx = await setupCtx(); + const session = await makeSession(ctx); + const message = await addMessage(ctx, session.id, "user", "hello", { clean: true }); + + const attached = await addChatMessageAttachment(ctx.layer.db, session.id, message.id, { + id: "att\u0000-1", + filename: "fu\u0000sion.log", + originalName: "fu\u0000sion.log", + mimeType: "text/plain\u0000", + size: 1, + createdAt: "2026-08-05T01:54:00.000Z", + }); + expect(attached.attachments).toEqual([{ + id: "att-1", + filename: "fusion.log", + originalName: "fusion.log", + mimeType: "text/plain", + size: 1, + createdAt: "2026-08-05T01:54:00.000Z", + }]); + + const merged = await updateChatMessageMetadata(ctx.layer.db, message.id, { + ["tool\u0000result"]: { text: "\u0000fnlvl=info\u0000" }, + }); + expect(merged.metadata).toEqual({ clean: true, toolresult: { text: "fnlvl=info" } }); + }); + it("updateChatMessageMetadata merges by default, replaces on merge:false, and throws for missing messages", async () => { ctx = await setupCtx(); @@ -235,6 +272,58 @@ pgDescribe("async chat store content search + edit primitives (PostgreSQL)", () ).rejects.toThrow(/not found/); }); + /* + FNXC:ChatPersistence 2026-08-05-01:54: + A completed tool result can contain Fusion's raw logger framing + (U+0000fnlvl=infoU+0000). Checkpoint persistence must strip it recursively + before jsonb binding, preserve all replay fields, and leave the input owned + by the live chat callback untouched. + */ + it("persists the NUL-marked in-flight tool snapshot and reads back its sanitized shape", async () => { + ctx = await setupCtx(); + const session = await makeSession(ctx); + const snapshot: ChatInFlightGenerationState = { + status: "generating", + streamingText: "prefix\u0000fnlvl=info\u0000suffix", + streamingThinking: "thought\u0000stream", + toolCalls: [{ + toolName: "ba\u0000sh", + args: { command: "tail\u0000 /tmp/fusion.log" }, + isError: false, + result: { + content: [{ type: "text", text: "log \u0000fnlvl=info\u0000 line" }], + ["nested\u0000key"]: ["a\u0000b"], + }, + status: "completed", + }], + replayFromEventId: 4, + updatedAt: "2026-08-05T01:54:00.000Z", + }; + + const updated = await setInFlightGeneration(ctx.layer.db, session.id, snapshot); + expect(updated?.inFlightGeneration).toMatchObject({ + status: "generating", + streamingText: "prefixfnlvl=infosuffix", + streamingThinking: "thoughtstream", + replayFromEventId: 4, + toolCalls: [{ + toolName: "bash", + args: { command: "tail /tmp/fusion.log" }, + result: { + content: [{ type: "text", text: "log fnlvl=info line" }], + nestedkey: ["ab"], + }, + }], + }); + expect(snapshot.streamingText).toBe("prefix\u0000fnlvl=info\u0000suffix"); + expect((snapshot.toolCalls[0].result as { content: Array<{ text: string }> }).content[0]?.text).toBe("log \u0000fnlvl=info\u0000 line"); + + const persisted = await getChatSession(ctx.layer.db, session.id); + expect(persisted?.inFlightGeneration).toEqual(updated?.inFlightGeneration); + await expect(setInFlightGeneration(ctx.layer.db, session.id, null)).resolves.toBeDefined(); + expect((await getChatSession(ctx.layer.db, session.id))?.inFlightGeneration).toBeNull(); + }); + /* FNXC:PostgresMigrationNulSanitize 2026-07-20: Production incident: the CEO agent's chat turn crashed mid-conversation diff --git a/packages/core/src/async-stores/async-chat-store.ts b/packages/core/src/async-stores/async-chat-store.ts index 9be90a44d0..ef4f7196e6 100644 --- a/packages/core/src/async-stores/async-chat-store.ts +++ b/packages/core/src/async-stores/async-chat-store.ts @@ -128,24 +128,34 @@ function rowToRoomMessage(row: Record): ChatRoomMessage { * Create a chat session. */ export async function createChatSession(handle: QueryHandle, session: ChatSession): Promise { + /* + FNXC:ChatPersistence 2026-08-05-01:54: + Chat snapshot fields may include arbitrary model and tool text. PostgreSQL + rejects U+0000 anywhere in jsonb, so strip it only at this persistence + boundary and return the same sanitized shape that is stored. + */ + const sanitized: ChatSession = { + ...session, + inFlightGeneration: sanitizeJsonbValue(session.inFlightGeneration), + }; await handle.insert(schema.project.chatSessions).values({ - id: session.id, - agentId: session.agentId, - title: session.title, - status: session.status, + id: sanitized.id, + agentId: sanitized.agentId, + title: sanitized.title, + status: sanitized.status, // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — the trigger/GUC owns the partition. - ownerProjectId: session.projectId, - modelProvider: session.modelProvider, - modelId: session.modelId, - thinkingLevel: session.thinkingLevel ?? null, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - pinnedAt: session.pinnedAt, - cliSessionFile: session.cliSessionFile, - inFlightGeneration: session.inFlightGeneration, - cliExecutorAdapterId: session.cliExecutorAdapterId, + ownerProjectId: sanitized.projectId, + modelProvider: sanitized.modelProvider, + modelId: sanitized.modelId, + thinkingLevel: sanitized.thinkingLevel ?? null, + createdAt: sanitized.createdAt, + updatedAt: sanitized.updatedAt, + pinnedAt: sanitized.pinnedAt, + cliSessionFile: sanitized.cliSessionFile, + inFlightGeneration: sanitized.inFlightGeneration, + cliExecutorAdapterId: sanitized.cliExecutorAdapterId, }); - return session; + return sanitized; } /** @@ -767,9 +777,16 @@ export async function setInFlightGeneration( ): Promise { const existing = await getChatSession(handle, id); if (!existing) return undefined; + /* + FNXC:ChatPersistence 2026-08-05-01:54: + Every checkpoint reaches this JSONB boundary from live streaming callbacks, + including tool results. Sanitizing here protects regular chat, QuickChat, + and future providers without mutating the caller's snapshot. + */ + const sanitizedInFlightGeneration = sanitizeJsonbValue(inFlightGeneration); await handle .update(schema.project.chatSessions) - .set({ inFlightGeneration }) + .set({ inFlightGeneration: sanitizedInFlightGeneration }) .where(eq(schema.project.chatSessions.id, id)); return getChatSession(handle, id); } @@ -790,7 +807,10 @@ export async function addChatMessageAttachment( if (!message || message.sessionId !== sessionId) { throw new Error(`Message ${messageId} not found in session ${sessionId}`); } - const updatedAttachments = [...(message.attachments ?? []), attachment]; + const updatedAttachments = sanitizeJsonbValue([ + ...(message.attachments ?? []), + attachment, + ]); await handle .update(schema.project.chatMessages) .set({ attachments: updatedAttachments }) @@ -930,7 +950,7 @@ export async function updateChatMessageMetadata( await handle .update(schema.project.chatMessages) - .set({ metadata: nextMetadata ?? null }) + .set({ metadata: sanitizeJsonbValue(nextMetadata) ?? null }) .where(eq(schema.project.chatMessages.id, messageId)); const updated = await getChatMessage(handle, messageId); @@ -1109,7 +1129,10 @@ export async function addChatRoomMessageAttachment( if (!message || message.roomId !== roomId) { throw new Error(`Message ${messageId} not found in room ${roomId}`); } - const updatedAttachments = [...(message.attachments ?? []), attachment]; + const updatedAttachments = sanitizeJsonbValue([ + ...(message.attachments ?? []), + attachment, + ]); await handle .update(schema.project.chatRoomMessages) .set({ attachments: updatedAttachments }) diff --git a/packages/core/src/postgres/nul-sanitize.ts b/packages/core/src/postgres/nul-sanitize.ts index 02e8e9634d..a52249d0d2 100644 --- a/packages/core/src/postgres/nul-sanitize.ts +++ b/packages/core/src/postgres/nul-sanitize.ts @@ -32,14 +32,19 @@ export function deepStripNulChars(value: unknown): unknown { return stripNulChars(value); } if (Array.isArray(value)) { - return value.map(deepStripNulChars); + const sanitized = value.map(deepStripNulChars); + return sanitized.some((entry, index) => entry !== value[index]) ? sanitized : value; } if (value !== null && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record).map( - ([key, entry]) => [stripNulChars(key), deepStripNulChars(entry)], - ), - ); + const entries = Object.entries(value as Record); + const sanitizedEntries = entries.map(([key, entry]) => [ + stripNulChars(key), + deepStripNulChars(entry), + ] as const); + const changed = sanitizedEntries.some(([key, entry], index) => ( + key !== entries[index]?.[0] || entry !== entries[index]?.[1] + )); + return changed ? Object.fromEntries(sanitizedEntries) : value; } return value; } diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index ec12037aa9..900e4f6139 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -169,6 +169,7 @@ describe("ChatManager.sendMessage", () => { }); mockChatStore.getMessages.mockReturnValue([]); mockChatStore.getRoomMessages.mockReturnValue([]); + mockChatStore.setInFlightGeneration.mockResolvedValue(undefined); mockAgentStore.init.mockResolvedValue(undefined); mockAgentStore.getAgent.mockResolvedValue({ @@ -203,6 +204,8 @@ describe("ChatManager.sendMessage", () => { }); afterEach(() => { + __setChatDiagnostics(null); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -1019,6 +1022,93 @@ describe("ChatManager.sendMessage", () => { expect(mockChatStore.setInFlightGeneration).toHaveBeenLastCalledWith("chat-001", null); }); + it("observes a debounced checkpoint rejection without an unhandled rejection", async () => { + vi.useFakeTimers(); + const warn = vi.fn(); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + __setChatDiagnostics({ log: vi.fn(), warn, error: vi.fn() }); + + let resolvePrompt: (() => void) | undefined; + __setCreateFnAgent(async (options: any) => ({ + session: { + prompt: vi.fn().mockImplementation(() => new Promise((resolve) => { + options.onToolEnd("bash", false, { + content: [{ type: "text", text: "log \u0000fnlvl=info\u0000 line" }], + }); + resolvePrompt = resolve; + })), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "done" }] }, + }, + })); + mockChatStore.setInFlightGeneration + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("jsonb rejected")) + .mockResolvedValue(undefined); + + const sending = createChatManager().sendMessage("chat-001", "Read the log"); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + expect(warn.mock.calls.filter(([message]) => message === "Failed to persist in-flight chat checkpoint for session chat-001")).toEqual([ + ["Failed to persist in-flight chat checkpoint for session chat-001"], + ]); + expect(unhandled).not.toHaveBeenCalled(); + expect(mockChatStore.setInFlightGeneration).toHaveBeenCalledWith("chat-001", expect.objectContaining({ + toolCalls: [expect.objectContaining({ + result: { content: [{ type: "text", text: "log \u0000fnlvl=info\u0000 line" }] }, + })], + })); + + resolvePrompt?.(); + await sending; + process.off("unhandledRejection", unhandled); + vi.useRealTimers(); + }); + + it("observes an immediate flush rejection and preserves the final clear", async () => { + const warn = vi.fn(); + __setChatDiagnostics({ log: vi.fn(), warn, error: vi.fn() }); + mockChatStore.setInFlightGeneration + .mockRejectedValueOnce(new Error("checkpoint unavailable")) + .mockResolvedValue(undefined); + __setCreateFnAgent(async () => ({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "done" }] }, + }, + })); + + await expect(createChatManager().sendMessage("chat-001", "Hello")).resolves.toBeUndefined(); + await Promise.resolve(); + expect(warn.mock.calls.filter(([message]) => message === "Failed to persist in-flight chat checkpoint for session chat-001")).toEqual([ + ["Failed to persist in-flight chat checkpoint for session chat-001"], + ]); + expect(mockChatStore.setInFlightGeneration).toHaveBeenLastCalledWith("chat-001", null); + }); + + it("cancels a stale debounced snapshot before flushing the latest clear", async () => { + vi.useFakeTimers(); + let onText: ((delta: string) => void) | undefined; + __setCreateFnAgent(async (options: any) => { + onText = options.onText; + return { + session: { + prompt: vi.fn().mockImplementation(async () => onText?.("queued")), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "done" }] }, + }, + }; + }); + + await createChatManager().sendMessage("chat-001", "Hello"); + await vi.advanceTimersByTimeAsync(200); + expect(mockChatStore.setInFlightGeneration).toHaveBeenCalledTimes(2); + expect(mockChatStore.setInFlightGeneration).toHaveBeenLastCalledWith("chat-001", null); + vi.useRealTimers(); + }); + it("broadcasts done with persisted assistant message snapshot", async () => { const events: Array<{ type: string; data: unknown }> = []; const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => { diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 74fed47af8..960bb154c4 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -1518,6 +1518,23 @@ export class ChatManager { this.cliChatProjectId = projectId; } + /* + FNXC:ChatPersistence 2026-08-05-01:54: + Checkpoint snapshots contain environment-controlled tool output. Persistence + is best-effort for crash recovery, but every fire-and-forget write must have + its rejection observed so one failed jsonb write cannot become a process-wide + unhandled rejection or interrupt the streaming turn. + */ + private persistInFlightGeneration(sessionId: string, snapshot: ChatInFlightGenerationState | null): void { + try { + void this.chatStore.setInFlightGeneration(sessionId, snapshot).catch(() => { + diagnostics.warn(`Failed to persist in-flight chat checkpoint for session ${sessionId}`); + }); + } catch { + diagnostics.warn(`Failed to persist in-flight chat checkpoint for session ${sessionId}`); + } + } + private queueInFlightGenerationPersist(sessionId: string, snapshot: ChatInFlightGenerationState | null): void { const existingTimer = this.inFlightPersistTimers.get(sessionId); if (existingTimer) { @@ -1526,7 +1543,7 @@ export class ChatManager { const timer = setTimeout(() => { this.inFlightPersistTimers.delete(sessionId); - this.chatStore.setInFlightGeneration(sessionId, snapshot); + this.persistInFlightGeneration(sessionId, snapshot); }, IN_FLIGHT_PERSIST_DEBOUNCE_MS); this.inFlightPersistTimers.set(sessionId, timer); } @@ -1537,7 +1554,7 @@ export class ChatManager { clearTimeout(existingTimer); this.inFlightPersistTimers.delete(sessionId); } - this.chatStore.setInFlightGeneration(sessionId, snapshot); + this.persistInFlightGeneration(sessionId, snapshot); } private async getChatModelSettings(): Promise<{