@@ -29,12 +29,20 @@ import type {
2929 DynamicToolPartAvailable ,
3030} from "@/common/types/toolParts" ;
3131import { isDynamicToolPart } from "@/common/types/toolParts" ;
32+ import { z } from "zod" ;
3233import { createDeltaStorage , type DeltaRecordStorage } from "./StreamingTPSCalculator" ;
3334import { computeRecencyTimestamp } from "./recency" ;
34- import { getStatusUrlKey } from "@/common/constants/storage" ;
35+ import { getStatusStateKey } from "@/common/constants/storage" ;
3536
3637// Maximum number of messages to display in the DOM for performance
3738// Full history is still maintained internally for token counting and stats
39+ const AgentStatusSchema = z . object ( {
40+ emoji : z . string ( ) ,
41+ message : z . string ( ) ,
42+ url : z . string ( ) . optional ( ) ,
43+ } ) ;
44+
45+ type AgentStatus = z . infer < typeof AgentStatusSchema > ;
3846const MAX_DISPLAYED_MESSAGES = 128 ;
3947
4048interface StreamingContext {
@@ -97,11 +105,9 @@ export class StreamingMessageAggregator {
97105
98106 // Current agent status (updated when status_set is called)
99107 // Unlike todos, this persists after stream completion to show last activity
100- private agentStatus : { emoji : string ; message : string ; url ?: string } | undefined = undefined ;
108+ private agentStatus : AgentStatus | undefined = undefined ;
101109
102- // Last URL set via status_set - persists even when agentStatus is cleared
103- // This ensures URL stays available across stream boundaries and through compaction
104- // Persisted to localStorage keyed by workspaceId
110+ // Last URL set via status_set - kept in memory to reuse when later calls omit url
105111 private lastStatusUrl : string | undefined = undefined ;
106112
107113 // Workspace ID for localStorage persistence
@@ -130,32 +136,48 @@ export class StreamingMessageAggregator {
130136 constructor ( createdAt : string , workspaceId ?: string ) {
131137 this . createdAt = createdAt ;
132138 this . workspaceId = workspaceId ;
133- // Load persisted lastStatusUrl from localStorage
139+ // Load persisted agent status from localStorage
134140 if ( workspaceId ) {
135- this . lastStatusUrl = this . loadLastStatusUrl ( ) ;
141+ const persistedStatus = this . loadPersistedAgentStatus ( ) ;
142+ if ( persistedStatus ) {
143+ this . agentStatus = persistedStatus ;
144+ this . lastStatusUrl = persistedStatus . url ;
145+ }
136146 }
137147 this . updateRecency ( ) ;
138148 }
139149
140- /** Load lastStatusUrl from localStorage */
141- private loadLastStatusUrl ( ) : string | undefined {
150+ /** Load persisted agent status from localStorage */
151+ private loadPersistedAgentStatus ( ) : AgentStatus | undefined {
142152 if ( ! this . workspaceId ) return undefined ;
143153 try {
144- const stored = localStorage . getItem ( getStatusUrlKey ( this . workspaceId ) ) ;
145- return stored ?? undefined ;
154+ const stored = localStorage . getItem ( getStatusStateKey ( this . workspaceId ) ) ;
155+ if ( ! stored ) return undefined ;
156+ const parsed = AgentStatusSchema . safeParse ( JSON . parse ( stored ) ) ;
157+ return parsed . success ? parsed . data : undefined ;
146158 } catch {
147- return undefined ;
159+ // Ignore localStorage errors or JSON parse failures
148160 }
161+ return undefined ;
149162 }
150163
151- /**
152- * Persist lastStatusUrl to localStorage.
153- * Once set, the URL can only be replaced with a new URL, never deleted.
154- */
155- private saveLastStatusUrl ( url : string ) : void {
164+ /** Persist agent status to localStorage */
165+ private savePersistedAgentStatus ( status : AgentStatus ) : void {
156166 if ( ! this . workspaceId ) return ;
167+ const parsed = AgentStatusSchema . safeParse ( status ) ;
168+ if ( ! parsed . success ) return ;
157169 try {
158- localStorage . setItem ( getStatusUrlKey ( this . workspaceId ) , url ) ;
170+ localStorage . setItem ( getStatusStateKey ( this . workspaceId ) , JSON . stringify ( parsed . data ) ) ;
171+ } catch {
172+ // Ignore localStorage errors
173+ }
174+ }
175+
176+ /** Remove persisted agent status from localStorage */
177+ private clearPersistedAgentStatus ( ) : void {
178+ if ( ! this . workspaceId ) return ;
179+ try {
180+ localStorage . removeItem ( getStatusStateKey ( this . workspaceId ) ) ;
159181 } catch {
160182 // Ignore localStorage errors
161183 }
@@ -208,7 +230,7 @@ export class StreamingMessageAggregator {
208230 * Updated whenever status_set is called.
209231 * Persists after stream completion (unlike todos).
210232 */
211- getAgentStatus ( ) : { emoji : string ; message : string ; url ?: string } | undefined {
233+ getAgentStatus ( ) : AgentStatus | undefined {
212234 return this . agentStatus ;
213235 }
214236
@@ -295,39 +317,31 @@ export class StreamingMessageAggregator {
295317 ( a , b ) => ( a . metadata ?. historySequence ?? 0 ) - ( b . metadata ?. historySequence ?? 0 )
296318 ) ;
297319
298- // First pass: scan all messages to build up lastStatusUrl from tool calls
299- // This ensures URL persistence works even if the URL was set in an earlier message
300- // Also persists to localStorage for future loads (survives compaction)
320+ // Replay historical messages in order to reconstruct derived state
301321 for ( const message of chronologicalMessages ) {
322+ if ( message . role === "user" ) {
323+ // Mirror live behavior: clear stream-scoped state on new user turn
324+ // but keep persisted status for fallback on reload.
325+ this . currentTodos = [ ] ;
326+ this . agentStatus = undefined ;
327+ continue ;
328+ }
329+
302330 if ( message . role === "assistant" ) {
303331 for ( const part of message . parts ) {
304- if (
305- isDynamicToolPart ( part ) &&
306- part . state === "output-available" &&
307- part . toolName === "status_set" &&
308- hasSuccessResult ( part . output )
309- ) {
310- const result = part . output as Extract < StatusSetToolResult , { success : true } > ;
311- if ( result . url ) {
312- this . lastStatusUrl = result . url ;
313- this . saveLastStatusUrl ( result . url ) ;
314- }
332+ if ( isDynamicToolPart ( part ) && part . state === "output-available" ) {
333+ this . processToolResult ( part . toolName , part . input , part . output , context ) ;
315334 }
316335 }
317336 }
318337 }
319338
320- // Second pass: reconstruct derived state from the most recent assistant message only
321- // (TODOs and agentStatus should reflect only the latest state)
322- const lastAssistantMessage = chronologicalMessages . findLast ( ( msg ) => msg . role === "assistant" ) ;
323-
324- if ( lastAssistantMessage ) {
325- // Process all tool results from the most recent assistant message
326- // processToolResult will decide what to do based on tool type and context
327- for ( const part of lastAssistantMessage . parts ) {
328- if ( isDynamicToolPart ( part ) && part . state === "output-available" ) {
329- this . processToolResult ( part . toolName , part . input , part . output , context ) ;
330- }
339+ // If history was compacted away from the last status_set, fall back to persisted status
340+ if ( ! this . agentStatus ) {
341+ const persistedStatus = this . loadPersistedAgentStatus ( ) ;
342+ if ( persistedStatus ) {
343+ this . agentStatus = persistedStatus ;
344+ this . lastStatusUrl = persistedStatus . url ;
331345 }
332346 }
333347
@@ -675,18 +689,18 @@ export class StreamingMessageAggregator {
675689 if ( toolName === "status_set" && hasSuccessResult ( output ) ) {
676690 const result = output as Extract < StatusSetToolResult , { success : true } > ;
677691
678- // Update lastStatusUrl if a new URL is provided, and persist to localStorage
679- if ( result . url ) {
680- this . lastStatusUrl = result . url ;
681- this . saveLastStatusUrl ( result . url ) ;
692+ // Use the provided URL, or fall back to the last URL ever set
693+ const url = result . url ?? this . lastStatusUrl ;
694+ if ( url ) {
695+ this . lastStatusUrl = url ;
682696 }
683697
684- // Use the provided URL, or fall back to the last URL ever set
685698 this . agentStatus = {
686699 emoji : result . emoji ,
687700 message : result . message ,
688- url : result . url ?? this . lastStatusUrl ,
701+ url,
689702 } ;
703+ this . savePersistedAgentStatus ( this . agentStatus ) ;
690704 }
691705 }
692706
@@ -821,6 +835,7 @@ export class StreamingMessageAggregator {
821835 // since stream-start/stream-end events are not persisted in chat.jsonl
822836 this . currentTodos = [ ] ;
823837 this . agentStatus = undefined ;
838+ this . clearPersistedAgentStatus ( ) ;
824839
825840 this . setPendingStreamStartTime ( Date . now ( ) ) ;
826841 }
0 commit comments