@@ -3,6 +3,7 @@ import { EventEmitter } from "events";
33import * as path from "path" ;
44import { stat , readFile } from "fs/promises" ;
55import { PlatformPaths } from "@/common/utils/paths" ;
6+ import { log } from "@/node/services/log" ;
67import { createMuxMessage } from "@/common/types/message" ;
78import type { Config } from "@/node/config" ;
89import type { AIService } from "@/node/services/aiService" ;
@@ -247,48 +248,57 @@ export class AgentSession {
247248 private async emitHistoricalEvents (
248249 listener : ( event : AgentSessionChatEvent ) => void
249250 ) : Promise < void > {
250- // Read partial BEFORE iterating history so we can skip the corresponding
251- // placeholder message (which has empty parts). The partial has the real content.
252- const streamInfo = this . aiService . getStreamInfo ( this . workspaceId ) ;
253- const partial = await this . partialService . readPartial ( this . workspaceId ) ;
254- const partialHistorySequence = partial ?. metadata ?. historySequence ;
255-
256- // Load chat history (persisted messages from chat.jsonl)
257- const historyResult = await this . historyService . getHistory ( this . workspaceId ) ;
258- if ( historyResult . success ) {
259- for ( const message of historyResult . data ) {
260- // Skip the placeholder message if we have a partial with the same historySequence.
261- // The placeholder has empty parts; the partial has the actual content.
262- // Without this, both get loaded and the empty placeholder may be shown as "last message".
263- if (
264- partialHistorySequence !== undefined &&
265- message . metadata ?. historySequence === partialHistorySequence
266- ) {
267- continue ;
251+ // try/catch/finally guarantees caught-up is always sent, even if replay fails.
252+ // Without caught-up, the frontend stays in "Loading workspace..." forever.
253+ try {
254+ // Read partial BEFORE iterating history so we can skip the corresponding
255+ // placeholder message (which has empty parts). The partial has the real content.
256+ const streamInfo = this . aiService . getStreamInfo ( this . workspaceId ) ;
257+ const partial = await this . partialService . readPartial ( this . workspaceId ) ;
258+ const partialHistorySequence = partial ?. metadata ?. historySequence ;
259+
260+ // Load chat history (persisted messages from chat.jsonl)
261+ const historyResult = await this . historyService . getHistory ( this . workspaceId ) ;
262+ if ( historyResult . success ) {
263+ for ( const message of historyResult . data ) {
264+ // Skip the placeholder message if we have a partial with the same historySequence.
265+ // The placeholder has empty parts; the partial has the actual content.
266+ // Without this, both get loaded and the empty placeholder may be shown as "last message".
267+ if (
268+ partialHistorySequence !== undefined &&
269+ message . metadata ?. historySequence === partialHistorySequence
270+ ) {
271+ continue ;
272+ }
273+ // Add type: "message" for discriminated union (messages from chat.jsonl don't have it)
274+ listener ( { workspaceId : this . workspaceId , message : { ...message , type : "message" } } ) ;
268275 }
269- // Add type: "message" for discriminated union (messages from chat.jsonl don't have it)
270- listener ( { workspaceId : this . workspaceId , message : { ...message , type : "message" } } ) ;
271276 }
272- }
273-
274- if ( streamInfo ) {
275- await this . aiService . replayStream ( this . workspaceId ) ;
276- } else if ( partial ) {
277- // Add type: "message" for discriminated union (partials from disk don't have it)
278- listener ( { workspaceId : this . workspaceId , message : { ...partial , type : "message" } } ) ;
279- }
280277
281- // Replay init state BEFORE caught-up (treat as historical data)
282- // This ensures init events are buffered correctly by the frontend,
283- // preserving their natural timing characteristics from the hook execution.
284- await this . initStateManager . replayInit ( this . workspaceId ) ;
278+ if ( streamInfo ) {
279+ await this . aiService . replayStream ( this . workspaceId ) ;
280+ } else if ( partial ) {
281+ // Add type: "message" for discriminated union (partials from disk don't have it)
282+ listener ( { workspaceId : this . workspaceId , message : { ...partial , type : "message" } } ) ;
283+ }
285284
286- // Send caught-up after ALL historical data (including init events)
287- // This signals frontend that replay is complete and future events are real-time
288- listener ( {
289- workspaceId : this . workspaceId ,
290- message : { type : "caught-up" } ,
291- } ) ;
285+ // Replay init state BEFORE caught-up (treat as historical data)
286+ // This ensures init events are buffered correctly by the frontend,
287+ // preserving their natural timing characteristics from the hook execution.
288+ await this . initStateManager . replayInit ( this . workspaceId ) ;
289+ } catch ( error ) {
290+ log . error ( "Failed to replay history for workspace" , {
291+ workspaceId : this . workspaceId ,
292+ error,
293+ } ) ;
294+ } finally {
295+ // Send caught-up after ALL historical data (including init events)
296+ // This signals frontend that replay is complete and future events are real-time
297+ listener ( {
298+ workspaceId : this . workspaceId ,
299+ message : { type : "caught-up" } ,
300+ } ) ;
301+ }
292302 }
293303
294304 async ensureMetadata ( args : {
0 commit comments