feat(FN-3927): persist and restore durable chat recovery state

- Persist in-flight chat generation snapshots in core chat store types and DB plumbing
- Restore durable recovery state through dashboard chat manager and chat hooks on reload
- Add coverage for chat store persistence and chat/useQuickChat recovery behavior
- Document durable chat recovery architecture and dashboard behavior

Fusion-Task-Id: FN-3927
This commit is contained in:
Fusion
2026-05-10 09:31:00 -07:00
committed by gsxdsm
parent eb148124ba
commit f6f7d3f031
13 changed files with 288 additions and 24 deletions

View File

@@ -71,6 +71,7 @@ describe("ChatStore", () => {
expect(session.modelId).toBeNull();
expect(session.createdAt).toBeTruthy();
expect(session.updatedAt).toBeTruthy();
expect(session.inFlightGeneration).toBeNull();
});
it("stores all provided fields", () => {
@@ -345,6 +346,27 @@ describe("ChatStore", () => {
});
});
describe("setInFlightGeneration", () => {
it("persists and clears in-flight generation snapshot", () => {
const session = createTestSession(store);
const updated = store.setInFlightGeneration(session.id, {
status: "generating",
streamingText: "partial",
streamingThinking: "thinking",
toolCalls: [{ toolName: "read", isError: false, status: "running" }],
replayFromEventId: 12,
updatedAt: new Date().toISOString(),
});
expect(updated?.inFlightGeneration?.streamingText).toBe("partial");
expect(store.getSession(session.id)?.inFlightGeneration?.replayFromEventId).toBe(12);
store.setInFlightGeneration(session.id, null);
expect(store.getSession(session.id)?.inFlightGeneration).toBeNull();
});
});
describe("archiveSession", () => {
it("sets status to archived", () => {
const session = createTestSession(store);

View File

@@ -25,6 +25,7 @@ import type {
ChatSessionUpdateInput,
ChatMessagesFilter,
ChatRoom,
ChatInFlightGenerationState,
ChatRoomCreateInput,
ChatRoomMember,
ChatRoomMessage,
@@ -82,6 +83,7 @@ interface ChatSessionRow {
createdAt: string;
updatedAt: string;
cliSessionFile: string | null;
inFlightGeneration: string | null;
}
/** Database row shape for chat_messages. */
@@ -156,6 +158,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
createdAt: row.createdAt,
updatedAt: row.updatedAt,
cliSessionFile: row.cliSessionFile ?? null,
inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null,
};
}
@@ -248,11 +251,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
createdAt: now,
updatedAt: now,
cliSessionFile: null,
inFlightGeneration: null,
};
this.db.prepare(`
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
session.id,
session.agentId,
@@ -263,6 +267,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
session.modelId,
session.createdAt,
session.updatedAt,
null,
);
this.db.bumpLastModified();
@@ -459,6 +464,20 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
this.db.bumpLastModified();
}
setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;
this.db
.prepare("UPDATE chat_sessions SET inFlightGeneration = ? WHERE id = ?")
.run(toJsonNullable(inFlightGeneration), id);
const updated = this.getSession(id)!;
this.db.bumpLastModified();
this.emit("chat:session:updated", updated);
return updated;
}
/**
* Delete a chat session and all its messages.
* Messages are cascade-deleted via foreign key constraint.

View File

@@ -19,6 +19,23 @@ export type ChatMessageRole = "user" | "assistant" | "system";
* A chat session between a user and an agent.
* Contains metadata about the conversation and references to the model used.
*/
export interface ChatInFlightToolCall {
toolName: string;
args?: Record<string, unknown>;
isError: boolean;
result?: unknown;
status: "running" | "completed";
}
export interface ChatInFlightGenerationState {
status: "generating";
streamingText: string;
streamingThinking: string;
toolCalls: ChatInFlightToolCall[];
replayFromEventId: number;
updatedAt: string;
}
export interface ChatSession {
id: string;
/** Session routing kind; legacy sessions default to direct */
@@ -51,6 +68,8 @@ export interface ChatSession {
* for sessions that have never produced an assistant reply.
*/
cliSessionFile: string | null;
/** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */
inFlightGeneration: ChatInFlightGenerationState | null;
}
/**

View File

@@ -912,6 +912,7 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
cliSessionFile: "TEXT",
inFlightGeneration: "TEXT",
},
chat_messages: {
id: "TEXT PRIMARY KEY",
@@ -1828,7 +1829,8 @@ export class Database {
modelProvider TEXT,
modelId TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
updatedAt TEXT NOT NULL,
inFlightGeneration TEXT
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxChatSessionsAgentId ON chat_sessions(agentId)`);

View File

@@ -890,6 +890,8 @@ export type {
export type {
ChatSessionStatus,
ChatMessageRole,
ChatInFlightToolCall,
ChatInFlightGenerationState,
ChatSession,
ChatSessionSummary,
EnrichedChatSession,